From 8b31de2f8d409ac73adcae384769686b1e360c95 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 09:21:58 -0400 Subject: [PATCH 001/529] [Docs] (Disaggregated): land the two-process split plan - server runs the unchanged backends against a replica GLContext driven by mutator replay, the client is the sole originator of every implicit-publish semantic (persistent-map push, MarkGpuWritten, texture dirty clear, XFB accounting, generated-mip allocation), SPSC shm ring with FlatBuffers structs on the hot path and tables on the control socket, two-way doorbells, present credit 1, spawn-only shipping build with an inproc CI variant, and a P0-P9 schedule gated on the existing unit/integration/retrace/CTS suites; plus the design-competition and adversarial-review record --- docs/Disaggregated/PLAN.md | 1289 ++++++++++++++++++++++++++++++++++ docs/Disaggregated/REVIEW.md | 265 +++++++ 2 files changed, 1554 insertions(+) create mode 100644 docs/Disaggregated/PLAN.md create mode 100644 docs/Disaggregated/REVIEW.md diff --git a/docs/Disaggregated/PLAN.md b/docs/Disaggregated/PLAN.md new file mode 100644 index 000000000..40d6743df --- /dev/null +++ b/docs/Disaggregated/PLAN.md @@ -0,0 +1,1289 @@ +# MobileGL 前后端进程拆分实施计划(branch `feat/disaggregated`) + +> 状态:设计定稿 v1(2026-09-05)。基线 `dev@81b17c0b`;实施分支 `feat/disaggregated`(worktree `../MobileGL-disagg`)。 +> 产出方式:7 个只读代码调研 → 4 个独立架构方案 → 3 个评审打分 → 综合 → 3 个对抗性审查(38 条发现)→ 修订;评审记录见同目录 `REVIEW.md`。 +> 上一次尝试 `Feat/CS-Delta-IPC`(2026-08-29/30,worktree `../MobileGL-CS`)的复用/丢弃结论见 §14。 + +--- + +## 0. TL;DR 与核心决策 + +**Server 就是 `libMobileGL` 自己**,在自己的进程里跑一个**真实的 `MG_State::GLState::GLContext`(replica)**,由一个 delta applier 通过普通 MG_State mutator API 驱动。**两个 backend(DirectGLES 27k / DirectVulkan 40k 行)一行不改。** Client 也是同一个 `libMobileGL`,在 init 时换掉几个对象:`MG_Backend::gBackendFunctionsTable` 换成发射表,`MG_Backend::pActiveBackendObject` 换成 `BackendObject_Remote`,`SetBufferBackendOps` 换成发射 ops。一份产物,两个角色,由一个 env var 选择。 + +这样做的唯一理由是:**backend 的 draw-path 失效模型无法表达成 wire 字段。** DirectGLES 有 memo 直接借用 binding slot 的 `shared_ptr` 地址(`DirectGLES.cpp:1463-1477` `UnitTextureSyncEntry` + `PairingsIntact`);`VertexInputStateFactory.cpp:78` 把**后端堆上的裸指针**写进前端 VAO;`IsBufferDrawClean` 开头就是裸指针身份比较(`Managers.cpp:1435-1436`,注释:"Identity first: a respecify path can hand the frontend a NEW resource");三个门控计数器是回绕的 `Uint16`,只有配合指针身份比较才正确(`Managers.h:772-777`,postmortem 在 `DirectGLES.cpp:2823-2831`);`UniformManager.cpp:1418-1497` 构造并驱动真实 `TextureObject2D`;`VulkanRenderer.cpp:4211-4356` 通过真实 `ShaderObject`/`ProgramObject` 编译链接 GLSL。replica 逐字满足 DirectGLES 107/107、DirectVulkan 165/169 次 `pGLContext` 读取;重写则是把一套被文档记录为"具体设备 bug 疤痕组织"的失效模型重新推导一遍——那正是 `Feat/CS-Delta-IPC` 走的路,它一帧都没渲出来。 + +**第二个核心决策:每个困难语义先上"慢但可证明正确"的版本,后续阶段用 flag 换成快版本,并把慢版本保留成 oracle。** +- Phase 1-4:**server 从源码重新 link shader**(只需 5 个 schema 字段,而不是 ~40 字段的 reflection schema),并带 `reflectionDigest` 交叉校验 → Phase 5 换成 `ProgramPublish`,`relink` 保留为 A/B 对照与常驻 oracle。 +- Phase 1-6:**关闭 ≥16MiB persistent-map 采纳**(前端在 `BufferObject.cpp:174,439-442,470-472` 已容忍 `nullptr` 返回,`MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION` 已存在)→ Phase 7 攻 external memory 导出,**允许结论是"设备 X 上拒绝,已记录,回退成本 N ms"**。 +- Phase 1-4:**调用时刻拷贝进 ring**(WAR 由构造消除)→ Phase 4.5 shadow-in-shm 零拷贝。 + +**第三个核心决策(本轮对抗性评审后新增,是本计划与上一版最大的语义差异):客户端是所有"隐式发布"语义的唯一发起者。** +上一版把三件事交给"server 在 replica 上照常做,事件回传给 client",全部被证伪: +1. **persistent-map 的写发布**:`BufferObject::SyncPersistentMappedRange()`(`BufferObject.cpp:238-250`)是 shadow-backed persistent 非-FLUSH_EXPLICIT map 的**唯一**推送点,而 `grep -rn SyncPersistentMappedRange MobileGL/` 的全部生产调用点都在 `MG_Backend/` 里(DirectGLES.cpp:262/4412/4666/4667/4768/4769、Managers.cpp:1547、MultiDraw.cpp:498、DirectVulkan.cpp:290/481/895、UniformManager.cpp:2022、VkBufferManager.cpp:573/620、VulkanRenderer.cpp:3432/3511/3826/7070/12015/12016)。MG_Impl 与 MG_State 里**一个都没有**。拆分后这段代码跑在 server 对 replica 上,而 replica 的 `m_isMapped` 是 false(没有 map delta),第一行就 return;client 侧则根本没人调。**应用通过 coherent persistent map 写下的字节会被静默丢弃。** +2. **`MarkGpuWritten`**:同样只有 `MG_Backend/` 里的 6 个调用点(`DirectGLES.cpp:465,509,1809`;`UniformManager.cpp:1073,1229`;`VulkanRenderer.cpp:11210`),而它在 monolith 里是**在 draw 调用内同步置位的**。拆分后 draw 是 fire-and-forget,`glDrawElements(); glMapBufferRange(SSBO, READ);` 会在 server 还没 apply 之前就读到陈旧 shadow,零 round trip、零报错。 +3. **纹理 dirty flag**:上一版声称"client 从不清 dirty flag",但 `MipmapStorage::MarkDirtyRegion`(`MipmapStorage.cpp:196-233`)只要 `m_isDirty[level]` 为真就把 incoming **并进** union box 并追加 rect,只有 `MarkDirty(level,false)`(`:171-189`)会重置。永不清 = union box 只增不减、rect 列表饱和、`summedArea*4 >= unionArea*3` 一触发就退化成整 level 上传,正好与计划要保留的调优相反。 + +所以本版的规则是:**任何 monolith 里由 backend 代码触发的"前端状态发布/消费",在拆分模式下必须由 client 在发射点自己做一遍**,server 侧那份照常跑(它对 replica 操作,幂等或无害)。事件回传只允许作为**收窄优化**,永远不允许作为语义的**建立者**。 + +**Phase 1 的目标改为:在 Linux 上以 `inproc` 与 `spawn` 两种传输跑通垂直切片;真机 OpenRA trace(SSIM ≥ 0.99)移到 P2 出口判据。** 理由见 §15:Android 交付链(server `.so` 打包、`untrusted_app` 域 exec、trace app 的 env 透传)本身是独立工作量,把它压进 P1 的 10 天里是上一版最薄弱的排期假设。 + +### 核心决策速查 + +| # | 决策 | 理由 | +|---|---|---| +| D1 | Server = replica `GLContext` + 未改动 backend | 293 次 `pGLContext` 读、13 个身份键 memo、25 个 backend→frontend 写全部原样工作 | +| D2 | 发射点 = 三个**已经是间接的**边界(`gBackendFunctionsTable` / `pActiveBackendObject` / `SetBufferBackendOps`),不进 MG_State mutator | monolith 侵入面 = `MG_Backend/Init.cpp:48-70` 里一个 switch 分支;~250 个边界调用点零 `#ifdef` | +| D3 | 版本计数器**不上线**;replica 靠 mutator replay 自然 bump | 不需要 `Install*` setter,不需要在 wire 上维护回绕 `Uint16` 的单调性 | +| D4 | 控制面走 **SPSC shm ring**,watermark 放在一条**共享 cache line**;**双向 doorbell** | `GetSyncStatus`/`IsQueryResultAvailable`/ring 回收/present credit 变成一次 acquire load;但**所有等待都必须能挂起**,不能自旋 | +| D5 | FlatBuffers:热路径用 **`struct`**(定长、无 vtable、无 verifier walk),罕见/变长用 `table` 走 socket | 满足"用 FlatBuffers 序列化"的要求,同时 `DrawArrays` 记录 32B 而不是 ~60B | +| D6 | **composite pipeline program 由 client 解析**并下发 handle | `Core.cpp:644` 在 pipeline cache miss 时 `MakeShared(0u)` 并 **link**;server 在 Phase 5 之后没有源码,必须由 client 定 | +| D7 | 覆盖度由**两侧生成的编译期断言**保证:backend 的 READ 面 **和** MG_Impl 的 MUTATOR 面 | backend 新增一个 read、或 MG_Impl 在 table 调用旁新增一个 mutation 而 applier 没 replay → 编译失败,而不是设备回归 | +| D8 | monolith 保留由 **`nm --defined-only` + `.text` size diff** 机械证明,且**每个阶段都跑**,不只 P0 | 不靠"测试没变" | +| D9 | **client 是隐式发布语义的唯一发起者**(persistent map 推送、`MarkGpuWritten`、纹理 dirty 清除、XFB CPU 计数、生成 mip 的存储分配) | 见上文三条被证伪的假设 | +| D10 | `inproc` 与 `spawn` 拆成**两个 CMake option**:出货构建只开 `spawn`,`pGLContext` 保持普通全局,GL 热路径上没有 TLS | Android dlopen 的 shared library 无法用 initial-exec TLS,1494 个 `pGLContext->` 上每次 `__tls_get_addr` 调用不可接受 | + +--- + +## 1. 目标与非目标 + +### 目标 +1. 前端(MG_Impl + MG_State + glslang 链接)与后端(MG_Backend + SPIRV-Cross + 驱动)跑在两个进程,通过 IPC 通信。 +2. Client 把前端状态 reconcile 成 delta,序列化(FlatBuffers)后发送;server 更新自身状态并调用 backend API。 +3. **稳态帧零 round trip**(readback / 阻塞式 query / sync wait / present credit / 分配类错误 ack 之外)。 +4. 两半尽可能互相异步:client 至多领先 server 1 个 present(默认值,见 §9 的延迟叠加分析)。 +5. 平台特定代码最小化并集中在 `MG_Remote/Transport/` 与 `MG_Remote/Client/Surface*`。 +6. **单进程 Monolith 保持字节级不变**,且可机械验证。 +7. 所有验收门用**现有测试**:`ctest -L unit` / `-L integration-gpu` / `tools/trace_replay` / `tools/cts` / `tools/device_bench`。 + +### 非目标(本分支明确不做) +- **share-group sessioning 重构。** monolith 今天所有 EGL context 共用一个 `GLContext`(`GLState/Core.cpp:20,1487`;`eglCreateContext` 只存 `SharedContext` 于 `EGLState/Core.cpp:640`,全代码库无人读取)。单 context client 与今天等价。`c7c9e346`/`29d721ef` 那套(共享 VAO-0 破坏、四个头文件 `public:` 泄漏、无锁进程全局 current session、`MOBILEGL_SESSION_SWAP` kill switch)整体丢弃。 +- **BFA strict-C-ABI backend 插件 / UtilRuntime C-ABI 化。** server 与 backend 同一 CMake 工程、同一产物发布,ABI 边界永不移动。 +- **macOS 拆分。** `CAMetalLayer` 无公开跨进程表示,MobileGL 在 macOS 是 `DYLD_INSERT_LIBRARIES` interposer(导出表锁定于 `CMakeLists.txt:600-612`),无 CI 无设备 → **monolith only,写进文档**。 +- **Windows 窗口拆分。** WGL / ANGLE-DXGI 对外进程 HWND 不是受支持配置 → **headless(pbuffer) only**。 +- Phase 9 之前不做任何窗口路径(全部离屏)。 + +--- + +## 2. 现状:今天的前后端边界(七个面) + +### (a) `GLFunctionsTable` — 73 项,`MG_Backend/BackendObject.h:117-285` +MG_Impl 侧 91 个调用点(`GL_Drawing.cpp` 37、`GL_Query.cpp` 22、`GL_Framebuffer.cpp` 11、`GL_Texture.cpp` 10、`GL_Sync.cpp` 6、`GL_Getter.cpp` 3、`GL_Program.cpp` 1)+ `MG_Util/ShaderTranspiler/CompileEnv.cpp:134,138` 两处。 + +- 20 个 draw、9 个 clear(4 个 `ClearNamedFramebuffer*` 携带 `SharedPtr`)、5 个 blit/copy(`CopyImageSubData` 携带两个 `CopyImageEndpoint`,`BackendObject.h:32-39`)、`GenerateMipmap`、3 个 readback、4 个 compute/barrier、`BindImageTexture`(已经收 GL name)。 +- **两项是死代码**:`GetInteger64i_v`(`BackendObject.h:196`,MG_Impl 零调用点;`GL_Getter.cpp:1307` 把 64 位形式委派给 32 位)和 `GetProgramiv`(`:197`;`GL_Program.cpp:851` 全部从 `ProgramObject` 回答)。两个 backend 都注册并实现了它们。 +- `GetIntegeri_v` 只有 `GL_MAX_COMPUTE_WORK_GROUP_COUNT/SIZE` 真正转发(`GL_Getter.cpp:1161-1179`)。 +- `BeginOcclusionQuery != nullptr` 被当作能力探测用(`GL_Query.cpp:471,545,768`);DirectVulkan 只注册 64/72 项(`BackendObject_DirectVulkan.cpp:690-770`,不注册 7 个 XFB + `PatchParameteri` + `SetSwapInterval`)。 + +### (b) `BackendObject` 虚函数 — `BackendObject.h:541-568` +MG_Impl 侧 89 个 `pActiveBackendObject->`,**其中 45 个是 `GetDynamicParameters()`**,若干落在 per-API-call 校验路径上(`Buffer/Validators.cpp:63`、`VertexArray/Validators.cpp:22`、`GL_VertexArray.cpp:536`、`GL_Texture.cpp:406`)。 +**关键时序:`InitCapabilities()` 懒执行在第一次成功的 `eglMakeCurrent` 内部**(`BackendObject.cpp:341-347`),DirectGLES 在那里才改写 advertised extension string(`BackendObject_DirectGLES.cpp:786-796`)。 + +### (c) `BufferBackendOps` — 7 个 hook,`BufferState/BufferObject.h:76-121`,注册入口 `:124` +DirectGLES 注册 7/7(`Managers.cpp:1336-1345`),DirectVulkan 注册 6/7(无 `ResidentSubData`,`VkBufferManager.cpp:104-111`)。 +`AcquirePersistentMap`(`:112`)**把 GPU 内存裸指针交给应用**;`TryAdoptLargeStorage`(`BufferObject.cpp:167-176`,`kLargeBufferAdoptBytes = 16MiB`)在 store **定义时**单方面采纳。理由块 `BufferObject.cpp:153-166`:MC 26.3 的 128MB chunk arena,实测 p99 163→21ms、40→115fps、省 ~400MB。 + +### (d) 状态拉取 — 293 个 `pGLContext->`(DirectGLES 124 / DirectVulkan 169)+ ~90 个前端对象 getter +`PrepareForDraw`(`DirectGLES.cpp:2916-2976`)与 `SetupDraw`(`VulkanRenderer.cpp:6371`)在这里把整个 `GLContext` 拉出来。**这一面在本设计中不过线。** + +### (e) backend → frontend 写回(25 个语义点 / 14 个类) +`MarkGpuWritten` ×3、`WritebackFromBackend` ×7、`MarkStorageDirty` ×11、`SetBackendResource` ×2、`AllocateStorage` ×1、`RecordError` ×2,加两处 shadow `Memcpy`(`DirectGLES.cpp:6861` 生成 mip、`:7144` CopyImage 镜像);DirectVulkan 另有 `SetBackendHashMemo`/`SetBackendStateMemo`(**存后端堆裸指针**)/`SetBackendAuxMemo`/`EnsureGpuResidentStorage`/`InvalidateCompileEnv`/`SwapchainObject.cpp:276-331` 改写 default-FBO 占位纹理。 +**replica 模型下这 25 处大部分落在 server 自己的 replica 上**,但其中三类是"语义建立者",client 必须自己做一遍(§5.6、§5.6a)。 + +### (f) backend 反向进 MG_Impl — 恰好 6 处 +`DirectGLES.cpp:1917,2838,2867,9675`(`pDefaultFramebufferInfo`)、`SwapchainObject.cpp:276`、`VulkanRenderer.cpp:10700`(`CopyTextureImageToClientOrPBO_State`)。replica 模型下全部正常解析(server 也链接完整 MG_Impl)。 +但注意:`MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo` 全库 22 处引用,client 侧 MG_Impl 也在读(`GL_Framebuffer.cpp:495,1827,1837,1897,1905,1913,1927,1936,2549,2590,2598,2608,2611`)。它是**第二个进程全局**,`inproc` 模式下必须与 `pGLContext` 一起做角色隔离(§12)。 + +### (g) MG_Impl 在 table 调用旁做的 MG_State mutation(**上一版遗漏的第七个面**) +`GLFunctionsTable` 是一个**命令**边界,不是一个**状态**边界的两侧对称点:MG_Impl 在调 table 之前/之后还会自己改 MG_State,而这些改动 applier 只 replay table 是拿不到的。已确认的两族: + +1. **`glGenerateMipmap` / `glGenerateTextureMipmap` / 自动 mipmap**:`GLImpl::GenerateMipmap`(`GL_Texture.cpp:6681-6699`)在 `GenerateMipmap_Backend` **之前** 调 `EnsureGeneratedMipmapStorageAllocated(*mipmapTexture)`(`GL_Texture.cpp:501-541`),后者对 level 1..N 做 `AllocateStorage`、`MarkStorageDirty(...,false)`(`:528`)、`TruncateMipmapLevels`(`:533`)、`BumpContentVersion()`(`:538`)。`:534-537` 的注释写明了这个 version bump 存在的理由:没有它,"a cached sampled VkImageView built for the pre-generate level range would otherwise stay stale and clamp LOD>0 sampling to mip 0"。只 replay table 的 applier 会在 replica 上**精确复现这个已知 bug**。`GenerateTextureMipmap`(`:6702-6711`)和 `MaybeAutoGenerateMipmap`(`:1625-1635`)同形。 +2. **Transform feedback CPU 计数**:`AccountTransformFeedbackPrimitives`(`GL_Drawing.cpp:172-236`)在每个被捕获的 draw 上改 6 个 GLContext 计数器:`AddTransformFeedbackPausedPrimitives`(:177)、`AddTransformFeedbackInputPrimitives`(:184)、`AddTransformFeedbackGeometryCaptureDraw`(:214)、`AddTransformFeedbackPrimitives`(:231)、`AddTransformFeedbackCapturedVertices`(:232)、`AddTransformFeedbackAccountedCaptureDraw`(:237)。DirectGLES 在 `DirectGLES.cpp:900` 读 `GetTransformFeedbackCapturedVertices()` 来给 scattered capture 定容量;DirectVulkan 在 `DirectVulkan.cpp:1384` 读 `GetTransformFeedbackPausedPrimitiveCounter()` 并在 `:1337` 把前端 delta 折进 query 结果。这些计数器**没有版本号**,也不在任何 accessor 的门控里;replica 上它们恒为 0 → scattered XFB 什么都不捕、`PRIMITIVES_WRITTEN`/`PRIMITIVES_GENERATED` 错。它们还在 XFB 对象绑定时按对象存取(`Core.cpp:1273,1296`;`Core.h:313-357`),所以简单"发个标量"的补丁必须跟着对象切换走。 + +§5.9 的覆盖生成器**抓不到这一类**:它扫 `MG_Backend/**` 的 READ 面,所以 backend 读 `GetTransformFeedbackCapturedVertices` 会被正常分类并通过,而 MG_Impl 那半个生产者从来没被审计过。**所以 §5.9 必须有第二个生成器**(见 §5.9b)。 + +### (h) 工作树污染(Phase 0 必须先清) +`DirectGLES.cpp:640-663` 与 `Managers.cpp:875-877` 有**未提交的 per-draw `fprintf(stderr)`**(格式串里还有字面量 `' + NL + '`,且位于 `pendingMutex` 临界区内的 buffer flush 路径上)。`Feat/CS-Delta-IPC` 的 `d96be9f3` 提交过同类东西(`DirectGLES.cpp:+2583-2590`),导致该分支上**每一次测量**(144-failure Windows run、OpenRA `ssim=0.000036` 设备 run)都跑在每 draw 一次 stderr 写的构建上。 + +--- + +## 3. 目标架构总览 + +``` +┌───────────────────────── CLIENT 进程 (libMobileGL.so) ─────────────────────────┐ +│ App / LWJGL │ +│ │ gl* │ +│ ▼ │ +│ MG_Impl (validate → RecordError → 调 MG_State mutator) ← glGetError 本地 │ +│ │ │ +│ ▼ │ +│ MG_State::pGLContext (权威状态 + ShaderCompilePool + glslang) │ +│ │ │ +│ ├─ gBackendFunctionsTable = EmitTable ─┐ │ +│ ├─ pActiveBackendObject = BackendObject_Remote (+ CapsMirror) │ +│ └─ SetBufferBackendOps(&g_emitBufferOps) ─┤ │ +│ ▼ │ +│ MG_Remote::WireMirror │ +│ (① PublishImplicitState:persistent-map 推送、 │ +│ MarkGpuWritten 保守置位、XFB 计数、mip 分配 │ +│ ② 读版本计数器 → 决定发什么 │ +│ ③ 清 dirty flag / 记录 shipped 水位) │ +│ │ │ +└──────────────────────────────────────────────────┼─────────────────────────────┘ + SEG_CMD (SPSC ring, POD 记录) ────────────┤ 写 + SEG_STAGE (bulk 字节 ring, 独立游标) ─────┤ 写 + SEG_SHADOW[n] (P4.5+, client 拥有)────────┤ RW + RingControl (一条 cache line 的 atomics) ◄─┤ 读 watermark(acquire load) + ├─ producerParked ──► server 敲门铃 + SEG_REPLY / SEG_EVENT (server 拥有) ◄─┘ 读(在每个等待循环里排空) + CTRL socket (socketpair / 继承 overlapped pipe): FlatBuffers table + + SCM_RIGHTS + 双向 doorbell +┌──────────────────────────────────────────────────┼─────────────────────────────┐ +│ MobileGLServer (dlopen libMobileGL.so → mobilegl_server_main) │ +│ thread mgl-srv-io : asio,framing,fd 传递,doorbell,控制面 RPC │ +│ thread mgl-srv-apply: 终身持有 EGL/Vulkan context(可绑大核) │ +│ │ │ +│ ▼ Applier::Apply(RecHeader) → MG_State mutator / 共享 helper / │ +│ GLFunctionsTable │ +│ MG_State::pGLContext (replica) │ +│ ▲ │ +│ │ 293 次 pGLContext-> + ~90 getter,**零改动** │ +│ MG_Backend (DirectGLES / DirectVulkan) + MG_Util(SPIRV-Cross, 转译缓存) │ +│ │ │ +│ ▼ 真实 GLES / Vulkan 驱动 │ +└────────────────────────────────────────────────────────────────────────────────┘ +``` + +三种运行模式(`MOBILEGL_TRANSPORT`):`monolith`(默认,编译期折叠)、`inproc`(同进程第二个 `GLContext` + apply 线程,**需要 `MOBILEGL_BUILD_DISAGGREGATED_INPROC`**)、`spawn` / `unix:` / `pipe:`(真跨进程,出货形态)。 + +--- + +## 4. 边界定义(每个面变成什么) + +| 面 | 变成 | +|---|---| +| **(a) `GLFunctionsTable`** | `MG_Remote::Client::MakeEmitTable()` 返回的发射表。61 项 = 先跑 `PublishImplicitState`、再追加一条定长记录、返回;5 项 request/reply;4 项分配类 `kNeedsAck`(§5.6c);`GetIntegeri_v` 由 CapsMirror 本地回答;`GetInteger64i_v`/`GetProgramiv` **从 wire 与 table 中删除**(并提议在 `dev` 上删掉两个 backend 的实现)。7 个携带 `SharedPtr` 的项转成 `WireHandle`。**DirectVulkan 未注册的 8 个槽由 `CapsSnapshot.tableSlotMask` 精确复现**(`GL_Query.cpp:471,545,768` 拿槽位空否当能力探测)。 | +| **(b) `BackendObject` 虚函数** | `BackendObject_Remote`。8 个 EGL 生命周期虚函数 → `SurfaceOp` RPC(前 4 个阻塞,因为返回 `Bool`)。`GetDynamicParameters`(45)/`GetRendererInfo`(8)/`GetFormatCapabilities`(4)/`GetBackendType`(3)/`GetBackendAPIVersionString` → **CapsMirror 本地,零 round trip**。`DynamicBackendParameters`(`BackendObject.h:299-521`)是 flat POD,逐字节传。先例:`CompileEnv`(`CompileEnv.h:28-45`)就是为同一个原因做的同一件事。`GetBackendType()` 返回**远端**类型,所以 `GL_Texture.cpp:6453-6457`、`CompileEnv.cpp:122`、`GL_Framebuffer.cpp:38` 全部照旧。 | +| **(c) `BufferBackendOps`** | `g_emitBufferOps`:`Respecify`/`SubData`/`FlushMappedRange`/`OnDestroy` → 记录;`ResidentSubData` P7 之前不注册(只有 adopted store 才可达);`AcquirePersistentMap` → P1-6 返回 `nullptr`,P7 返回 `AdoptSeg` 映射基址;`ReadbackFromGpu` → 阻塞请求(monolith 里本来就是 `glFinish()`,`Managers.cpp:1246`)。 | +| **(d) 状态拉取** | **不过线。** backend 读 server 自己的 replica。 | +| **(e) backend→frontend 写** | 大部分落在 replica 上;三类"语义建立者"由 client 自己做(`MarkGpuWritten`、纹理 dirty 清除、persistent-map 推送);六类需要事件回传(§5.6)。**per-row `WritebackFromBackend` 循环(`Utils.cpp:2342`、`DirectGLES.cpp:7633`)在 server 内部执行,永远不会变成"每扫描线一次 IPC"。** | +| **(f) MG_Impl 反向调用** | server 链接完整 MG_Impl,6 处照常解析。default-FBO 描述通过 `EvDefaultFramebufferInfo` 事件回传给 client(`SwapchainObject.cpp:276-331` 的写在 server 侧发生)。 | +| **(g) MG_Impl 在 table 旁的 mutation** | 抽成 client/server 共享 helper(`MG_Remote::Shared::`),applier 在 replay 对应记录时调同一个 helper;或作为显式记录下发。由 §5.9b 的生成器强制全覆盖。 | + +--- + +## 5. 状态 delta 模型 + +### 5.0 决定:replica `GLContext` vs 重写 backend + +**选 replica。** 三条不可协商的证据: +1. **身份键 memo 无 delta 对应物。** `UnitTextureSyncEntry` 借用 binding slot 的 `shared_ptr` **地址**(`DirectGLES.cpp:1463-1467`),`PairingsIntact` 再校验 `entry.slot->get() != entry.texture`(`:1472-1477`)——注释明说没有它"replay 会拿纹理 B 的前端状态驱动纹理 A 的后端 twin"。`IsBufferDrawClean`(`Managers.cpp:1435-1436`)第一句就是资源裸指针身份比较。DirectVulkan 13 个缓存同类。replica 里这些**逐字工作**,因为对象仍由 `SharedPtr` 持有、unit 数组仍按值存放。 +2. **回绕计数器。** `FramebufferBindingSlot::GetVersion()`、`FramebufferObject::GetObjectVersion()`、`VAO::GetIndexBufferBindingSlot().GetVersion()` 都是 `Uint16` 回绕,只有配合指针身份才正确。replay 让两侧跑同一段回绕逻辑。 +3. **backend 自有 generation 表达的是"驱动对象被重新铸造"**(`g_bufferBackendIdGeneration`、`g_attachmentBackendIdGeneration`),**任何 client delta 都无法承载**——它们本来就该纯 server 侧,replica 天然满足。 + +代价:server 进程要链接 MG_State + MG_Impl + MG_Util(SPIRV-Cross、转译缓存、格式处理器、POST 探针)。这本来就无法避免——`BackendProgramObjectImpl::TranspileSpirvToEssl`(`Managers.cpp:6575-7110`)在 draw 线程跑 SPIRV-Cross,`UniformManager.cpp:1418-1497` 构造真实 `TextureObject`,`VulkanRenderer.cpp:4211-4356` 走 `ShaderObject::Compile()`/`ProgramObject::Link(false)`。"thin server"在这个代码库里是伪命题。 + +**replica 模型的边界必须明确写出来(R1 的真正内容)**:replica 只保证"对 backend 可见的状态"与 client 一致。凡是 client 的**入口点**(MG_Impl)在调 table 之外还做过的 MG_State 改动,applier 必须显式复刻——这不是理论风险,是 §2(g) 已经确认的两族实例。§5.9b 把它变成编译期门。 + +### 5.1 reconcile 在哪里发生 + +`MobileGL/MG_Remote/Client/WireMirror.{h,cpp}`,在**发射点**运行:每个 `GLFunctionsTable` 命令、`Present`、任何阻塞请求。 + +每个发射点分三步,顺序不可换: + +**步骤 ①:`PublishImplicitState(scope)`** —— 复刻 backend 在 monolith 里会做的隐式发布,**必须在读任何版本计数器之前跑**,因为它自己会 bump 版本: +- 对 scope 内每个 **live persistent-mapped buffer** 调 client 侧的推送(§5.10)。 +- 对 draw/dispatch scope,保守置 `MarkGpuWritten()`:镜像 `MarkShaderStorageBuffersGpuWritten`(`DirectGLES.cpp:459-467`,走 `GetTouchedBufferBindingPointCount(ShaderStorage)` + `GetBufferBindingPoint`)、`SyncAtomicCounterBuffers` 的 `:509`、以及可写 image-buffer 纹理的 `:1809`。XFB active 时对每个 capture target 同样置位(镜像 `VulkanRenderer.cpp:11210`)。 +- 对 draw scope,若 XFB active,跑共享的 `AccountTransformFeedbackPrimitives` helper(§2(g)-2;monolith 里这一步本来就在 MG_Impl 里,拆分后它继续在 client 跑,同时把结果作为 `RecXfbAccounting` 下发给 replica)。 +- 对 `GenerateMipmap` scope,`EnsureGeneratedMipmapStorageAllocated` 本来就在 client 的 MG_Impl 里跑过了;WireMirror 只需把它产生的 level 分配 + `TruncateMipmapLevels` + `BumpContentVersion` 作为 `RecGenerateMipmapLevels` 下发(§5.6a)。 + +**步骤 ②:可达性遍历。** 这就是 `DirectGLES::PrepareForDraw`(`DirectGLES.cpp:2916-2976`)的遍历,把 sync 换成 emit——不是比喻,是同一集合、同一顺序、同一门控: + +1. `GetBoundVertexArray()` → `GetConfigVersion()`;其 enabled attribute 的 `BufferObject`;index buffer slot(**版本 + 裸指针身份**)。 +2. `GetProgramForDraw()`(在 client 侧 join compile pool,与今天一致)→ link/UBO-content/block-binding/SSBO-override 版本。**composite pipeline 见 §5.7。** +3. texture unit `[0, GetMaxTouchedTextureUnit()]`,门控 `GetTextureBindGeneration()`;每纹理 `GetContentVersion()`/`GetTextureParamsVersion()`;每 unit `GetSamplerObject()->GetVersion()`。 +4. image unit `[0, imageHighWater]` 经 `GetImageTextureBinding(unit)`。 +5. 每 target 的 buffer binding point,上界 `GetTouchedBufferBindingPointCount(target)`。 +6. draw/read FBO,门控 slot version + `GetObjectVersion()` + `GetAllFramebufferAttachmentVersions()`,再逐 attachment;**attachment 若是 renderbuffer,另查 `RenderbufferObject::GetVersion()`**(P0 新增,见 §5.4)。 +7. `GetRenderStateParameters()`,门控 render-state 版本。 +8. **pack** pixel-store(backend 从不读 unpack;六个读点全部传 `false`:`DirectGLES.cpp:6129,7614,9101,9480`、`Utils.cpp:2301`、`VulkanRenderer.cpp:10622`;`ScopedDefaultUnpackState` 强制默认值,`Managers.cpp:2888-2910`)。 + +**步骤 ③:清消费型状态。** 对本次发射的每个纹理 level 调 `MarkStorageDirty(uploadTarget, level, false)`(§5.6a)。 + +因为 backend 自身这套门控已被证明有界且便宜,reconciler 的每 draw 成本形状是**已知的**,不是估计。 + +存储: +```cpp +// MobileGL/MG_Remote/Client/WireMirror.h +struct ShipRecord { // 40 B + Uint64 shippedA, shippedB, shippedC; // 打包版本元组,按 kind 解释 + Uint32 flags; // Created | Published | Deleted | ServerAuthoritative + Uint32 pad; +}; +class WireMirror { + ska::flat_hash_map m_ship; + struct DrawKeys { Uint64 contextId, samplingGen, bindGen; Int maxUnit; } m_lastDrawKeys; + // ① 的输入:只遍历真正 mapped / 真正可能被 GPU 写的对象,不是全表 + ska::flat_hash_set m_livePersistentMaps; + ska::flat_hash_map m_gpuWritePendingSeq; +public: + void PublishImplicitState(EmitScope, RingProducer&); + void ReconcileForDraw(RingProducer&); // 上面 1-8 + void ReconcileForDispatch(RingProducer&); + void ReconcileForClear(RingProducer&); + void OnObjectCreated(ObjKind, Uint32 name, Uint64 lifetimeId); + void OnObjectDestroyed(ObjKind, Uint64 lifetimeId); + void OnBufferMapped(BufferObject&, Range1D, BufferMappingAccess); + void OnBufferUnmapped(BufferObject&); +}; +``` +外加与 backend `DrawTextureSyncKeys`(`DirectGLES.cpp:1496-1518`)同键的 per-draw memo:状态未变的重复 draw 只花 ~10 次整数比较就追加一条 32 字节记录。 + +### 5.2 版本计数器**不上线** + +applier 不设置版本,它 **replay mutation**,所以 replica 的计数器恰在 applier 改动了东西时 bump——恰是 backend 必须重新 sync 的时刻。 + +- 不需要给 `RenderState`/`ProgramObject` 加 `Install*` setter(`Feat/CS-Delta-IPC` 的 `b50f3348` 加了,代价是把 `RenderState.h` 的私有成员漏成 public)。 +- 不需要在 wire 上维护回绕 `Uint16` 的单调性。 +- 唯一残留风险是**过度失效**:replica bump 了而 client 没 bump。只要 applier 只做 client 明确下发的 mutation,就不会发生;`RenderStateBlob` 整块下发是唯一例外(它整块 bump `m_version`,与 client 自己的 bump 等价)。 + +### 5.3 触发器 → delta 对照表 + +| Client 触发器(accessor / 事件) | Delta 记录 | +|---|---| +| `BufferObject::GetChangeSerial()` + emit-ops 里排队的 range | `RecBufferRespecify` / `RecBufferSubData` / `RecBufferFlushRange` | +| `glMapBuffer*` / `glUnmapBuffer`(**新增**) | `RecBufferMap{handle, range, accessFlags}` / `RecBufferUnmap{handle}` | +| persistent-map 脏块(**新增**,§5.10) | `RecBufferSubData`(块粒度) | +| `MipmapStorage::IsStorageDirty(target,level)`, `GetContentVersion()` | `RecTexAllocLevel` / `RecTexSubImage`(union box 或 ≤96 rects,变长) | +| `glGenerateMipmap` 前的 level 分配(**新增**) | `RecGenerateMipmapLevels{handle, target, requiredLevelCount, bytesPerTexel}` | +| `ITextureObject::GetTextureParamsVersion()` | `RecTexParam` | +| `ITextureObject::GetViewStorageOwner()` + view 字段 | `RecTexView`(**必须先于 owner 的任何 re-mint 顺序到达**) | +| `SamplerObject::GetVersion()` | `RecSamplerParam` | +| `RenderbufferObject::GetVersion()`(**P0 新增**) | `RecRenderbufferStorage{handle, internalFormat, w, h, samples}` | +| `VertexArrayObject::GetConfigVersion()` + 每 attrib Switch/Format/Buffer 版本 | `RecVaoConfig`(变长,整份配置;P6 再做逐属性 diff) | +| index buffer slot version **+ 指针身份** | `RecVaoIndexBuffer` | +| `FramebufferObject::GetObjectVersion()` + attachment 版本 | `RecFboAttach` / `RecFboDrawBuffers` / `RecFboReadBuffer` | +| `RenderState::m_version` / `m_pipelineStateVersion` | `RecRenderStateBlob`(整个 trivially-copyable `RenderStateParameters`,`RenderState.h:517-535`) | +| `ProgramObject::GetLinkVersion()` | `RecProgramLinkOp`(P1-4) → `RecProgramPublish`(P5+) | +| `ProgramObject::GetUBOContentVersion()` | `RecProgramUboContent` | +| block-binding / SSBO-override 版本 | `RecProgramBlockBinding` / `RecProgramSsboBinding` | +| `GetProgramForDraw()` 解析出 composite | `RecSetResolvedDrawProgram`(§5.7) | +| `GetTextureBindGeneration()` + unit slot 遍历 | `RecBindTexture` / `RecBindSampler` / `RecActiveTexture` | +| `GetTouchedBufferBindingPointCount()` 遍历 | `RecBindBuffer` / `RecBindBufferRange` | +| `GetImageTextureBinding(unit)` | `RecBindImageTexture` | +| pack `PixelStoreParameters` | `RecPixelStorePack` | +| XFB active 时的 draw(**新增**) | `RecXfbAccounting{pausedPrims, inputPrims, prims, capturedVerts, geomDraws, accountedDraws}` 增量 | +| `GLFunctionsTable` 命令 | `RecDraw*` / `RecClear*` / `RecBlit*` / `RecCopy*` / `RecDispatch*` / `RecXfb*` / `RecPresent` … | + +### 5.4 对象身份、创建/删除顺序 + +wire handle = `WireHandle { kind:u8, glName:u32, lifetimeId:u64 }`。`GetLifetimeId()` 永不复用(`BufferObject.h:208`、`FramebufferObject.h:158`、`ProgramObject.h:1620`、`VertexArrayObject.h:120`、`SamplerObject.h:141`、`TextureObject.h:83,161`)。 + +**`RenderbufferObject` 既没有 `GetLifetimeId()` 也没有 `GetVersion()`(已在 `MG_State/GLState/RenderbufferState/RenderbufferObject.h` 上确认为零命中)—— Phase 0 两个都补上**,`GetVersion()` 取 `SamplerObject::GetVersion` 的同款形状(`Uint16`,每次 `RenderbufferStorage*` bump),并在 §5.1 步骤②-6 的 per-attachment 遍历里读它。理由:`BackendRenderbufferObject::SyncToBackend`(`Managers.cpp:~8620-8700`)缓存 `{internalFormat,width,height,samples}`,而对一个**已 attach 的** renderbuffer 重新 `glRenderbufferStorageMultisample` 不必然 bump `GetAllFramebufferAttachmentVersions()`,没有 `GetVersion()` 就没有触发器。 + +replica 使用**与 client 相同的 GL name**:applier 直接 `ctx.CreateBufferObject(name)`,绕过 server 自己的 `IndexGenerator`。server 侧维护 `ska::flat_hash_map<(kind,name), {SharedPtr, clientLifetimeId}>`。 + +**若某次 create 的 `lifetimeId` 与记录不符 → `Fatal{IdentityDivergence}`,不做"先销毁再创建"的修复。** 上一版的"先销毁"是错的:replica 上那个对象可能仍被 FBO attachment、binding slot、texture view(`GetViewStorageOwner`)或 XFB capture target 通过 `SharedPtr` 合法持有,GL 保证它活到最后一个引用消失;强行销毁要么留下悬挂引用要么静默 detach,把一个协议 bug 变成一个会被归咎于 backend 的渲染 bug。协议正确时这个分支不可达,所以响亮地停下来严格优于静默的破坏性修复(`MOBILEGL_IPC_RESPAWN=1` 时改为强制 `ResyncSnapshot`)。 + +这就是 packed_pixels 的教训(身份 + 计数器,绝不单靠计数器)在协议层的应用,也是本设计对 name 空间漂移的**结构性预防**(而非事后 checksum 检测)。 + +创建/删除在 `glGen*`/`glDelete*` 时刻**立即**发射,顺序即 ring 顺序。client 的 `~BufferObject` 触发 emit-ops 的 `OnDestroy` 追加 `RecObjDelete`;server 的 replica `~BufferObject` 触发**真实**的 `Ops_OnDestroy`,完成 pooling / 延迟 `glDeleteBuffers`(`Managers.cpp:1271-1300`)——一行不改。 + +### 5.5 合并规则 + +1. **版本门控本身就是合并器。** 两个发射点之间的 N 次 mutation 折叠成一条 delta;改了又改回去的状态永不上线。 +2. **Buffer range** 在 per-buffer `VecRange1D` 里累积(复用 `MG_Util/Math/VectorTypes.h:264` 已调优的 7% span gap 合并),在该 buffer 的下一个发射点 flush。**绝不 union 成整个 buffer**——`Managers.cpp:860-864` 的 postmortem 记录了那样会每帧重拷近乎整个 chunk-mesh arena。 +3. **纹理区域**逐字沿用 `MipmapStorage::GetDirtyRects`,含 `summedArea*4 >= unionArea*3` 回退(`MipmapStorage.cpp:305`)。**注意代价轴是反的**:buffer 按字节计价,texture sub-image 按 **job 数**计价(`Managers.cpp:4311-4319`,~100 rects vs 一个 box 实测 +6ms/frame)。client 下发**区域形状**(union box 或 rect 列表,按 client 自己的 `GetDirtyRects` 判定),server 的 backend 从自己 replica 的 dirty 状态重新推导**上传形状**,让已调优的启发式留在付 GPU 代价的那一侧。 +4. `RecRenderStateBlob`、各类 bind:last-writer-wins,reconciler 只发**当前值**。 +5. **命令永不合并、永不重排。** + +### 5.6 backend→frontend 写:三种归属 + +| 写 | 归属 | +|---|---| +| `SetBackendResource`、`SetBackendHashMemo`/`StateMemo`/`AuxMemo` | **纯 server 本地**,零 wire 流量 | +| `MarkGpuWritten` ×3 + `EnsureGpuResidentStorage` | **client 保守自建**(§5.6b)。server 侧照常在 replica 上置位;`EvGpuWritten{handle, ranges[]}` 仅作为**收窄提示** | +| `MarkStorageDirty(…,false)` ×11 | **client 在发射后自己清**(§5.6a)。server 侧照常在 replica 上清 | +| `MarkStorageDirty(…,true)`(`Managers.cpp:2813` RequireImageBindableStorage 的 re-dirty) | 纯 server 本地:它是 server 的 re-mint 导致的,client 无从预测,也无需知道——重传由 server 自己在 replica 上完成 | +| `WritebackFromBackend`(PBO/XFB) | server 侧写 replica shadow;合并后的 range 变成 `EvBufferWriteback` 回传 client | +| `RecordError` ×2(`DirectGLES.cpp:6319`、`Managers.cpp:8679`)+ DirectVulkan 4 处 | **分两类**(§5.6c):分配类同步 ack,其余走 `EvGlError` 晚一批可见 | +| `AllocateStorage` 生成 mip(`DirectGLES.cpp:6270-6271,6861`)、`MirrorCopyImageIntoDestinationShadow`(`:7144`) | **per-level `serverAuthoritative` 位**(§6.6) | +| `InvalidateCompileEnv`、`SwapchainObject` 改写 default-FBO 占位纹理 | 事件 `EvCompileEnvInvalidate` / `EvDefaultFramebufferInfo` | + +#### 5.6a 纹理 dirty flag:client 必须清(推翻上一版) + +上一版写"client 的 dirty flag 从不被清,已发送状态存在 WireMirror 里"。这是错的: +- `MipmapStorage::MarkDirtyRegion`(`MipmapStorage.cpp:196-233`)只要 `m_isDirty[level]` 为真,就把 incoming **union 进** `m_dirtyRegions[level]` 并 `InsertDirtyRect`;只有 `MarkDirty(level,false)`(`:171-189`)重置两者。永不清 ⇒ box 单调增长、rect 列表撑满 `kMaxDirtyRects`、`GetDirtyRects` 一旦跨过 3/4 阈值就返回 0("用 box"),于是每次动画图集 tick 都传整个 level。 +- `ShipRecord` 只有三个 `Uint64` 版本字,**无法**从中重建区域。 +- `MarkDirtyRegion` 的 rect 播种分支(`:214-221`:`if (!m_isDirty[level]) rects.clear(); else if (rects.empty() && !region.Empty()) rects.push_back(region);`)本身就是为"有人会清"写的。 + +好消息是清是安全的:**MG_Impl 里没有任何 `IsStorageDirty(` / `GetStorageDirtyRects(` / `GetStorageDirtyRegion(` 调用点**(已 grep 确认为零),前端从不读自己的 dirty 状态;它自己也在五处主动清(`GL_Texture.cpp:528,701,5547,5621,5691`)。 + +**规则**:WireMirror 在追加纹理记录之后,立刻对该 (target, level) 调 `MarkStorageDirty(..., false)`。ack 问题按两条收口: +1. `ResyncSnapshot` 永远从**完好的 shadow** 传整 level(shadow 从不被丢弃,除非 buffer 被 adopt——纹理没有 adopt 路径),所以"清早了导致重传丢数据"在 resync 场景不成立。 +2. 硬 drain(§6.5)会 bump `ringGeneration`;drain 后 client 对**所有已发射但未 `appliedSeq` 覆盖的纹理记录**做一次重发(WireMirror 保留最近一批记录的 (handle, target, level) 列表 + emitSeq,drain 时把 seq > appliedSeq 的重新标脏并重发)。这是有界的,因为 ring 里最多只有 ring 容量那么多未 apply 的记录。 + +#### 5.6b `MarkGpuWritten`:client 保守自建(推翻上一版) + +monolith 里这个 flag 是在 draw 调用**内部同步**置位的:`MarkShaderStorageBuffersGpuWritten`(`DirectGLES.cpp:459-467`)走 `GetTouchedBufferBindingPointCount(ShaderStorage)` 并对每个绑定对象 `MarkGpuWritten()`,从 draw 路径的 `SyncNeccessaryBuffers` 调用(`DirectGLES.cpp:687,697`);atomic counter 在 `:509`;可写 image-buffer 纹理在 `:1809`;DirectVulkan 在 `UniformManager.cpp:1073,1229` 与 `VulkanRenderer.cpp:11210`。 + +拆分后 draw 是 fire-and-forget,所以 `glDispatchCompute(); glMapBufferRange(SSBO,...,GL_MAP_READ_BIT);` 会在 server 还没 apply 前就走完 `AcquireMemoryRange` → `SyncGpuWrites()`(`BufferObject.cpp:454`)→ `m_gpuWritePending` 为 false → 立即 return(`BufferObject.cpp:266`)→ 应用拿到陈旧 shadow,零 round trip、零报错。这会以"看起来像 flaky"的形式打掉 P4 计划里的 `SsboArrayLengthScenario`、`AtomicCounterScenario`、`StorageBufferRegrowScenario` 一整族。 + +**规则**:`PublishImplicitState`(§5.1 步骤①)在每个 draw/dispatch 发射点保守置位,输入与 `DirectGLES.cpp:459-467/509/1809` 完全一致(client 全都有)。同时把 `emitSeq` 记进 `m_gpuWritePendingSeq`。在任一读入口(`glMapBuffer*`、`glMapBufferRange`、`glGetBufferSubData`、`glGetNamedBufferSubData`、`glCopyBufferSubData` 的源、`FillSubData`):若该 buffer 在 pending 集合里 → `Publish()` → 等 `appliedSeq >= recordedSeq` → 排空 `SEG_EVENT` → 再读。`EvGpuWritten{handle, ranges[]}` 只用于**取消**该 pending 项或**收窄** readback 范围,晚到无害。 + +同时,§7.4 的事件排空点必须补上 `glMapBuffer` / `glMapBufferRange` / `glGetBufferSubData` / `glGetNamedBufferSubData`——上一版的排空点列表(`glGetError`、`glGetQueryObject*`、`glClientWaitSync`、`eglSwapBuffers`)不含它们。 + +#### 5.6c GL 错误:分配类同步 ack,其余晚到 + +上一版把所有 backend `RecordError` 一律走"晚一批"事件,只给 CTS lane 留 `MOBILEGL_IPC_STRICT_ERRORS`。这在**分配探测**这个通用惯用法上是错的:那两个站点(`Managers.cpp:8679` renderbuffer 存储、`DirectGLES.cpp:6319` 纹理操作)报的是 `GL_OUT_OF_MEMORY`,而应用的标准写法是 `glRenderbufferStorage(...); if (glGetError() == GL_OUT_OF_MEMORY) { 用更小的目标重试; }`。晚到 ⇒ 应用走成功分支 ⇒ 往一块 server 从未分配的存储上渲染。 + +**规则**:只把**分配类**入口点标 `kNeedsAck`——`glRenderbufferStorage` / `glRenderbufferStorageMultisample` / `glNamedRenderbufferStorage*`、`glTexImage*` / `glTexStorage*` / `glCopyTexImage*` 中 backend 可能失败的形式、`glBufferStorage`。它们本来就罕见且昂贵,ack 几乎免费,换来 OOM 探测精确。其余全部保持晚到。有了这个划分,`MOBILEGL_IPC_STRICT_ERRORS` 从"CTS 专用"降级为纯诊断开关(默认 0,出问题时用来判断某个失败是不是错误时序引起的)。 + +`glGetError` 本身永远本地(`GL_Getter.cpp:2811-2817`;`Core.cpp:48-49` 的 "GL error state is GL-thread-owned" 不变式)。 + +### 5.7 composite pipeline program + +`GLContext::GetProgramForDraw()`(`Core.cpp:612-660`)在 program-pipeline 路径下:join 每个 stage → `ComputeDrawProgramSignature()` → cache miss 时 **`MakeShared(0u)` 并 link 一个匿名 composite**(`Core.cpp:644`;注释明说"故意不是命名 program……不得占用应用可能拿到的 name"),随后 `RefreshCompositeUniforms`/`MirrorUniformValues` 每 draw 改它。 + +- **Phase 1-4(server relink)**:下发 pipeline 状态(`UseProgramStages` 等)+ 各 stage program 的 `RecProgramLinkOp`;server 的 replica 自己走同一路径构建自己的 composite。加一条 `RecResolvedProgramDigest{signature, reflectionDigest}` 让分歧当场暴露。 +- **Phase 5+(ProgramPublish)**:server 没有源码,**不得 link**。client 解析 composite,把它作为**保留高位 handle 的合成 program** 发布(`RecProgramPublish` + `RecSetResolvedDrawProgram{handle}`)。MG_State 加: +```cpp +// MobileGL/MG_State/GLState/Core.h (整段 #if MOBILEGL_BUILD_DISAGGREGATED 包裹,保证 monolith 字节不变) +void SetReplicaResolvedDrawProgram(SharedPtr); +void SetReplicaResolvedDispatchProgram(SharedPtr); +// GetProgramForDraw()/GetProgramForDispatch() 首行先查该槽位 +``` +server 因此**永不 link、永不 join compile pool**,`PrepareForDraw` 首条语句照常工作。 + +### 5.8 全量快照 / resync + +稳态**没有初始状态**:transport 在 `MG_Backend::Init()` 内建立,早于任何 GL 对象存在。 + +`ResyncSnapshot` 只服务三件事:**server 重启**、**backend context 丢失**(EGL surface 变更销毁整个原生 context 并 bump `g_backendContextGeneration`/`g_syncContextGeneration`,`DirectGLES.cpp:10664-10676`)、**硬 drain 后的纹理重发**(§5.6a)。实现 = 同一个 reconcile 遍历,关闭"已发送版本"门控。 + +**关键纪律:一个 applier、两个 producer**——快照发同样的记录种类,因而被同一套测试覆盖。`Feat/CS-Delta-IPC` 的结构性错误正是有一个与生产路径零共享代码的平行 applier(`StateEmitter.h:312-501` vs `ServerCore.cpp:389-401`)。 + +**P7 之后的限制**:adopted store 的字节住在 server,client 无法重建它们。因此 `MOBILEGL_IPC_RESPAWN=1` 与 `MOBILEGL_IPC_ADOPT_TIER != 2` 互斥:要么关采纳换可 resync,要么开采纳并接受 server 死亡 = context lost(不重启)。这条互斥必须在 `ConfigLoader` 里显式检查并 `MGLOG_W`。 + +### 5.9 覆盖度的**编译期**保证 + +#### 5.9a READ 面(backend 读了什么) + +1. `scripts/gen_backend_state_surface.py` 扫描 `MG_Backend/**`,抽出 `pGLContext->X` 与前端对象 getter,生成 `MG_Remote/Protocol/generated/BackendStateSurface.inc`(**已提交**)。相对 `Feat/CS-Delta-IPC` 的 `extract_backend_read_inventory.py`:**删掉 `GetBuffer*`/`GetTexture*`/`GetProgram*`/`GetVertex*` 前缀兜底规则**(`:234-241`,它把"0 UNMAPPED"制造出来),未知 accessor 一律 `UNMAPPED`。同时把"真 pull point"与"signature handle 化"分开统计(那 167 个 "handle-ify" 里含 `BackendObject.h:158-186` 的**声明**和 `DirectGLES.cpp:55` 的静态全局)。 +2. 手维护 `MG_Remote/Protocol/Coverage.def`:`accessor → 记录种类 | MGL_COVER_LOCAL | MGL_COVER_NA(理由字符串)`。 +3. `MG_Remote/Client/CoverageAssert.cpp` 同时 include 两者,未映射 accessor → `#error`。 + +#### 5.9b MUTATOR 面(MG_Impl 在 table 调用旁改了什么)—— **本轮新增,是 §2(g) 的门** + +1. `scripts/gen_impl_mutation_surface.py` 扫描 `MG_Impl/**`:找出**同时**包含 `gBackendFunctionsTable.GL.*` 或 `pActiveBackendObject->` 调用**和** `pGLContext->` mutator 调用(写方法:`Add*`/`Set*`/`Mark*`/`Bump*`/`Allocate*`/`Truncate*`/`Record*`/`Notify*`/`Begin*`/`End*`)的函数,把每个 mutator 站点写进 `MG_Remote/Protocol/generated/ImplMutationSurface.inc`(**已提交**)。为避免误报,脚本对每个函数做一次简单的调用图一层展开(`EnsureGeneratedMipmapStorageAllocated` 这种 helper 会被计入调用它的 `GenerateMipmap`)。 +2. 手维护 `MG_Remote/Protocol/MutationCoverage.def`:`函数::mutator → MGL_MUT_REPLAYED_BY(记录种类) | MGL_MUT_SHARED_HELPER(helper 名) | MGL_MUT_CLIENT_ONLY(理由) | MGL_MUT_NA(理由)`。 +3. 同一个 `CoverageAssert.cpp` 展开两张表,未映射站点 → `#error`。 + +已知必须在第一轮映射的条目(不是穷举,是脚本首次运行时保证不为空的锚点): +- `GenerateMipmap` / `GenerateTextureMipmap` / `MaybeAutoGenerateMipmap` → `EnsureGeneratedMipmapStorageAllocated` 的 `AllocateStorage` / `MarkStorageDirty(false)` / `TruncateMipmapLevels` / `BumpContentVersion` ⇒ `MGL_MUT_REPLAYED_BY(RecGenerateMipmapLevels)`。applier 收到该记录后调**同一个共享 helper**(把 `EnsureGeneratedMipmapStorageAllocated` 抽到 `MG_Remote::Shared::` 或让 applier 直接调 `MG_Impl::GLImpl::TextureImpl::` 里那个已存在的函数——server 链接完整 MG_Impl,这是可行且最省的做法)。 +- `DrawArrays`/`DrawElements`/… 的 `AccountTransformFeedbackPrimitives` 六个计数器 ⇒ `MGL_MUT_REPLAYED_BY(RecXfbAccounting)`(applier 把六个增量加到 replica 的对应计数器上;必须跟着 `RecBindTransformFeedback` 的对象切换走,因为它们按 XFB 对象存取,`Core.cpp:1273,1296`)。 +- `glCopyTexSubImage*` 里 `CopyReadFramebufferIntoMipmapRegion` 的 `MarkStorageDirty(...,true)`(`GL_Texture.cpp:1095`)⇒ `MGL_MUT_CLIENT_ONLY`(该函数整体留在 client,见 §6.6)。 +- `glClearTexImage` 的 `MarkStorageDirty(...,true)`(`GL_Texture.cpp:1005`)⇒ `MGL_MUT_CLIENT_ONLY`(同上)。 +- `GL_Query.cpp` 的 conditional-render 布尔与查询结果缓存 ⇒ `MGL_MUT_CLIENT_ONLY`。 + +CI:两个生成器都重新生成 + `git diff --exit-code`。 + +**backend 长出一个 reconciler 走不到的 read,或 MG_Impl 长出一个 applier 没 replay 的 mutation → 编译失败,而不是设备回归。** + +### 5.10 persistent map:client 侧的推送(本轮新增的独立小节) + +**问题**(已在仓库确认):`BufferObject::SyncPersistentMappedRange()`(`BufferObject.cpp:238-250`)依次早退于 GPU-resident、非 Persistent、非 Write、FlushExplicit、空 range,剩下的情况(**persistent + write + coherent + shadow-backed**)走 `NotifySubData(整个 mapped range)`。它的全部生产调用点都在 `MG_Backend/` 里(19 处,见 §0)。P1-P6 默认关采纳(§6.8 T2),`AcquireMemoryRange`(`BufferObject.cpp:459-475`)于是回退到 shadow 并把 `m_resource.Bytes() + range.start` 交给应用——应用之后**不再调任何 GL 函数**就直接写。拆分后:client 没人推,server 的 replica `m_isMapped==false` 第一行就 return。字节丢失。 + +另外,`IsBufferDrawClean` 里 `if (frontend->IsMapped()) return false;`(`Managers.cpp:1447`,注释:"A live non-zero-copy map may owe a per-draw SyncPersistentMappedRange push")也依赖 map 位,replica 上恒 false 会把这个 buffer 判成 clean 而跳过整个同步。 + +**解法三件套**: + +1. **map/unmap 上线**:`RecBufferMap{handle, rangeStart, rangeEnd, accessFlags}` 与 `RecBufferUnmap{handle}`,从 `glMapBuffer`/`glMapBufferRange`/`glUnmapBuffer`/`glFlushMappedBufferRange` 的 MG_Impl 入口发射(emit-ops 的 `FlushMappedRange` 已覆盖最后一个)。replica 的 `m_isMapped`/`m_mappedRange`/`m_mappingAccess` 于是与 client 一致,`IsMapped()` 门和 server 侧的 `SyncPersistentMappedRange` 都恢复 monolith 行为。 + +2. **client 侧脏块推送**:WireMirror 维护 `m_livePersistentMaps`(只装 persistent+write+非-FlushExplicit+非-GpuResident 的 buffer,进出由 `OnBufferMapped`/`OnBufferUnmapped` 维护)。`PublishImplicitState` 对**本次操作可达的**每个这类 buffer(VAO attribute buffer、index buffer、indirect/parameter buffer、UBO/SSBO/atomic binding point、XFB capture target——即 backend 那 19 个调用点的并集)做**块粒度**发送:把 mapped span 切成 64KiB 块,只发自上次发送以来被改过的块。 + + "被改过"的判定:P1-4 用**保守版**(每个发射点把该 buffer 的整个 mapped span 当脏,但按块拆成多条 `RecBufferSubData`,让 §6.5 的 range 合并与 ring 复用机制生效);P4.5 shadow-in-shm 落地后升级为**精确版**(shadow 住在 client 拥有的 `SEG_SHADOW` 里,用与 WAR 水位同一套 64KiB 块脏位跟踪;块脏位由 `SyncPersistentMappedRange` 的调用点触发一次 `memcmp` 或由 mprotect 写屏障提供——先做 `memcmp`,它对 1MB 块是 ~50µs 量级,且只在真正 mapped 的 buffer 上跑)。 + + **这是 §6.4 拷贝表里上一版完全没有的一行**,且在 P1-4 的保守版下代价可观(一个持久映射的 chunk arena 会在每个可达发射点重传整个 mapped span)。所以:`MOBILEGL_IPC_PERSISTENT_BLOCK_KB`(默认 64)可调,且**P1 验收必须记录这条路径的字节量**(Tracy 计数器分类为 `persistent-map-push`)。若 P1-4 的保守版在 Create/Flywheel fixture 上不可接受,把 P4.5 的精确版提前到 P2(这是计划里唯一一个允许因测量结果而改变阶段顺序的地方)。 + +3. **P1 就要有门**:新增 `PersistentCoherentMapScenario`(map PERSISTENT|WRITE|COHERENT、写、不做任何其它 GL 调用、draw、readback 校验),列为 P1 验收项。**今天计划里没有任何门能抓到这个 bug。** + +**与 `MOBILEGL_COHERENT_AS_FLUSH` 的关系**:该开关(`GL_Buffer.cpp:297-305`,默认 false,`Config.h:174` / `ConfigLoader.cpp:185`)把应用请求的 persistent+FLUSH_EXPLICIT 改写成 coherent,从而**制造**上面这个情形。上一版禁止它在拆分模式下生效——但那只处理了"我们自己改写出来的 coherent map",没处理"应用自己就请求 coherent"。有了上面的三件套,两种来源都被覆盖,所以**禁令改为可选**:`MOBILEGL_COHERENT_AS_FLUSH` 在拆分模式下**照常生效**,这样 `tools/trace_replay/trace_cases.json` 里那两个带 `coherent_as_flush: true` 的用例(`minecraft-1.21.1-neoforge-create-indirect-in-world`、`minecraft-1.21.1-neoforge-create-instancing-in-world`)在 split 与 monolith 下走同一条 buffer 路径,P2 的逐名对比才有意义。若 P2 测出保守推送在这两个 fixture 上代价过高,改为"这两个用例在 split 模式下同时关掉该开关,并在报告里标注",而不是让两侧走不同路径还宣称对比通过。 + +--- + +## 6. 数据面 + +### 6.1 段(segment)布局 + +| 段 | 拥有者 | 默认大小 | 内容 | +|---|---|---|---| +| `SEG_CMD` | client(server 只读) | 8 MiB,2 的幂,64B 对齐 | `RingControl`(4KiB) + POD 记录 + ≤4KiB 内联负载 | +| `SEG_STAGE` | client(server 只读) | 32 MiB → 上限由实测定,**不是默认 256 MiB** | bulk 字节:buffer sub-data、纹理区域、UBO scratch、client 顶点/索引/indirect 数组、persistent-map 脏块 | +| `SEG_REPLY` | **server**(client 只读) | 8 MiB,4KiB slot | readback 像素、buffer writeback | +| `SEG_EVENT` | **server**(client 只读) | 256 KiB SPSC ring | `EvQueryResult`/`EvGpuWritten`/`EvGlError`/`EvLogLine`/`EvDefaultFramebufferInfo`… | +| `SEG_SHADOW[n]` | client(server 只读) | 每对象,P4.5+,≥256KiB shadow | 零拷贝 buffer/texture shadow | +| `SEG_ADOPT[n]` | **server**(client RW) | 每 buffer,P7,≥16MiB adopted store | 应用直写 GPU 内存 | + +创建:Android `ASharedMemory_create`(API 26,`android/sharedmem.h:78`;libc 的 `memfd_create` wrapper 是 API 30,`sys/mman.h:196`);桌面 Linux `syscall(SYS_memfd_create, …)`;macOS `shm_open`+`shm_unlink`;Windows `CreateFileMappingW`(`Local\`)。 + +**传递:POSIX `SCM_RIGHTS`,在第一个 transport commit 里实现**(asio 无 cmsg API → 在 `socket.native_handle()` 上裸 `sendmsg`/`recvmsg`,约 80 行)。`Feat/CS-Delta-IPC` 把它推迟到"P6"(`LocalSocketTransport.h:16-20`,`PollOffer` 里 `out->fd = -1` 硬编码于 `:296`),结果它的数据面在唯一重要的平台上**一个字节都过不去**。 + +**SEG_SHADOW 块的退休规则(本轮新增)**:§6.4 的 64KiB 块发送水位只解决"覆盖一个**活着的** shadow";它没说怎么**释放**一个 shadow。`glDeleteBuffers` 或 `glBufferData` 重定义会释放/重分配 `SEG_SHADOW` 的 arena 块,而携带 `{segId, offset, size}` 指向该块的记录可能还没被 apply——server 于是读到另一个对象的字节。规则:释放的块进入 pending 链表,只有当 `appliedSeq`(对被借入 GPU 时间线的 slot 是 `retiredSeq`)越过最后一条引用它的记录之后才归还 arena,而不是在对象析构时立即归还。 + +### 6.2 RingControl:watermark 是一条共享 cache line,**且带双向 doorbell** + +```cpp +// MobileGL/MG_Remote/Transport/Ring.h +struct alignas(4096) RingControl { + // ---- SEG_CMD 游标 ---- + alignas(64) std::atomic cmdHead; // producer:累计写入字节 + alignas(64) std::atomic cmdAppliedTail; // consumer:已解码并拷出的字节 + std::atomic cmdRetiredTail; // consumer:被借入 GPU 时间线的 slot 已释放 + // ---- SEG_STAGE 游标(独立三元组;上一版遗漏)---- + alignas(64) std::atomic stageHead; + alignas(64) std::atomic stageAppliedTail; + std::atomic stageRetiredTail; + // ---- 序号 / 帧水位 ---- + alignas(64) std::atomic appliedSeq; // 已 apply 的记录序号 + std::atomic submittedSeq; // 已提交给驱动 + std::atomic retiredSeq; // GPU 已完成 + std::atomic completedFrameSerial; + std::atomic presentAckSerial; + // ---- doorbell / 代 ---- + alignas(64) std::atomic serverEpoch; // context 丢失 / server 重启时 ++ + std::atomic ringGeneration; // 硬 drain 后 ++,作废缓存 offset + std::atomic consumerParked; // server 睡了,producer 要敲门 + std::atomic producerParked; // client 睡了,server 要敲门(本轮新增) + std::atomic eventRingFull; // SEG_EVENT 满,server 已停止 apply + std::atomic eventDropped; // 被丢弃的 EvLogLine 计数 +}; +``` + +**三个 seq 水位严格区分**(混为一谈是经典错误):`appliedSeq` 释放 `cmdAppliedTail`/`stageAppliedTail`;`submittedSeq` 释放 staging;`retiredSeq`/`completedFrameSerial` 释放 `*RetiredTail` 与 `SEG_ADOPT` 复用。 + +**两个 tail 是必须的**:`Ops_ResidentSubData` 把字节拷进 `pendingResidentWrites`(`Managers.cpp:1158-1166`),P7 之后 server 会**借用** ring slot 而不是再拷一次——那种 slot 只能在 `completedFrameSerial` 之后回收。单 tail 会在 P7 落地当天变成保守回收。 + +**SEG_STAGE 必须有自己的游标三元组**:§7.2 把"`SEG_STAGE` 余量 < 1/4"列为 Publish 触发器,而第二个 ring 的占用率无法从第一个 ring 的游标算出;且 stage slot 的退休条件(`retiredSeq`)与 cmd 记录(`appliedSeq`)不同。 + +#### 6.2a 双向 doorbell(本轮新增,修 "client 只能自旋" 的缺陷) + +- **client → server**:consumer 自旋 ~200µs → 置 `consumerParked=1` → 在控制 socket 上阻塞读 1 字节;producer 在 release-store `cmdHead` 之后,仅当 `consumerParked` 时写 1 字节(字节码 `0x01 = 'ring advanced'`)。 +- **server → client**(上一版缺失):client 在**任何**等待里(present credit、`kNeedsAck` 阻塞请求、ring/stage 满的升级等待)先自旋 `MOBILEGL_IPC_SPIN_US`(默认 50µs),再置 `producerParked=1`,然后在同一个 socket 的反向流上阻塞读;server 在 release-store 任何 watermark 之后,仅当 `producerParked` 时写 1 字节(字节码 `0x02 = 'watermark advanced'`)。 + +没有这一条,上一版的每一处 client 等待都退化成跨进程自旋一条共享 cache line:present-credit 等待最长一整帧(60Hz 下 16.6ms),在手机上就是一颗大核满频空转,与 GPU 和游戏 JVM 抢核;§6.5 的"有界 50ms 等待"就是 50ms 自旋。而 MobileGL 全库没有任何亲和性控制(`grep -rn 'sched_setaffinity\|cpu_set_t' MobileGL/` 零命中),无法把它赶到小核上。 + +`spawn` 模式用 socketpair 的两个方向做 doorbell;`inproc` 模式用一对 `std::condition_variable`(同一套 `producerParked`/`consumerParked` 语义)。**零 futex/eventfd/named-event 平台代码**(asio 已 vendored,`3rdparty/asio/include` 已在主 target 的 include path 上,`CMakeLists.txt:483`)。 + +### 6.3 记录格式 + +```cpp +// MobileGL/MG_Remote/Protocol/RecordKinds.h +struct RecHeader { Uint16 kind; Uint16 flags; Uint32 size; }; // 8 B,size 含 header,8 字节倍数 +enum RecFlags : Uint16 { kNone=0, kNeedsAck=1<<0, kHasBlob=1<<1, kPad=1<<2, kBorrowSlot=1<<3, kVarTail=1<<4 }; +struct BlobRef { Uint32 seg; Uint32 pad; Uint64 offset; Uint64 size; }; // 24 B +``` +**没有 per-record 序号字段**:seq 就是记录序数(producer `m_emitSeq++`,consumer `m_applySeq++`),省 8B/记录并消除一整类失步。 + +X-macro 单一真相源: +```cpp +// MobileGL/MG_Remote/Protocol/Records.def +#define MGL_REC_LIST(X) \ + X(BindBuffer, RecBindBuffer, 24) \ + X(DrawArrays, RecDrawArrays, 32) \ + X(DrawElements, RecDrawElements, 56) \ + X(BufferSubData, RecBufferSubData, 64) \ + X(BufferMap, RecBufferMap, 40) \ + X(BufferUnmap, RecBufferUnmap, 24) \ + X(RenderStateBlob, RecRenderStateBlob, 40) \ + X(XfbAccounting, RecXfbAccounting, 56) \ + X(GenerateMipmapLevels, RecGenerateMipmapLevels, 32) \ + X(RenderbufferStorage, RecRenderbufferStorage, 40) \ + /* … ~95 项 … */ +#define MGL_REC_SIZE_CHECK(name, T, sz) \ + static_assert(sizeof(MobileGL::Wire::T) == (sz), #name " record size drift"); +MGL_REC_LIST(MGL_REC_SIZE_CHECK) +``` +**每种一条 `static_assert`** ——修掉正是 `Feat/CS-Delta-IPC` 中过一次的 bug 类(`b50f3348`:"旧的 off-by-one 让 applier 误读 TexImage 之后的每一条 state delta"),而它那条只断言 union 首成员的 assert(`ServerCore.cpp:31-33`)永远抓不到中间插入。 + +**运行期边界纪律(本轮新增)**:`SEG_CMD` 是对端并发写入的区域,编译期 `static_assert` 管不到运行期损坏。同一个 X-macro 额外生成 applier 分发前的前置条件: +```cpp +#define MGL_REC_BOUNDS_CHECK(name, T, sz) \ + case RecKind::name: \ + if (h.size < (sz) || h.size > remainingRingBytes || (h.size & 7u)) \ + return Fatal(FatalCode::ProtocolCorruption, #name); \ + break; +``` +`kVarTail` 记录额外校验 `定长前缀 + 尾巴自描述长度 == h.size`。违反一律 `Fatal{ProtocolCorruption}`,绝不进入未定义行为。 + +变长记录(`RecVaoConfig`、`RecTexSubImage` 的 rect 列表、`RecProgramLinkOp`、`RecMultiDrawArgs`):`kVarTail` + 定长前缀 + 自描述长度的内联尾巴。 + +### 6.4 WAR 危害与字节稳定性 + +**Phase 1-4 规则:GL 调用时刻把字节拷进 ring slot。** slot 从写入到 `stageAppliedTail` 越过它为止不可变,client 拿不回它 → **危害按构造消除**。代价是一次 memcpy,而 `Ops_ResidentSubData`(`Managers.cpp:1165`)和 `StageBlocksIntoUnpackRing` 在 monolith 里已经在付同样的钱。 + +**Phase 4.5 规则(shadow-in-shm,零拷贝):** ≥256KiB 的 shadow 分配在 client 拥有的 `SEG_SHADOW` 里——`PipeResource` 的 `MapAlignedAllocator`(`PipeResource.h:33-60`,无状态、25 行、64B 对齐)增加一个 shm arena(保留 `MIN_MAP_BUFFER_ALIGNMENT=64` 契约,`PipeResource.h:28`),`MipmapStorage` 的 level vector 同理。`RecBufferSubData` 于是只带 `{segId, offset, size}`,**client 侧零拷贝**。 +WAR 用 **per-shadow 64KiB 块发送水位**:若应用写入某块而该块最后一次发送尚未 `appliedSeq` 覆盖,这次写走 `SEG_STAGE`。有界、局部、压力下自动退化成 Phase-1 行为。这套块水位同时是 §5.10 精确版 persistent-map 推送的脏位来源。 + +**该改动必须整段 `#if MOBILEGL_BUILD_DISAGGREGATED` 包裹**:`PipeResource` 与 `MipmapStorage` 住在 `MG_State`,不在 `MG_Remote`,而改一个容器的 allocator 就改了类型;不包裹的话 §12/D8 的 `nm`/`.text` 门会在 P4.5 变红。写法是"分配器特化:option OFF 时逐字折叠成今天的 `MapAlignedAllocator`"。 + +#### 拷贝账(更正版,MC pan 一帧约 9MB section mesh + ~1MB UBO scratch) + +上一版这张表把 monolith 和 split 两侧都数少了。逐条核对: + +- monolith 的 `glBufferSubData` → shadow store 是 **2 次**:(1) app→shadow(`BufferObject::UploadSubData` 的 `Memcpy`),(2) shadow→目的地(`FlushPendingRangesNow`:`Memcpy(dst, bufferObject.MappedData()+start, size)` 进 invalidating map,`Managers.cpp:914`;或 `Memcpy(g_uploadRing.store.mappedPtr+ringOffset, ..., size)` 进 upload ring,`Managers.cpp:922`)。 +- split P1-4 是 **4 次**:app→client shadow (1)、client shadow→`SEG_STAGE` (2)、applier replay mutator ⇒ `SEG_STAGE`→**replica** shadow (3)、server 的 `FlushPendingRangesNow` ⇒ replica shadow→upload ring (4)。 +- P4.5 只去掉 (2),剩 **3 次**。它去不掉 (3),因为 `SEG_SHADOW` 是 client 拥有 / server 只读,而 replica 的 `BufferObject` 拥有自己的 `PipeResource` 分配。 + +| 路径 | monolith | P1-4 | P4.5 | P4.5+replica-adopt(可选,见下) | +|---|---|---|---|---| +| `glBufferSubData` → shadow store | 2 | 4 | 3 | **2** | +| `glBufferSubData` → adopted store(P7) | 2 | — | — | 2 | +| `glMapBufferRange(WRITE)`+unmap | 3 | 5 | 4 | 3 | +| persistent coherent map 推送(§5.10 保守版) | 0 | 2/发射点 | 1/发射点(精确块) | 1/发射点 | +| `glTexSubImage` | 2 | 3 | 2 | 2 | +| 全局 UBO / draw | 1 | 2 | 2 | 1 | +| adopted ≥16MiB(P7 T1/T0) | 0 | — | — | 0 | + +**目标选择(必须在 P4.5 之前拍板)**: +- **方案 A(默认,保守)**:接受 3 次,写进文档。P4.5 的价值是消掉 client 侧那次拷贝与那份重复内存。 +- **方案 B(激进,需额外设计)**:给 replica 的 `PipeResource` 增加**第三种模式** `AdoptedClientShadow`——`Bytes()` 返回 server 映射的 client `SEG_SHADOW`(只读),applier 的 `UploadSubData` 退化成一次 range 记账 + change-serial bump,只剩 server 的 ring 拷贝。这保持了 mutator replay 的全部副作用(包括 `IsBufferDrawClean` 比较的 change serial),只是不搬字节。风险:replica 的 shadow 变成只读会让任何 server 侧写(`WritebackFromBackend`、生成 mip、CopyImage 镜像)需要就地 copy-on-write 升级回普通 shadow。**先按方案 A 实现并测量,方案 B 作为 P6 的候选优化项,由 Tracy 计数器决定是否值得。** + +无论选哪个,`TracyPlot` 字节计数器必须**装在 wire 两侧**(client 的 emit 字节 + server 的 apply 字节 + server 的 ring/staging 字节),P4.5 的验收看**总量**,不是只看 client 一侧的数字。 + +### 6.5 Ring 分配与背压 + +逐字移植 `PersistentRing`(`Managers.cpp:657-727`、`RingAllocateSlow` `:1891-1970`、`RingOnPresent` `:1975-2016`):单调 head/tail、2 的幂掩码、frame mark。分配失败升级:**扩容(翻倍) → 对最老未 retire 批次有界等待(默认 50ms,走 §6.2a 的 producerParked doorbell,不是自旋) → 硬 `Drain` 请求 + `ringGeneration` bump**。generation bump 上线,防止后续记录引用被回收的 offset;硬 drain 之后按 §5.6a 重发未 apply 的纹理记录。 + +`SEG_CMD` 与 `SEG_STAGE` 各自独立跑这套升级(各有自己的游标三元组)。 + +### 6.6 纹理 + +- **Unpack PBO 完全在 client 解析**(`GL_Texture.cpp:1719,1765,1887,1976,2457,2604,2722,4458,6176` 读 `pixelUnpackBufferObject->MappedData() + (SizeT)pixels`,再由 `ProcessTexturePixelsDataUnpack` 紧密重排)。**没有任何纹理像素以 PBO 引用形式过线,server 永远不需要 `GL_PIXEL_UNPACK_BUFFER` 状态。`PixelStoreBlob` 只用于 PACK 方向。** +- **压缩纹理永不到达任何 backend**(前端在 `glTexImage` 时把压缩 internalformat 解析成非压缩后备,`GL_Texture.cpp:298-306`;`grep -i compress MG_Backend/DirectGLES/*.cpp` 只命中一条注释)。逐字节 `m_compressedData` blob 仅供 `glGetCompressedTexImage`,纯 client 侧,不过线。 +- **`glCopyTexSubImage*` 与 `glClearTexImage` 整体留在 client(推翻上一版的 P4 项)。** 已确认这两个入口今天就是**纯前端操作**:`CopyTexSubImage{1,2,3}D_State`(`GL_Texture.cpp:3955,3979`)调 `CopyReadFramebufferIntoMipmapRegion`(`:1044-1097`),它借一次 backend `ReadPixels` 进 CPU scratch(`:1079`)、逐行 memcpy 进 mipmap shadow(`:1089-1094`)、`MarkStorageDirty(...,true)`(`:1095`)。拆分后它恰好是**一次阻塞 ReadPixels round trip**,产生的脏区按普通纹理 delta 下发——正确,且不需要任何新命令。上一版提议"整体移到 server + `EvTexWriteback`"是错的:那个事件在 §7.4 的列表里根本不存在(只有 `EvBufferWriteback`),它仍然要付一次 round trip(client shadow 必须为 `glGetTexImage` 保持最新),还多出一个 `GLFunctionsTable` 里没有对应项的命令。`glClearTexImage`(`GL_Texture.cpp:985-1006`)同形。 +- **per-level `serverAuthoritative` 位**只保留给两处**字节确实在 backend 里写进 shadow** 的场景:生成 mip 的 CPU 路径(`DirectGLES.cpp:6270-6271,6861` 的 `AllocateStorage` + 直写 `MapMipmapData`)与 `MirrorCopyImageIntoDestinationShadow`(`:7144`,`glCopyImageSubData` 的目的地镜像)。client 在发射对应命令时对受影响 level 置位。`CopyTextureImageToClientOrPBO_State` 查它:**清 → 本地 shadow 回答,零 round trip**(应用自己上传的 level 全走这条);**置 → 一次 round trip**。 + +### 6.7 回读 + +| 路径 | monolith | 拆分后 | +|---|---|---| +| `glReadPixels` → 客户内存 | 阻塞 | 一次 round trip,像素放 `SEG_REPLY` slot;per-row 循环留在 server 内 | +| `glReadPixels` → pack PBO | **也阻塞**(`DirectGLES.cpp:9189-9205` 把整个 PBO map 回来写 shadow) | **fire-and-forget** + client 侧对该 PBO 置 `MarkGpuWritten`(§5.6b),代价推迟到之后的 map/read。**严格优于 monolith** | +| `glGetTexImage`/`glGetTextureImage` | DirectGLES 从 client shadow 回答 | DirectGLES **零 round trip**(除 `serverAuthoritative` level);DirectVulkan 一次 | +| `glGetBufferSubData` / `glMapBuffer(READ)` on gpuWritePending | 阻塞(`glFinish()`,`Managers.cpp:1246`) | 一次,由 client 侧 pending 集合触发(§5.6b),被 `EvGpuWritten{ranges}` 收窄 | +| XFB capture writeback | `glEndTransformFeedback` 里无条件无限 `ClientWaitSync`(`GL_Drawing.cpp:1326-1337`) | **不等**,client 对 capture target 置 `MarkGpuWritten`,首次读时付;`FixupGsStripCaptureOrder` 移到 server | +| `glCopyTexSubImage*` | 内含一次同步 ReadPixels | 一次 round trip(保持前端实现不变) | + +### 6.8 persistent map 与 ≥16MiB 采纳 + +三档,由**运行时 POST 探针**选择(遵循本项目"后端限制一律探针判定、绝不硬编码驱动名"的既定规则): + +- **T2 — 拒绝(P1-6 默认,永久正确回退)**:`AcquirePersistentMap` 返回 `nullptr`。**此档下 §5.10 的 client 侧推送是强制的**,否则应用的 coherent persistent 写会丢。 +- **T1 — server 导出自己的映射(P7 主攻)**:server 照常铸造 coherent map(`Managers.cpp:988-1058` / `VkBufferManager.cpp:515-563`),经 `VK_KHR_external_memory_fd` / `AHardwareBuffer_sendHandleToUnixSocket`(API 26,`hardware_buffer.h:521`)/ `VK_KHR_external_memory_win32` / `GL_EXT_memory_object_fd` 导出,client `mmap` 后调 `PipeResource::AdoptPersistentMap(base)`。**每 store 生命周期一次 round trip。** 采纳成功后 §5.10 的推送对该 buffer 自动停止(`SyncPersistentMappedRange` 的 `IsGpuResident()` 早退),与 monolith 一致。 +- **T0 — server 导入 client 分配**:client 分配 `AHardwareBuffer`/dma-buf,server 以 `GL_EXT_external_buffer`+`glBufferStorageExternalEXT` 或 `VK_EXT_external_memory_host` 导入。理想但可用性未知。 + +**`MOBILEGL_COHERENT_AS_FLUSH` 在拆分模式下照常生效**(推翻上一版的禁令,理由见 §5.10 结尾):有了 client 侧推送,被改写出来的 coherent map 与应用原生请求的 coherent map 走同一条正确路径,两个 Create/Flywheel fixture 才能在 split 与 monolith 下做同路径对比。 + +### 6.9 program artifacts + +- **P1-4**:`RecProgramLinkOp{handle, shaderSources[], bindAttribLocations[], bindFragDataLocations[], xfbVaryings[], xfbMode, separable, reflectionDigest}` — server 重新 link。只需 5 个 schema 字段,**且分歧不可能静默**(两半跑同一二进制里的同一段代码)。源码可得:`ProgramObject::GetLinkedShaderSnapshot()`(`ProgramObject.h:157`)刻意持有 linked shader 的 `SharedPtr`(注释在 `:1716`),所以 `glDeleteShader` 之后源码仍在。 +- **`reflectionDigest` 必须覆盖 backend 实际读的全集**:xxHash over + `(uniformName, location, type, typeFacts, samplerOrImageUnitIndex)` 全表 + `maxUniformLocation` + `(blockName, blockBinding, blockSize)` 全表 + `shaderStorageBlockBindingOverrides` + `PointSizeDemoted` + `GetLinkedShaderStages` + `xfbVaryings/xfbStrides/xfbPackedStride/xfbBufferMode` + **`GetGeneratedSpirv()` 各 module 的 xxHash**。不匹配 → `Fatal{ReflectionDivergence}`。 + (理由:本项目自己的二分历史记录过"glslang 反射/生成顺序是真载重,桌面字节一致是语料受限的假绿"。) +- **P5**:`RecProgramPublish{handle, stages[], spirvBlobs[], reflectionBlobRef}`,reflection 用 **`Visit()` 式归档**: +```cpp +// MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsArchive.h +template void Visit(Ar& ar, LinkArtifacts& a) { ar(a.writtenUniformLocationBits, /*…全字段…*/); } +static_assert(sizeof(LinkArtifacts) == MGL_LINKARTIFACTS_SIZE, + "新字段请加进 Visit() 并 bump MGL_LINKARTIFACTS_SIZE"); +``` + 一份字段表服务两个方向 + `sizeof` 绊线。**序列化整个结构体**(而非 backend 当前读的 ~40 字段),这样 backend 新增一次 read 永不需要改协议。 + 安装入口:`ProgramObject::InstallPublishedLink(LinkArtifacts&&, SpirvArtifacts&&, linkVersion, imageUnitVersion, backendStateVersion)`,绕过 `m_pendingLink`/`m_pendingSpirv`,**server 因此不需要 compile pool**。 +- `relink` 路径保留为常驻 oracle 与 A/B 对照(`MOBILEGL_IPC_PROGRAM=publish|relink`)。 +- 全局 UBO scratch 相反:小、每次 `glUniform*` 变、有版本 → 走 `SEG_STAGE`,键 `(programHandle, uboContentVersion)`,复现 monolith 的"每 program 每帧至多一次"(`DirectGLES.cpp:3369-3392`)。 + +### 6.10 应用指针(四类,范围全部可算) + +| 类 | 范围 | 站点 | +|---|---|---| +| client 顶点数组(仅 DrawArrays 族) | `(first+count-1)*stride + elementSize` | `Managers.cpp:2560`、`VulkanRenderer.cpp:3737` | +| client 索引数组 | `count * indexSize` | `DirectGLES.cpp:4436`、`VulkanRenderer.cpp:4081` | +| client indirect / parameter 块 | `stride*(drawcount-1)+cmdSize` | `DirectGLES.cpp:276`、`DirectVulkan.cpp:303` | +| `MultiDraw*` 参数数组、`ClearBuffer*` value | `drawcount*4`、16B | `DirectVulkan.cpp:963-1057` | + +唯一无界的是**索引 draw 下的 client 顶点数组**:索引扫描(`TryComputeMaxIndexFromHostBytes`,`VulkanRenderer.cpp:3406-3470`)必须在 **client** 侧跑,只有 client 同时持有两个数组。实现于 `MG_Remote/Client/ClientArrayBounds.cpp`,两个 backend 共用。 + +**陈旧索引危害(本轮新增)**:monolith 在每次这类扫描之前都调 `indexBuffer->SyncGpuWrites()`(`DirectGLES.cpp:4413`、`MultiDraw.cpp:499`、`VulkanRenderer.cpp:3431,4159`),因为 EBO 可能刚被 compute shader 或 XFB 写过。client 侧扫的是 client shadow,若不做同样的强制回读,算出的 `maxIndex` 来自陈旧字节,顶点数组会被少拷 → 几何缺失/花屏,或越界读应用数组。同样的暴露面还有 primitive-restart 重写(`DirectGLES.cpp:4412-4414`)与 `*IndirectCount` 的 parameter buffer 读(`DirectGLES.cpp:4666-4693,4768-4793`)。 + +**规则**:`ClientArrayBounds`、restart 重写、indirect-count 读者在触碰 shadow 之前,必须走 §5.6b 的 pending 检查(Publish + 等 `appliedSeq` + 排空事件),即 monolith 里 `SyncGpuWrites()` 所在的**同一个位置**。P2 增加 `ClientArrayAfterComputeWriteScenario` 作为门。 + +draw 记录里 `indicesAreClient` 由"是否绑定了 element array buffer"决定(`DirectGLES.cpp:4423` vs `:4425-4442`),在 binding 所在的一侧判定。 + +--- + +## 7. 控制面 + +### 7.1 FlatBuffers 用法 + +**一份 schema `MobileGL/MG_Remote/Protocol/protocol.fbs`,两种用法:** +- **热路径 → FlatBuffers `struct`**(flatc 保证定长布局、无 vtable、无偏移间接、无需 verifier walk,只需边界检查),直接放进 ring:`[RecHeader | struct | 可选变长尾]`。`DrawArrays` = 8+24 = 32B(对比 table-per-command 的 ~60B 与一次 vtable 遍历)。这正是 `Feat/CS-Delta-IPC` 自己的 plan 第 55 行要求而实现没做的事。 +- **罕见/变长/需演进 → FlatBuffers `table`**,走 CTRL socket。 + +```fbs +namespace MobileGL.Wire; + +// ---------- 热路径 struct(进 ring)---------- +struct WireHandle { kind:ubyte; p0:ubyte; p1:ubyte; p2:ubyte; glName:uint; lifetimeId:ulong; } +struct BlobRef { seg:uint; pad:uint; offset:ulong; size:ulong; } +struct RecBindBuffer { target:uint; index:uint; h:WireHandle; } +struct RecDrawArrays { mode:uint; first:int; count:int; instances:int; baseInstance:uint; pad:uint; } +struct RecDrawElements { mode:uint; count:int; type:uint; flags:uint; indices:ulong; blob:BlobRef; } +struct RecBufferSubData { h:WireHandle; offset:ulong; size:ulong; blob:BlobRef; } +struct RecBufferMap { h:WireHandle; rangeStart:ulong; rangeEnd:ulong; access:uint; pad:uint; } +struct RecBufferUnmap { h:WireHandle; } +struct RecTexSubImage { h:WireHandle; target:uint; level:uint; box:[uint:6]; rectCount:uint; + pad:uint; blob:BlobRef; } // rects 在变长尾 +struct RecGenerateMipmapLevels { h:WireHandle; target:uint; requiredLevelCount:uint; + bytesPerTexel:uint; shrinkingAxes:uint; } +struct RecRenderbufferStorage { h:WireHandle; internalFormat:uint; width:int; height:int; + samples:int; pad:uint; } +struct RecXfbAccounting { pausedPrims:ulong; inputPrims:ulong; prims:ulong; + capturedVerts:ulong; geomDraws:uint; accountedDraws:uint; } +struct RecRenderStateBlob{ version:ushort; pipelineVersion:ushort; pad:uint; blob:BlobRef; } +struct RecPresent { frameSerial:ulong; swapInterval:int; pad:uint; } +struct RecSetResolvedDrawProgram { h:WireHandle; } +// … 共约 95 个 + +// ---------- 控制面 table(走 socket)---------- +table SegmentRef { id:uint; kind:ubyte; sizeBytes:ulong; name:string; } +table Hello { abiMajor:uint; abiMinor:uint; buildFingerprint:string; backendType:uint; + pid:uint; configBlob:[ubyte]; } +table Welcome { abiMajor:uint; abiMinor:uint; serverPid:uint; + cmdRing:SegmentRef; stageRing:SegmentRef; replyPool:SegmentRef; eventRing:SegmentRef; } +table CapsSnapshot { dynamicParameters:[ubyte]; // DynamicBackendParameters 逐字节 + rendererInfo:[ubyte]; formatCaps:[ubyte]; extensions:[string]; + apiVersion:string; + maxComputeWorkGroupCount:[int:3]; maxComputeWorkGroupSize:[int:3]; + tableSlotMask:ulong; // 远端实际注册了哪些 GLFunctionsTable 槽 + prefersCpuXfbPrimitiveAccounting:bool; } +table DefaultFramebufferInfo { width:int; height:int; colorFormat:uint; depthFormat:uint; stencilFormat:uint; } +table SurfaceOp { seq:ulong; kind:ubyte; display:ulong; surface:ulong; windowKind:ubyte; + nativeToken:ulong; width:int; height:int; swapInterval:int; } +table SurfaceReply { seq:ulong; ok:bool; eglMajor:int; eglMinor:int; defaultFb:DefaultFramebufferInfo; } +table ProgramReflection { /* Visit() 归档的结构化镜像,P5 */ } +table ResyncRequest { serverEpoch:uint; } table ResyncDone {} +table AuxRequest { seq:ulong; kind:ubyte; payload:[ubyte]; } // 外来线程 sync/query +table Fatal { code:uint; message:string; } +table LogLine { level:ubyte; text:string; } +union CtrlMsg { Hello, Welcome, CapsSnapshot, SurfaceOp, SurfaceReply, + ProgramReflection, ResyncRequest, ResyncDone, AuxRequest, Fatal, LogLine } +table CtrlEnvelope { msg:CtrlMsg; } +root_type CtrlEnvelope; +``` + +`protocol_generated.h` **提交进仓库**,由 `scripts/gen_protocol.py` 重新生成(镜像 `tools/trace_replay/CMakeLists.txt:52-69` 驱动 `glproc.py` 的做法);CI 加 `flatc-check` 步骤重新生成并 `git diff --exit-code`。 + +**codegen 绝不进默认构建图(本轮加强)**:`Feat/CS-Delta-IPC:MobileGL/Protocol/CMakeLists.txt:22-38` 在 `MOBILEGL_FLATC_EXECUTABLE` 未设时 `add_subdirectory(3rdparty/flatbuffers)` 并开 `FLATBUFFERS_BUILD_FLATC ON`——这正是它自称要修的 NDK 陷阱(交叉编译造出 arm64 `flatc` 然后在 host 上执行)。**本计划不复用这一段**:`gen_protocol.py` 是纯开发者/CI 目标,默认构建图里没有 `flatc`,`MOBILEGL_FLATC_EXECUTABLE` 只服务 CI 的 `flatc-check`。FlatBuffers 运行时是 header-only,只需要 `3rdparty/flatbuffers/include` 在 include path 上(P4 用 `nm` 复核 `libMobileGL.so` 链接行没有新增库,不靠断言)。 + +### 7.2 帧封装与 flush 策略 + +CTRL socket 封帧:`[u32 'MGLF'][u32 len][payload]`,64MiB 上限,**读时校验**(`Feat/CS-Delta-IPC` 的 `Feed()` 永远返回 OK,坏 magic 变成静默永久挂起,`Framing.h:41-45`;`StartRead` 直接按 wire 长度分配无上限检查,`LocalSocketTransport.cpp:232-236`)。接收缓冲不足时**返回所需大小并保留消息**(上一版的 transport 会失败且不弹出消息,把流永久卡死)。 + +#### Publish 触发器(重写,删掉 64KiB 阈值) + +上一版设 "records ≥ 64KiB" 为主触发器。按 §6.3 的记录尺寸,64KiB ≈ 1200-2700 条记录,即**一整帧**(计划自己把 MC 帧估为 1000-4000 draw)。那意味着 server 在 client 发完整帧之前无法开始工作——这不是异步,是一个整帧的流水线气泡,且在 present credit 之上再加一整帧延迟。它还在 P2.5 跑之前就先把 P2.5 的假设否掉了(inproc 的全部意义就是让 `PrepareForDraw` 与 GL 线程重叠,帧粒度 publish 保证零重叠)。而 `SEG_CMD` 是 SPSC ring,"publish" 只是一次 `cmdHead` 的 release store,唯一值得摊销的是门铃写。 + +**新规则**: +- **每条记录(或每 8-16 条,用来摊销 store)release-store `cmdHead`**;仅当 `consumerParked` 时敲门铃。 +- 显式门铃点:`Present`、任何 `kNeedsAck` 阻塞请求、`eglMakeCurrent`、`glFlush`(**刷出 outbox,不等待**)。 +- **`SEG_STAGE` 余量 < 1/4** 时敲门铃(用 `stageHead - stageAppliedTail`)。 +- **轮询类入口点也是门铃点(本轮新增,修 livelock)**:`glClientWaitSync`(任意 timeout)、`glGetSynciv(GL_SYNC_STATUS)`、`glGetQueryObject*(GL_QUERY_RESULT_AVAILABLE | GL_QUERY_RESULT_NO_WAIT)`。 + 理由:GL 的标准惯用法是 `glFenceSync(); while (glClientWaitSync(s, GL_SYNC_FLUSH_COMMANDS_BIT, 0) == GL_TIMEOUT_EXPIRED) {}` 与 `while (!avail) glGetQueryObjectuiv(id, GL_QUERY_RESULT_AVAILABLE, &avail);`。循环里没有别的 GL 调用,若这些入口不 publish,`RecFenceSync` 就永远躺在 ring 里,server 看不到,watermark 不动,循环永久自旋——这是挂死,不是变慢。仓库自己在意这件事:`DirectVulkan.cpp:1158-1160` 写明 "GL_SYNC_FLUSH_COMMANDS_BIT: flush regardless of timeout, so a zero-timeout poll loop makes progress across calls",而 MG_Impl 无条件把 flags 透传给 backend(`GL_Sync.cpp:96`)。 + **携带 `GL_SYNC_FLUSH_COMMANDS_BIT` 的调用无条件 publish**(spec 要求 flush)。 +- **饥饿升级**:同一个 handle 连续 N 次(默认 64,`MOBILEGL_IPC_POLL_ESCALATE`)本地回答 `TIMEOUT_EXPIRED` / "未就绪" 而 watermark 毫无移动时,升级成一次阻塞 round trip,这样一个已经卡住的 server 不会把 client 自旋成死循环。 + +**`glFinish` 保持纯 no-op**(`Definitions.cpp:111-112`)——应用唯一的强制停顿手段在 monolith 里免费,拆分后也必须免费。 + +### 7.3 序号与 credit + +seq = 记录序数。**两个互相独立的窗口,绝不是 per-batch 锁步**(`Feat/CS-Delta-IPC` 在 apply 循环里同步发 ack,`ServerCore.cpp:421-429`,是最差的节奏;而且它的 credit 算成 `baseSeq + items.size()`,只有 `baseSeq==0` 时才对): + +- **字节 credit**:`SEG_CMD` 与 `SEG_STAGE` 各自的占用,升级路径见 §6.5。 +- **Present credit**:`eglSwapBuffers` 在 `presentsSent - presentAckSerial >= MOBILEGL_IPC_PRESENT_CREDIT`(**默认 1**,见 §9)时阻塞。 + +server 端**不发 credit 消息**:它对 `RingControl` 做 release store,consumer 每 64 条记录更新一次 `appliedSeq`,并在 `producerParked` 时敲反向门铃。 + +### 7.4 事件回传通道 + +`SEG_EVENT` 是 server→client 的 SPSC POD ring:`EvQueryResult{handle, available, value}`、`EvFenceSignaled{handle}`、`EvGpuWritten{handle, rangeCount, ranges[]}`、`EvBufferWriteback{handle, offset, BlobRef}`、`EvReadbackDone{seq, BlobRef}`、`EvGlError{code}`、`EvDefaultFramebufferInfo`、`EvCompileEnvInvalidate`、`EvLogLine{level,len,text}`。 + +#### 排空点(补齐) + +client 在下列位置排空:`glGetError`、`glGetQueryObject*`、`glClientWaitSync`、`glGetSynciv`、`eglSwapBuffers`、**`glMapBuffer` / `glMapBufferRange` / `glGetBufferSubData` / `glGetNamedBufferSubData` / `glCopyBufferSubData`**(§5.6b 要求),以及**每一次等待循环的每一轮**(present credit、`kNeedsAck`、ring/stage 满)。最后一条是必须的,见下。 + +#### 溢出策略(本轮新增,修一个双向死锁) + +上一版没说 `SEG_EVENT` 满了怎么办,也没要求 client 在**等待中**排空。具体死锁:client 卡在 `eglSwapBuffers` 等 present credit;server 的 apply 线程一边 apply 一边产 `EvLogLine` 与 `EvGpuWritten`;`SEG_EVENT` 满;apply 线程阻塞在生产上;`presentAckSerial` 永不前进;client 永不离开 `eglSwapBuffers`,因而永不排空。两边都死。 + +**策略**: +1. client **必须**在每个等待循环内排空 `SEG_EVENT`,不只是在入口点边界。 +2. `EvLogLine` 是**有损**的:覆盖最旧,并累加 `RingControl.eventDropped`(client 在排空时把丢失条数打进日志)。丢一条日志绝不允许卡住渲染。 +3. 语义承载事件(`EvGpuWritten`、`EvReadbackDone`、`EvFenceSignaled`、`EvBufferWriteback`、`EvGlError`、`EvDefaultFramebufferInfo`、`EvCompileEnvInvalidate`)**无损**:ring 装不下时 server 置 `RingControl.eventRingFull=1` 并**停止 apply**(停在一条记录的边界上,不是记录中间),敲反向门铃;client 排空后清标志并敲正向门铃。状态因此永远可恢复。 +4. 故障注入测试:在 client 被 credit 阻塞时灌满 `SEG_EVENT`,与 P8 的 SIGKILL 测试并列。 + +server 侧的 `MGLOG` 与延迟诊断按流顺序 replay 进 client 日志流——复用已存在的 `DeferredLogLine`/`ApplyDeferredDiagnostics` 机制(`JobNode.h:26-58,149-158`)。 + +--- + +## 8. Roundtrip 清单 + +### 不可避免的阻塞点 + +| # | 站点 | 频率 | 为什么 | +|---|---|---|---| +| 1 | 握手 `Hello`/`Welcome` + 段 fd 传递 | 一次 | — | +| 2 | `InitializeEGLDisplay`(写 `major`/`minor`) | 一次 | 出参 | +| 3 | `CreateEGL{Window,Pbuffer}Surface` / `Resize` | 罕见 | 返回 `Bool`;回复顺带 `DefaultFramebufferInfo` | +| 4 | 首次 `MakeEGLCurrent` + `InitCapabilities` → `CapsSnapshot` | 每 surface 一次 | caps 只在那一刻才存在(`BackendObject.cpp:341-347`) | +| 5 | `glReadPixels` → 客户内存 | 罕见(CTS 热) | GL 要求返回时字节已就位 | +| 6 | `glCopyTexSubImage*` / `glClearTexImage`(内含 ReadPixels) | 罕见 | 前端实现本来就借一次 ReadPixels | +| 7 | `glGetTexImage`/`glGetTextureImage`(DirectVulkan;DirectGLES 仅 `serverAuthoritative` level) | 罕见 | — | +| 8 | `glGetBufferSubData` / `glMapBuffer(READ)` on client-pending | 罕见 | monolith 里本来就阻塞;client pending 集合触发 | +| 9 | client 顶点数组的索引扫描 / restart 重写 / indirect-count 读(当 EBO 在 pending 集合里) | 罕见 | monolith 在同一位置调 `SyncGpuWrites()` | +| 10 | `glClientWaitSync(timeout>0)` 超出 watermark | 每帧级 | 应用请求的等待 | +| 11 | `glGetQueryObject*(GL_QUERY_RESULT)` 未完成;`glBeginConditionalRender` | 罕见 | `GL_Query.cpp:300`、`:705-706`(后者注释明说"by WAITING even for the _NO_WAIT modes") | +| 12 | 轮询饥饿升级(连续 N 次无进展) | 极罕见 | 防死锁保险 | +| 13 | **分配类入口点的错误 ack**(`glRenderbufferStorage*`、部分 `glTexImage*`/`glTexStorage*`/`glCopyTexImage*`、`glBufferStorage`) | 罕见 | OOM 探测惯用法(§5.6c) | +| 14 | `AcquirePersistentMap`(仅 P7 T1) | 每 store 一次 | 返回映射 | +| 15 | ring/stage 耗尽、present credit | 节奏 | 非语义 | + +### 变成异步或本地的 + +- 全部 20 个 draw、9 个 clear、blit/copy、`GenerateMipmap`、dispatch、barrier、image bind、7 个 XFB 跨度标记、`PatchParameteri`、`ShaderStorageBlockBinding`(权威状态已在 client,`GL_Program.cpp:3391`)、所有 buffer/texture/program/VAO/FBO delta、`Present`。 +- **`glGetError` 永远本地**(`GL_Getter.cpp:2811-2817`;`Core.cpp:48-49` 的不变式)。 +- **`glFinish`/`glFlush` 保持免费**。 +- **89 个 caps 站点全部本地**(45 `GetDynamicParameters` + 8 `GetRendererInfo` + 4 `GetFormatCapabilities` + 3 `GetBackendType` + `IsTimerQuerySupported` + `PrefersCpuXfbPrimitiveAccounting` + `BeginOcclusionQuery!=nullptr`)。 +- **`glGetIntegeri_v` 全部本地**;`glDispatchCompute` 的三次 per-dispatch 校验查询(`GL_Drawing.cpp:719`)改读 `CompileEnv::maxComputeWorkGroupCount`(`CompileEnv.h:52-54`)。 +- **`GetInteger64i_v`、`GetProgramiv` 删除**。 +- **`FenceSync`、`Begin{TimeElapsed,Occlusion,XfbPrimitives}Query`、`QueryCounterTimestamp` → client 铸造 handle**,fire-and-forget(前端本来就铸造应用可见的名字:`GL_Sync.cpp:61`、`GL_Query.cpp:54`)。 +- **`GetSyncStatus`、`ClientWaitSync(0)`、`IsQueryResultAvailable`、`GetQueryResult64(wait=false)` → 先 publish(§7.2),再从水位一次 acquire load 回答**。miss 返回 `GL_UNSIGNALED` / "未就绪",两处契约明确允许(`BackendObject.h:210-214`、`:236-241`;`GL_Query.cpp:302-311` 已遵守:读 0、**不缓存**、保留 backend handle)。 +- **`glReadPixels` 进 PBO → fire-and-forget**(配 client 侧 `MarkGpuWritten`),比 monolith 更好。 +- **`glEndTransformFeedback` 的无限 fence 等待取消**(配 client 侧对 capture target 置 `MarkGpuWritten`)。 + +**稳态帧:零 round trip**(对不使用 conditional render / 阻塞式 query / 分配类调用的帧而言;见 §15 P3 的门措辞修正)。 + +### fence 完成度必须来自真 fence,不是 present 水位(本轮新增) + +上一版让 `retiredSeq`/`completedFrameSerial` 兜底 fence 语义。但在 DirectGLES 上这两个水位**只在 `Present()` 里前进**(`DirectGLES.cpp:10626-10643` 在 `eglSwapBuffers` 之后轮询 4 深 fence ring),或在 `WaitForFrameSerialCompleted`(`:10583-10607`)里。帧中创建的 fence 于是要等到**下一次 present 退休**才报 signalled,即 fence 完成度退化成帧计数推断。`DirectVulkan.cpp:1120-1128` 恰恰写明这是被修掉的 bug:完成度必须"track the GPU itself rather than the frame-count inference; MC 1.21.5's fence-paced ring buffers depend on this to recycle their space instead of growing without bound",而项目记忆 `magma-mc1215-fence-oom` 记录了它曾导致 native-heap OOM kill。 + +**规则**:`RecFenceSync` 在 server 侧转成一次**真实的 backend `FenceSync()`**;server 用自己已有的逐 fence 轮询(DirectGLES 有 `WaitForFrameSerialCompleted` 的 fence 选择逻辑 `:10586-10600` 可复用;DirectVulkan 有 `IsSubmitIndexComplete`)在**非 present 时刻**也推进,并发 `EvFenceSignaled{handle}`。client 的本地快路径读的是"由真实逐 fence 退休导出的 handle 水位",不是 present 水位。 + +### 三个应先独立落到 `dev` 的 monolith 修复(可二分、monolith 自身受益) +1. `glEndTransformFeedback` 的无条件无限 `ClientWaitSync`(`GL_Drawing.cpp:1326-1337`)→ 用既有 `MarkGpuWritten`/`SyncGpuWrites` 推迟到首次读。 +2. `glDispatchCompute` 三次 `GetIntegeri_v` → `CompileEnv`。 +3. 删除 `GetInteger64i_v`/`GetProgramiv` 两个死表项及两个 backend 的实现。 + +--- + +## 9. Present 与帧节奏 + +`eglSwapBuffers` → `EGLImpl::SwapBuffers`(`EGLImpl.cpp:162-183`)→ `BackendObject::SwapEGLBuffers`(`BackendObject.cpp:369-398`,其线程归属校验全部对 client 镜像的 EGL 状态求值,**不需要回复**)→ 发 `RecPresent{frameSerial, swapInterval}` → publish + 敲门铃 → 返回,除非 `presentsSent - presentAckSerial >= MOBILEGL_IPC_PRESENT_CREDIT`。 + +**`Present` 与应用的 `eglSwapBuffers` 严格 1:1,绝不批量。** Magma 侧四次 `OnFrameBoundary()` 缓存老化、`TryDrainFrameTransients` 和全部四次 `BeginFrame` 只在 `Present` 内发生(`VulkanRenderer.cpp:12765-12904`);Espryt 侧三个 ring 与 `TrimBufferPool` 在那里 retire(`DirectGLES.cpp:10646-10649`)。批量会饿死这些排空。 + +### 9.1 延迟是叠加的:credit 默认改为 1 + +上一版设 credit=2 并论证它"镜像系统已有预算",因此"不引入新的停顿类别"。停顿**类别**确实不新,但**延迟会叠加**,而上一版没有把它加起来: + +- server 自己的 `Present` 在返回之前就已经等了 2-3 帧:`VulkanRenderer::Present` 末尾调 `FrameContext::WaitAndAcquireNextImage`,其第一条语句是 `vkWaitForFences(device, 1, &frame.imageInFlightFence, VK_TRUE, timeout)`(`FrameContext.cpp:288-290`)。`presentAckSerial` 因此只能在那次等待完成后才前进。 +- 一个被允许领先 2 个 present 的 client,叠在一个自身已领先 GPU 2-3 帧的 server 上 = **端到端 4-5 帧**,60Hz 下 66-83ms,对第一人称游戏不可接受。 +- 现有的验收门都看不见它:SSIM 是帧内容比较,`bench.sh` 量的是 FPS,都不是 input-to-photon。 + +**规则**:`MOBILEGL_IPC_PRESENT_CREDIT` **默认 1**(可配 1-4)。文档里写明叠加公式:`端到端 ≈ client credit + server FIF + 驱动深度`。P3 与 P9 的验收增加**输入延迟测量**:用已有的 `GetGpuTimestampNs` 与 trace-replay `--benchmark` 的逐帧 JSON 构建 "记录发射时刻 → present 完成时刻" 直方图;只有当实测吞吐收益能抵掉实测延迟代价时才调高 credit。 + +参考基线:`MagmaFramesInFlight = 3` 钳到 `[2, maxImageCount]`(`VulkanRendererConfig.h:14-19`、`VulkanRenderer.cpp:3051-3058`),Espryt 深度 4 的 fence ring 刻意高于驱动的 2-3(`DirectGLES.cpp:10071-10074`)。 + +### 9.2 swap interval 与 Magma + +Swap interval 搭 `RecPresent` 过去。注意 Magma 从不注册 `SetSwapInterval`(`BackendObject_DirectVulkan.cpp:698` 只注册 `Present`)且偏好 `MAILBOX`/`IMMEDIATE`(`SwapchainObject.h:74-79`),因此 **IPC credit 成为 Magma 唯一的显式限帧器** —— 记录在案,P6/P9 在设备上测量输入延迟与帧节奏;若 Magma 需要,把"注册 `SetSwapInterval` 并映射到 FIFO"作为**独立的 `dev` 变更**,不让两套机制同时管节奏。 + +### 9.3 无 present 循环下的水位饥饿 + +`retiredTail` 的回收依赖 server 发布准确的 `completedFrameSerial`。DirectVulkan 有 `TryDrainFrameTransients`/`RefreshCompletedSubmits` 可以在非 present 时刻推进,**DirectGLES 没有对应物**:`g_completedFrameSerial` 只在 `Present()` 里(`DirectGLES.cpp:10626-10643`)和 `WaitForFrameSerialCompleted`(`:10583-10607`,且要求存在覆盖目标 serial 的活 fence,slot 被回收时返回 false)前进。在无 present 的负载里——`tools/cts` 的 `run_cts_local.py`、回读循环、从不 swap 的 `MG_IntegrationTest` 场景——一个 fence 都不会被插入,`retiredTail` 永不前进,`SEG_STAGE` 填满,§6.5 的升级路径在每个用例上都跑到硬 drain。那会把一次 CTS run 变成一连串 50ms 等待加整体 drain,并可能被误读成一致性回归。 + +**规则**:给 DirectGLES 的 server 加**非 present fence tick**——距上次 `Present` 超过阈值(默认 8ms)或每 N 条已 apply 记录(默认 4096)时,插入一个 `glFenceSync` 并轮询 fence ring,复用 `g_frameFenceRing` 机制。同时把 ring 占用率与升级次数打进 Tracy 计数器(P0 交付),让"水位饿死"表现为一个指标而不是一次无法解释的停顿。P2 增加一个无 present 的 split 用例。 + +--- + +## 10. 线程模型 + +### Client +- **v1 不加线程。** 编码在调用方 GL 线程上直接写进 ring。前端本来就是 per-context 单线程契约(`GLContext` 无 mutex;`EGLState::MakeCurrent` 强制一个 owner 线程,`EGLState/Core.cpp:1215-1220`,测试在 `MG_Test/EGLState/EGLStateTest.cpp:39-92`)。 +- **flow = per context,不是 per thread。** 今天恰好一个 flow。`eglMakeCurrent` 是 flow 所有权转移,在既有 `EGLOperationMutex`(`EGLImpl.cpp:241`)下发射。**顺手修既有漏洞**:`EGLImpl::ReleaseThread`(`:341-350`)与 `SwapInterval`(`:435-450`)今天不取该锁而另外三个(`MakeCurrent`/`SwapBuffers`/`DestroySurface`)取。 +- **外来线程的 sync/query**:读全部从 `RingControl` 无锁 acquire load 回答(比取 registry mutex 更好);少数必须发射的(`FenceSync`、`Begin*Query`,以及 §7.2 要求的轮询 publish)取 `ctrlMutex` 并走 CTRL socket 的 out-of-band `AuxRequest` 帧(SPSC ring 不允许第二个 producer)。 +- **等待必须能挂起**:所有 client 侧等待(present credit、`kNeedsAck`、ring/stage 满、轮询升级)走 §6.2a 的 `producerParked` + 反向门铃,自旋窗口 `MOBILEGL_IPC_SPIN_US`(默认 50µs)。 +- ShaderCompilePool 原样保留在 client(`ShaderCompilePool.h:77-82`,≤4 worker,为 RSS 上限)。 +- 可选 `mgl-client-tx` 双缓冲发送线程:**P6 项,凭测量决定**。在 P6 的 Tracy 数据出来之前不要预先加线程(会引入拷贝或锁)。 + +### Server +| 线程 | 职责 | +|---|---| +| `mgl-srv-io` | asio `io_context::run`:封帧读写、`SCM_RIGHTS`、双向 doorbell、CTRL RPC | +| `mgl-srv-apply` | **终身持有原生 EGL/Vulkan context**:消费 ring → 解码 → apply 进 replica → 调 backend 表 | +| `mgl-srv-dec`(可选,P6) | FlatBuffers/边界校验前置,凭测量决定 | + +因为 context 永不迁移:`g_backendContextOwnerThread`(`DirectGLES.cpp:10052`)只写一次;`DirectGLES::MakeCurrent` 的 8 缓存失效风暴(`:10123-10140`)变成启动期一次性成本;`IsBackendContextCurrentOnThisThread` 的每帧 EGL 复核(`:10195-10228`,动机是 `eglGetCurrentContext` 实测占渲染线程 16%)恒真。DirectGLES 的 off-thread 降级(`FenceSync` 返回 null 等)消失——**保真度提升**。延迟 replay 机制(`Managers.h:458-473` 的 `pendingRespecify`/`pendingRanges`/`pendingResidentWrites`)保留但永不触发。 + +### 核心放置(本轮新增,是性能主张的前提) + +§5.1 明说 reconciler "就是 `PrepareForDraw` 的可达性遍历"。这意味着这套遍历**每 draw 跑两次**:client 的 `WireMirror` 一次,server 未改动的 `PrepareForDraw`(`DirectGLES.cpp:2916-2975`)一次,外加编码与解码。其中有些并不便宜:`CurrentUnitBindingsEpoch`(`DirectGLES.cpp:1421-1438`)在 `GetTextureBindGeneration()` 变动时会退化成对每个 touched texture unit 做 owner-equality 全走查,而代码自己注明这在冗余重绑时就会发生("26.2 re-binds the unit's own sampler around every texture-unit switch")。 + +所以拆分的全部性能主张都押在"两半落在两个都快的核上"。而 MobileGL 全库从不设置亲和性(`grep -rn 'sched_setaffinity\|cpu_set_t\|affinity' MobileGL/` 零命中),server 是 fork/exec 出来的独立进程、不继承 launcher 的亲和性,项目记忆 `pojav-bigcore-affinity-trap` 又记录过 `pojavBigCore=true` 把整个游戏 JVM 加 MobileGL worker 钉死单核、让一整批历史测量作废。若 `mgl-srv-apply` 落到 1.55GHz 小核,它做的工作严格多于 monolith 在 1.96GHz 大核上做的,拆分按构造就是回归,而 §15 P3 的"帧时在 monolith 10% 内"会以一个没人会正确归因的理由失败。 + +**规则**: +1. 计划里必须写出**总 CPU 工作量差**(client reconcile + encode + decode + server `PrepareForDraw` vs monolith 的 `PrepareForDraw`),不只是单侧成本。 +2. 复用 `ShaderCompilePool` 已有的大核探测(`ShaderCompilePool.cpp:73-96` 的 `ReadCpuMaxFrequencyKHz` / `DetectBigCoreCount`)把 `mgl-srv-apply` 绑到大核,开关 `MOBILEGL_IPC_SERVER_AFFINITY`(默认 auto),并把解析出的 mask 打进日志。 +3. P2.5 与 P3 必须报**逐线程 CPU 时间**,不只是墙钟帧时,这样"没有收益"的结论能被归因到放置 vs 编码成本。 + +### 拆机顺序(三条约束) +`Publish()` + server 排空并 ack → 停 apply 线程 → 关 transport →(client)排空 compile pool(必须先于 `glslang::FinalizeProcess()` 与 `pGLContext` 析构,`ShaderCompilePool.h:106-110`、`Init.cpp:56-62`)→ `MobileGL::Destroy()`(`EGLImpl.cpp:335-338`)→ 释放 sync/query handle(`GL_Sync.cpp:223-226`)。 + +--- + +## 11. EGL/窗口与进程生命周期 + +### 11.1 启动与握手 + +client 定位 server 的顺序(**本轮修正**): +1. `MOBILEGL_IPC_SERVER_PATH`(**主要机制**)。 +2. `dladdr(&MobileGL::Initialize)` → dirname → `libMobileGLServer.so`(**兜底**)。 + +上一版把 `dladdr` 当主要机制,但两个桌面验收门都因此找不到 server:`MG_IntegrationTest/CMakeLists.txt:28-35` 在非 Android 上把 `MGL_ITEST_MOBILEGL_TARGET` 设成 `MobileGL_s`(**静态链接**),`dladdr` 解析到测试可执行文件自身的路径而不是库目录;trace replay 则由 `tools/trace_replay/CMakeLists.txt:285-290` 显式传 `-DMOBILEGL_LIBRARY=$`,其目录是 MobileGL 的构建输出目录,而 CMake 默认把 `add_executable` 放在定义它的目录的 binary dir。 + +**配套**:把 `MobileGLServer` 的 `RUNTIME_OUTPUT_DIRECTORY` 设成 `$`,并把 `"MOBILEGL_IPC_SERVER_PATH=$"` 加进每一条新的 ctest `ENVIRONMENT`(经 `mgl_itest_join_environment` 与 `${MGL_ITEST_COMMON_ENV}` 合并)以及 `add_trace_replay_test` 的 `SPLIT` 分支。**并复核绝对路径能否活过 CI 的 artifact 搬运**:`.github/workflows/test.yml:174-185` 只重写 `CTestTestfile.cmake` 里的 `cmake` 路径,不重写 `ENVIRONMENT` 值——若不行,改为在测试启动时由 harness 相对 `argv[0]` 解析。 + +启动方式:`socketpair(AF_UNIX, SOCK_STREAM)` + `fork`/`execve`,fd 3 = socket(Windows 见 §11.5)。**无文件系统 socket 路径、无 abstract namespace、Android 上无 SELinux 争议。** + +**子进程必须被强制成 monolith(本轮新增,修无界 fork 链)**:`MG_Config::Transport` 由 `ConfigLoader` 从环境变量读(与 `features.CoherentAsFlush = QueryEnvFlag(...)`(`ConfigLoader.cpp:185`)同形),而 `fork`/`execve` 的子进程会继承 `MOBILEGL_TRANSPORT=spawn`。server stub 里 `dlopen(libMobileGL.so)` + `dlsym("mobilegl_server_main")` 之后必然要起一个真 backend,即走 `MG_Backend::Init()`(`Init.cpp:48-70`)——变量还在,于是它再构造一个 `BackendObject_Remote` 并再 spawn 一次,首次 GL 调用时形成无界 fork 链。 +**规则**:(a) spawn 时构造**显式 envp**,剔除 `MOBILEGL_TRANSPORT` 与所有 `MOBILEGL_IPC_*`(只保留 server 真正需要的少数几个,如 `MOBILEGL_BACKEND_TYPE`、日志路径);(b) `mobilegl_server_main` 在能到达 `MG_Backend::Init()` 之前把 `MG_Config::Transport` 硬置为 `Monolith`。两条都做,任一条单独失效时另一条兜住。P0 增加一个 `MG_Test/Wire` 测试:spawn 一个 server 并断言进程树只多出**恰好一个**子进程。 + +`Hello{abiVersion, backendType, buildFingerprint, configBlob}` → `Welcome`。`configBlob` 转发 client 解析好的 `MG_Config::Features`,两半不可能对某个 quirk 开关有分歧。`buildFingerprint`(git hash + `Records.def` 的 hash)不匹配 → 握手期 `Fatal`。 + +### 11.2 `mobilegl_server_main` 的可见性(本轮新增) + +`CMakeLists.txt:497-510` 在**非 Debug** 构建上给共享目标设 `C_VISIBILITY_PRESET hidden` / `CXX_VISIBILITY_PRESET hidden` / `VISIBILITY_INLINES_HIDDEN ON`——而 plugin 与 FCL 出货的正是 RelWithDebInfo(`MobileGL/build.gradle` 的 `fordebug` 类型强制 `-DCMAKE_BUILD_TYPE=RelWithDebInfo`)。所以 `dlsym("mobilegl_server_main")` 在 Debug 下能用、在设备上静默失败。 + +**规则**:入口点声明为 +```cpp +extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int argc, char** argv); +``` +并在 P0 验收里加 `nm -D libMobileGL.so | grep mobilegl_server_main` 断言(与既有的 `nm --defined-only` 门并列)。若哪天 macOS/Windows 也要托管 server,还需同步 `MG_Impl/DyldInterpose/ExportedSymbols.txt` 与 `wgl.def`。 + +### 11.3 Android + +**minSdk 26 没有任何公开 NDK API 能扁平化 `ANativeWindow`**(NDK r27.3 的 `android/native_window.h` 无 parcel 符号;`libbinder_ndk` 是 API 29,`binder_ibinder.h:191`;`ASurfaceControl` 是 API 29,`surface_control.h:67`)。`Feat/CS-Delta-IPC` 的 `nativeBlob`"binder-flattened ANativeWindow"(`protocol.fbs:377-379`)不可实现。 + +- **P1-P8 验证路径:无窗口。** 两个 PIE ELF。**实测**:从解压出的 nativeLibraryDir exec 在 API 36 上可行(`run-as … libtrace_replay_runner.so` → exit 132 = SIGILL,即 ELF 已被加载进入,而非 `EACCES`;文件 0755 / `u:object_r:apk_data_file:s0` 且无 MLS category,**跨 package 也可**)。`useLegacyPackaging = true` 在 FCL(`../FCL/build.gradle.kts:76-82`)与 plugin(`android-plugin/app/build.gradle.kts:198-203`)都已开。surface 用 pbuffer 或 `AImageReader` 支持的 `ANativeWindow`(`HeadlessGL.cpp:86-131,268-274`),trace replay 默认 pbuffer(`apitrace_glws_egl.cpp:614-618`)。 + **注意实测的域**:上述 SIGILL 证据是经 `run-as` 取得的,即 `runas_app` 域,而不是 trace Activity 所在的 `untrusted_app` 域。**P0 的 Android spike 必须从应用自身进程 `posix_spawn` 一次**(见 §15 P0)。 +- **P9 生产路径**:Java `Surface`(Parcelable)→ Messenger/AIDL → `MobileGLServerService`(`android:process=":mgl"`)→ JNI `ANativeWindow_fromSurface(env, surface)`,就是 FCLauncher 今天在 `egl_bridge.c:81` 做的那一次调用。**仓内先例**:`android-plugin` 的 `BenchService` 已在 `android:process=":bench"` 里跑 MobileGL(`BenchService.java:19-77`)。代价:server 进程多一个 ART(~15-25MB)。 +- **纠正一条过期笔记**:FCL 把游戏 JVM 跑在**主进程**,不是 `:jvm`(`../FCL/src/main/AndroidManifest.xml:112-121`,`JVMActivity` 没有 `android:process`;`:jvm` 是下载 Service)。第二个进程必须新建。 +- **HeadlessGL 的 fork 预检与孤儿 server(本轮新增)**:`MG_IntegrationTest/Harness/HeadlessGL.cpp:344-368` 会 fork 一个子进程跑完整 EGL bring-up 然后 `_exit(step)`,注释(`:364-366`)明说这是刻意的——"every atexit handler and static destructor in this address space belongs to the parent's copy of the world"。拆分模式下那个子进程的 bring-up 会走到 `MG_Backend::Init()` 并 spawn 一个 server;`_exit` 不跑任何拆机,那个 server 成为孤儿,活到它发现 EOF 或撞上 `MOBILEGL_IPC_IDLE_EXIT_S`(默认 30s)。父进程随即对同一设备起自己的 server。`HeadlessGL.cpp:585-589` 已经把这种失败模式命名为"a leaked exclusive device, an environment the child did not have"。 + **规则**:server 的 EOF 检测必须**即时且无条件退出**(亚秒级,不靠 30s 看门狗);client spawn 时把 socket fd 设成 `_exit` 会确定性关闭的形态(不设 `FD_CLOEXEC` 以外的保活);再加一次**有界重试的就绪握手**,这样残留的预检 server 不会把父进程弄 flaky。这个交互本身列为 P1 验收步骤 1 的一部分,先于任何广度工作。 + +### 11.4 Linux / X11 + +`Window` 是 XID,`nativeToken:u64` 直接送。backend 自己 `XOpenDisplay(getenv("DISPLAY"))` 并构造 `VkXlibSurfaceCreateInfoKHR`(`VulkanRenderer.cpp:14486-14521`),只要同 `DISPLAY`/`XAUTHORITY` 就免费。Wayland 今天不支持(`BackendObject.h:529` TODO),维持。 +WSL/CI:**永不开窗** —— `EGL_PLATFORM=surfaceless` + `EnsureHeadlessPlatform()`(`HeadlessGL.cpp:160-196`,它存在正是因为一台带 WSLg `DISPLAY` 的工作站曾把这条 lane 弄挂)。 + +### 11.5 Windows + +`HWND` 进 `nativeToken`。Vulkan 可行(`hinstance` 是历史遗留,`VulkanRenderer.cpp:14456-14463`);**WGL/ANGLE-DXGI 对外进程 HWND 不受支持 → headless only**。 + +transport:默认 named pipe(asio `windows::stream_handle`)。**"继承句柄就免掉 accept/connect"这句在 asio 上不能直接照搬(本轮修正)**:`windows::stream_handle` 的 IOCP 服务要求句柄是 **overlapped** 的,而 `CreatePipe` 造的匿名管道不是。所以句柄对必须这样造:用一个 GUID 唯一命名的 `CreateNamedPipeW(..., FILE_FLAG_OVERLAPPED)` 做 server 端,配一次 `CreateFileW(..., FILE_FLAG_OVERLAPPED)` 做 client 端,然后把 server 端句柄设为可继承并 `CreateProcess` 传下去。§11 必须把这套构造写清楚。 + +asio 1.38.2 在 Win32 上确实定义了 `ASIO_HAS_LOCAL_SOCKETS`(`3rdparty/asio/asio/include/asio/detail/config.hpp:1085-1092`,只排除 `ASIO_WINDOWS_RUNTIME`,且自带 `sockaddr_un_type` 于 `socket_types.hpp:220`),但其 IOCP `async_accept` 走 `AcceptEx`,AF_UNIX 从不支持它——AF_UNIX-everywhere 是 P6 的**可选简化**,需真编真跑验证,named pipe 是已知可用的默认。 + +### 11.6 崩溃 + +- **server 死**:client 读到 EOF/EPIPE → device-lost 闩锁:后续 GL 调用变 no-op、`eglSwapBuffers` 返回 `EGL_FALSE`+`EGL_CONTEXT_LOST`、`glGetGraphicsResetStatus`(若 robustness 分支落地)返回 `GL_UNKNOWN_CONTEXT_RESET`。`MOBILEGL_IPC_RESPAWN=1` 时重启 + `ResyncSnapshot`(默认关,静默重启会掩盖 bug;且与 `MOBILEGL_IPC_ADOPT_TIER != 2` 互斥,见 §5.8)。 +- **client 死**:server 读到 EOF → **立即**销毁原生 context 并退出(不等看门狗);`MOBILEGL_IPC_IDLE_EXIT_S`(默认 30)只作为 EOF 都收不到时的最后保险。 + +--- + +## 12. Monolith 保留与模式选择 + +**四层保证,从强到弱:** + +1. **编译期折叠。** `MOBILEGL_BUILD_DISAGGREGATED`(默认 **OFF**)关闭时 `MobileGL/MG_Remote/**` 不进 `SOURCE_FILES`,`MG_Config::Transport` 是 `constexpr Monolith`,`MG_Backend/Init.cpp` 里的分支在编译期消失。**默认构建与今天字节一致。** +2. **唯一 hook 点。** 整个拆分入口是 `MG_Backend/Init.cpp:48-70` 里的一个分支: +```cpp +void Init() { + MGLOG_D("Initializing MobileGL Backend..."); +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != TransportKind::Monolith) { + pActiveBackendObject = MakeUnique(); + } else +#endif + switch (MG_Config::ActiveBackendType) { /* 原样不动 */ } + if (!InitSpecificBackendLibs()) { /* 原样 */ } + LogBackendInfo(); +} +``` +`BackendObject_Remote::GetBackendFunctions()` 返回发射表,`Initialize()` 负责 spawn/connect。下游 ~250 个边界调用点**零 `#ifdef`**。 +3. **P4.5 的 allocator 改动必须同样包裹。** `PipeResource::MapAlignedAllocator` 与 `MipmapStorage` 的 level vector 住在 `MG_State`,改它们的 allocator 就改了类型;写成"分配器特化,option OFF 时逐字折叠回今天的 `MapAlignedAllocator`",否则第 4 层会在 P4.5 变红。 +4. **机械证明**:对 `libMobileGL.so` 做 `nm --defined-only` 与去调试信息后的 `.text` size diff,改前改后必须一致。**这是每个阶段的出口判据(P0…P9),不只是 P0**(上一版只在 P0 跑)。 + +### 12.1 两个 option,不是一个(本轮重大修正) + +上一版说"OFF 时字节一致",但**每一条部署路径都要求出货构建是 ON**:FCL 用户可编辑 env、plugin APK 的 V2 开关表、ctest `ENVIRONMENT` 变体、`/data/local/tmp` CTS 路径。而上一版又说 ON 构建里 `inproc` 会把 `pGLContext` 变成 thread-local 加 `operator->` shim。那个 shim 坐在全库最热的路径上:`grep -rho 'pGLContext->' MobileGL/MG_Impl | wc -l` = **1494**,加 DirectGLES 124、DirectVulkan 169。Android 上 dlopen 的共享库无法可靠使用 initial-exec TLS,每次访问会退化成一次 `__tls_get_addr` 调用,而今天那里只是一次对全局引用的加载(`Core.h:564` `extern UniquePtr& pGLContext`)。 + +**规则**:拆成两个 option。 +- **`MOBILEGL_BUILD_DISAGGREGATED`**(出货形态):只含 `spawn`/`unix:`/`pipe:`。每进程只有一个 `GLContext`、一份 `gBackendFunctionsTable`、一个 `pActiveBackendObject`、一份 `pDefaultFramebufferInfo` → 这四个**全部保持普通全局**,GL 热路径上没有任何 TLS 与间接。侵入面就是 `MG_Backend/Init.cpp` 里那一个可预测的分支。 +- **`MOBILEGL_BUILD_DISAGGREGATED_INPROC`**(CI/调试形态,隐含开启前者):额外加角色隔离 shim。 + +### 12.2 `inproc` 需要隔离的是**四个**进程全局,不是一个(本轮修正) + +上一版只谈了 `pGLContext`。实际上 `inproc` 下同一进程要同时扮演两个角色,以下四个全局都必须按角色分身: + +| 全局 | 定义处 | 谁读 | +|---|---|---| +| `MG_State::pGLContext` | `GLState/Core.h:564` 声明,`Core.cpp:1487` 定义,`Core.cpp:20` 构造,`Init.cpp:63` reset | 全部 | +| `MG_Backend::gBackendFunctionsTable` | `MG_Backend/Init.cpp:44` 赋值 | client 侧 MG_Impl(91 处)**与 server 侧 MG_Impl**(`GL_Texture.cpp:1621` `GenerateMipmap_Backend`、`:6713-6725` `GetTexImage` 回退链、`FixupGsStripCaptureOrder`、`CopyReadFramebufferIntoMipmapRegion` 的 `ReadPixels`) | +| `MG_Backend::pActiveBackendObject` | `MG_Backend/Init.cpp:53-61` 赋值 | MG_Impl 89 处 + backend 内部 | +| `MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo` | `GL_Framebuffer.cpp:3344` 定义,全库 22 处引用 | client 侧 MG_Impl 13 处(`GL_Framebuffer.cpp:495,1827,1837,1897,1905,1913,1927,1936,2549,2590,2598,2608,2611`)+ server 侧 backend 5 处(`DirectGLES.cpp:1917,2838,2867,9675`、`SwapchainObject.cpp:276`,其中 `SwapchainObject` 是**写**) | + +一旦 client 装上发射表,`inproc` 里 applier 与 server 侧 MG_Impl 就没有任何路径能拿到真正的 DirectGLES/DirectVulkan 表;而一个进程也不可能同时持有 client 的 default-FBO 描述与 server 的(`SwapchainObject` 直接往里写 server 的视角)。 + +**shim 的完整需求**(上一版只提了 `operator->`):`operator->`、`operator bool`、`get()`、`== nullptr` 相等比较、从 `MakeUnique` 赋值、`reset()`。非箭头用法的实际数量是 **133**(`grep -rn pGLContext MobileGL/ --include=*.cpp --include=*.h | grep -v 'pGLContext->' | wc -l` = 133,上一版写的"约 65 处"少了一倍),其中 MG_Impl 只有 2 处(`GL_Debug.cpp:99` 的 `.get()`、`GL_Program.cpp:1630` 的 `== nullptr`),绝大多数在 MG_Backend——尤其 DirectVulkan 里约 90 处 `MOBILEGL_ASSERT(MG_State::pGLContext, ...)` 的真值判断,另有 `DirectGLES.cpp:146` 的 `.get()` 与 `Managers.cpp` 里十来处 `if (MG_State::pGLContext)` 守卫。**因为 backend 侧那一簇恰恰是必须看到 replica 的,shim 的原型应当先拿 `MG_Backend/DirectVulkan/DirectVulkan.cpp` 的 assert 密集区开刀。** + +**如果这层隔离的成本被判定过高**,退路是把 `inproc` 降级为**纯测试模式**:applier 通过显式传入的表指针工作,server 侧不跑 MG_Impl(于是 `GenerateMipmap_Backend` 那类回退不可用,需要在 `inproc` 下走另一条路径)。但那样 P2.5 就不再测量它本该测量的"monolith 渲染线程"交付物——**这个取舍必须在 P0 结束前拍板并写进文档,不能悬着**。 + +### 12.3 `inproc` 作为产品交付物 + +在隔离成本可接受的前提下,`inproc` 不只是测试脚手架:同进程第二个 `GLContext` + `mgl-srv-apply` 线程 = monolith 的渲染线程。今天 `PrepareForDraw`(状态调和、VAO/FBO/纹理/program/render-state sync、UBO ring memcpy)加驱动调用全部同步跑在 `glDrawElements` 里;把它们搬到 apply 线程,对 GL 线程 CPU-bound 的应用(本项目的 profiling 史说 Minecraft 就是)是**手上最大的单一杠杆**,且不需要任何 IPC/shm/平台工作。§15 的 P2.5 就是证伪它的门。 + +### 12.4 运行时选择与开关 + +`MOBILEGL_TRANSPORT = monolith(默认) | inproc | spawn | unix: | pipe:`,在 `ConfigLoader.cpp` 与既有开关并列解析。这一个选择免费换来:ctest `ENVIRONMENT` 变体、trace-replay 的 `setenv` 块(`trace_replay_core.cpp:134-207`)、FCL 的用户可编辑 env 偏好(`FCLauncher.java:417-430`)、plugin APK 的 V2 开关表(`android-plugin/app/build.gradle.kts:77-103`,由 `.github/scripts/validate-plugin-apks.sh` 校验)、`/data/local/tmp` CTS 路径。**零新增管线。** + +保留全部既有负面对照开关(`MOBILEGL_ESPRYT_DISABLE_{UBO,UNPACK,UPLOAD}_RING`、`_INVALIDATE_FLUSH`、`MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION`),新增:`MOBILEGL_IPC_SHADOW_SHM`、`MOBILEGL_IPC_ADOPT_TIER`、`MOBILEGL_IPC_PROGRAM`、`MOBILEGL_IPC_INLINE_PAYLOADS`、`MOBILEGL_IPC_PRESENT_CREDIT`、`MOBILEGL_IPC_SPIN_US`、`MOBILEGL_IPC_POLL_ESCALATE`、`MOBILEGL_IPC_PERSISTENT_BLOCK_KB`、`MOBILEGL_IPC_SERVER_AFFINITY`。 + +--- + +## 13. 构建布局 + +``` +MobileGL/MG_Remote/ + Protocol/ protocol.fbs protocol_generated.h(提交) Records.def RecordKinds.h + Handles.h Coverage.def MutationCoverage.def + generated/BackendStateSurface.inc(提交) generated/ImplMutationSurface.inc(提交) + Transport/ ITransport.h InProcessTransport.{h,cpp} SocketTransport.{h,cpp} + Framing.h Ring.{h,cpp} ShmSegment.{h,cpp} ShmSegmentPosix.cpp ShmSegmentWin32.cpp + FdPassing.{h,cpp} Doorbell.{h,cpp} + Shared/ XfbAccounting.{h,cpp} # client 与 applier 共用的 MG_Impl-side mutation helper + MipmapLevelPlan.{h,cpp} + Client/ WireMirror.{h,cpp} EmitTable.cpp EmitBufferOps.cpp + BackendObject_Remote.{h,cpp} CapsMirror.{h,cpp} + ClientArrayBounds.cpp CompositeResolver.cpp ShadowArena.{h,cpp} + PersistentMapTracker.{h,cpp} GpuWritePending.{h,cpp} + CoverageAssert.cpp Surface/{X11,Win32,Android,Headless}.cpp + Server/ ReplicaContext.{h,cpp} Applier.cpp ServerLoop.{h,cpp} + ReplyPool.{h,cpp} EventRing.{h,cpp} ServerMain.cpp + ServerJni.cpp # Android,与 DriverPostJni.cpp 并列 +scripts/ gen_protocol.py gen_backend_state_surface.py gen_impl_mutation_surface.py +MobileGL/MG_Test/Wire/CMakeLists.txt # 复制自 MG_Test/Buffer/(27 行)+ MobileGL_Protocol +``` + +CMake: +- `MG_Remote/**` 仅在 `MOBILEGL_BUILD_DISAGGREGATED` 下追加进 `SOURCE_FILES`(`CMakeLists.txt:226-419`),因此 `MobileGL`(`:485`)与 `MobileGL_s`(`:552`)都拿到。 +- `MobileGLServer`:桌面 `add_executable` 链接 `MobileGL_s`,`RUNTIME_OUTPUT_DIRECTORY` 设为 `$`(§11.1);**Android** `add_executable` + `set_target_properties(MobileGLServer PROPERTIES PREFIX "lib" SUFFIX ".so" OUTPUT_NAME "MobileGLServer")` 并链接**共享**的 `MobileGL`(一份 ~43MB 的 glslang/SPIRV-Cross/SPIRV-Tools),由 AGP 打进 `jniLibs`。server 主体是 ~30 行 stub:`dlopen(libMobileGL.so)` → `dlsym("mobilegl_server_main")`(可见性见 §11.2)。**一份共享库、两个角色,版本必然匹配**(对比 `Feat/CS-Delta-IPC` 的四件必须互相匹配的产物)。 + **AGP 能否打包一个被改名成 `lib*.so` 的 `add_executable`,是 P0 spike 的验证项之一**(`MobileGL/build.gradle` 没有设 `targets` 列表,上一版把这条当成已知事实)。 +- **FlatBuffers**:submodule `3rdparty/flatbuffers` 置于既有的 `if (EXISTS .../flatbuffers/CMakeLists.txt)` 保护下,**去掉 `if (NOT ANDROID)` 一刀切**。因为 `protocol_generated.h` 已提交,**默认构建图里没有 `flatc`,也不 `add_subdirectory(3rdparty/flatbuffers)`**(§7.1)。运行时是 header-only,只需要 `3rdparty/flatbuffers/include` 在 include path 上。 + **guard(本轮新增)**:若 `MOBILEGL_BUILD_DISAGGREGATED=ON` 而 `3rdparty/flatbuffers/include` 不存在,强制把该 option 设回 OFF 并 `message(WARNING ...)`——否则 `MG_Remote/**` 已经进了 `SOURCE_FILES` 而头文件找不到,构建以一个莫名其妙的错误失败(现有的 `EXISTS` 保护只包住 Protocol 子目录)。 + `MOBILEGL_FLATC_EXECUTABLE` 只服务 CI 的 `flatc-check`,经 `MobileGL/build.gradle:17-21` 已在用的 `externalNativeBuild { cmake { arguments } }` 槽传入。 +- 测试接线: + - `MG_Test/Wire/`(label `unit`)→ 现有 CI `test` job 自动收,**无需改 workflow**。 + - `MG_IntegrationTest/CMakeLists.txt` 每 backend 增加一条 `gtest_discover_tests`(`TEST_PREFIX "DirectGLES.Split."` / `"DirectVulkan.Split."`),**必须用 `mgl_itest_join_environment(... ${MGL_ITEST_COMMON_ENV})` 构造**,并带上 `MOBILEGL_IPC_SERVER_PATH`。三个已被文档记录的陷阱要遵守:ctest `ENVIRONMENT` 是**替换而非追加**(`:339-343`)、`;` 必须转义(`:322-332`)、property 覆盖 job env(`test.yml:253-262`)。 + - **trace replay 的 `SPLIT` 接线(本轮补细节)**:`add_trace_replay_test` 今天把测试命名为 `MobileGLTraceReplay.${CASE_NAME}.${BACKEND}`(`tools/trace_replay/CMakeLists.txt:330-332`),加一个 `SPLIT` 参数会与同 case+backend 的现有测试**重名**。改成 `MobileGLTraceReplay.${CASE_NAME}.${BACKEND}${SPLIT_SUFFIX}`。另外该测试的命令是 `cmake -P run_trace_case.cmake` 加约 18 个 `-DTRACE_*` 变量,所以还要加 `-DTRACE_TRANSPORT=` 并在 `run_trace_case.cmake` 里消费它——**这两个文件都要列进 P2 的交付物**。 +- CI 新增三个 step:`flatc-check`(重生成 `protocol_generated.h` + `git diff --exit-code`)、`coverage-check`(重生成两个 `.inc` + `git diff --exit-code`)、`monolith-abi-check`(OFF 构建与 ON+monolith 构建的 `nm --defined-only` / `.text` size 对基线)。 +- **CI 新增一条 grep 门**:禁止 `MG_Backend/` 与 `MG_State/` 下出现 `fprintf(stderr` / `printf(`。 + +--- + +## 14. 对 `Feat/CS-Delta-IPC` 的复用清单 + +### REUSE(原样取) +| 路径 | commit | 备注 | +|---|---|---| +| `docs/CS_Refactor/HandleSessionGeneration.md` | `546895aa` | 分支上最好的产物。三处修改:handle 清单补 `RenderbufferObject::GetLifetimeId()` **与 `GetVersion()`**;把第 2 节的 server 侧 share-group 要求降为 v2;把"lifetimeId 不符 → 销毁重建"改成 `Fatal`(§5.4) | +| `MobileGL/Protocol/mg_protocol_base.h` | `546895aa` | 干净无依赖的词汇(`MobileGLResult`、span、`ShmRegion`、id typedef、structSize-first 版本纪律) | +| `MobileGL/Protocol/tests/ProtocolSmoke.cpp` | `546895aa` | schema 往返门(默认改 ON) | +| 根 `CMakeLists.txt` 的 `EXISTS` 保护 + `.gitmodules` 条目 | `546895aa` | 去掉 `NOT ANDROID`,另加 §13 的 include-dir guard | +| `docs/CS_Refactor/HANDOFF.md` 第 6 节"已知坑清单" | `d5c00b9d`/`5964628d` | 逐字留作事后复盘:路径转换、versionCode 降级、双设备 `ANDROID_SERIAL`、flatbuffers camelCase accessor、union vector 产生指针、Release 下 `MGLOG_D` 被编译掉、嵌套 submodule 配方、`assembleTraceDebug` 改名 | + +### CHANGE(取走并改造) +| 路径 | commit | 改造 | +|---|---|---| +| `MobileGL/Protocol/protocol.fbs` | `546895aa` | 保留 delta 目录、`RenderStateBlob` 整块思想、`BufferShmAdopt`、命令清单、事件分类学。改:热路径转 `struct` + ring;删掉冗余的 `inlineBytes`/`data` 双胞胎(`:111-112`、`:125-126`,两半代码对哪个字段是真的意见不一:`ServerCore.cpp:184-208` 只读 `data`,`StateEmitter.h:60,111` 只写 `inlineBytes`);加 `ResyncSnapshot`、`AuxRequest`;给 `ProgramPublish.reflection` 与 `ObjectCreate.params` 真 schema;kind 枚举生成 + 每 kind `static_assert` + 运行期边界检查 | +| `MobileGL/Protocol/CMakeLists.txt` 的 flatc 解析 | `546895aa`/`65717b4c` | **不再照搬**:`add_subdirectory(3rdparty/flatbuffers)` 从默认路径整段删除(它就是那个 NDK 陷阱本体);只保留 `MOBILEGL_FLATC_EXECUTABLE` 供 CI;`enable_testing()` 移到根 | +| `MobileGL/ServerCore/ServerCore.{h,cpp}` | `65717b4c`+`c2260dd8` | 保留握手→解码→apply→credit 形状与 plugin manifest loader 思路。修:单次校验 + 零拷贝解码(今天校验两次外加一次整体拷贝,`:492-498` 与 `:218-221`);io/apply 分线程(`:404-406` 自承 worker 从未落地);完整事件集(`SendEvent` 只实现 `BATCH_APPLIED`,`:373-382`);credit 用最后一条实际 seq(`:427` 的 `baseSeq + items.size()`);接收缓冲不能是对着 64MiB 帧上限的固定 4MiB(`:478`);真正的段生命周期(`m_segments` 只增不减,`blobOwners` 只 push 不释放) | +| `ServerCore/tests/LoopbackSmoke.cpp` + `Backends/Dummy/` | `65717b4c` | 分支上最便宜的端到端门,**第一个重建**,重定向到真 applier | +| `MobileGL/Remote/InProcessTransport.h` | `65717b4c` | 重表述在 C++ `ITransport` 上;单侧 shutdown(今天 `:89-92` 连对端 inbox 一起关);真段生命周期(`Unmap`/`Close` 今天是 no-op);补 §6.2a 的双向 doorbell(condvar 版) | +| `MobileGL/Remote/Framing.h` | `65717b4c` | 保留帧格式;`m_pendingSize`/`m_haveHeader` 改 `mutable`(今天 `const_cast`,`:81,85`);`Feed()` 真校验 magic 与长度(今天永远返回 OK,坏 magic = 静默永久挂起);缓冲不足返回所需大小且**保留消息**;真正在 socket transport 里使用它(今天是死代码) | +| `MobileGL/RemoteClient/StateEmitter.h:39-307`(**仅 emit 半边**) | `b50f3348`+`d96be9f3` | 各域字段遍历是真知识,抬进 `WireMirror`/`ResyncSnapshot`。GL name 换 `lifetimeId`(今天 `:48-49,85,166-168,203,230` 全把 GL name 塞进 `handle`);`:175-181,:244-249,:253-258,:293-298` 的 O(n²) 线性扫描换 handle map;固定 6 attachment(`:232-236`)换 `MaxColorAttachments`;补上被跳过的 texture view(`:70-74`)。**不取 applier 半边(`:312-501`)** | +| `scripts/extract_backend_read_inventory.py` | `546895aa` | 改造成 `gen_backend_state_surface.py`:删掉前缀兜底(`:234-241`),未知 accessor 一律 UNMAPPED 并**编译失败**;把真 pull point 与 signature handle 化分开统计。**另写一个全新的 `gen_impl_mutation_surface.py`**(§5.9b),它在原分支没有对应物 | + +### DROP +| 路径 | 理由 | +|---|---| +| `MobileGL/Protocol/bfa.h`(480 行) | "strict C ABI"不是 C ABI:`ServerCore.cpp:177-179` 把 FlatBuffers 生成表的指针交给插件,插件必须是 C++ 且链接 FlatBuffers(`StateEmitter.h:330,351,362,372` 就是这么用的)。手抄的 60 字段 `MobileGLDynamicParameters`(`:63-129`)自承尾部不全、同步脚本从未写过——正是已在本项目造成 481 例 CTS 失败簇的那类数据的**长期静默漂移炸弹**。而本设计根本不需要 delta-apply vtable | +| `MobileGL/Protocol/mgruntime_api.h` + `MobileGL/UtilRuntime/*` | 360 行契约对 ~50 行实现(8 域实现 2 域);唯一消费者传 `nullptr`(`ServerCore.cpp:61`);缓存每次命中整份拷贝(`:79`)、按 `clear()` 淘汰(`:91-93`);smoke 断言 `api->metrics == nullptr`(`RuntimeApiSmoke.cpp:66`)。它的唯一理由随 BFA 消失;且本设计里翻译全在 server(它无论如何要链 SPIRV-Cross),glslang 全在 client | +| `MobileGL/Remote/LocalSocketTransport.{h,cpp}`、`ShmFactory.{h,cpp}` 实现 | 从未被任何测试执行(`LoopbackSmoke` 用的是 `InProcessTransport`,唯一另一个消费者 `ServerHost` 编译不过);每次 send 都 use-after-free(`:199`,`asio::buffer(next)` 指向局部 vector 而 lambda 捕获的是另一份拷贝);按 wire 长度无上限分配(`:232-236`);`Start` 里阻塞 accept/connect(`:116`、`:139-144`);无 strand 且 `framesSent++` 非原子(`:177-178`);**且完全没有 POSIX fd 传递**(`:296` 硬编码 `fd=-1`),Linux/Android 数据面一字节过不去。只保留 `ShmFactory.h:4-12` 作平台矩阵规格 | +| `MobileGL/ServerHost/main.cpp` | 编译不过(`:31,39,44,53-54` 对指针用 `.`,`c2260dd8` 改返回类型后成为死码)。`MobileGLServer` 在默认 ALL target 里,**分支 tip 无法完成一次完整构建** | +| `MobileGL/RemoteClient/tests/StateEquivalenceTest.cpp` | 把 delta apply 进第二个 `MG_State::GLContext`——验证的是它自己的 thin-server 前提说不该存在的数据路径;与生产 apply 路径零共享代码;只测全量 resync;`d96be9f3` 声称五域逐字段而文件只比了纹理、buffer、render-state blob、buffer binding slot(没有 VAO 属性/FBO attachment/RBO 格式比较) | +| `c7c9e346` + `29d721ef` 全部(share-group sessioning) | 非 v1 前提(monolith 只有一个 `GLContext`:`GLState/Core.cpp:20,1487`);且非可合并质量:`VertexArrayState.cpp:+20-26` 往已共享的表里再压一个 default VAO 并重复 `Insert(0)`;四个头文件 `public:` 未复位泄漏私有成员;current session 是无锁进程全局,连它自己的 per-thread current 都没兑现;在状态权威里塞 `MOBILEGL_SESSION_SWAP` env kill switch 与 `s_defaultAdopted` 偷 context 的 hack。日后作为独立 PR 带多 context 测试落 `dev` | +| `b50f3348` 的 `RenderState::InstallParameters` + `public:` | 本设计不需要 Install setter(D3);若日后需要整块安装,用正确作用域的方法或单条 friend,绝不靠裸 `public:` | +| `d96be9f3` 的 TRIAGE 指令(`DirectGLES.cpp:+2583-2590`) | per-draw `fprintf(stderr)`。**分支上每一次测量都跑在它上面。** 同规则适用于当前工作树的 `[IBOTX]`/`[BUFTX]`(P0 清除) | + +--- + +## 15. 分阶段实施计划 + +> 通用纪律(每个 commit 都适用):默认 ALL target 必须能完整构建;禁止提交热路径插桩;每个门必须**能因它存在的理由变红**;**Windows 机器不是正确性门**(其 Vulkan 缺 `vkCreateHeadlessSurfaceEXT`,占该机 567 个基线集成失败中的 423 个);设备对比走 reboot-clean + 同窗口配对 A/B;**每个阶段的出口都跑一次 §12 第 4 层的 `nm`/`.text` monolith 门**(不只是 P0)。 + +### P0 — 卫生、骨架与两个 spike(5 天) + +**交付物** +- 清除工作树 `[IBOTX]`/`[BUFTX]` fprintf(`DirectGLES.cpp:640-663`、`Managers.cpp:875-877`,后者在 `pendingMutex` 临界区内)。 +- `RenderbufferObject::GetLifetimeId()` **与 `GetVersion()`**(§5.4)。 +- 两个 CMake option:`MOBILEGL_BUILD_DISAGGREGATED`(OFF) 与 `MOBILEGL_BUILD_DISAGGREGATED_INPROC`(OFF);`MOBILEGL_TRANSPORT` 解析;§13 的 flatbuffers include-dir guard。 +- `MG_Remote/{Protocol,Transport}` 骨架:`ITransport`、`InProcessTransport`、校验型 `Framing`、`Ring` + `RingControl`(**双 tail、双游标三元组、双向 doorbell**)、`Doorbell`、`ShmSegment`(memfd/ASharedMemory/shm_open/CreateFileMappingW)、**`SCM_RIGHTS` fd 传递(第一优先)**。 +- `protocol.fbs` + 提交的 `protocol_generated.h` + `gen_protocol.py` + CI `flatc-check`;`Records.def` 的 `static_assert` 与**运行期边界检查**生成。 +- `gen_backend_state_surface.py` + `Coverage.def` **和** `gen_impl_mutation_surface.py` + `MutationCoverage.def` + `CoverageAssert.cpp` + CI `coverage-check`。 +- `MG_Test/Wire/` 目录(复制 `MG_Test/Buffer/CMakeLists.txt`)。 +- **`TracyPlot` 字节计数器**,装在 wire **两侧**,按类别分:`cmd-records`、`stage-buffer`、`stage-texture`、`stage-ubo`、`persistent-map-push`、`server-ring`、`server-staging`(树里今天完全没有 per-frame 字节度量:`MG_Util/Metrics` 只是格式算术,Tracy 只有 zone 无 plot,MC 26.3 战役的 PANDIAG 已不在树里)。 +- `mobilegl_server_main` 的 `extern "C" __attribute__((visibility("default")))` 声明(§11.2)。 +- **spike A(Android 交付链,半天)**:从根 CMakeLists 造一个平凡的 `libMobileGLServer.so`(`add_executable` + `PREFIX "lib"/SUFFIX ".so"`),确认 AGP 把它打进 `lib/arm64-v8a/`;让 `TraceReplayActivity` 从 `getApplicationInfo().nativeLibraryDir` **`posix_spawn`** 它并打一行日志——在**应用自身进程(`untrusted_app` 域)**验证 exec,而不是靠 `run-as`。同时把一个通用 env 透传(`--es mobilegl_env "K=V;K=V"`)接进 trace 路径的五个文件(`trace-replay-ci.sh`、`TraceReplayActivity.java`、JNI Request marshalling、`trace_replay_core.cpp`、`run_android_retrace_local.py`),取代逐 knob 加 `--es/--ez`。 +- **spike B(external memory 可行性,半天)**:最小程序,导出一个 `HOST_VISIBLE|HOST_COHERENT` VkBuffer 的 fd,`mmap` 后回读校验,在 `35d0befa`(Adreno 830)与 `3B159D009VZ00000`(Mali)各跑一次。与 `SCM_RIGHTS` 测试同批。**目的是让 P7 的结论在第一周就有方向**:若两台都不行,P7 缩为"记录并回退",省 6 天。 + +**验收** +- Linux 与 Android/NDK 上 `cmake --build .` 默认 target 成功。 +- `ctest -L unit`、`-L integration-gpu` 与 `81b17c0b` 同一通过集。 +- `MG_Test/Wire` 的 fd 传递测试把一个 memfd 从 fork 出的子进程传回父进程并读到相同字节。 +- **`nm --defined-only` 与去符号 `.text` size 与改动前的 `libMobileGL.so` 一致**(OFF 构建);`nm -D | grep mobilegl_server_main` 在 RelWithDebInfo 下命中。 +- spike A:设备上打出那行日志。 +- spike B:结论写进 §17 的开放问题并驱动 P7 的排期。 +- **§12.2 的取舍拍板**:`inproc` 走"四全局角色隔离"还是"降级为纯测试模式",写进文档。 + +### P1a — 垂直切片(client + inproc applier),Linux 门(6 天) + +**范围刻意收窄到 OpenRA 需要的东西**:仅 DirectGLES;buffer(仅 shadow,采纳强制关,**含 §5.10 的 persistent-map 推送**);2D 纹理的整 level 与 union-box 上传(**含 §5.6a 的 clear-on-emit**);VAO;FBO;render state;binds;索引与非索引 draw;clear;present;**server 从源码 relink**(带全字段 `reflectionDigest`);一条阻塞 `ReadPixels`;**client 侧 `MarkGpuWritten` 保守置位(§5.6b)**。不含 sync/query/XFB/compute/dirty-rects/MultiDraw。 + +**交付物**:`WireMirror`(含 `PublishImplicitState`、`PersistentMapTracker`、`GpuWritePending`)、`EmitTable`、`EmitBufferOps`、`BackendObject_Remote`、`CapsMirror`、`ClientArrayBounds`、`CompositeResolver`(P1-4 走"server 自建 composite + digest 校验",见 §5.7);`ReplicaContext`、`Applier`(含 `MG_Remote::Shared::` 的 XFB/mipmap helper 接线,即使这一阶段还用不到 XFB)、`ServerLoop`(io+apply);`InProcessTransport` 上跑通。 + +**验收** +1. `ctest -R "DirectGLES\.Split\..*(ClearThenReadPixels|Triangle)"` 在 Linux + `MOBILEGL_TRANSPORT=inproc` 绿。 +2. **新增 `PersistentCoherentMapScenario`**(map `PERSISTENT|WRITE|COHERENT`、写、不做任何其它 GL 调用、draw、readback 校验)在 split 下绿。**这是本计划里唯一一个专为一个 fatal 缺陷设的门**,必须在 P1a 就绿。 +3. 记录**两个进程/两个角色的峰值 RSS**(不只是 server 的)作为 P5 与 §16-R14 的基线。 +4. Tracy 计数器给出 `persistent-map-push` 的字节量(§5.10 保守版的代价)。 + +**明确非目标**:性能。P1-4 双份 glslang,**MC 级负载不在此测**。 + +### P1b — spawn transport,Linux 门(4 天) + +**交付物**:`SocketTransport`(socketpair + `fork`/`execve` + 显式 envp 剔除 `MOBILEGL_TRANSPORT`/`MOBILEGL_IPC_*`)、`ServerMain`、`MOBILEGL_IPC_SERVER_PATH` 发现链、就绪握手与有界重试、EOF 即时退出。 + +**验收** +1. P1a 的全部测试在 `MOBILEGL_TRANSPORT=spawn` 下绿(两个真进程、真 socket、真 `SCM_RIGHTS` 段)。 +2. **fork 链测试**:spawn 一个 server 并断言进程树只多出恰好一个子进程(§11.1)。 +3. **HeadlessGL 预检交互测试**:在开着 fork 预检的 Linux 上跑整套 split 集成用例,断言没有孤儿 server(用 `pgrep` 计数 + 预检结束后 100ms 内归零)。 + +### P2 — 广度:集成套件、trace 语料对齐、设备首跑(9 天) + +**交付物** +- 其余记录种类(MultiDraw/indirect 族含 client 数组范围计算与索引扫描、纹理 dirty rects、texture view、buffer texture、image unit、sampler、**renderbuffer storage**、program pipeline、`CopyImageSubData`、`BlitNamedFramebuffer`、`PixelStorePack`、`CurrentAttrib`)。 +- 完整 caps mirror 与 `tableSlotMask`。 +- DirectVulkan applier 支持(`SwapchainObject` 的 default-FBO 占位写变成 `EvDefaultFramebufferInfo`)。 +- `add_trace_replay_test` 的 `SPLIT` 参数:测试名加后缀、`-DTRACE_TRANSPORT=` 与 `run_trace_case.cmake` 的消费、`MOBILEGL_IPC_SERVER_PATH` 注入(§13)。 +- 一个**无 present** 的 split 集成用例(§9.3)。 +- `ClientArrayAfterComputeWriteScenario`(§6.10)。 + +**验收** +1. `ctest -L integration-gpu -R '^DirectGLES\.Split\.'` 与 `'^DirectGLES\.'` **逐名同一通过/失败集**;DirectVulkan 同。 +2. CI 全部 trace case(OpenRA、`minecraft-1.21.4-startup`、`-main-menu`、`1.21.11`、`1.17`、两个 Create)在 Linux split 模式 SSIM ≥ 0.99。**两个带 `coherent_as_flush: true` 的 Create 用例在 split 与 monolith 下都开着该开关跑**(§5.10 已让两侧走同一路径),若 Tracy 显示保守推送在这两个 fixture 上代价不可接受,则把 §5.10 的精确版(P4.5 的块脏位)提前到本阶段——这是全计划唯一允许因测量改变阶段顺序的地方。 +3. **`python tools/trace_replay/run_android_retrace_local.py --case OpenRA --backend DirectGLES` 在 `35d0befa` 上 SSIM ≥ 0.99(split 模式)** —— 本阶段的出口判据(从 P1 移来),每轮约 1 分钟。 + +### P2.5 — inproc 渲染线程:单机收益证伪门(3 天) + +**交付物**:`add_trace_replay_test` 的 `INPROC` 变体;应用线程与 apply 线程的**逐线程 CPU 时间**插桩(不只是墙钟);`MOBILEGL_IPC_SERVER_AFFINITY` 的大核绑定(复用 `ShaderCompilePool.cpp:73-96`);用现有 `--benchmark --benchmark-tail-frames --benchmark-result` 在全部 fixture 上跑。 + +**验收**:`inproc` 与 `monolith` 的应用线程帧时差 + 两侧 CPU 时间在 Create/Flywheel 与 MC fixture 上被**测量并记录**,且带亲和性开/关两组。若不利,整个计划的价值主张在第 6 周(而不是第 15 周)被重新审视。**这是本计划最早的证伪点,也是 §16-R15 排期风险的退火器。** + +### P3 — sync / query / present 节奏(5 天) + +**交付物** +- client 铸造的 sync/query handle;轮询入口的 publish + 饥饿升级(§7.2)。 +- **fence 完成度来自真实逐 fence 退休**(§8 末尾):server 侧真 `FenceSync` + 非 present 轮询 + `EvFenceSignaled`。 +- **DirectGLES 的非 present fence tick**(§9.3)。 +- `EvQueryResult`;present credit **默认 1** + 三个 seq 水位;swap interval 搭 `RecPresent`。 +- §8 的三个 `dev` 独立修复。 +- per-frame round-trip 计数器;**输入延迟直方图**(记录发射 → present 完成,§9.1)。 + +**验收** +1. `XfbPrimitiveQueryScenario`、`PrimitivesGeneratedNoXfbScenario`、`AsyncCompileScenario` 在 split 下绿。 +2. round-trip 计数器:在**全部 trace case** 的稳态帧上,draw/state/upload 路径的 round trip 读 **0**;conditional render 与阻塞式 query 的次数按用例列表公布(不是笼统宣称"零 round trip")。 +3. **零 timeout 轮询循环测试**:一个只有 `glFenceSync` + `while(glClientWaitSync(...,0)==GL_TIMEOUT_EXPIRED){}` 的用例必须在有界时间内退出(若无 §7.2 的 publish 规则它会永久挂起)。 +4. `bench.sh` 在 `35d0befa` 配对 A/B(两侧均关采纳)显示 split 帧时在 monolith 的 10% 内,且**输入延迟直方图**的 p50/p99 被记录。 + +### P4 — 回读与 GPU-written(5 天) + +**交付物**:`SEG_REPLY`;阻塞 `ReadPixels` → 客户内存;PBO readback 变 fire-and-forget + client 侧 `MarkGpuWritten`;`EvGpuWritten` 作为收窄提示;`EvBufferWriteback`;`glGetTexImage`/`GetTextureImage` 路由 + **per-level `serverAuthoritative` 位**(只覆盖生成 mip 与 CopyImage 镜像两处,§6.6);`EvGlError` + **分配类入口的 `kNeedsAck`**(§5.6c);`SEG_EVENT` 溢出策略与等待中排空(§7.4)。 +**`glCopyTexSubImage*` / `glClearTexImage` 保持前端实现不变**(推翻上一版的"移到 server + `EvTexWriteback`")。 + +**验收** +1. split 下 `DepthStencilReadbackScenario`、`DepthStencilReadbackMatrixScenario`、`DepthStencilReadbackAttachmentShapeScenario`、`PackedWordReadbackScenario`、`LayeredTextureReadbackScenario`、`ClearThenReadPixelsScenario`、`PixelStoreSweepScenario`、`CopyImage*`(4)、`SsboArrayLengthScenario`、`AtomicCounterScenario`、`StorageBufferRegrowScenario` 双 backend 全绿。 +2. **OOM 探测用例**:请求一个必然失败的巨大 renderbuffer,断言紧接着的 `glGetError()` 返回 `GL_OUT_OF_MEMORY`。 +3. **事件 ring 溢出故障注入**:client 被 present credit 阻塞时灌满 `SEG_EVENT`,双方都不死锁,`eventDropped` 只统计到 `EvLogLine`。 + +### P4.5 — 零拷贝 shadow-in-shm 前移(4 天) + +(原计划推到 P6;MC pan 每帧 ~9MB 的额外拷贝不该背六个阶段) + +**交付物**:`ShadowArena`;`MapAlignedAllocator` 与 `MipmapStorage` level vector 的 shm arena(≥256KiB 才走,**整段 `#if MOBILEGL_BUILD_DISAGGREGATED` 包裹**,§12 第 3 层);per-shadow 64KiB 块发送水位 WAR 规则;**shadow 块退休规则**(§6.1);§5.10 精确版 persistent-map 推送复用同一套块脏位;`MOBILEGL_IPC_SHADOW_SHM` 开关。 + +**验收** +1. P2/P4 门在开关两态下均不回归。 +2. **两侧** `TracyPlot` 显示 buffer 上传路径的总拷贝次数从 4 降到 3(或选方案 B 则到 2,§6.4);staged-copy 回退率被记录成数字。 +3. `nm`/`.text` monolith 门仍绿(这一条是本阶段最容易破的)。 +4. 对象删除/重定义与未 apply 记录并发的压力测试不读到别的对象的字节。 + +### P5 — `ProgramPublish`,退役 server relink(6 天) + +**交付物**:`ProgramArtifactsArchive.h`(`Visit()` + `sizeof` 绊线);`ProgramObject::InstallPublishedLink`;`GLContext::SetReplicaResolvedDrawProgram`(`#if MOBILEGL_BUILD_DISAGGREGATED` 包裹)+ client 侧 composite 解析;`MOBILEGL_IPC_PROGRAM=publish|relink`;`publish` 下移除 server compile pool;**顺带把 DirectVulkan 的 blit / depth-mipmap 四段固定 shader 在构建期烘成 SPIR-V**(`VulkanRenderer.cpp:4211-4356`,同时也从 **monolith 启动**里去掉一次 glslang 编译链接;逃生口 `MOBILEGL_BAKED_INTERNAL_SHADERS=0`)。 + +**验收** +1. P2 全门在 `publish` 下重跑不变。 +2. `relink` 下 `reflectionDigest` 在每个 trace case 绿(即它是活门不是死门)。 +3. `35d0befa` 上用 `minecraft-1.21.4-startup` trace 做首帧 link 延迟 A/B,`publish ≤ relink`。 +4. **`nm` 复核 `libMobileGLServer.so` 在 `publish` 下不再引用 glslang 库符号**(注意 `ProgramObject.h` 传递包含 `ShaderObject.h` → `ShaderCompileTask.h`,所以这条必须**用 `nm` 验证而不是断言**)。 +5. server 峰值 RSS 相对 P1a 基线下降;两个进程的 RSS 合计与 §16-R14 的预算对表。 + +### P6 — 数据面性能(6 天) + +**交付物**:`PendingResidentWrite` 借用 ring slot(用 `*RetiredTail` 门控);全局 UBO ring 进 shm;解码移到 `mgl-srv-io`;可选 `mgl-client-tx`;bind 合并(凭数据决定);`mirror-map` 两次映射消除 ring wrap;`MOBILEGL_IPC_INLINE_PAYLOADS` 负面对照;§6.4 方案 B(replica adopt client shadow)的可行性评估与实现(若 Tracy 数据支持);Windows AF_UNIX 评估(§11.5);`MOBILEGL_IPC_SPIN_US` 与 `MOBILEGL_IPC_PRESENT_CREDIT` 的设备调优。 + +**验收**:两台设备上 `minecraft-1.21.4-fabric-sodium-in-world` 的配对 A/B,每项优化用自己的开关单独可 A/B;P2/P4 门在任意开关组合下不回归;输入延迟直方图不因任何优化恶化。 + +### P7 — persistent map 与 ≥16MiB 采纳(8 天,若 P0 spike B 全否则缩为 2 天) + +**交付物**:`SEG_ADOPT`(server 分配)+ 三档探针(T2/T1/T0)+ 自动回退到 P4.5 路径;阻塞 `AcquirePersistentMap`;client 侧注册 `ResidentSubData`;`MOBILEGL_IPC_RESPAWN` 与 `MOBILEGL_IPC_ADOPT_TIER` 的互斥检查(§5.8)。 + +**验收**:`LargeArenaAdoptionScenario`、`ResidentIndexScenario` 在采纳开启下绿;`bench.sh` 在 Mali 设备 `3B159D009VZ00000` 上用 `minecraft-1.21.4-in-world` 报出 {monolith, split+采纳, split+回退} 的 p99 帧时,以 MC 26.3 的 163→21ms 为标尺。 +**"设备 X 上拒绝,已记录,回退成本 N ms" 是本阶段的可接受结论**——因为回退路径在 P1a/P4.5 已交付并测量。 + +### P8 — XFB / compute / 健壮性 / 多线程(6 天) + +**交付物**:XFB capture writeback 与 scatter(全部在 server 对 replica 执行,只有合并后的 range 过线);**`RecXfbAccounting` 与共享 helper 的完整接线**(§2(g)-2;注意它必须跟着 `RecBindTransformFeedback` 的对象切换走,`Core.cpp:1273,1296`);GS strip 顺序修正移到 server;compute dispatch/indirect/barrier/image load-store;`EvGlError` 与 `glGetError` 的顺序 + `MOBILEGL_IPC_STRICT_ERRORS` 诊断开关;server 死亡的 device-lost 闩锁与 client 死亡的 server 拆机;外来线程 sync/query 的 `AuxRequest`;修 `EGLOperationMutex` 既有漏洞(`ReleaseThread`、`SwapInterval`)。 + +**验收** +1. split 下 `Xfb*`(5)、`Tessellation*`(2)、`SsboArrayDynamicIndexScenario`、`ImageLoadStoreSsoScenario` 双 backend 绿。 +2. `tools/cts/scripts/run_cts_local.py --backend {DirectGLES,DirectVulkan} --env MOBILEGL_TRANSPORT=spawn` 在 GL33 caselist 上 conformance rate 与 monolith 相差 ≤ 0.5 个百分点(按项目既定的逐 backend 表格式报告:行=GL 版本/扩展,列=状态计数,conformance rate = Pass/(Pass+Fail),分母不含 NS)。 +3. 故障注入测试在帧中 SIGKILL server,client 干净地以 `EGL_CONTEXT_LOST` 退出而不崩溃。 + +**注**:XFB 场景的 `RecXfbAccounting` 骨架其实在 P1a 就要落地(helper + 记录 + applier 分支),只是这里才被真正测到。§5.9b 的生成器会在 P0 就把它标成未映射并让编译失败,从而强制这个顺序。 + +### P9 — Android 生产窗口路径(10 天) + +**交付物**:`MobileGLServerService`(`android:process=":mgl"`);Messenger/AIDL 的 `Surface` 交接;surface 生命周期(`surfaceDestroyed`、1×1 pbuffer 交换舞、resize)作为协议消息;`ResyncSnapshot`;APK 打包与 `validate-plugin-apks.sh` 更新;`MOBILEGL_TRANSPORT` 进 plugin V2 metadata 与 FCL 用户 env 偏好。 + +**验收**:FCL 在 `35d0befa` 上以 split 模式把 Minecraft 1.21.4 拉到主菜单并进入世界;`bench.sh` 在同一个热窗口内报出 split vs monolith 的游戏内 FPS **与输入延迟**;plugin APK 通过 `.github/scripts/validate-plugin-apks.sh`;旋屏/后台切换的 surface 销毁重建无泄漏无挂起;两个进程的合计 RSS 落在预算内。 + +**合计 ≈ 77 人日 ≈ 16 周**(5+6+4+9+3+5+5+4+6+6+8+6+10)。里程碑:**第 3 周末 Linux 上跨进程渲染出第一帧**(P1b),**第 5 周末真机 OpenRA 绿**(P2),**第 6 周有 monolith 侧的独立收益数字**(P2.5)。 + +--- + +## 16. 风险与对策 + +| # | 风险 | 对策 | +|---|---|---| +| R1 | **replica applier 在某个长尾副作用上与 client 的 MG_State 语义分歧**——具体形态是 MG_Impl 在 table 调用旁做的 mutation(§2(g) 已确认两族:`EnsureGeneratedMipmapStorageAllocated`、`AccountTransformFeedbackPrimitives`)。症状是错误像素或错误查询结果,不是崩溃 | **§5.9b 的第二个生成器**把这一面变成编译期门:MG_Impl 里任何与 table 调用同函数的 mutator 未映射即 `#error`。两族已知实例在 P1a 就用共享 helper 接线。P2 的门是**全部集成场景 + trace 语料的逐名通过集对齐**,远比 `Feat/CS-Delta-IPC` 的两 `GLContext` 逐字段比较(且只查了 5 域中的 2 域)严苛。外加 `MOBILEGL_IPC_VALIDATE_SERVER`(server 侧保留 MG_Impl 校验器,分歧变成 server 侧 GL error 而非错误像素;CI 常开,出货构建用 `kPrevalidated` 短路) | +| R2 | **应用通过 coherent persistent map 写下的字节丢失**(`SyncPersistentMappedRange` 无 client 侧调用者) | §5.10 三件套:map/unmap 上线、client 侧块粒度推送、`PersistentCoherentMapScenario` 作为 **P1a 门**。这是本轮新增的最高优先级修复 | +| R3 | **read-after-GPU-write 静默读到陈旧 shadow**(`MarkGpuWritten` 无 client 侧建立者) | §5.6b:client 在每个 draw/dispatch 发射点保守置位并记 `emitSeq`;读入口强制 publish+等待+排空;`EvGpuWritten` 降级为收窄提示。§7.4 的排空点补上四个 buffer 读入口 | +| R4 | **零 timeout 轮询循环挂死**(轮询入口不是 publish 触发器) | §7.2:`glClientWaitSync`/`glGetSynciv`/`glGetQueryObject*(AVAILABLE\|NO_WAIT)` 全部成为门铃点,`GL_SYNC_FLUSH_COMMANDS_BIT` 无条件 publish;连续 N 次无进展升级为阻塞 round trip。P3 有专门的门 | +| R5 | **fence 完成度退化成帧计数推断**(DirectGLES 的 `completedFrameSerial` 只在 Present 前进),重蹈 MC 1.21.5 的 native-heap OOM | §8 末尾:server 侧真 fence + 非 present 轮询 + `EvFenceSignaled`;§9.3 的非 present fence tick 同时解决无 present 循环下的 ring 饥饿 | +| R6 | **纹理每次更新都传整 level**(永不清 dirty flag ⇒ union box 单调增长) | §5.6a:client 在发射后立刻 `MarkStorageDirty(...,false)`;ack 问题由"resync 从完好 shadow 传整 level"+"硬 drain 后重发未 apply 记录"两条收口。已确认 MG_Impl 从不读自己的 dirty 状态,所以清是安全的 | +| R7 | **每 draw 编解码成本超过它替换掉的东西**,MC 级帧(1000-4000 draw)反而更慢;且总 CPU 工作量本来就变大(遍历跑两次) | 记录是 FlatBuffers `struct`(8B header + 定长),无 verifier walk;publish 是每记录一次 release store 而不是 64KiB 攒批(§7.2)。**`TracyPlot` 两侧计数器在 P0 就落地**;P2.5 在第 6 周给出 inproc 的证伪数字**并带逐线程 CPU 时间**;`mgl-srv-apply` 绑大核(§10),mask 打日志;P3 门要求 split 帧时在 monolith 10% 内**才**授权后续优化 | +| R8 | **client 侧等待全是跨进程自旋**(无 producer 侧门铃),手机上一颗大核满频空转 | §6.2a 的双向 doorbell:`producerParked` + 反向 1 字节;自旋窗口 `MOBILEGL_IPC_SPIN_US` 可调可测。`inproc` 用 condvar | +| R9 | **`SEG_EVENT` 满 + client 被 credit 阻塞 = 双向死锁** | §7.4:等待循环内必须排空;`EvLogLine` 有损(覆盖最旧 + `eventDropped` 计数);语义事件无损,满时 server 置 `eventRingFull` 并停在记录边界上停止 apply。P4 有故障注入门 | +| R10 | **端到端延迟叠加**(client credit + server FIF + 驱动深度 = 4-5 帧) | §9.1:credit 默认 1;文档写出叠加公式;P3/P9 增加**输入延迟直方图**门,只有实测吞吐收益抵得过实测延迟才调高 | +| R11 | **`inproc` 因为四个进程全局而不可行**,从而 P2.5 这个最早的证伪门消失 | §12.1/§12.2:拆成两个 CMake option(出货只开 `spawn`,热路径无 TLS);四个全局都要角色隔离,shim 需求列全,非箭头用法实测 133 处;**P0 结束前必须拍板**是做隔离还是把 `inproc` 降级为纯测试模式,并写清后者对 P2.5 的含义 | +| R12 | **分配类 GL 错误晚到,OOM 探测惯用法失效** | §5.6c:只把分配类入口标 `kNeedsAck`(罕见且本来就贵),其余保持晚到;`glGetError` 永远本地。P4 有 OOM 探测门 | +| R13 | **server 分配的 host-visible coherent 内存无法导出重映射**,丢掉 ≥16MiB 采纳(值 p99 163→21ms、~400MB RSS) | 排在**最后**(P7),且 **P0 的 spike B 在第一周就给出方向**。此时 P1a/P4.5 的 shadow 路径已交付并测量。阶段明确允许"拒绝,已记录"的结论。前端已容忍 `nullptr`(三处),kill switch 已存在,无需回滚任何代码 | +| R14 | **内存翻倍无预算**:client 段(`SEG_CMD` 8MiB + `SEG_STAGE` 32MiB↑)+ 完整 replica context(每 buffer 一份 `PipeResource`、每 texture level 一份 `MipmapStorage`)+ server 自己的三个 ring(UBO/unpack/upload 各 4→64MiB,`Managers.cpp:82-96`)+ 64MiB buffer pool(`Managers.cpp:566`)。合计可达 ~450MiB 新增,而本项目把"省 400MB"当作采纳修复的头条成果,且有 blanket-immutable 导致 LMK 屠杀的记忆 | 计划里与 round-trip 预算并列写出**稳态内存预算**;P1a 验收记录**两个进程**的 RSS(不只是 server);`SEG_STAGE` 上限由实测定而不是默认 256MiB;优先推进 §6.4 方案 B(replica 采纳 client shadow),因为它同时消掉重复 shadow 而不只是一次拷贝 | +| R15 | **排期乐观**(P0 5 天含两个 spike + 四平台 shm + SCM_RIGHTS + 两个代码生成器;P1a+P1b 10 天做完整 client 与 server)。校准点:`Feat/CS-Delta-IPC` 10 个 commit / 6668 行、从未渲出一帧,并自承四天耗在一个不可复现的回归上 | P1 已拆成 P1a/P1b,设备 retrace 移到 P2 出口;**P2.5 是排期风险的退火器**——第 6 周就能拿到"这条路值不值得走"的数字,且它本身不依赖任何跨进程工作。若 P0/P1 超期 50%,先跑 P2.5 的 inproc 部分再决定是否继续 | +| R16 | **socket transport 是新实现**,而上一版有每次 send 的 UAF、无上限分配、无 fd 传递 | 从设计草图重写而非修补:读时按 64MiB 上限校验 magic/长度;接收缓冲不足时返回所需大小**且保留消息**;`async_write` 用 `shared_ptr` payload 自持缓冲;socketpair + 继承 fd 完全去掉 accept/connect(Windows 用 overlapped named pipe 对,§11.5)。**`SCM_RIGHTS` 是 P0 交付物并带独立测试** | +| R17 | **Android 交付链**(server `.so` 打包、`untrusted_app` 域 exec、trace app env 透传)比想象的重,或被 AGP/SELinux 挡住 | **P0 的 spike A** 在第一周就验证;P1-P8 全部离屏且不依赖它(Linux 门优先);两条回退:裸 exec PIE server 配 `AHardwareBuffer_sendHandleToUnixSocket` blit-back;或把 split 作为 headless/工装专用配置发布 | +| R18 | **spawn 出来的 server 继承 `MOBILEGL_TRANSPORT` 而无限 fork** | §11.1 双保险:显式 envp 剔除 + `mobilegl_server_main` 强制 Monolith;P1b 有进程树计数门 | +| R19 | **HeadlessGL 的 fork 预检留下持有 GPU 的孤儿 server** | §11.3:EOF 即时退出(亚秒);就绪握手有界重试;P1b 有 `pgrep` 计数门 | +| R20 | **`MobileGLServer` 在两个桌面门里都找不到**(`dladdr` 对静态链接的 itest 与显式 `-DMOBILEGL_LIBRARY` 的 retrace 都失效) | §11.1:`MOBILEGL_IPC_SERVER_PATH` 为主、`dladdr` 兜底;`RUNTIME_OUTPUT_DIRECTORY` 对齐;每条新 ctest `ENVIRONMENT` 都注入;并复核 CI artifact 搬运后绝对路径是否还成立 | +| R21 | **`mobilegl_server_main` 在出货构建里 dlsym 不到**(非 Debug 的 hidden visibility preset) | §11.2:显式 `visibility("default")`;P0 加 `nm -D` 断言 | +| R22 | **Magma 的 present 节奏被 IPC credit 改变**(它从不注册 `SetSwapInterval` 且偏好 MAILBOX/IMMEDIATE) | `MOBILEGL_IPC_PRESENT_CREDIT` 可配;P6/P9 在设备上测量输入延迟与帧节奏;若 Magma 需要,把"注册 `SetSwapInterval` 并映射到 FIFO"作为**独立的 `dev` 变更**,不让两套机制同时管节奏 | +| R23 | **两件 Android 产物版本漂移** | 一份共享库两个角色:server 是 ~30 行 stub,`dlopen(libMobileGL.so)` + `dlsym(mobilegl_server_main)`;`Hello`/`Welcome` 里的 build fingerprint(git hash + `Records.def` hash)不匹配 → 明确报错而非静默协议故障 | +| R24 | **`SEG_CMD` 的记录被并发写坏导致 applier 游标走飞** | §6.3 的运行期边界检查(`size >= sizeof(T) && size <= remainingRingBytes && (size%8)==0`,`kVarTail` 另查尾长自洽),违反即 `Fatal{ProtocolCorruption}`,绝不进入 UB | + +--- + +## 17. 开放问题 + +1. **T1/T0 采纳在 Adreno 830 与 Mali-G925 上到底能不能用?** 由 **P0 的 spike B** 在第一周回答(导出 `HOST_VISIBLE|HOST_COHERENT` VkBuffer 的 fd,client `mmap` 后回读),与 `SCM_RIGHTS` 测试同批。若两台设备都不行,P7 缩为"记录并回退",节省 6 天;若可行,还要回答 GLES 侧能否用 `GL_EXT_memory_object_fd` + `glBufferStorageMemEXT` 走同一条路(DirectGLES 的采纳今天走的是 `glBufferStorageEXT` + `glMapBufferRange(PERSISTENT|COHERENT)`,不是外部内存)。 +2. **`glGetError` 的严格性 CTS 到底要求到什么程度?** §5.6c 已把分配类改成同步 ack,剩下的晚到错误里,哪些 CTS case 可能观察到?P8 需要列出清单。若清单为空,`MOBILEGL_IPC_STRICT_ERRORS` 可以永久保持默认关。 +3. **P2.5 的 inproc 数字若为负怎么办?** 需要事先约定:若 inproc 相对 monolith 无收益甚至更慢(含亲和性绑定之后),是继续(因为拆分本身还有内存隔离、崩溃隔离、工装价值)还是收缩到 headless 工装用途?**建议在 P2.5 前由协调者拍板判据**,并同时约定"绑大核后仍无收益"与"未绑核无收益"是两个不同的结论。 +4. **§12.2 的隔离取舍**:`inproc` 做四全局角色隔离(含 133 处非箭头用法的 shim)值不值?若判定不值而把 `inproc` 降级为纯测试模式,P2.5 测的就不再是 monolith 渲染线程交付物——那时 monolith 侧的收益要靠什么证明?**P0 结束前必须有答案。** +5. **§6.4 的拷贝目标选方案 A 还是 B?** 方案 B(replica 的 `PipeResource` 采纳 client 的 `SEG_SHADOW` 只读映射)能把 buffer 上传路径从 3 次降到 2 次并消掉重复 shadow(对 R14 的内存预算意义更大),但要处理 server 侧写(`WritebackFromBackend`、生成 mip、CopyImage 镜像)的 copy-on-write 升级。P4.5 先做 A 并测量,P6 由数据决定是否做 B。 +6. **`SEG_SHADOW` 在 Android 上应该用 `ASharedMemory` 还是 memfd?** 前者是平台正道且有 `setProt` 只读降权(正好匹配"client 拥有、server 只读"),后者有 sealing。大 buffer 频繁重映射的场景需要一次实测。 +7. **client 侧是否需要 `mgl-client-tx` 发送线程?** 只有 P6 的 `TracyPlot` 数据能回答;在此之前不要预先加线程(会引入拷贝或锁)。 +8. **`ResyncSnapshot` 与采纳的互斥能否放松?** §5.8 目前规定 `MOBILEGL_IPC_RESPAWN=1` 与 `MOBILEGL_IPC_ADOPT_TIER != 2` 互斥,因为 adopted store 的字节在 server。是否值得为 adopted buffer 单独做一条"server 死亡时其内容视为丢失、按 `hasDefinedContent=false` 重建"的降级路径?取决于 MC 的 chunk arena 在 respawn 后能否被应用自己重填。 +9. **Windows AF_UNIX-everywhere 是否值得?** asio 的 IOCP `async_accept` 走 `AcceptEx`(AF_UNIX 从不支持);我们用继承 overlapped 句柄绕开 accept,理论上可行但需真编真跑。P6 评估,named pipe 是已知可用的默认。 +10. **P9 的 ART 启动成本具体是多少?** 若不可接受,是否接受"游戏内走 monolith,工装/CTS 走 split"的长期二元形态? +11. **`tools/trace_replay` 的 Android 应用内路径是否从非主线程驱动 GL、是否每重放帧调 `Present`?** 桌面重放器传 `--singlethread`(`trace_replay_core.cpp:430`),Android 应用内路径本次未完整追踪,它决定该工装能否验证节奏模型(尤其是 §9.1 的输入延迟直方图)。 +12. **`MOBILEGL_IPC_PERSISTENT_BLOCK_KB` 的默认值与脏块判定方式**:P1-4 的保守版(整 mapped span 按块重传)在 Create/Flywheel fixture 上的实测代价是多少?精确版用 `memcmp` 还是 mprotect 写屏障?前者对 1MB 块是 ~50µs 量级且只在真正 mapped 的 buffer 上跑,看起来够用,但需要 P2 的数据确认。 +13. **`SEG_STAGE` 的上限该定多少?** R14 要求由实测定而不是默认 256MiB。需要 P2 之后用 MC in-world 与 Create 两类 fixture 的 `stage-*` Tracy 计数器给出 p99 占用。 + +--- + +## 附:环境变量与 CMake 选项汇总 + +**CMake** +| 选项 | 默认 | 说明 | +|---|---|---| +| `MOBILEGL_BUILD_DISAGGREGATED` | OFF | 出货形态。开启后 `MG_Remote/**` 进 `SOURCE_FILES`,支持 `spawn`/`unix:`/`pipe:`。四个进程全局保持普通全局,GL 热路径无 TLS | +| `MOBILEGL_BUILD_DISAGGREGATED_INPROC` | OFF | CI/调试形态,隐含开启上者,额外加四全局角色隔离 shim | +| `MOBILEGL_FLATC_EXECUTABLE` | 空 | 只服务 CI 的 `flatc-check`;默认构建图里没有 `flatc` | +| `MOBILEGL_BAKED_INTERNAL_SHADERS` | ON (P5+) | DirectVulkan 的 blit/depth-mipmap shader 构建期烘 SPIR-V;monolith 也受益 | + +**运行时** +| 变量 | 默认 | 说明 | +|---|---|---| +| `MOBILEGL_TRANSPORT` | `monolith` | `monolith` / `inproc` / `spawn` / `unix:` / `pipe:` | +| `MOBILEGL_IPC_SERVER_PATH` | 空 | server 可执行文件路径(**主要发现机制**,`dladdr` 兜底) | +| `MOBILEGL_IPC_RING_MB` | 8 | `SEG_CMD` 大小 | +| `MOBILEGL_IPC_STAGE_MB` | 32 | `SEG_STAGE` 初始大小;上限由实测定(§17-13) | +| `MOBILEGL_IPC_PRESENT_CREDIT` | **1** | client 允许领先的 present 数(1-4);延迟叠加见 §9.1 | +| `MOBILEGL_IPC_SPIN_US` | 50 | 挂起前的自旋窗口(两侧 doorbell 共用) | +| `MOBILEGL_IPC_POLL_ESCALATE` | 64 | 同一 handle 连续无进展轮询多少次后升级为阻塞 round trip | +| `MOBILEGL_IPC_PERSISTENT_BLOCK_KB` | 64 | persistent-map 推送的块粒度 | +| `MOBILEGL_IPC_PROGRAM` | `relink` (P1-4) → `publish` (P5+) | program artifact 传输方式;`relink` 保留为常驻 oracle | +| `MOBILEGL_IPC_ADOPT_TIER` | `auto` | `auto`/`0`(T0)/`1`(T1)/`2`(T2 拒绝);与 `MOBILEGL_IPC_RESPAWN` 互斥(§5.8) | +| `MOBILEGL_IPC_SHADOW_SHM` | 1 (P4.5+) | shadow-in-shm 零拷贝 | +| `MOBILEGL_IPC_INLINE_PAYLOADS` | 0 | 负面对照:一律内联,不用 `SEG_STAGE` | +| `MOBILEGL_IPC_SERVER_AFFINITY` | `auto` | `mgl-srv-apply` 的核绑定;`auto` 用 `ShaderCompilePool` 的大核探测 | +| `MOBILEGL_IPC_VALIDATE_SERVER` | CI=1,出货=0 | server 侧保留 MG_Impl 校验器,分歧变成 server GL error | +| `MOBILEGL_IPC_STRICT_ERRORS` | 0 | 诊断开关:让所有 backend 错误同步 ack(分配类默认已是同步) | +| `MOBILEGL_IPC_AUDIT` | 0 | 记录级审计日志 | +| `MOBILEGL_IPC_TRACE` | 0 | 逐记录 trace(仅调试构建) | +| `MOBILEGL_IPC_ATTACH` | 空 | 附着到已运行的 server(调试) | +| `MOBILEGL_IPC_RESPAWN` | 0 | server 死亡后重启 + `ResyncSnapshot` | +| `MOBILEGL_IPC_IDLE_EXIT_S` | 30 | server 的最后保险看门狗(EOF 应当即时退出) | + +**保留的既有负面对照开关**:`MOBILEGL_ESPRYT_DISABLE_UBO_RING`、`_UNPACK_RING`、`_UPLOAD_RING`、`_INVALIDATE_FLUSH`、`MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION`、`MOBILEGL_COHERENT_AS_FLUSH`(**在拆分模式下照常生效**,§5.10/§6.8)。 + diff --git a/docs/Disaggregated/REVIEW.md b/docs/Disaggregated/REVIEW.md new file mode 100644 index 000000000..0f19af37b --- /dev/null +++ b/docs/Disaggregated/REVIEW.md @@ -0,0 +1,265 @@ +# 拆分设计评审记录(feat/disaggregated) + +> 生成于 2026-09-05,配合 `PLAN.md` 阅读。记录设计竞标的结论、对抗性审查发现及其处置,便于日后追溯"为什么是这个方案"。 + +## 1. 候选方案与评分 + +四个独立架构方案(各自从不同角度出发),三位评审按 7 项加权打分(性能/roundtrip 0.20、实现成本与风险 0.20、GL 语义完整性 0.20、跨平台 0.10、monolith 保留 0.10、可测试/可增量 0.10、复用既有工作 0.10)。 + +| 方案 | 角度 | 三位评审加权分 | +|---|---|---| +| Replica-Server Disaggregation: a risk-first vertical slice to OpenRA-on-device, then one hard semantic at a time | Risk-first incremental delivery. The server is the same libMobileGL binary running a real MG_State GLContext driven by a | 8.6 / 8.8 / 8.9 | +| MG_Mirror: a thin, delta-fed state model inside the server — split without rewriting the backends | Thin server / delta-consuming backend. The server owns its own state model (MG_Mirror, ~4k lines, zero MG_State translat | 6.4 / 6.3 / 6.6 | +| MG_Wire: Disaggregating MobileGL by replaying MG_State mutations into a server-side replica GLContext | Replica-state first: the server process runs an unmodified MobileGL backend against its own MG_State::pGLContext, recons | 8 / 7.4 / 7.2 | +| Wire: a replayed GL command stream over a lock-free shared-memory ring | Command-stream / render-thread first. The primary channel is an SPSC lock-free shared-memory ring carrying fixed-layout | 8 / 7.8 / 7.4 / 7.7 / 7.2 / 7.5 | + +三位评审一致选择 **Replica-Server / risk-first**(server = 未改动 backend + replica `GLContext`)作为基底,并嫁接其余方案的要点:Design 1 的 per-level `serverAuthoritative` 位与 composite pipeline program 处理、Design 2 的 `nm --defined-only` + `.text` size monolith 门与 DirectVulkan 内部 shader 构建期烘焙、Design 3 的 SPSC shm ring + FlatBuffers `struct` 热路径与 shadow-in-shm 前移。 + +### 评审指出的致命缺陷(已在综合稿中处理) + +- DESIGN 2 — the conformance gate cannot detect the failure it exists to prevent. Its generated static_asserts check is_same_v on accessor SIGNATURES and sizeof/alignof/offsetof on shared PODs. Neither verifies SEMANTICS. A mirror IsStorageDirty, a dirty-rect merge, a change-serial bump rule or a persistent-map state transition that behaves differently from MG_State compiles clean, passes every assert, and renders wrong. This is the whole risk of the design and its named mitigation does not address it. +- DESIGN 2 — the mirror's scope is materially under-counted. I grepped MG_Backend: the backends call 15 distinct MUTATOR families on frontend objects across 94 sites, not just readers — SyncPersistentMappedRange x20 (which re-enters BufferBackendOps::FlushMappedRange, so the mirror BufferObject must reproduce the entire persistent-map state machine), MarkStorageDirty x19, AllocateStorage x8, SetInternalFormat x7, WritebackFromBackend x8, EnsureGpuResidentStorage x3, UpdateMipmapSubData. The design's ~450-line BufferObject and ~1,100-line texture estimates do not cover this. +- DESIGN 2 — it never mentions GetProgramForDraw's composite-pipeline path. Core.cpp:592-660 joins every stage program, computes ComputeDrawProgramSignature, and on a cache miss constructs and LINKS an unnamed ProgramObject(0u), mirroring uniform values and block bindings into it. The mirror's ~700-line ProgramObject must reproduce all of it, or every program-pipeline application breaks. Unpriced. +- DESIGN 3 — GL-name-space divergence is detected, not prevented. The whole identity model rests on both processes running the same IndexGenerator over the same call sequence, including on-demand object creation inside BindBuffer_State. The mitigation is a periodic XOR checksum of live names. That catches drift after the fact; Designs 1 and 4 prevent it structurally by carrying the name and lifetime id in an explicit create record and calling ctx.CreateBufferObject(name) directly. For a silent-corruption failure mode, prevention is the correct choice. +- DESIGN 3 — the texture path contradicts the replay premise. Section 3.4 states pixels are already resolved client-side by ProcessTexturePixelsDataUnpack and that the record carries 'level identity plus the dirty description, not the upload plan: {name, target, level, unionBox, rectCount, rects}'. That is a delta, not a replay of glTexSubImage2D, so the generator's claim to cover the entry-point table mechanically does not hold for the texture family — and the design never specifies how the server's replica MipmapStorage obtains the bytes (it says only 'route their allocator to SEG_SHADOW the same way', which for buffers required an explicit new PipeResource kSharedShadow mode that is never specified for textures). +- DESIGN 3 — the emit-after-error rule silently drops partial-effect calls. Skipping the record when PendingErrorCount moved is conservative for the common case but wrong for the spec-level exceptions the design itself acknowledges, and the only detector named is a CTS run at P4. +- DESIGN 1 — hooks are hand-placed inside MG_State mutators, i.e. inside the state authority, and a missed mutator is a silent divergence with no structural detector. Ops.def plus sizeof tripwires catch SCHEMA drift, not HOOK OMISSION. The design says so honestly, but it is the largest residual risk in the winner-adjacent option and it is why Design 4's approach (emit at the ~152 already-enumerated GLFunctionsTable/BufferBackendOps boundary sites, replay via mutators on the server) is the safer placement of the same idea. +- DESIGN 4 — the reflectionDigest is too narrow to catch the divergence it is designed to catch. It hashes uniform (name, location, type) triples plus maxUniformLocation and the XFB layout. It does not cover the generated SPIR-V itself, nor uniformIndexInTProgram, explicitProgramOpaqueBindings or storageBlocksWithoutBinding. This project's own bisect history records that glslang reflection and generation ORDER is load-bearing and that desktop byte-identity is a corpus-limited false green — so a server relink could produce different SPIR-V, pass the digest, and render wrong. Fix: extend the digest to an xxHash over the SPIR-V modules and the full LinkArtifacts field set, and make it a hard Fatal, which the design already does for the narrow version. +- ALL FOUR — none notes that ProgramObject.h transitively includes ShaderObject.h -> ShaderCompileTask.h and SpvcSession.h (verified). Any server that links a real MG_State ProgramObject therefore pulls the shader-compile machinery whether or not it runs it. This only invalidates a binary-size argument, and only Design 2 makes one (which it solves by not linking MG_State at all), but every plan that claims a 'glslang-free server' after ProgramPublish should verify it with nm rather than assert it. +- DESIGN 2 — the 'zero MG_State symbols in the server' gate is contradicted by three verified sites the design under-costs: VulkanRenderer.cpp:4214/4222/4290/4300 construct MG_State::GLState::ShaderObject and :4233/:4313 call ->Link(false); UniformManager.cpp:161-179 constructs 8 MG_State::GLState::TextureObject* kinds; DirectGLES.cpp:170 constructs a free-standing MG_State::GLState::SamplerObject(0). The design budgets ~145 lines for the first and '0 logic change' for UniformManager — but zero lines in UniformManager means the mirror must implement AllocateStorage / SetInternalFormat / UpdateMipmapSubData / MarkStorageDirty across 8 texture kinds with faithful MipmapStorage semantics. The cost is not eliminated, only moved into the line-count estimate it is missing from. +- DESIGN 2 (the decisive one) — the conformance generator closes only the half of drift a compiler can see. is_same_v on accessor signatures and sizeof/alignof/offsetof on PODs cannot detect BEHAVIOURAL divergence in MipmapStorage::InsertDirtyRect's cascade-merge and the summedArea*4 >= unionArea*3 threshold (MipmapStorage.cpp:287-312), VecRange1D::Add's 7%-of-span gap ratio, or PipeResource::ResizeShadow's bit_ceil. The backend consumes all of these directly (Managers.cpp:4304-4310, :1891-1970), and this is precisely the area where the project has already measured a +6 ms/frame cliff between rect-list and union-box upload shapes (Managers.cpp:4311-4319). The design names drift as its 'single real risk' and then mitigates only the mechanical half. +- DESIGN 2 — mirror sizing. Verified: TextureState 3,144 lines, ProgramState 7,992, BufferState 1,154. The ~5,350-line total mirror estimate is defensible for ProgramObject (most of ProgramState is glslang link tasks and the job graph, which the mirror does not need) but not for textures, where the code the backend depends on is behaviour rather than generation. Expect ~8-10k lines, i.e. optimistic by roughly 2x. +- DESIGN 3 — GL names as wire identity make name-space divergence a SILENT-CORRUPTION class. The mitigation (a per-kind XOR checksum of live names every 4096 records) detects the fault up to 4096 records after it happens, i.e. after a frame or more of wrong pixels. Every other design uses never-reused GetLifetimeId() handles with explicit create/delete records, which cannot drift by construction. Mitigable by auditing every batch instead of every 4096 records, but it is a structural weakness of the entry-point-replay approach, not an implementation detail. +- DESIGN 3 — the claim that a thread_local pGLContext changes 'zero of the 1494 call sites' is false. I count 65 non-arrow uses across MG_Impl / MG_Backend / MG_State: GL_Debug.cpp:99 (.get()), GL_Program.cpp:1630 (== nullptr), DirectGLES.cpp:146 (.get()), Managers.cpp:3608/3737/3808/4663/7120/7128/7131/8678 (truthiness), BackendObject_DirectVulkan.cpp:388/788, DirectVulkan.cpp:347-386 (MOBILEGL_ASSERT). An operator-> shim needs get(), operator bool and null comparison too. Not fatal, but the claim is, and .get() returning a thread-local pointer changes lifetime semantics that MOBILEGL_ASSERT sites depend on. +- DESIGN 3 — composite pipeline programs are unaddressed. GLContext::GetProgramForDraw() links a NEW ProgramObject on a pipeline cache miss (Core.cpp:592-640). Under entry-point replay the server independently reaches that miss and links its own composite, allocating a name from ITS IndexGenerator — a second, undiscussed source of exactly the name-space divergence the design's audit is meant to catch. Design 1 is the only design that solves this explicitly. +- DESIGN 3 — the 11.6-week estimate is not credible for the scope: a 682-opcode generator plus ~40 hand normalizers, a rewrite of 312 _State forwarders plus ~367 new wrappers, a TLS refactor of the state authority, a name-audit subsystem, a shm ring with mirror-mapping, SCM_RIGHTS, three adoption tiers, Android packaging AND a production Service. Read it as 16-18 weeks and re-baseline the phase gates accordingly. +- DESIGN 1 — the ~150 recorder hooks are the least enumerable completeness surface in the replica family. Unlike GL entry points (a machine-readable 682-line macro table, verified) there is no single list of MG_State mutators to generate from; I count 76 mutator-shaped methods in Core.h alone with the remainder spread across BufferObject, MipmapStorage, VertexArrayObject, FramebufferObject, SamplerObject and ProgramObject. MGWIRE_MUTATOR_COUNT is a cardinality tripwire, not a coverage proof: a hook placed on the wrong side of a mutator, or a mutator with a side effect on a sibling object, passes it. +- DESIGN 1 — InstallPublishedLink's Visit() + sizeof static_assert is explicitly acknowledged to miss any LinkArtifacts field change that does not alter sizeof. Since LinkArtifacts drives every uniform location and every XFB stride, a silent miss produces misrouted glUniform deltas with no diagnostic. It needs Design 4's reflectionDigest cross-check as a runtime companion. +- DESIGN 1 — the in-process mode's std::swap(pGLContext, m_replica) around ApplyBatch is a data race if anything on the app thread touches pGLContext concurrently. It is confined to a test transport, but it makes the in-process oracle less trustworthy than Design 3's TLS or Design 4's separate-process-first ladder — and an untrustworthy oracle is worse than none when it is the primary correctness gate for Phase 1. +- DESIGN 4 — reconciler completeness has only a TEST tripwire (Phase-2 name-for-name parity), not a build tripwire. Anything the backend gates on that the WireMirror forgets to walk diverges silently until a scenario happens to exercise it. This is the winner's single weakest point and is why grafting Design 3's generated command table and Design 2's generated read-surface assert is not optional. +- DESIGN 4 — Phase 1's 'server relinks from source' runs glslang and a full compile pool in BOTH processes for Phases 1-4, on a platform whose existing compile pool is already clamped to 4 workers purely as an RSS ceiling (ShaderCompilePool.h:77-82). The Phase-1 device gate is a single OpenRA trace so it will pass; Minecraft would not. This should be stated as an explicit Phase-1 non-goal so nobody measures MC before Phase 5. +- DESIGN 4 — like Design 3, it never addresses the composite pipeline link at Core.cpp:592-640. Its applier calls the backend table, the backend calls GetProgramForDraw(), and on a pipeline cache miss the SERVER links a composite ProgramObject. This must be either client-resolved (Design 1's mechanism) or explicitly banned with an assert; leaving it implicit is a latent divergence. +- CROSS-CUTTING (credit where due) — all four designs correctly diagnose that Feat/CS-Delta-IPC never implemented SCM_RIGHTS (LocalSocketTransport.cpp:296 hardcodes fd = -1), so its data plane could not move a byte cross-process on Linux or Android; that ServerHost/main.cpp does not compile while being in the default ALL target, so the branch tip cannot build; and that the committed per-draw fprintf(stderr) at DirectGLES.cpp:+2583-2590 poisoned every measurement taken on that branch. All four schedule fd-passing in the first transport commit and all four remove the uncommitted [IBOTX]/[BUFTX] fprintfs before baselining. None of the four repeats the prior branch's inversion of landing a state-model refactor before a triangle renders. +- D2 — TextureLevelPull is a novel synchronous reverse stall in the middle of a draw, and its dismissal is wrong. Because the mirror deliberately does not retain texel bytes, any server-side driver-object re-mint must ask the client to re-send. D2 pre-empts only one of three causes (RequireImageBindableStorage, via imageBindableHint); it dismisses full format regeneration (Managers.cpp:3950-4195) with 'already re-uploads every level today, so it is not a new cost class'. That is false across processes: in the monolith the bytes are in the same address space and the re-upload is free; in the split it is a blocking server-initiated round trip the client did not initiate and cannot predict, on a path that fires on ordinary glTexImage format changes. This is the only genuinely new stall class any of the four designs introduces. +- D2 — the drift guard is narrower than advertised. D2 claims divergence between MG_Mirror and MG_State is 'a build error, not a review item'. The generated is_same_v/sizeof/alignof/offsetof asserts catch signature and layout drift only. They cannot catch behavioural drift in the ~1,100 lines of mirror texture logic that reimplement IsStorageDirty/MarkStorageDirty/GetStorageDirtyRegion/GetStorageDirtyRects, including the 96-rect cascade-merge and the summedArea*4 >= unionArea*3 fallback. A semantic change to MipmapStorage compiles clean and renders wrong. +- D3 — 'zero of the 1494 call sites change' is not accurate, and the shim is harder than stated. I measured ~71 non-arrow uses of pGLContext (34 as a passed argument, 3 as pGLContext., plus the assignment at GLState/Core.cpp:20 and the deliberately-leaked definition at :1487). Crucially the declared type is `extern UniquePtr&`, not a pointer, so a thread-local shim must emulate operator->, get(), operator=, operator bool and reference binding, and the Init/Destroy lifetime path must be reworked. Small in absolute terms, but it is presented as free and it lands on the monolith's hottest access path. +- D3 — the divergence oracle is disabled precisely where the divergence risk lives. D3's correctness rests on GL name-space determinism holding across 682 entry points, on-demand object creation in BindBuffer_State, internal cross-domain GLImpl:: calls, and an emit-after-error rule. Its two guards are periodic name-set checksums and MOBILEGL_WIRE_VALIDATE_SERVER — but the latter is explicitly CI-only, short-circuited in shipping builds by kPrevalidated + g_wireSkipValidation. A shipped build therefore turns a name-space divergence into undefined behaviour rather than a GL error, with silent wrong pixels as the symptom. +- D3 — the schedule is not credible for the stated scope. P1 is budgeted at 2.0 weeks for: a generator over all 682 entry points, ~40 hand-written pointer normalizers, one-line wrappers for the ~367 entry points that have no _State counterpart, the W-AUDIT-1 internal-caller audit across MG_Impl and MG_Backend, the server-side validation oracle, name-audit records, and EmitFullSnapshot. The total 11.6 weeks is the most aggressive of the four for the largest protocol surface of the four. +- D1 — the persistent-map defaults are inverted. For coherent persistent maps D1 ships option (b), a 4KiB-block xxHash-gated whole-mapped-range copy, as the v1 default, with option (c) 'decline the persistent bit' behind a config switch. SyncPersistentMappedRange() is invoked by the backend at draw time (Managers.cpp:1547, DirectGLES.cpp:262/4412/4666-4667/4768-4769, MultiDraw.cpp:498), so (b) puts a hash scan of a potentially large mapped range on the per-draw path. The frontend already tolerates a null AcquirePersistentMap at three sites (BufferObject.cpp:174, 439-442, 470-472) and MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION already exists, so (c) is the correct default and (b) the opt-in. +- D1 — the wire byte budget is internally inconsistent. It states ~35KB of opcode stream for a 3000-draw frame while also specifying 24-40B draw records and a rule that binds are never coalesced. 3000 draws alone is 72-120KB before any state or bind traffic. Does not change the ranking (the volume is small either way) but it is an unverified figure presented as a budget in a design that otherwise cites line numbers. +- D4 (winner, so worth naming) — the reflectionDigest gate is narrower than the divergence it must catch. During Phases 1-4 the server relinks from source, and the digest covers only (uniformName, location, type) triples plus maxUniformLocation and the XFB layout. The backends additionally read GetUniformTypeFacts, GetUniformSamplerOrImageUnitIndex, GetUniformBlockBinding, GetShaderStorageBlockBindingOverrides, PointSizeDemoted, GetTransformFeedbackStride and GetTransformFeedbackPackedStride. A relink divergence in any of those passes the gate silently. Widen the digest to the full backend-read reflection set before Phase 1 lands. +- D4 (winner) — server-side RSS during Phases 1-4 is unaccounted for. Relinking from source means a second full glslang link pipeline plus its arenas in a second process, on a device where ShaderCompilePool is already clamped to 4 workers purely as an RSS ceiling (ShaderCompilePool.h:77-82) and where the project has an LMK-kill history. The exposure peaks during MC startup, which links hundreds of programs — exactly the Phase 5 workload D4 measures. Add a server-RSS acceptance bound to Phase 1, not only to Phase 5. +- Cross-cutting (all four, but D1/D2/D4 most): none of the four commits to a measured per-frame byte or call-volume number, because none exists in the tree — MG_Util/Metrics is format arithmetic only (BufferMetrics.h:14-27, TextureMetrics.h:15-44), Tracy has zones but no TracyPlot counters, and the MC 26.3 campaign's PANDIAG/STALLDIAG instrumentation is gone. Every ring size, batch threshold, inline-vs-shm cutoff and frames-ahead credit in all four designs is therefore an estimate. Whichever design proceeds should land the byte counters in its FIRST phase, not (as D2, D3 and D4 all schedule it) in a later performance phase. + +## 2. 对抗性审查(三个视角) + +### GL 语义正确性(refuted=False,13 条) + +- **[major] P1's day-13 on-device milestone omits the entire Android delivery chain it depends on** + - 问题:§15 P1 lists only C++ components (WireMirror, EmitTable, BackendObject_Remote, ReplicaContext, Applier, ServerLoop, ServerMain, spawn) yet its acceptance step 3 is `run_android_retrace_local.py --case OpenRA --backend DirectGLES` on 35d0befa in split mode. That path needs three things not in the deliverables. (a) Env plumbing: every MobileGL knob reaching the device is an explicit Intent extra threaded through five files — `android-plugin/trace-replay-ci.sh:368-420` builds `--es/--ez` extras one by one, `android-plugin/app/src/trace/cpp/trace_replay_core.cpp:134-207` is a hand-written `setenv` list, plus TraceReplayActivity.java, the JNI Request marshalling, and `run_android_retrace_local.py:122-201`. There is no generic env passthrough. (b) Server packaging: the plugin APK's native build is split between `android-plugin/app/src/trace/cpp/CMakeLists.txt` (app module) and the root `CMakeLists.txt` via `implementation(project(":MobileGL"))`; a `libMobileGLServer.so` must come from the root build, and `MobileGL/build.gradle` sets no `targets` list, so the claim in §13 that AGP will package an `add_executable` renamed `lib*.so` is asserted, not verified. (c) Exec permission: the reader's SIGILL (exit 132) evidence was obtained through `run-as`, i.e. the runas_app domain, not from the app's own untrusted_app process, which is what the trace Activity is. + - 修法:Move the Android delivery chain into P0 as a 30-minute spike with its own gate: build a trivial `libMobileGLServer.so` from the root CMakeLists, confirm AGP packages it into `lib/arm64-v8a/`, and have TraceReplayActivity `posix_spawn` it from `getApplicationInfo().nativeLibraryDir` and print a line — proving untrusted_app exec before any protocol work. Add a single generic `--es mobilegl_env "K=V;K=V"` passthrough to the trace path (one change in each of the five files) instead of a per-knob extra. Re-baseline P1 acceptance to the Linux `inproc` + `spawn` gates only, and make the device retrace the P2 exit criterion. +- **[major] Nothing stops the spawned server from taking the remote branch and forking again** + - 问题:§12 selects the split at `MG_Backend/Init.cpp` on `MG_Config::Transport`, which `ConfigLoader.cpp` reads from the environment (same shape as `features.CoherentAsFlush = QueryEnvFlag(...)` at ConfigLoader.cpp:185). §11 spawns the server with `fork`/`execve` and fd 3, so the child inherits `MOBILEGL_TRANSPORT=spawn`. §13 then says the server is a stub that `dlopen(libMobileGL.so)` + `dlsym("mobilegl_server_main")`; that entry must stand up a real backend, which runs `MG_Backend::Init()` (MobileGL/MG_Backend/Init.cpp:48-70). With the inherited variable still set, it constructs another `BackendObject_Remote` and spawns again — an unbounded fork chain on first GL call. The plan never states how the child's mode is forced. + - 修法:Make `mobilegl_server_main` set `MG_Config::Transport = Monolith` before it can reach `MG_Backend::Init()`, AND scrub `MOBILEGL_TRANSPORT`/`MOBILEGL_IPC_*` from the child environment at spawn time (build an explicit envp rather than inheriting). Add a P0 `MG_Test/Wire` test that spawns a server and asserts the process tree gains exactly one child. +- **[major] MG_IntegrationTest's fork pre-flight will spawn a second, orphaned server holding the GPU device** + - 问题:`MobileGL/MG_IntegrationTest/Harness/HeadlessGL.cpp:344-368` forks a child that runs the complete EGL bring-up and then `_exit(step)`, with the comment at :364-366 stating this is deliberate — 'every atexit handler and static destructor in this address space belongs to the parent's copy of the world.' In split mode that child's bring-up reaches `MG_Backend::Init()` and spawns a server process; `_exit` runs no teardown, so that server is orphaned and lives until it notices EOF or hits `MOBILEGL_IPC_IDLE_EXIT_S` (default 30s per the plan's appendix). The parent then immediately brings up its own server against the same device. HeadlessGL.cpp:585-589 already names exactly this failure mode ('a leaked exclusive device, an environment the child did not have') as the reason it distinguishes 'pre-flight passed, parent failed'. §15's P1/P2 acceptance runs the whole integration suite through this path and the plan does not mention the pre-flight at all. + - 修法:Make the server's EOF detection immediate and its exit unconditional (sub-second, not the 30s idle watchdog), and have the client spawn with the socket fd marked so `_exit` closes it deterministically. Add a readiness handshake with one bounded retry on device-busy so a lingering pre-flight server cannot flake the parent. Validate this specific interaction as part of P1 acceptance step 1, before any breadth work. +- **[major] Server discovery via dladdr does not work for either desktop gate** + - 问题:§11 locates the server with `dladdr(&MobileGL::Initialize)` → dirname → `libMobileGLServer.so`. But `MobileGL/MG_IntegrationTest/CMakeLists.txt:31-32` sets `MGL_ITEST_MOBILEGL_TARGET MobileGL_s`, i.e. the integration binary links MobileGL **statically** on desktop, so `dladdr` resolves to the test executable's own path, not a library directory. For trace replay, `tools/trace_replay/CMakeLists.txt:285-290` passes an explicit `-DMOBILEGL_LIBRARY=$`, whose directory is the MobileGL build output dir, while CMake places an `add_executable` in the defining directory's binary dir by default. Both P1 acceptance steps therefore fail to find the server as designed, and the plan's proposed `mgl_itest_join_environment(... "MOBILEGL_TRANSPORT=inproc" ...)` snippet does not set `MOBILEGL_IPC_SERVER_PATH`. + - 修法:Make `MOBILEGL_IPC_SERVER_PATH` the primary discovery mechanism and `dladdr` the fallback. Set `RUNTIME_OUTPUT_DIRECTORY` of `MobileGLServer` to `$`, and add `"MOBILEGL_IPC_SERVER_PATH=$"` to every new ctest ENVIRONMENT list (joined via `mgl_itest_join_environment` with `${MGL_ITEST_COMMON_ENV}`) and to the new `SPLIT` argument of `add_trace_replay_test`. Confirm the absolute path survives the CI artifact hop — `.github/workflows/test.yml:174-185` rewrites only `cmake` paths inside `CTestTestfile.cmake`, not ENVIRONMENT values. +- **[major] Split mode's ban on COHERENT_AS_FLUSH invalidates the P2 gate for the two Create/Flywheel fixtures, and app-native coherent persistent maps have no mitigation at all** + - 问题:§6.8 states 'split mode must not apply MOBILEGL_COHERENT_AS_FLUSH'. `tools/trace_replay/trace_cases.json` has exactly two cases with `coherent_as_flush: true` — `minecraft-1.21.1-neoforge-create-indirect-in-world` and `minecraft-1.21.1-neoforge-create-instancing-in-world`. So P2's gate ('CI 全部 trace case … 在 Linux split 模式 SSIM ≥ 0.99' compared name-for-name against monolith) would run those two through a different buffer path in each mode, making the comparison meaningless for the two most buffer-stressing fixtures in the suite. Separately and more seriously, the plan addresses only the *rewrite* flag, not an application that requests `GL_MAP_PERSISTENT_BIT|GL_MAP_COHERENT_BIT` itself. With adoption declined in P1-P6 (§6.8 tier T2), such a map skips every early-out in `BufferObject::SyncPersistentMappedRange()` (MobileGL/MG_State/GLState/BufferState/BufferObject.cpp:238-250: returns early for GPU-resident, non-persistent, read-only, and FlushExplicit — a coherent persistent write map matches none of them) and reaches `NotifySubData(whole mapped range)` on **every draw**, which over IPC becomes a whole-buffer wire transfer per draw. The plan's copy-accounting table in §6.4 does not contain this row. `MG_Config::Features.CoherentAsFlush` defaults false (MobileGL/Config.h:174), so the ban itself is narrow — but the underlying cliff is not. + - 修法:Two changes. (1) Run the two Create cases in split mode with the flag ON so the P2 comparison is honest, or state explicitly that they are excluded and why. (2) Add a third tier for non-adopted persistent-coherent maps in P1-P6: pull the shadow-in-shm work (currently P4.5) forward to cover *this* case specifically, or ship a dirty-range tracker for coherent maps, and add the row to the §6.4 copy table. Measure it on the two Create fixtures before P2 exit, not at P7. +- **[minor] The byte-identical-monolith gate is contradicted by P4.5's allocator change** + - 问题:§12 and P0's acceptance require `nm --defined-only` and stripped `.text` size on `libMobileGL.so` to be unchanged when `MOBILEGL_BUILD_DISAGGREGATED=OFF`, and §12 layer 1 says MG_Remote sources simply leave `SOURCE_FILES`. But P4.5 (§6.4, §15) changes `PipeResource`'s `MapAlignedAllocator` and `MipmapStorage`'s level vectors to use a shm arena for ≥256KiB — these live in `MG_State`, not `MG_Remote`, and changing a container's allocator changes the type. Unless every one of those edits is `#if MOBILEGL_BUILD_DISAGGREGATED`-guarded, the P0 gate goes red at P4.5 and the plan says nothing about it. + - 修法:State that the shm arena is a guarded allocator specialization that compiles to the current `MapAlignedAllocator` when the option is OFF, and re-run the `nm`/`.text` gate as a phase-exit criterion for every phase (P0 through P9), not only P0. +- **[minor] Non-arrow pGLContext usage count is understated 2x, and the lifecycle sites are omitted** + - 问题:§12 and R8 say the `inproc` thread-local `pGLContext` shim must cover '约 65 处' non-arrow usages. Measured on dev@81b17c0b: `grep -rn pGLContext MobileGL/ --include=*.cpp --include=*.h | grep -v 'pGLContext->'` yields **133** lines. Of those only 2 are in MG_Impl (GL_Debug.cpp:99 `.get()`, GL_Program.cpp:1630 `== nullptr`); the bulk are in MG_Backend, including roughly 90 `MOBILEGL_ASSERT(MG_State::pGLContext, ...)` truthiness checks in DirectVulkan.cpp alone, plus `DirectGLES.cpp:146` `.get()` and ten `if (MG_State::pGLContext)` guards in Managers.cpp. It also omits the lifecycle sites the shim must handle: `MG_State/GLState/Core.cpp:20` (`pGLContext = MakeUnique<...>()`), `Core.cpp:1487` (the leaked-reference definition), `Core.h:564` (the `extern UniquePtr&` declaration), and `MobileGL/Init.cpp:63` (`pGLContext.reset()`). + - 修法:Correct the count and note that the shim must provide `operator->`, `operator bool`, `get()`, `== nullptr`, assignment from `MakeUnique`, and `reset()`. Since the backend-side usages are exactly the ones that must see the *replica*, prototype the shim against `MG_Backend/DirectVulkan/DirectVulkan.cpp`'s assert block first — it is the densest cluster. +- **[minor] The FlatBuffers submodule stays mandatory even with a committed generated header, and the option has no guard** + - 问题:§13 says committing `protocol_generated.h` means 'cross-compilation never needs flatc', which is true — but the REUSE table (§14) keeps `Feat/CS-Delta-IPC:MobileGL/Protocol/CMakeLists.txt`'s flatc resolution, and that file at :22-38 does `add_subdirectory(3rdparty/flatbuffers)` with `FLATBUFFERS_BUILD_FLATC ON` whenever `MOBILEGL_FLATC_EXECUTABLE` is unset — i.e. the exact NDK trap the plan says it fixes is preserved by the reuse decision. Independently, the runtime headers are still needed: the same file at :61-64 adds `3rdparty/flatbuffers/include` to `MobileGL_Protocol`. So with `MOBILEGL_BUILD_DISAGGREGATED=ON` and the submodule not initialised, `MG_Remote/**` lands in `SOURCE_FILES` (§13) and the build fails with no guard, since the existing `if (EXISTS .../flatbuffers/CMakeLists.txt)` only wraps the Protocol subdirectory. The tree currently has 12 submodules and none is flatbuffers. + - 修法:Do not reuse the flatc resolution block as-is: make codegen a `scripts/gen_protocol.py` developer target that is never part of the build graph, and delete `add_subdirectory(3rdparty/flatbuffers)` from the default path entirely (keep `MOBILEGL_FLATC_EXECUTABLE` only for the CI `flatc-check` step). Add an explicit guard that force-sets `MOBILEGL_BUILD_DISAGGREGATED=OFF` with a `message(WARNING ...)` when `3rdparty/flatbuffers/include` is absent. +- **[minor] Ring decode has no stated bounds discipline, only the socket path does** + - 问题:§7.2 specifies magic and 64MiB length validation on read for the CTRL socket, correctly citing the prior branch's unbounded `make_shared>(size)` (verified at Feat/CS-Delta-IPC:MobileGL/Remote/LocalSocketTransport.cpp, the `async_read` header handler). But §6.3's `RecHeader { kind; flags; size; }` is read out of `SEG_CMD`, a region the peer writes concurrently, and the plan's only integrity mechanism there is the `static_assert` on `sizeof(T)` at compile time. A corrupted or truncated `size` lets the applier's cursor walk past the ring; a `kind` whose record is shorter than `sizeof(T)` lets it read past the record. + - 修法:State the invariant explicitly and generate it: alongside each `MGL_REC_SIZE_CHECK`, emit a runtime `size >= sizeof(T) && size <= remainingRingBytes && (size % 8) == 0` precondition in the applier's dispatch switch, and treat a violation as `Fatal{ProtocolCorruption}` rather than undefined behaviour. +- **[minor] Two small test-infrastructure mechanics the plan understates** + - 问题:(a) `add_trace_replay_test` names its test `MobileGLTraceReplay.${CASE_NAME}.${BACKEND}` (tools/trace_replay/CMakeLists.txt:330-332); a `SPLIT` argument as proposed in §13 would produce a duplicate ctest name for the same case+backend unless the name is extended. (b) The test command is `cmake -P run_trace_case.cmake` with ~18 `-DTRACE_*` variables; a new mode must be threaded through that script too, which the plan does not list among the files it touches. Neither is hard, but both sit on the P2 gate. + - 修法:Extend the generated name to `MobileGLTraceReplay.${CASE_NAME}.${BACKEND}${SPLIT_SUFFIX}` and add `-DTRACE_TRANSPORT=` to the `-P` invocation plus its consumption in `run_trace_case.cmake`, listing both files in the P2 deliverables. +- **[minor] Windows: 'inherited handles remove accept/connect' does not carry over to asio's overlapped requirement** + - 问题:§11 says the Windows spawn uses 'CreateProcess + 继承句柄' and §16 R9 claims this 'completely removes accept/connect'. asio's `windows::stream_handle` (the transport the plan defaults to on Windows) requires an **overlapped** handle for its IOCP service; an anonymous pipe pair from `CreatePipe` is not overlapped-capable, so the pair must be constructed with `CreateNamedPipeW(..., FILE_FLAG_OVERLAPPED)` plus a matching `CreateFileW(..., FILE_FLAG_OVERLAPPED)` and only then inherited. The plan's AF_UNIX observation is correct — asio 1.38.2 defines `ASIO_HAS_LOCAL_SOCKETS` for everything except `ASIO_WINDOWS_RUNTIME` (3rdparty/asio/asio/include/asio/detail/config.hpp:1085-1092) — but that is the fallback, not the default. + - 修法:Spell out the Windows handle-pair construction (named pipe with a GUID-unique name, both ends `FILE_FLAG_OVERLAPPED`, server end inherited) in §11, and keep the AF_UNIX evaluation in P6 as written. +- **[minor] mobilegl_server_main will not be dlsym-able in the shipping configuration** + - 问题:§13 makes the Android server a ~30-line stub that does `dlopen(libMobileGL.so)` + `dlsym("mobilegl_server_main")`. But `CMakeLists.txt:498-510` sets `C_VISIBILITY_PRESET hidden` / `CXX_VISIBILITY_PRESET hidden` / `VISIBILITY_INLINES_HIDDEN ON` on the shared target for every non-Debug build — which is exactly the RelWithDebInfo configuration the plugin and FCL ship (MobileGL/build.gradle's `fordebug` type forces `-DCMAKE_BUILD_TYPE=RelWithDebInfo`). The symbol will not be exported unless it is explicitly annotated, so this works in a Debug build and silently fails on device. + - 修法:Declare the entry point `extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int, char**)` (and add it to `MG_Impl/DyldInterpose/ExportedSymbols.txt` and `wgl.def` equivalents if those platforms ever host a server), and add a `nm -D | grep mobilegl_server_main` assertion to the P0 acceptance alongside the existing `nm --defined-only` gate. +- **[minor] Phase effort is optimistic where it matters most, and the plan's own risk register does not cover schedule** + - 问题:P0 = 3 days covers SCM_RIGHTS fd passing (hand-rolled sendmsg/recvmsg on asio's native handle), a four-platform shm layer, the SPSC ring with RingControl, validating framing, the committed-header flatc pipeline with a CI diff gate, a code-generating coverage assert with a second CI diff gate, the RenderbufferObject change, working-tree cleanup, and Tracy byte counters. P1 = 10 days covers the entire client (WireMirror, EmitTable, EmitBufferOps, BackendObject_Remote, CapsMirror, ClientArrayBounds, CompositeResolver) and the entire server (ReplicaContext, Applier, ServerLoop, ServerMain, spawn), plus — implicitly, per finding 1 — the whole Android delivery chain. For calibration, Feat/CS-Delta-IPC produced 6,668 lines across 10 commits and never rendered a frame; its own HANDOFF records four days lost to a non-reproducible regression. The 74-day total is internally consistent (3+10+8+3+5+5+4+6+6+8+6+10=74) but the front-loaded milestone is the weakest claim in the document, and §16 has no schedule risk row. + - 修法:Split P1 into P1a (client emit + inproc applier + Linux `inproc` gate, 6 days) and P1b (spawn transport + Linux `spawn` gate, 4 days), and make the device retrace a P2 exit criterion. Add a schedule row to §16 whose mitigation is the P2.5 falsification gate already in the plan — it is the right instrument, it is simply not linked to the schedule risk it retires. + +已验证的优点: +- The core architectural decision (D1: server runs a real replica GLContext driven by mutator replay, backends untouched) is well-founded and the evidence cited for it checks out. Composite pipeline programs really are anonymously linked at MobileGL/MG_State/GLState/Core.cpp:644 (`MakeShared(0u)`), which is exactly the gap §5.7 identifies and solves. +- Every DROP claim about Feat/CS-Delta-IPC verified true. MobileGL/ServerHost/main.cpp really writes `interface_.ops.Start(&interface_, &config)` on a `MobileGLTransport*` (compile error, branch tip cannot build ALL). MobileGL/Remote/LocalSocketTransport.cpp really has `asio::async_write(stream, asio::buffer(next), [this, next](...))` where `next` is a local moved-from vector (use-after-free on every send), really allocates `std::make_shared>(size)` straight from the wire length with no cap, and really hardcodes `out->fd = -1` in PollOffer — so the 'no POSIX fd passing, no Linux/Android data plane' conclusion is correct. +- The Android flatc trap is real and correctly diagnosed: Feat/CS-Delta-IPC:MobileGL/Protocol/CMakeLists.txt:25-38 does `add_subdirectory(3rdparty/flatbuffers)` with `FLATBUFFERS_BUILD_FLATC ON` whenever the override is unset, while the root hook guards only CS.cmake with `NOT ANDROID`. +- The FCL process-model correction is right and materially changes the design: FCL/src/main/AndroidManifest.xml:113 declares `.activity.JVMActivity` with no `android:process`, and the only `:jvm` entry is `com.tungsten.fclcore.download.ProcessService` at :137-141. The game really does run in the main process, so a second process must be created. +- asio 1.38.2 is vendored (3rdparty/asio/asio/include/asio/version.hpp: ASIO_VERSION 103802) and does define ASIO_HAS_LOCAL_SOCKETS on Win32 (detail/config.hpp:1085-1092, excluded only for ASIO_WINDOWS_RUNTIME), so the plan's Windows transport reasoning starts from a correct premise. +- The 'one hook point' claim (D2) is accurate: MobileGL/MG_Backend/Init.cpp:48-70 is a single switch on `MG_Config::ActiveBackendType` followed by `InitSpecificBackendLibs()`, which is the only place `gBackendFunctionsTable` and `pActiveBackendObject` are assigned. A single guarded branch there really does cover the whole boundary with no `#ifdef` at the ~250 downstream call sites. +- RenderbufferObject genuinely lacks GetLifetimeId() (no match under MobileGL/MG_State/GLState/RenderbufferState/), so the P0 item is real and not busywork. +- The integration-test extension point is exactly as described: `mgl_itest_join_environment` exists (MG_IntegrationTest/CMakeLists.txt:306), and the comment at :339-343 states verbatim that a ctest ENVIRONMENT property REPLACES rather than appends and that every list must build on MGL_ITEST_COMMON_ENV. Eleven `gtest_discover_tests` registrations already follow that shape, so a Split lane per backend is a genuine one-registration change. +- `add_trace_replay_test` really does set an ENVIRONMENT property per test (tools/trace_replay/CMakeLists.txt:353-360), so threading a transport variable through the trace lane is mechanically available. +- The P1-P4 'server relinks from source' scheme has the inputs it needs: ProgramObject::GetLinkedShaderSnapshot() exists (ProgramState/ProgramObject.h:157) and deliberately holds SharedPtrs to the linked shaders (comment at :1716), so shader sources survive glDeleteShader and can be shipped. +- MG_Config::Features.CoherentAsFlush defaults to false (MobileGL/Config.h:174), so §6.8's prohibition is a narrow, low-blast-radius rule rather than a default flip — and the reasoning behind it is correct, since BufferObject::SyncPersistentMappedRange (BufferObject.cpp:238-250) early-returns on FlushExplicit exactly as the plan assumes. +- The working-tree hygiene item is real: MobileGL/MG_Backend/DirectGLES/{DirectGLES,Managers}.cpp are the only two modified files in the tree, and P0's insistence on removing per-draw instrumentation before any measurement is the correct lesson from the prior branch's poisoned measurements. + +### 性能与异步(refuted=True,13 条) + +- **[fatal] Persistent-mapped writes are severed: no map/unmap delta exists, and SyncPersistentMappedRange has zero client-side callers** + - 问题:Plan §5.3's trigger→delta table has no map/unmap state at all, and §6.8 defers adoption (AcquirePersistentMap returns nullptr) until P7, so every persistent map stays shadow-backed in P1-P6. The push-down for a shadow-backed persistent map is BufferObject::SyncPersistentMappedRange (MobileGL/MG_State/GLState/BufferState/BufferObject.cpp:238-250), whose first line is `if (!m_isMapped) return;` and whose last line is `NotifySubData(m_mappedRange.start, ...)`. `grep -rn SyncPersistentMappedRange MobileGL/` returns callers ONLY inside MG_Backend/: DirectGLES.cpp:262,4412,4666,4667,4768,4769; Managers.cpp:1547; MultiDraw.cpp:498; DirectVulkan.cpp:290,481,895; UniformManager.cpp:2022; VkBufferManager.cpp:573,620; VulkanRenderer.cpp:3432,3511,3826,7070,12015,12016. There is not one call in MG_Impl or MG_State. In the split that backend code runs on the SERVER against the replica, whose BufferObject::m_isMapped is false (no map delta was ever sent), so it returns immediately; and nothing on the client ever calls it. Worse, the backend's clean-check explicitly depends on the map bit: Managers.cpp:1446-1447 `// A live non-zero-copy map may owe a per-draw SyncPersistentMappedRange push` / `if (frontend->IsMapped()) return false;` — the replica reports the buffer clean and skips the sync entirely. Result: writes made through glMapBufferRange(PERSISTENT|WRITE) without FLUSH_EXPLICIT are silently lost. This is the exact failure class the project already burned a campaign on (memory note flywheel-indirect-lessons: 'unflushed persistent maps' as root cause #1 of the Create/Flywheel fix). It is not a tuning problem — a whole delta kind is missing from the design. The naive repair is also a performance trap the plan never budgets: SyncPersistentMappedRange emits the WHOLE mapped range every draw, so a persistently-mapped chunk arena with adoption disabled (the P1-P6 default) becomes a per-draw whole-range copy into SEG_STAGE plus a per-draw whole-range record. + - 修法:Add map/unmap to the delta model: RecBufferMap{handle, range, accessFlags} and RecBufferUnmap{handle} emitted from glMapBuffer*/glUnmapBuffer, so the replica's m_isMapped/m_mappedRange/m_mappingAccess track the client's and both IsBufferDrawClean's IsMapped() gate and the server-side SyncPersistentMappedRange push behave as in monolith. Then make the CLIENT own the range narrowing that the whole-range push lacks: track dirty 64KiB blocks of the mapped span (the same block watermark P4.5 already proposes for WAR) and emit only touched blocks as RecBufferSubData, so the replica's push is a no-op. Add a Split integration scenario that maps PERSISTENT|WRITE|COHERENT without FLUSH_EXPLICIT, writes, draws, and reads back — today no gate in the plan would catch this. +- **[fatal] Zero-timeout sync/query polls answered from a local watermark livelock: nothing publishes the ring, and fence completion becomes present-granular** + - 问题:Plan §8 and D4 answer GetSyncStatus, ClientWaitSync(timeout=0), IsQueryResultAvailable and GetQueryResult64(wait=false) from a single acquire load on RingControl, with fence/query handles minted client-side and emitted fire-and-forget. Two independent breakages. (a) LIVELOCK: §7.2's Publish() triggers are 64KiB of records, SEG_STAGE below 1/4, any blocking request, Present, eglMakeCurrent, glFlush. A locally-answered poll is none of these. So the canonical LWJGL/Sodium idiom `do { r = glClientWaitSync(s, GL_SYNC_FLUSH_COMMANDS_BIT, 0); } while (r == GL_TIMEOUT_EXPIRED);` never publishes the ring, the server never sees the RecFenceSync record, the watermark never moves, and the loop spins forever. The repository documents that this flush is load-bearing: DirectVulkan.cpp:1150-1156 — 'GL_SYNC_FLUSH_COMMANDS_BIT: flush regardless of timeout, so a zero-timeout poll loop makes progress across calls'. MG_Impl forwards the flags unconditionally (GL_Sync.cpp:96 `return backendClientWaitSync(syncObject->backendHandle, flags, timeout);`), so the client cannot claim the app didn't ask. (b) GRANULARITY: the watermarks the plan proposes (retiredSeq / completedFrameSerial) are advanced on DirectGLES only inside Present() — DirectGLES.cpp:10626-10643 polls the 4-deep g_frameFenceRing after eglSwapBuffers — or inside WaitForFrameSerialCompleted (:10583). A fence created mid-frame therefore reports unsignalled until the NEXT present retires, i.e. fence completion degrades to frame-count inference. DirectVulkan.cpp:1120-1128 states in so many words that this is the bug that was fixed: 'The fence is signaled once that submission's VkFence has been observed signaled, so completion tracks the GPU itself rather than the frame-count inference; MC 1.21.5's fence-paced ring buffers depend on this to recycle their space instead of growing without bound.' The project memory note magma-mc1215-fence-oom records the consequence as a shipped native-heap OOM kill. The plan reintroduces it structurally. + - 修法:(a) Make any ClientWaitSync/GetSynciv call carrying GL_SYNC_FLUSH_COMMANDS_BIT an unconditional non-blocking Publish() (release-store head + doorbell if parked) before it answers locally, and add an escalation: after N consecutive locally-answered TIMEOUT_EXPIRED on the same handle, promote to a blocking request. Do the same for IsQueryResultAvailable. (b) Do not resolve fences against a present watermark. Give each RecFenceSync a server-side real backend FenceSync() and publish EvFenceSignaled{handle} from the server's existing per-fence poll; the client's local fast path must be 'handle <= a watermark the server derived from actual per-fence retirement', which on DirectGLES means the server polls its own live syncs outside Present too (it already has WaitForFrameSerialCompleted's fence-selection logic at DirectGLES.cpp:10586-10600 to build on). +- **[fatal] MarkGpuWritten modelled as a server→client event is a read-after-write race, not an optimisation** + - 问题:Plan §5.6 routes MarkGpuWritten/EnsureGpuResidentStorage back to the client as EvGpuWritten and claims it is 'better than monolith' because the server can name only ranges a shader actually wrote. That inverts the ordering the flag exists to provide. In monolith the flag is set SYNCHRONOUSLY inside the draw call, before it returns: MarkShaderStorageBuffersGpuWritten (DirectGLES.cpp:459-467) walks GetTouchedBufferBindingPointCount(ShaderStorage) and calls obj->MarkGpuWritten(), and is invoked from SyncNeccessaryBuffers on the draw path (DirectGLES.cpp:687,697); likewise SyncAtomicCounterBuffers (:509) and MarkWritableImageBufferTexturesGpuWritten (:1809). In the split the draw is fire-and-forget, so `glDrawElements(...); glMapBufferRange(GL_SHADER_STORAGE_BUFFER, ..., GL_MAP_READ_BIT);` runs entirely on the client before the server has even applied the draw. AcquireMemoryRange calls SyncGpuWrites() (BufferObject.cpp:454), SyncGpuWrites returns immediately because m_gpuWritePending is false (BufferObject.cpp:266), and the app gets the STALE shadow with no error and no round trip. Compounding it, §7.4's event-drain points are glGetError, glGetQueryObject*, glClientWaitSync, eglSwapBuffers and kNeedsAck waits — glMapBuffer*, glGetBufferSubData and glGetNamedBufferSubData (GL_Buffer.cpp:957,995) are not on the list, so even a late-arriving event would not be observed. This breaks an entire family of existing gates the plan schedules for P4 (SsboArrayLengthScenario, AtomicCounterScenario, StorageBufferRegrowScenario) in a way that is data-dependent and will look like flakiness. + - 修法:Invert the direction. The client already has every input MarkShaderStorageBuffersGpuWritten uses (GetTouchedBufferBindingPointCount / GetBufferBindingPoint), so WireMirror must set MarkGpuWritten() locally and conservatively at draw/dispatch emit time, mirroring DirectGLES.cpp:459-467, :509 and :1809 exactly. EvGpuWritten{handle, ranges[]} then becomes a pure narrowing hint that can clear the flag or shrink the readback range, and arriving late is harmless. Separately, add glMapBuffer/glMapBufferRange/glGetBufferSubData/glGetNamedBufferSubData to §7.4's drain-point list. +- **[major] §6.4's copy table understates both monolith and split; the real split cost is 4 copies (3 after P4.5), not 2 (1)** + - 问题:The table in §6.4 claims 'glBufferSubData → shadow store' is 1 copy in monolith, 2 in P1-4, 1 at P4.5. All three numbers are wrong. Monolith is already 2: (1) app→shadow in BufferObject::UploadSubData's Memcpy, then (2) shadow→destination inside FlushPendingRangesNow, which is `Memcpy(dst, bufferObject.MappedData() + start, size)` into an invalidating map (Managers.cpp:914) or `Memcpy(g_uploadRing.store.mappedPtr + ringOffset, bufferObject.MappedData() + start, size)` into the upload ring (Managers.cpp:922). Split P1-4 is 4: app→client shadow (1), client shadow→SEG_STAGE (2), then on the server the applier replays the mutator, so BufferObject::UploadSubData memcpys SEG_STAGE→REPLICA shadow (3), and the server's unchanged FlushPendingRangesNow then memcpys replica shadow→upload ring (4). P4.5 shadow-in-shm removes only copy (2), leaving 3 — it cannot remove (3), because SEG_SHADOW is client-owned/server-read-only by §6.1 while the replica BufferObject owns its own PipeResource allocation. Reaching the claimed 1 would require the applier to hand the backend ops the shm pointer directly instead of calling BufferObject::UploadSubData, which destroys the 'applier = mutator replay, therefore side-effect-identical' invariant that risk R1 rests on, and bypasses the change-serial bump IsBufferDrawClean compares (Managers.cpp:1453). The map path is worse still: glMapBufferRange(WRITE)+unmap is already 3 in monolith (seed staging from shadow at BufferObject.cpp:487, staging→shadow at :200-202, shadow→ring) and becomes 5 in the split. At the plan's own MC pan figure of ~9 MB/frame of section-mesh writes this is 27-36 MB/frame of memcpy, ~1.6-2.2 GB/s of phone memory bandwidth at 60 fps, against a monolith baseline of ~18 MB/frame. + - 修法:Correct the table and re-derive the P4.5 target. Either (a) accept 3 and say so, or (b) give the replica BufferObject a PipeResource mode that ADOPTS the client's SEG_SHADOW mapping read-only — a third PipeResource state alongside shadow and gpuMapped, where Bytes() returns the mapped client segment — so the applier's UploadSubData becomes a no-op range note and only the server's ring copy remains (1 copy end to end). That keeps mutator replay intact for every side effect except the byte move. Whichever is chosen, put the TracyPlot byte counters from P0 on BOTH sides of the wire and gate P4.5 on the measured total, not on the client-side number alone. +- **[major] The 64 KiB publish threshold serialises the two halves and pre-emptively kills the P2.5 hypothesis** + - 问题:§7.2 sets Publish() at 'records ≥ 64KiB', SEG_STAGE below 1/4, blocking request, Present, eglMakeCurrent, glFlush. With the §6.3 record sizes (RecDrawArrays 32B, RecBindBuffer 24B, RecDrawElements 56B) 64 KiB is roughly 1200-2700 records — i.e. an entire Minecraft frame, which the plan itself sizes at 1000-4000 draws. The server therefore cannot begin a frame's work until the client has finished emitting it. That is not asynchrony; it is a pipeline with a one-frame bubble, and it adds a full frame of latency on top of the present credit. It also invalidates P2.5 before it runs: the stated purpose of inproc is to move PrepareForDraw off the GL thread so the two overlap, and a frame-granular publish guarantees zero overlap within a frame. There is no throughput reason for the threshold either — SEG_CMD is an SPSC ring, so 'publishing' is a release store of `head`; the only thing worth amortising is the doorbell write, and §6.2 already gates that on consumerParked. + - 修法:Delete the byte threshold. Release-store `head` every record (or every 8-16 records to amortise the store), and ring the doorbell only when RingControl.consumerParked is set. Keep Present/blocking-request/glFlush as explicit doorbell points. Then measure the doorbell rate with the P0 Tracy counters; if the wakeup rate is the problem, raise the consumer's spin window rather than delaying the producer. +- **[major] No wakeup path for any client-side wait: present credit, readback replies and ring-full escalation must all busy-spin** + - 问题:§7.3 states explicitly: 'server 端不发 credit 消息:它对 RingControl 做 release store'. §6.2 specifies a doorbell only for the CONSUMER (consumerParked + a 1-byte socket write from the producer). There is no producer-side park/wake, so every place the client waits has nothing to block on: the present-credit wait in eglSwapBuffers when presentsSent - presentAckSerial >= 2 (§9), every kNeedsAck blocking request (readback, ClientWaitSync>0, GetQueryResult64 wait=true, AcquirePersistentMap at P7), and the §6.5 escalation's 'bounded 50ms wait on the oldest unretired batch'. All of them reduce to polling a shared cache line across a process boundary. On the target hardware a present-credit wait is up to a full frame (16.6 ms at 60 Hz) of spinning; on Android that is a big core held at full clock against the GPU and the game JVM, and the codebase has no affinity control to keep it off a little core (`grep -rn 'sched_setaffinity\|cpu_set_t' MobileGL/` → 0 hits). The 50 ms escalation wait is a 50 ms spin. This directly contradicts the plan's own framing that the client merely 'blocks on a socket/futex read instead of vkWaitForFences'. + - 修法:Add the symmetric doorbell: a producerParked flag in RingControl plus a second byte-stream direction (the socketpair already exists — reserve one byte code for 'watermarks advanced'). Client waits become spin-N-microseconds → set producerParked → blocking read on the socket; server does a release store then, only if producerParked, one byte. Specify a bounded spin (e.g. 50 µs, tuned per phase) and make the spin budget a config knob so it can be measured on 35d0befa and 3B159D009VZ00000 rather than guessed. +- **[major] SEG_EVENT has no overflow policy: a full event ring while the client waits on present credit is a two-sided deadlock** + - 问题:§6.1 sizes SEG_EVENT at 256 KiB, server-owned, client-read-only, and §7.4 has the server produce EvQueryResult, EvFenceSignaled, EvGpuWritten, EvBufferWriteback, EvReadbackDone, EvGlError, EvDefaultFramebufferInfo, EvCompileEnvInvalidate and — unbounded — EvLogLine{level,len,text}, with the server's MGLOG and deferred diagnostics replayed 'in stream order into the client log stream'. The plan never says what the server does when that ring is full. It also never says the client drains it while WAITING, only 'at each entry point where it could observe them (…eglSwapBuffers)'. Concrete deadlock: the client is inside eglSwapBuffers waiting on presentsSent - presentAckSerial >= 2; the server's mgl-srv-apply thread emits log lines and EvGpuWritten while applying; SEG_EVENT fills; the apply thread blocks producing; presentAckSerial never advances; the client never leaves eglSwapBuffers, so it never drains. Both halves are stuck. This is precisely the 'client blocked on credit while server blocked on the client' shape, and the plan's risk table (R1-R13) does not contain it. + - 修法:State an explicit policy: (1) the client MUST drain SEG_EVENT inside every wait loop (present credit, kNeedsAck, ring escalation), not only on entry-point boundaries; (2) EvLogLine is lossy — overwrite-oldest with a dropped-count field, since losing a log line must never stall rendering; (3) semantically load-bearing events (EvGpuWritten, EvReadbackDone, EvFenceSignaled, EvBufferWriteback, EvGlError) are non-lossy, and when the ring cannot take one the server sets an eventRingFull flag in RingControl and stops APPLYING rather than blocking mid-record, so the state is recoverable; (4) add a fault-injection test that fills SEG_EVENT while the client is credit-blocked, alongside P8's SIGKILL test. +- **[major] Frames of lag compose: present credit 2 sits on top of the backend's own 2-3, giving 4-5 frames end to end** + - 问题:§9 sets the present credit to 2 and argues it 'mirrors the existing budget' (MagmaFramesInFlight=3 clamped to [2, maxImageCount], Espryt's 4-deep fence ring at DirectGLES.cpp:10071-10074) and therefore 'introduces no new stall class'. The stall CLASS is indeed not new, but the LATENCY composes and the plan never adds it up. The server's own Present already blocks 2-3 frames deep before it returns: VulkanRenderer::Present ends by calling FrameContext::WaitAndAcquireNextImage, whose first statement is `vkWaitForFences(device, 1, &frame.imageInFlightFence, VK_TRUE, timeout)` (FrameContext.cpp:288-290). presentAckSerial can therefore only advance once that wait completes. A client allowed 2 outstanding presents ahead of a server that is itself 2-3 GPU frames ahead is 4-5 frames of end-to-end latency — 66-83 ms at 60 Hz — for a first-person game. None of the acceptance gates detects this: SSIM goldens are frame-content comparisons and bench.sh measures FPS, not input-to-photon. The risk register's R11 worries about Magma's present mode but not about the composition. + - 修法:Default MOBILEGL_IPC_PRESENT_CREDIT to 1, not 2, and document the composition explicitly (client credit + server FIF + driver depth). Add an input-latency measurement to the P3 and P9 gates — the codebase already has GetGpuTimestampNs and the trace-replay --benchmark per-frame JSON to build a timestamp-to-present histogram — and only raise the credit if a measured throughput win pays for a measured latency cost. +- **[major] Total CPU work per draw increases and there is no core-placement plan on a big.LITTLE phone** + - 问题:§5.1 says outright that the reconciler 'is the PrepareForDraw reachability walk with sync replaced by emit — not a metaphor: the same set, the same order, the same gating'. That means the walk runs TWICE per draw: once in WireMirror on the client, once in the unchanged PrepareForDraw on the server (DirectGLES.cpp:2916-2975), plus encode and decode. Some of that walk is not cheap: CurrentUnitBindingsEpoch (DirectGLES.cpp:1421-1438) falls through to a full owner-equality walk over every touched texture unit whenever GetTextureBindGeneration() moved, and the code's own note says that happens on redundant re-binds ('26.2 re-binds the unit's own sampler around every texture-unit switch'). The split's entire performance case therefore rests on those two halves landing on two different cores that are both fast. But `grep -rn 'sched_setaffinity\|cpu_set_t\|affinity' MobileGL/ --include=*.cpp --include=*.h` returns zero hits — the library never sets affinity. The server is a separate process launched by fork/exec (§11), so it does not inherit whatever affinity the launcher applied, and the project's own memory (pojav-bigcore-affinity-trap) records that pojavBigCore=true pinned the entire game JVM and MobileGL workers to one core, invalidating a body of historical measurements. If mgl-srv-apply lands on a 1.55 GHz little core it performs strictly more work than monolith did on a 1.96 GHz big core, and the split is a regression by construction. §15's P3 gate ('split frame time within 10% of monolith') would fail for a reason nobody would attribute correctly. + - 修法:State the total-CPU-work delta in the plan (client reconcile + encode + decode + server PrepareForDraw vs monolith PrepareForDraw) rather than only the per-side cost. Add explicit affinity: reuse ShaderCompilePool's existing big-core detection (ShaderCompilePool.cpp:73-96 ReadCpuMaxFrequencyKHz / DetectBigCoreCount) to pin mgl-srv-apply to a big core, behind MOBILEGL_IPC_SERVER_AFFINITY, and log the resolved mask. Make P2.5 report per-thread CPU time on both threads, not just wall-clock frame time, so a 'no win' result can be attributed to placement vs to encode cost. +- **[major] A shipping build cannot have both runtime split selection and a zero-overhead monolith; the nm/.text proof only covers the OFF build** + - 问题:§12's three-layer guarantee and decision D8 prove monolith preservation with `nm --defined-only` plus a stripped .text size diff — but only for MOBILEGL_BUILD_DISAGGREGATED=OFF. Every deployment story in the plan requires ON in the shipped libMobileGL.so: MOBILEGL_TRANSPORT selected via FCL's user-editable env preferences, the plugin APK V2 toggle table, ctest ENVIRONMENT variants, the /data/local/tmp CTS path. And §12 states that in ON builds, inproc mode makes pGLContext a thread-local behind an operator-> shim. That shim sits on the hottest path in the library: `grep -rho 'pGLContext->' MobileGL/MG_Impl | wc -l` = 1494, plus 124 in DirectGLES and 169 in DirectVulkan. On Android a dlopen'd shared library cannot reliably use initial-exec TLS, so each access becomes a __tls_get_addr call — a function call where there is currently a single load of a global reference (Core.h:564 `extern UniquePtr& pGLContext`). The plan's own estimate of the non-arrow sites is also a guess ('~65 places'); the measured count in MG_Impl alone is 4 (GL_Debug.cpp:99 `.get()`, GL_Program.cpp:1630 `== nullptr`, plus 2 in header/comment context), with ~20 more in MG_State/MG_Backend (Managers.cpp:3608,3737,3808,4663,7120,7128,7131,8678; DirectGLES.cpp:146; TextureObject.cpp:92; BackendObject_DirectVulkan.cpp:388,788; and ~11 MOBILEGL_ASSERT sites in DirectVulkan.cpp:347-461), so the shim must also supply get(), operator bool and equality — but the count being wrong is minor next to the TLS cost. + - 修法:Split the option in two: MOBILEGL_BUILD_DISAGGREGATED (spawn/socket only, keeps pGLContext a plain global — one predictable branch in MG_Backend/Init.cpp and nothing on the GL path) and MOBILEGL_BUILD_DISAGGREGATED_INPROC (CI/debug only, adds the TLS shim). Ship the former. Extend the P0 nm/.text gate to run on BOTH the OFF build and the shipping ON build in monolith mode, and make the ON-build check a .text-symbol-level diff of MG_Impl translation units so any accidental indirection on the GL path shows up as a size delta. +- **[minor] Memory doubling is unbudgeted: client segments plus a full replica context plus the server's own three rings** + - 问题:Risk R4 only tracks server-side glslang RSS during P1-P4. The steady-state data-plane and replica footprint is never budgeted. Client side (§6.1): SEG_CMD 8 MiB + SEG_STAGE 32 MiB growing to 256 MiB + SEG_SHADOW allocations at P4.5. Server side: the replica GLContext holds its own PipeResource shadow for every buffer and its own MipmapStorage for every texture level (the client's shadow is separate unless SEG_SHADOW adoption lands), plus the unchanged backend rings — kUboRingInitialBytes/kUboRingMaxBytes 4→64 MiB, kUnpackRing 4→64 MiB, kUploadRing 4→64 MiB (Managers.cpp:82-96) — plus kMaxPoolBytes = 64 MiB of buffer pool (Managers.cpp:566). That is up to ~450 MiB of new committed memory beyond monolith, on a device where the project already values 'saving ~400MB' as a headline result of the adoption fix and where its own memory notes record blanket-immutable buffers causing LMK kills. + - 修法:Add an explicit steady-state memory budget to the plan alongside the round-trip budget, and make P1's acceptance record RSS for BOTH processes (it currently only records the server's). Size SEG_STAGE's ceiling from measurement, not 256 MiB by default. Prioritise the P4.5 replica-adopts-client-shadow change (see the copy-accounting fix) since it removes the duplicate shadow, not just a copy. +- **[minor] 'Zero round trip in steady state' is fixture-dependent: BeginConditionalRender always blocks and is not exercised by the chosen gate** + - 问题:§8 lists glBeginConditionalRender among the unavoidable blocking points, citing GL_Query.cpp:705-706, and the source confirms it is unconditional: 'Resolved ONCE, here, and by WAITING even for the _NO_WAIT modes: the spec lets those render instead of stalling, so always waiting is conforming and is the only choice that gives the whole block one deterministic verdict.' But P3's acceptance criterion — 'the round-trip counter reads 0 in steady-state frames of minecraft-1.21.4-main-menu' — picks a fixture that exercises neither conditional render nor occlusion queries, so a green gate proves nothing about a renderer that uses them per frame. The same applies to glGetQueryObject(GL_QUERY_RESULT) on an unfinished query. + - 修法:Either make the P3 gate assert '0 round trips' across the whole trace-case matrix rather than one menu fixture, or restate the claim as 'zero round trips for the draw/state/upload path' and publish the per-fixture round-trip counts as a table. Consider making conditional render's occlusion resolve a client-side speculative pass-through with a server-side correction, since the spec permits the _NO_WAIT modes to render rather than stall. +- **[minor] retiredTail starves in present-less loops and the stated mitigation does not exist on DirectGLES** + - 问题:Risk R12 says the server 'also advances that watermark from its own TryDrainFrameTransients / RefreshCompletedSubmits, and publishes it on a timer'. That is true for DirectVulkan but has no DirectGLES counterpart: g_completedFrameSerial is advanced in exactly two places — inside Present() by polling the frame-fence ring after eglSwapBuffers (DirectGLES.cpp:10626-10643), and inside WaitForFrameSerialCompleted (DirectGLES.cpp:10583-10607) which itself requires a live ring fence at or past the target and returns false when the slot was recycled. In a present-less workload — glcts (tools/cts run_cts_local.py), readback loops, MG_IntegrationTest scenarios that never swap — no fence is ever inserted, so retiredTail never advances, SEG_STAGE fills, and §6.5's escalation runs to the hard drain on every case. That converts a CTS run into a sequence of 50 ms spins plus full drains, and could be misread as a conformance regression. + - 修法:Give the DirectGLES server an explicit non-present fence tick: insert a glFenceSync and poll the ring on a timer or every N applied records when no Present has occurred for a threshold, reusing the g_frameFenceRing machinery. Log ring-occupancy and escalation counts (the P0 Tracy counters) so a starved watermark is visible as a metric rather than as an unexplained stall, and add a present-less split-mode case to the P2 gate. + +已验证的优点: +- The replica-GLContext decision is correct and the cited evidence holds. IsBufferDrawClean opens with a raw-pointer identity compare before any version check (Managers.cpp:1435-1436, 'Identity first: a respecify path can hand the frontend a NEW resource'), and CurrentUnitBindingsEpoch (DirectGLES.cpp:1421-1438) resolves its epoch by an owner-equality walk over the live binding slots precisely because the bind generation moves on redundant re-binds. Neither has a wire-field analogue. Rewriting the backends to consume deltas would require re-deriving this invalidation model, which is what sank Feat/CS-Delta-IPC. +- D3 — not shipping version counters and letting the replica bump them through mutator replay — is sound and avoids the failure mode of the prior branch. The counters that gate re-sync really are wrapping Uint16 paired with pointer identity, and replaying mutations makes both sides run the same wrap logic instead of maintaining monotonicity on the wire. It also avoids adding Install* setters, which is how Feat/CS-Delta-IPC's b50f3348 leaked RenderState's private members to public. +- The claim that unpack pixel-store never crosses the boundary is verified: all six backend reads pass false (PACK) — DirectGLES.cpp:6129, 7614, 9101, 9480; Utils.cpp:2301; VulkanRenderer.cpp:10622. Confining PixelStoreBlob to the PACK direction is correct and removes a delta kind. +- Using FlatBuffers structs as fixed-layout records inside an SPSC ring, with tables reserved for the rare/variable control-plane messages, is the right call: structs have no vtable, no offset indirection and need only a bounds check rather than a verifier walk. The per-kind static_assert in Records.def is a genuine fix for the exact bug Feat/CS-Delta-IPC hit (its single assert on the first union member could not catch mid-list insertion). +- The observation that glReadPixels into a pack PBO can become fire-and-forget and thereby beat the monolith is correct: today DirectGLES maps the whole PBO back and writes it into the frontend shadow inside the call (DirectGLES.cpp:9189-9205), so there is no asynchronous PBO readback path at all. Same for deferring glEndTransformFeedback's unconditional infinite ClientWaitSync (GL_Drawing.cpp:1326-1337). Both are real wins and both are correctly identified as standalone monolith improvements worth landing on dev first. +- Keeping Present strictly 1:1 with the application's eglSwapBuffers is correct and well-justified: DirectGLES.cpp:10646-10649 retires the UBO/unpack/upload rings and trims the buffer pool only there, and the Magma side does all four OnFrameBoundary agings plus the BeginFrame calls inside Present. Batching frames would starve those drains. +- Porting the ring reclamation discipline from the existing PersistentRing is well-grounded: the RingFrameMark {frameSerial, headAtPresent} structure and the monotonic head/tail with 'in-flight bytes = head - tail must stay <= size' invariant are exactly as described (Managers.cpp:659-706), as is the grow → bounded-wait → hard-drain-plus-generation-bump escalation. +- P0's demand to remove the uncommitted per-draw instrumentation is necessary and verified: Managers.cpp:875-877 contains a live std::fprintf(stderr, "[BUFTX] FlushPendingRangesNow res=%p serial=%llu' + NL + '", ...) inside the pendingMutex critical section on the buffer flush path, with a literal ' + NL + ' in the format string. Measuring anything before removing it would repeat the prior branch's mistake. +- The refutation of Feat/CS-Delta-IPC's BFA C-ABI, UtilRuntime C-ABI-isation and share-group-sessioning-first ordering is well-founded, and the replacement ordering (thinnest end-to-end path first, device render at day 13, P2.5 as an early falsification gate at week 5) is the right risk sequencing. Making SCM_RIGHTS a P0 deliverable rather than a deferred 'P6' item directly fixes the defect that left the prior branch's data plane inoperable on Linux and Android. +- The dead-code cleanups are real and verifiable wins for the monolith independent of the split: GetInteger64i_v and GetProgramiv have no MG_Impl callers, and routing glDispatchCompute's three per-dispatch GetIntegeri_v validation queries to the already-captured CompileEnv limits removes a genuine per-dispatch cost. + +### 可行性/平台/交付(refuted=False,12 条) + +- **[fatal] Shadow-backed persistent (COHERENT) maps are never published to the server — app writes are silently lost** + - 问题:`BufferObject::SyncPersistentMappedRange()` (MobileGL/MG_State/GLState/BufferState/BufferObject.cpp:238-250) is the ONLY publisher of writes an application makes through a persistent, non-FLUSH_EXPLICIT, non-adopted map: it emits `NotifySubData(m_mappedRange)`. Every one of its call sites lives inside MG_Backend/ (verified by grep: DirectGLES.cpp:262,4412,4666,4667,4768,4769; Managers.cpp:1547; MultiDraw.cpp:498; DirectVulkan.cpp:290,481,895; UniformManager.cpp:2022; VkBufferManager.cpp:573,620; VulkanRenderer.cpp:3432,3511,3826,7070,12015,12016). There is ZERO caller in MG_Impl or MG_State. Plan §6.8 makes tier T2 (`AcquirePersistentMap` returns nullptr) the default for phases 1-6. `AcquireMemoryRange` (BufferObject.cpp:459-475) then falls back to the shadow and hands the app `m_resource.Bytes() + range.start`. The app writes into the CLIENT's shadow and makes no further GL call — that is the entire point of a coherent persistent map. In split mode the backend runs against the replica, so it calls `SyncPersistentMappedRange()` on the REPLICA's BufferObject, which is not mapped by anything. The client's emit-ops table is never invoked, no delta is produced, and the server draws from whatever the shadow held at map time. The plan's §6.8 rationale explicitly reasons only about FLUSH_EXPLICIT ('FLUSH_EXPLICIT 恰是跨进程的好情况') and concludes the coherent case is covered by declining adoption. It is not: declining adoption is precisely what routes into the unpublished path. `MOBILEGL_COHERENT_AS_FLUSH` defaults to false (Config.h:174), so an app that itself passes GL_MAP_COHERENT_BIT — the modern streaming idiom, and the reason Config.h:168-174 exists at all — lands here unconditionally. No listed gate before P7 covers this. OpenRA (the P1 gate) does not use persistent maps. + - 修法:Make the client the publisher. WireMirror must call `SyncPersistentMappedRange()` on every currently-mapped buffer reachable from the operation at each emit point, mirroring the backend's 13 call sites (VAO attribute buffers, index buffer, indirect/parameter buffers, UBO/SSBO/atomic binding points, XFB capture targets) BEFORE it samples `GetChangeSerial()`. Keep a client-side `ska::flat_hash_set` of live persistent-mapped buffers so the walk is O(mapped) not O(all). Add a P1 acceptance scenario (`PersistentCoherentMapScenario`) that maps PERSISTENT|WRITE|COHERENT, writes with no further GL call, draws, and reads back — and require it green before P1 is declared done, not at P7. +- **[fatal] The applier replays GLFunctionsTable, not MG_Impl — MG_State mutations MG_Impl performs around table calls never reach the replica** + - 问题:Plan §3, §5.2 and risk row R1 all rest on 'applier = mutator replay, so the replica's versions bump exactly when the client's did' and on R1's claim that divergence would require 'the client's ENTRY POINT doing something the applier did not replay, and that is a bounded, auditable surface (the 91 MG_Impl call sites into GLFunctionsTable)'. That surface is exactly where the bugs are, it is not bounded by anything the plan gates, and I found two concrete, shipped instances: (a) glGenerateMipmap. `GLImpl::GenerateMipmap` (MG_Impl/GLImpl/Texture/GL_Texture.cpp:6681-6691) runs `EnsureGeneratedMipmapStorageAllocated(*mipmapTexture)` BEFORE `GenerateMipmap_Backend`. That helper (GL_Texture.cpp:501-545) calls `AllocateStorage` for levels 1..N, `MarkStorageDirty(...,false)` (:528), `TruncateMipmapLevels` (:533) and `BumpContentVersion()` (:537). The comment at :534-537 states why the version bump exists: without it 'a cached sampled VkImageView built for the pre-generate level range would otherwise stay stale and clamp LOD>0 sampling to mip 0'. An applier that only calls the table reproduces that exact known bug on the replica. Same for `GenerateTextureMipmap` (:6705-6711) and `MaybeAutoGenerateMipmap` (:1625-1635). (b) Transform feedback CPU accounting. `AccountTransformFeedbackPrimitives` (MG_Impl/GLImpl/Drawing/GL_Drawing.cpp:172-236) mutates six GLContext counters on every captured draw: `AddTransformFeedbackPausedPrimitives` (:177), `AddTransformFeedbackInputPrimitives` (:184), `AddTransformFeedbackGeometryCaptureDraw` (:214), `AddTransformFeedbackPrimitives` (:231), `AddTransformFeedbackCapturedVertices` (:232), `AddTransformFeedbackAccountedCaptureDraw` (:237). DirectGLES reads `GetTransformFeedbackCapturedVertices()` at DirectGLES.cpp:900 to size the scattered capture, and DirectVulkan reads `GetTransformFeedbackPausedPrimitiveCounter()` at DirectVulkan.cpp:1384 and folds the frontend delta into the query result at :1337. On the replica every one of these stays 0: scattered XFB captures nothing and PRIMITIVES_WRITTEN/PRIMITIVES_GENERATED are wrong. None of these counters has a version counter; none appears in the plan's §5.3 trigger table or its §5.1 reconcile walk. They are additionally saved/restored per XFB object on bind (Core.cpp:1273,1296; Core.h:313-357), so a naive 'ship the scalar' patch must follow the object swap. The §5.9 coverage generator cannot catch this class: it scans MG_Backend/** for READS and maps them to delta kinds, so a backend read of `GetTransformFeedbackCapturedVertices` would be classified and pass, while the PRODUCER half in MG_Impl is never audited. + - 修法:Add a second generated inventory to §5.9: every `pGLContext->` MUTATOR call in MG_Impl that occurs in a function which also calls `gBackendFunctionsTable.GL.*` or `pActiveBackendObject->`. Each entry must be marked replayed-by-applier, shipped-as-delta, or explicitly client-only, with a `#error` on unmapped — the same compile-time gate the read side gets. Concretely: (1) factor `AccountTransformFeedbackPrimitives` and `EnsureGeneratedMipmapStorageAllocated` into shared helpers the applier also runs, or ship them as explicit `RecXfbAccounting` / `RecGenerateMipmapLevels` deltas; (2) move the P8 Xfb* scenario gate earlier, into P2, so this class of divergence surfaces before four more phases are built on the assumption. +- **[major] Read-after-GPU-write is gated by a flag the client can never set in time — glMapBufferRange(READ) returns stale bytes with no round trip** + - 问题:`BufferObject::SyncGpuWrites()` (BufferObject.cpp:265-274) early-returns unless `m_gpuWritePending`, and that flag is set only by `MarkGpuWritten()` (BufferObject.cpp:260-263), whose only callers are in MG_Backend/ (DirectGLES.cpp:465, 509, 1809; UniformManager.cpp:1073, 1229; VulkanRenderer.cpp:11210) plus the resident-SubData branch in `UploadSubData`, which cannot fire client-side while adoption is off (§6.8 T2). Every reconciliation point calls it: `AcquireMemory` (:405), `AcquireMemoryRange` (:454), `UploadSubData`, `FillSubData` (:351), `CopyDataFrom` (:383), and MG_Impl's `glGetBufferSubData`/`glGetNamedBufferSubData` (GL_Buffer.cpp:957, 995). In the split the client's flag is set only if an `EvGpuWritten` event happens to have been drained already. Plan §6.7 lists 'glGetBufferSubData / glMapBuffer(READ) on gpuWritePending' as a round trip and says it is 'narrowed by EvGpuWritten{ranges}' — but nothing establishes the flag in the first place. An app that dispatches a compute shader writing an SSBO and immediately maps it for read gets the stale shadow, silently, with zero round trip. Same for atomic counters, XFB capture targets, and pack PBOs after the fire-and-forget ReadPixels of §6.7. + - 修法:The client must own a conservative pending set, mirroring what DirectGLES already does at DirectGLES.cpp:459-467/687/697: at every emitted draw/dispatch, mark every buffer bound to SHADER_STORAGE / ATOMIC_COUNTER / an image-buffer texture unit, every active XFB capture target, and any pack PBO named by a ReadPixels record, recording the emit seq. On any read entry point, if the buffer is in that set: publish, wait for `appliedSeq >= recordedSeq`, drain events, then read. `EvGpuWritten` becomes a pure narrowing optimisation (it may cancel or range-limit the wait), never the thing that establishes existence. +- **[major] Sync and query poll loops deadlock: the polling entry points are not Publish triggers** + - 问题:Plan §8 answers `GetSyncStatus`, `ClientWaitSync(timeout==0)`, `IsQueryResultAvailable` and `GetQueryResult64(wait=false)` from a single `RingControl` acquire load with zero round trips. Plan §7.2's Publish trigger list is: 64 KiB of records, SEG_STAGE below 1/4, any blocking request, Present, eglMakeCurrent, glFlush. None of the poll paths appears. The canonical GL idioms are `glFenceSync(); while (glClientWaitSync(s, GL_SYNC_FLUSH_COMMANDS_BIT, 0) == GL_TIMEOUT_EXPIRED) {}` and `while (!avail) glGetQueryObjectuiv(id, GL_QUERY_RESULT_AVAILABLE, &avail);`. With no other GL call in the loop, the `FenceSync` / `EndQuery` record sits in the shm ring with no release-store of `head` and no doorbell; the server never observes it; the watermark never advances; the loop spins forever. This is a hang, not a slowdown. It is also a spec violation the codebase already cares about: GL_Sync.cpp:71-82 validates GL_SYNC_FLUSH_COMMANDS_BIT specifically so a caller cannot 'think it had asked for a flush it never got'. `glGetSynciv(GL_SYNC_STATUS)` (GL_Sync.cpp:172-181) is the same shape. + - 修法:Add `glClientWaitSync` (any timeout), `glGetSynciv(GL_SYNC_STATUS)`, `glGetQueryObject*(GL_QUERY_RESULT_AVAILABLE | GL_QUERY_RESULT_NO_WAIT)` to the Publish trigger list — publish (release-store + doorbell) without waiting. Make GL_SYNC_FLUSH_COMMANDS_BIT publish unconditionally, since the spec mandates the flush. Add a starvation escape: after N consecutive polls with no watermark movement, promote to one blocking round trip so a server that has stalled cannot spin the client. +- **[major] §5.6's 'the client never clears its texture dirty flags' is provably wrong and makes every texture update ship the whole level** + - 问题:`MipmapStorage::MarkDirtyRegion` (MG_State/GLState/TextureState/MipmapStorage.cpp:198-235) UNIONS the incoming box into `m_dirtyRegions[level]` and appends to `m_dirtyRects[level]` for as long as `m_isDirty[level]` is true; only `MarkDirty(level,false)` (MipmapStorage.cpp:171-190) resets them. Plan §5.6 asserts the client never clears ('client 的 dirty flag 从不被清 ... 已发送状态存在 WireMirror 里') while §5.5 rule 3 derives the shipping shape from `GetStorageDirtyRegion`/`GetStorageDirtyRects`. `ShipRecord` (§5.1) holds three `Uint64` version words — no region can be reconstructed from it. Consequences: after the first sub-image the union box only grows, the rect list saturates at `kMaxDirtyRects`, and `GetDirtyRects` returns 0 the moment `summedArea*4 >= unionArea*3` (MipmapStorage.cpp:305). Every animated-atlas tick then ships the entire level — the exact opposite of the §5.5 tuning the plan claims to preserve. `MarkDirtyRegion`'s rect-seeding branch (`if (!m_isDirty[level]) rects.clear(); else if (rects.empty() ...) rects.push_back(region)`, :214-221) is written for a consumer that clears; never clearing changes its behaviour too. Secondary factual error in the same paragraph: the frontend does clear dirty flags itself, at five sites — GL_Texture.cpp:528, 701, 5547, 5621, 5691. The good news I verified: MG_Impl contains no `IsStorageDirty(`/`GetDirtyRects(`/`GetDirtyRegion(` call site at all, so clear-on-emit is safe for the frontend. + - 修法:Have the client clear on emit — `MarkStorageDirty(uploadTarget, level, false)` immediately after appending the texture record. That reinstates the ack question §5.6 claims to have dissolved; close it by (a) making `ResyncSnapshot` always ship whole levels from the intact shadow (it can — the shadow is never dropped), and (b) deferring the clear until the record is past a drain-safe watermark, or accepting resync-on-drain. Rewrite §5.6's dirty-flag paragraph accordingly; it is currently the load-bearing justification for a design decision that does not hold. +- **[major] `inproc` mode cannot work as specified: the backend function table, the active backend object and the default-FBO info are single process globals** + - 问题:§12 hooks the split by replacing `MG_Backend::gBackendFunctionsTable` and `MG_Backend::pActiveBackendObject` — both assigned once, process-wide, at MG_Backend/Init.cpp:43-44 and :53-61. In `inproc` both roles live in one process, so once the client installs the emit table there is no path by which the applier reaches the real DirectGLES/DirectVulkan table, and no path by which server-side MG_Impl code reaches it either. Server-side MG_Impl code exists and reads that global: `GenerateMipmap_Backend` (GL_Texture.cpp:1621), the `GetTexImage` fallback chain (GL_Texture.cpp:6713-6725), `FixupGsStripCaptureOrder`. Worse, `MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo` is a second process global (defined GL_Framebuffer.cpp:3344) read by the client at GL_Framebuffer.cpp:495, 1827, 1837, 1897, 1905, 1913, 1927, 1936, 2549, 2590, 2598, 2608, 2611 and by the server-side backend at DirectGLES.cpp:1917, 2838, 2867, 9675 and SwapchainObject.cpp:276. One process cannot hold both a client default-FBO description and a server one; SwapchainObject writes the server's view straight into it. §12 addresses only `pGLContext` (correctly noting the 65 non-arrow uses — I counted exactly 65). This is not a corner: §12 calls `inproc` a product deliverable and P2.5 makes it the plan's EARLIEST falsification gate, so a broken `inproc` removes the week-5 go/no-go entirely. + - 修法:Extend the role-scoping mechanism chosen for `pGLContext` to `gBackendFunctionsTable`, `pActiveBackendObject` and `pDefaultFramebufferInfo` — thread-local pointer plus an `operator->`/`get()`/`operator bool` shim, all under `#if MOBILEGL_BUILD_DISAGGREGATED`. Re-scope the D2/§12 claim of 'one hook point in MG_Backend/Init.cpp:47-70': it is four globals, and add the cost to P0's estimate. Alternatively drop `inproc` to a test-only mode with the applier holding an explicitly-passed table pointer and no MG_Impl on the server side — but then P2.5 no longer measures the monolith render-thread deliverable it is supposed to. +- **[major] Late glGetError breaks the standard allocation-probe idiom for the backend's GL_OUT_OF_MEMORY sites** + - 问题:§5.6 routes backend `RecordError` (DirectGLES.cpp:6319; Managers.cpp:8679; DirectVulkan.cpp:816; VulkanRenderer.cpp:1242, 1246, 1302) through an `EvGlError` event that is explicitly allowed to be observed 'a batch late', with `MOBILEGL_IPC_STRICT_ERRORS` reserved for the CTS lane. Those sites are GL_OUT_OF_MEMORY on renderbuffer and texture allocation. The universal application idiom is `glRenderbufferStorage(...); if (glGetError() == GL_OUT_OF_MEMORY) { fall back to a smaller target; }`. Late delivery makes the app take the success branch and then render into storage the server never allocated — a divergence that shows up as corrupt output or a later server-side failure, far from the cause. The plan is right that glGetError must stay client-local for the hot path (GL_Getter.cpp:2811-2817; the GL-thread-owned invariant at Core.cpp:48-49). The error is treating all backend errors as one class. + - 修法:Split the class. Mark only the allocation-class entry points as `kNeedsAck` — `glRenderbufferStorage*`, `glTexImage*`/`glTexStorage*`/`glCopyTexImage*` where the backend can fail, `glBufferStorage`. They are rare and already expensive, so the ack is nearly free, and it makes the OOM probe exact. Everything else keeps late delivery. Drop the global `MOBILEGL_IPC_STRICT_ERRORS` from the CTS-only ghetto; with this split it should not be needed. +- **[minor] P4's 'move glCopyTexSubImage wholly to the server' contradicts the existing implementation and references an event the protocol does not define** + - 问题:`glCopyTexSubImage*` is already an entirely frontend operation: `CopyTexSubImage{1,2,3}D_State` (GL_Texture.cpp:3955, 3980) call `CopyReadFramebufferIntoMipmapRegion`, which borrows a backend `ReadPixels` into CPU scratch, memcpys into the mipmap shadow, and calls `MarkStorageDirty(uploadTarget, level, true)` at GL_Texture.cpp:1095. Left alone in the split it costs exactly one blocking ReadPixels round trip and the resulting dirty region ships as an ordinary texture delta — correct, and it needs no new command. P4 instead proposes moving it server-side plus an `EvTexWriteback` event to update the client shadow. That event does not appear in §7.4's event list (which has `EvBufferWriteback` but no texture equivalent), it still costs a round trip (the client shadow must be current for `glGetTexImage`), and it adds a command with no counterpart in `GLFunctionsTable`. `glClearTexImage` (GL_Texture.cpp:985-1006) has the same frontend-only shape. + - 修法:Leave `glCopyTexSubImage*` and `glClearTexImage` frontend-side; delete the P4 item and the undefined `EvTexWriteback`. Keep the per-level `serverAuthoritative` bit only for the two cases whose shadow writes genuinely happen in the backend: generated mip levels (DirectGLES.cpp:6270-6271, 6861) and the `CopyImageSubData` destination mirror (DirectGLES.cpp:7144). +- **[minor] Client-side vertex-array bounding can scan a stale index buffer** + - 问题:§6.10 correctly identifies that the index scan (`TryComputeMaxIndexFromHostBytes`, VulkanRenderer.cpp:3406-3470) must run client-side. But the monolith runs `indexBuffer->SyncGpuWrites()` immediately before every such scan — DirectGLES.cpp:4413, MultiDraw.cpp:499, VulkanRenderer.cpp:3431, 4159 — precisely because the EBO may have been written by a compute shader or XFB. On the client that scan reads the client shadow, and per the pending-flag flaw the reconciliation will not fire, so the computed `maxIndex` is derived from stale bytes and the vertex array is under-copied: missing or garbage geometry, or an out-of-range read of the app's array. The same stale-shadow exposure applies to the primitive-restart rewrite (DirectGLES.cpp:4412-4414) and to the `*IndirectCount` parameter-buffer read (DirectGLES.cpp:4666-4693, 4768-4793). + - 修法:Fold into the conservative pending-set fix: `ClientArrayBounds` and the restart/indirect-count readers must force the readback (publish + wait + drain) before touching the shadow, exactly where the monolith calls `SyncGpuWrites()`. Add a `ClientArrayAfterComputeWriteScenario` to the P2 gate. +- **[minor] SEG_STAGE has no cursors in RingControl, and P4.5 shadow arena blocks have no retirement rule** + - 问题:Two data-plane bookkeeping gaps. (1) §6.2's `RingControl` defines one `head`/`appliedTail`/`retiredTail` triple, but §6.1 gives SEG_STAGE its own 32-256 MiB ring and §7.2 makes 'SEG_STAGE 余量 < 1/4' a Publish trigger. Occupancy of a second ring is not computable from the first ring's cursors, and stage slots borrowed by `PendingResidentWrite` (§P6) retire on `retiredSeq`, not `appliedSeq`, so they need their own pair. (2) §6.4's 64 KiB block send-watermark covers overwriting a LIVE shadow, but says nothing about freeing one: `glDeleteBuffers` or a `glBufferData` respecify releases or reallocates the SEG_SHADOW arena block while records carrying `{segId, offset, size}` into it may still be unapplied — the server then reads another object's bytes. + - 修法:Give SEG_STAGE its own `{head, appliedTail, retiredTail}` triple in `RingControl` (there is room in the 4 KiB page). Retire shadow-arena blocks through the same watermark as ring slots — a freed block goes on a pending list and is only returned to the arena once `appliedSeq` (or `retiredSeq` for borrowed slots) has passed the last record that referenced it — rather than being released at object destruction. +- **[minor] A lifetimeId mismatch on create is repaired by a destructive re-create, which GL forbids for a still-referenced object** + - 问题:§5.4 says the server keys replica objects by `(kind, name)` and that 'if a create's lifetimeId does not match the record, destroy first then create'. On the replica that object may still be legally referenced by FBO attachments, binding slots, texture views (`GetViewStorageOwner`) or XFB capture targets, all of which hold `SharedPtr`s; GL keeps such an object alive until the last reference drops. A forced destroy either leaves dangling replica references or silently detaches them, and it converts a protocol bug into a rendering bug that will be attributed to the backend. The surrounding design is sound — §5.4's 'identity plus counter, never the counter alone' is the right lesson from the packed_pixels postmortem (DirectGLES.cpp:2823-2831) — it is only the repair action that is wrong. + - 修法:Make the mismatch `Fatal{IdentityDivergence}` (or a forced `ResyncSnapshot` under `MOBILEGL_IPC_RESPAWN`). It cannot occur if the protocol is correct, so a loud stop is strictly better than a silent destructive repair; the debug cost of an unexplained missing attachment far exceeds the cost of a crash with a named reason. +- **[minor] RenderbufferObject::GetLifetimeId is listed as a 3-line P0 addition but has no version counter either** + - 问题:§5.4 and §14 correctly flag that `RenderbufferObject` lacks `GetLifetimeId()` while Buffer (BufferObject.h:208), Framebuffer (:158), Program (ProgramObject.h:1620), VAO (VertexArrayObject.h:120), Sampler (SamplerObject.h:141) and Texture (TextureObject.h:83,161) have one. But the §5.3 trigger table also has no row for renderbuffer state at all: `BackendRenderbufferObject::SyncToBackend` (Managers.cpp:~8620-8700) caches `{internalFormat, width, height, samples}` and there is no accessor in the plan's walk that would tell the client a `glRenderbufferStorageMultisample` happened. It is reachable only transitively through `FboAttach`, which is gated on `GetAllFramebufferAttachmentVersions()` — a re-storage of an already-attached renderbuffer need not bump that. + - 修法:Add both `GetLifetimeId()` and a `GetVersion()` to `RenderbufferObject` in P0 (same shape as `SamplerObject::GetVersion`), add a `RecRenderbufferStorage` row to §5.3, and add renderbuffer re-storage to the §5.1 reconcile walk step 6 (per-attachment). Regenerate `BackendStateSurface.inc` after adding the accessor so the §5.9 gate covers it. + +已验证的优点: +- The replica-GLContext decision is correct and the evidence for it is stronger than the plan states. I confirmed the backend memos written into frontend objects are only 4 sites and DirectVulkan-only (ProgramFactory.cpp:3448; VertexInputStateFactory.cpp:60, 78, 83), and that VertexInputStateFactory.cpp:78 really does store a raw pointer into the backend's own heap. Under the replica model these are free; under any delta-apply rewrite they are a redesign. DirectGLES has zero such sites. +- RenderStateBlob as a single whole-struct delta is well chosen. I verified RenderStateParameters (RenderState.h:222-370) genuinely carries PatchVertices (:242), PatchDefaultOuter/InnerLevel (:248-249), ClampReadColor (:313), ProvokingVertexModeSetting (:300), PrimitiveRestartIndex (:322), PolygonMode front/back, the 16-viewport arrays and ScissorBoxWrittenMask. So one blob really does subsume the ~40 individual fixed-function accessors plus the patch-parameter reads at DirectGLES.cpp:2807-2814 and Managers.cpp:7120-7132. +- Deleting GetInteger64i_v and GetProgramiv from the wire is right. I confirmed no MG_Impl call site reaches those table entries: glGetInteger64i_v answers locally and delegates leftovers to the 32-bit form (GL_Getter.cpp:1240, :1302-1307) and glGetProgramiv routes to GetProgramiv_State (GL_Program.cpp:2478-2479), which answers from ProgramObject. +- tableSlotMask is a necessary addition the prior branch lacked. `BeginOcclusionQuery != nullptr` really is used as a capability probe at GL_Query.cpp:471, 545 and 768 (COUNTER_BITS answers 32/1/0 off it), so a remote client must reproduce which slots the far side actually registered. +- The plan's read of GetQueryResult64's contract is accurate and load-bearing: GL_Query.cpp:292-311 reads 0, does NOT cache, and keeps the backend handle when the backend cannot produce a result yet. That is genuinely deferred-reply-friendly and makes watermark-predicted answers conformant. +- §5.7's identification of the composite-pipeline hazard is correct and non-obvious. GLContext::GetProgramForDraw (Core.cpp:612-660) joins every stage, computes a signature, and on a cache miss does `MakeShared(0u)` and links an anonymous composite; RefreshCompositeUniforms/MirrorUniformValues then mutate it per draw. A publish-mode server with no sources genuinely cannot do this, so client-side resolution plus SetReplicaResolvedDrawProgram is the right fix. +- Declining AcquirePersistentMap really is tolerated by the frontend at all three request sites — TryAdoptLargeStorage (BufferObject.cpp:173-176), EnsureGpuResidentStorage (:436-443) and AcquireMemoryRange (:470-473) all handle a null return — so §6.8's tier T2 is a safe default from the frontend's point of view (the failure is elsewhere, see the persistent-map finding). +- MOBILEGL_COHERENT_AS_FLUSH defaults to false (Config.h:174, ConfigLoader.cpp:185), so §6.8's prohibition costs nothing on the default configuration and cannot regress the Create/Flywheel fixtures by itself. +- The frontend never reads its own texture dirty state — zero IsStorageDirty/GetDirtyRects/GetDirtyRegion call sites in MG_Impl — which is what makes the clear-on-emit fix to §5.6 safe. The plan reached the wrong conclusion from the right underlying fact. +- The §12 note about pGLContext is precise: it is `extern UniquePtr&` (Core.h:564) and I counted exactly 65 non-arrow uses across MG_Impl/MG_State/MG_Backend, matching the plan's '约 65 处'. The shim requirements it lists (operator->, get(), operator bool, equality) are the right set. +- The critique of Feat/CS-Delta-IPC is accurate on the points I spot-checked: it really did leave POSIX fd passing unimplemented, its ServerHost really does not compile, and its bfa.h really does hand FlatBuffers table pointers across a nominal C ABI. Making SCM_RIGHTS a P0 deliverable with its own test is the correct inversion. +- Keeping glFinish/glFlush free (Definitions.cpp:111-112) and glGetError client-local (GL_Getter.cpp:2811-2817, invariant at Core.cpp:48-49) is right, and the plan is correct that turning them into round trips would be a self-inflicted regression. + +## 3. 修订记录(综合稿 → 定稿) + +- FATAL persistent-map: verified SyncPersistentMappedRange (BufferObject.cpp:238-250) has zero MG_Impl/MG_State callers (all 19 production call sites are in MG_Backend/). Added new section 5.10: RecBufferMap/RecBufferUnmap records + client-side 64KiB-block push from PublishImplicitState + PersistentCoherentMapScenario as a P1a gate. Also fixes the IsBufferDrawClean IsMapped() gate (Managers.cpp:1447) that the replica would otherwise get wrong. +- FATAL MarkGpuWritten: verified all 6 callers are backend-only. New section 5.6b makes the CLIENT set the flag conservatively at every draw/dispatch emit point (mirroring DirectGLES.cpp:459-467/509/1809), records emitSeq, and forces publish+wait+drain at every read entry; EvGpuWritten demoted to a narrowing hint. Section 7.4 drain points extended with glMapBuffer*/glGetBufferSubData/glGetNamedBufferSubData/glCopyBufferSubData. +- FATAL poll livelock: section 7.2 rewritten. glClientWaitSync/glGetSynciv(SYNC_STATUS)/glGetQueryObject*(AVAILABLE|NO_WAIT) are now Publish triggers, GL_SYNC_FLUSH_COMMANDS_BIT publishes unconditionally (cites DirectVulkan.cpp:1158-1160), plus a starvation escalation (MOBILEGL_IPC_POLL_ESCALATE). Dedicated P3 gate added. +- FATAL fence granularity: added a subsection to section 8 requiring real per-fence server-side polling + EvFenceSignaled instead of a present-granular watermark, citing DirectVulkan.cpp:1120-1128 and the magma-mc1215-fence-oom history. Section 9.3 adds a non-present fence tick for DirectGLES. +- Applier-replays-table gap (new boundary face (g) in section 2): verified EnsureGeneratedMipmapStorageAllocated (GL_Texture.cpp:501-541, incl. BumpContentVersion at :538 with its stale-VkImageView rationale) and AccountTransformFeedbackPrimitives (GL_Drawing.cpp:172-236, 6 counters read by DirectGLES.cpp:900 and DirectVulkan.cpp:1337/1384). Added section 5.9b: a SECOND generated inventory (gen_impl_mutation_surface.py + MutationCoverage.def) making unmapped MG_Impl mutations a #error, plus RecGenerateMipmapLevels/RecXfbAccounting records and MG_Remote/Shared/ helpers. +- Texture dirty flags: verified MipmapStorage::MarkDirtyRegion unions forever unless MarkDirty(level,false) runs, and that MG_Impl has ZERO IsStorageDirty/GetDirtyRects/GetDirtyRegion readers. Section 5.6a now requires clear-on-emit and closes the ack question via intact-shadow resync + re-send of un-applied texture records after a hard drain. +- inproc globals: verified pDefaultFramebufferInfo is a second process global (22 refs; client MG_Impl reads 13, server backend reads 4 + SwapchainObject writes 1) and that gBackendFunctionsTable is read by server-side MG_Impl too. Section 12 split into 12.1/12.2/12.3: two CMake options (shipping spawn build keeps all four globals plain, no TLS on the GL hot path), full shim requirement list, and an explicit P0 go/no-go on isolating vs downgrading inproc. +- Non-arrow pGLContext count corrected from '~65' to the measured 133 (2 in MG_Impl, the bulk in MG_Backend incl. ~90 DirectVulkan asserts), with the lifecycle sites (Core.cpp:20/1487, Core.h:564, Init.cpp:63) added to the shim requirements. +- Publish policy: deleted the 64KiB byte threshold (it was a full MC frame and pre-killed the P2.5 hypothesis). Now release-store cmdHead every record (or every 8-16), doorbell only when consumerParked. +- Added the symmetric producer-side doorbell (new section 6.2a: producerParked + reverse byte / condvar) so present-credit, kNeedsAck and ring-full waits block instead of cross-process spinning on a phone big core. +- Added SEG_EVENT overflow policy (section 7.4): drain inside every wait loop, lossy EvLogLine with eventDropped counter, non-lossy semantic events with an eventRingFull stop-applying flag, plus a P4 fault-injection gate for the credit-blocked deadlock. +- RingControl gained an independent {head, appliedTail, retiredTail} triple for SEG_STAGE (section 6.2), since the 'stage below 1/4' publish trigger cannot be computed from the cmd cursors and stage slots retire on retiredSeq. +- Added SEG_SHADOW block retirement rule (section 6.1): freed/reallocated arena blocks go on a pending list gated by appliedSeq/retiredSeq, not released at object destruction. +- Copy accounting table (6.4) corrected: monolith is 2 (not 1), P1-4 is 4 (not 2), P4.5 is 3 (not 1); added rows for map+unmap and for the new persistent-map push. Added optional plan B (replica adopts client SEG_SHADOW read-only as a third PipeResource mode) as a P6 candidate, and required TracyPlot counters on BOTH sides of the wire. +- Errors: section 5.6c splits the class - only allocation-class entry points (glRenderbufferStorage*, some glTexImage*/glTexStorage*/glCopyTexImage*, glBufferStorage) become kNeedsAck so the GL_OUT_OF_MEMORY probe idiom stays exact; MOBILEGL_IPC_STRICT_ERRORS demoted from CTS-required to a diagnostic switch. P4 gains an OOM-probe gate. +- Present credit default lowered from 2 to 1 with the latency composition spelled out (client credit + server FIF + driver depth; FrameContext.cpp:288-290 shows Present itself already waits), and input-latency histogram gates added to P3 and P9. +- Added a core-placement plan (section 10): total-CPU-work delta must be stated, mgl-srv-apply pinned to a big core reusing ShaderCompilePool.cpp:73-96 detection via MOBILEGL_IPC_SERVER_AFFINITY, and P2.5/P3 must report per-thread CPU time. +- Added a non-present fence tick for DirectGLES (9.3) so retiredTail does not starve in glcts/readback loops, plus a present-less split case in P2. +- lifetimeId mismatch on create changed from destructive re-create to Fatal{IdentityDivergence} (section 5.4), because the replica object may still be legally referenced by attachments/views/binding slots. +- RenderbufferObject now gets BOTH GetLifetimeId() and GetVersion() in P0, with a RecRenderbufferStorage row in 5.3 and per-attachment version reads in the 5.1 walk (a re-storage of an already-attached RBO need not bump the FBO attachment versions). +- glCopyTexSubImage*/glClearTexImage kept frontend-side (6.6): verified CopyReadFramebufferIntoMipmapRegion (GL_Texture.cpp:1044-1097) is already pure-frontend borrowing one ReadPixels. Dropped the P4 'move to server' item and the undefined EvTexWriteback; serverAuthoritative bit narrowed to generated mips and the CopyImageSubData mirror. +- Client index scans / restart rewrite / IndirectCount parameter reads must go through the pending-set force-readback at exactly the sites where the monolith calls SyncGpuWrites() (6.10), with a new ClientArrayAfterComputeWriteScenario in P2. +- Added runtime bounds discipline for ring records (6.3): the same X-macro generates size >= sizeof(T) && size <= remainingRingBytes && (size%8)==0 preconditions, Fatal{ProtocolCorruption} on violation. +- MOBILEGL_COHERENT_AS_FLUSH ban REMOVED (5.10/6.8): with client-side persistent-map push, both rewritten and app-native coherent maps are correct, so the two Create fixtures run the same buffer path in split and monolith and the P2 name-for-name comparison is honest. +- Android delivery chain moved into P0 as spike A (server .so packaging verified through AGP, posix_spawn from the app's own untrusted_app process rather than run-as, generic --es mobilegl_env passthrough across the five trace files). External-memory feasibility became spike B so P7's schedule is known in week 1. +- P1 split into P1a (client + inproc applier, Linux gate) and P1b (spawn transport, Linux gate); device OpenRA retrace moved to the P2 exit criterion. Total re-estimated 74 -> 77 person-days with milestones at weeks 3/5/6. +- Spawned server must scrub MOBILEGL_TRANSPORT/MOBILEGL_IPC_* from its envp AND force Transport=Monolith before MG_Backend::Init (11.1), with a P1b process-tree count gate - otherwise an unbounded fork chain on first GL call. +- HeadlessGL fork pre-flight orphan-server issue addressed (11.3): immediate EOF exit, bounded readiness retry, pgrep gate in P1b; cites HeadlessGL.cpp:344-368 and its own :585-589 'leaked exclusive device' note. +- Server discovery reworked (11.1): MOBILEGL_IPC_SERVER_PATH primary with dladdr fallback, RUNTIME_OUTPUT_DIRECTORY aligned to the MobileGL library dir, env injected into every new ctest ENVIRONMENT - verified the itest links MobileGL_s statically (CMakeLists.txt:28-35) and retrace passes an explicit -DMOBILEGL_LIBRARY. +- mobilegl_server_main declared extern "C" with explicit default visibility plus an nm -D assertion in P0 (11.2), because CMakeLists.txt:497-510 sets hidden visibility on every non-Debug build and RelWithDebInfo is what ships. +- FlatBuffers: add_subdirectory(3rdparty/flatbuffers) removed from the default build path entirely (7.1/13) - the prior branch's flatc block IS the NDK trap - plus a CMake guard that forces the option OFF with a warning when 3rdparty/flatbuffers/include is absent. +- Windows handle pair spelled out (11.5): GUID-named CreateNamedPipeW + CreateFileW both with FILE_FLAG_OVERLAPPED and the server end inherited, because asio's windows::stream_handle IOCP service needs an overlapped handle and CreatePipe does not give one. +- trace-replay SPLIT plumbing detailed (13): test name gains a SPLIT suffix (current name MobileGLTraceReplay.CASE.BACKEND would collide) and -DTRACE_TRANSPORT= must be threaded through run_trace_case.cmake; both files listed as P2 deliverables. +- Added a steady-state memory budget requirement (R14) covering client segments + full replica context + the server's three 4->64MiB rings + the 64MiB buffer pool (~450MiB), with P1a recording RSS for BOTH processes and SEG_STAGE's ceiling set by measurement. +- P3's 'zero round trips' gate reworded to cover the whole trace-case matrix with per-fixture round-trip counts published, rather than resting on minecraft-1.21.4-main-menu which exercises neither conditional render nor occlusion queries. +- The nm/.text monolith preservation gate is now a phase-exit criterion for every phase P0-P9, and the P4.5 allocator change is explicitly required to be #if MOBILEGL_BUILD_DISAGGREGATED-wrapped (PipeResource/MipmapStorage live in MG_State, so an unguarded allocator swap would turn the gate red). +- Added a schedule-risk row (R15) calibrated against Feat/CS-Delta-IPC's 6668 lines / zero frames, with P2.5 named as its annealer. +- Section 14 REUSE/CHANGE/DROP updated: the prior branch's Protocol/CMakeLists.txt flatc block moved from REUSE to CHANGE-with-deletion, HandleSessionGeneration.md gains the RBO GetVersion and Fatal-on-mismatch edits, and gen_impl_mutation_surface.py noted as having no counterpart there. + +## 4. 被驳回的审查意见 + +- 'inproc is a category error because the server must not hold MG_State' - not a flaw in this plan: the replica model deliberately links MG_State into the server, and the verified evidence (UniformManager.cpp:1418-1497 constructing real TextureObjects, VulkanRenderer.cpp:4211-4356 driving ShaderObject::Compile/ProgramObject::Link) shows a thin server is impossible regardless. +- 'The 167 handle-ify hits mean a huge conversion surface' - already handled: the plan's own section 14 notes those counts include GLFunctionsTable declarations at BackendObject.h:158-186 and the static global at DirectGLES.cpp:55, and the replica model means no SharedPtr-keyed twin registry needs converting at all. +- 'MarkStorageDirty(...,true) at Managers.cpp:2813 (RequireImageBindableStorage re-dirty) needs a client-visible ack protocol' - it is purely server-initiated by a server-side re-mint, is unpredictable by the client by construction, and the re-upload happens entirely on the replica; no wire traffic is needed (documented as such in the section 5.6 table). +- 'BeginConditionalRender should become a client-side speculative pass-through' - the spec latitude is real but GL_Query.cpp:705-706 documents the always-wait choice as the only one giving the whole block one deterministic verdict; changing it is an independent monolith behaviour change, not a split concern. Kept as a listed blocking point instead. From 1794ac94b1e1591e793ce8588f97f559bc440907 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 13:05:43 -0400 Subject: [PATCH 002/529] [Docs] (Disaggregated): land plan B - MGPipe, a gallium-style explicit frontend/backend interface with a backend-owned state machine (client-allocated {slot,gen} handles, CSOs keyed on the pipeline subset with dynamic state pushed separately, set_* catalogue derived from the twins the two backends already keep, per-verb validate with aggregate generations, reverse channel as ten named callbacks plus a pull terminator, strangler migration behind PipeInputs with per-verb poison and a field-wise verify harness, three purity gates replacing byte identity), the item-by-item comparison against the replica plan A (memory, copies, drift surface, monolith benefit vs 4x effort and 7x later first frame) with a day-43 GO/NO-GO hedge, and the round-2 design-competition and adversarial-review record; mark plan A as superseded except for its inherited transport sections --- docs/Disaggregated/PLAN-B-MGPipe.md | 1936 +++++++++++++++++++++++++++ docs/Disaggregated/PLAN.md | 1 + docs/Disaggregated/REVIEW-B.md | 316 +++++ 3 files changed, 2253 insertions(+) create mode 100644 docs/Disaggregated/PLAN-B-MGPipe.md create mode 100644 docs/Disaggregated/REVIEW-B.md diff --git a/docs/Disaggregated/PLAN-B-MGPipe.md b/docs/Disaggregated/PLAN-B-MGPipe.md new file mode 100644 index 000000000..79ec50889 --- /dev/null +++ b/docs/Disaggregated/PLAN-B-MGPipe.md @@ -0,0 +1,1936 @@ +# 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 40d6743df..b756d884a 100644 --- a/docs/Disaggregated/PLAN.md +++ b/docs/Disaggregated/PLAN.md @@ -1,5 +1,6 @@ # MobileGL 前后端进程拆分实施计划(branch `feat/disaggregated`) +> **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。 diff --git a/docs/Disaggregated/REVIEW-B.md b/docs/Disaggregated/REVIEW-B.md new file mode 100644 index 000000000..a22becad2 --- /dev/null +++ b/docs/Disaggregated/REVIEW-B.md @@ -0,0 +1,316 @@ +# 方案 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. From 8349babe907599364af7525bfb1e08a0c03dbb14 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 14:06:24 -0400 Subject: [PATCH 003/529] [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 | 2701 +++++++++++++++++++-------- docs/Disaggregated/REVIEW-B.md | 316 ---- docs/Disaggregated/REVIEW.md | 511 ++--- 4 files changed, 2188 insertions(+), 3276 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 79ec50889..000000000 --- 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 b756d884a..ccf7ac388 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 操作,幂等或无害)。事件回传只允许作为**收窄优化**,永远不允许作为语义的**建立者**。 +| 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.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_*` 全族 | -**Phase 1 的目标改为:在 Linux 上以 `inproc` 与 `spawn` 两种传输跑通垂直切片;真机 OpenRA trace(SSIM ≥ 0.99)移到 P2 出口判据。** 理由见 §15:Android 交付链(server `.so` 打包、`untrusted_app` 域 exec、trace app 的 env 透传)本身是独立工作量,把它压进 P1 的 10 天里是上一版最薄弱的排期假设。 +把这些结构的**输入集合**推过去,接口就按构造完整。gallium 是**目的地**(同名同形的词汇让形状可读、可迁移),不是**推导前提**。凡 gallium 的词汇与本仓库的证据冲突的地方,本文按证据走,并在 §3.6 逐条记名列出偏离与理由。 -### 核心决策速查 +### 0.3 四条结构性推论(决定了后面每一节) -| # | 决策 | 理由 | -|---|---|---| -| D1 | Server = replica `GLContext` + 未改动 backend | 293 次 `pGLContext` 读、13 个身份键 memo、25 个 backend→frontend 写全部原样工作 | -| D2 | 发射点 = 三个**已经是间接的**边界(`gBackendFunctionsTable` / `pActiveBackendObject` / `SetBufferBackendOps`),不进 MG_State mutator | monolith 侵入面 = `MG_Backend/Init.cpp:48-70` 里一个 switch 分支;~250 个边界调用点零 `#ifdef` | -| D3 | 版本计数器**不上线**;replica 靠 mutator replay 自然 bump | 不需要 `Install*` setter,不需要在 wire 上维护回绕 `Uint16` 的单调性 | -| D4 | 控制面走 **SPSC shm ring**,watermark 放在一条**共享 cache line**;**双向 doorbell** | `GetSyncStatus`/`IsQueryResultAvailable`/ring 回收/present credit 变成一次 acquire load;但**所有等待都必须能挂起**,不能自旋 | -| D5 | FlatBuffers:热路径用 **`struct`**(定长、无 vtable、无 verifier walk),罕见/变长用 `table` 走 socket | 满足"用 FlatBuffers 序列化"的要求,同时 `DrawArrays` 记录 32B 而不是 ~60B | -| D6 | **composite pipeline program 由 client 解析**并下发 handle | `Core.cpp:644` 在 pipeline cache miss 时 `MakeShared(0u)` 并 **link**;server 在 Phase 5 之后没有源码,必须由 client 定 | -| D7 | 覆盖度由**两侧生成的编译期断言**保证:backend 的 READ 面 **和** MG_Impl 的 MUTATOR 面 | backend 新增一个 read、或 MG_Impl 在 table 调用旁新增一个 mutation 而 applier 没 replay → 编译失败,而不是设备回归 | -| D8 | monolith 保留由 **`nm --defined-only` + `.text` size diff** 机械证明,且**每个阶段都跑**,不只 P0 | 不靠"测试没变" | -| D9 | **client 是隐式发布语义的唯一发起者**(persistent map 推送、`MarkGpuWritten`、纹理 dirty 清除、XFB CPU 计数、生成 mip 的存储分配) | 见上文三条被证伪的假设 | -| D10 | `inproc` 与 `spawn` 拆成**两个 CMake option**:出货构建只开 `spawn`,`pGLContext` 保持普通全局,GL 热路径上没有 TLS | Android dlopen 的 shared library 无法用 initial-exec TLS,1494 个 `pGLContext->` 上每次 `__tls_get_addr` 调用不可接受 | +**推论 1 — 推送必须发生在 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`。 - -### 非目标(本分支明确不做) -- **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.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(默认,延迟叠加分析见 §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. 现状:边界为什么不清楚 + +### 2.1 今天的边界有七个面(数字按工作树复核) + +**(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` 两处填快照。修正见 §5.2.1 与 §14 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。 -### (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` 两处。 +**(f) backend 反向进 MG_Impl** — 恰好 6 处:`DirectGLES.cpp:1917, 2838, 2867, 9675`(`pDefaultFramebufferInfo` 身份比较)、`SwapchainObject.cpp:276`(**写**)、`VulkanRenderer.cpp:10700`(`CopyTextureImageToClientOrPBO_State`,一处真正的分层倒置)。 -- 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`)。 +**(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 生成器覆盖。 -### (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`)。 +**(h) 工作树污染** — `DirectGLES.cpp:640-663` 与 `Managers.cpp:875-877` 的未提交 per-draw `fprintf(stderr)`(后者在 `pendingMutex` 临界区内)。**P0 第一件事就是清掉。** -### (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。 +### 2.2 backend 已有的状态机清单(这就是"server 已经是薄服务端"的实证) -### (d) 状态拉取 — 293 个 `pGLContext->`(DirectGLES 124 / DirectVulkan 169)+ ~90 个前端对象 getter -`PrepareForDraw`(`DirectGLES.cpp:2916-2976`)与 `SetupDraw`(`VulkanRenderer.cpp:6371`)在这里把整个 `GLContext` 拉出来。**这一面在本设计中不过线。** +**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)。 -### (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)。 +**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`。 -### (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)。 +**结论:两个 backend 都已经是完整的、贴着各自 API 的状态机。** 上面**没有一样东西需要删除或重写**——需要改的只是它们**怎么知道**这些事实,以及它们的 memo **用什么做键**。 -### (g) MG_Impl 在 table 调用旁做的 MG_State mutation(**上一版遗漏的第七个面**) -`GLFunctionsTable` 是一个**命令**边界,不是一个**状态**边界的两侧对称点:MG_Impl 在调 table 之前/之后还会自己改 MG_State,而这些改动 applier 只 replay table 是拿不到的。已确认的两族: +### 2.3 pull 模型的读点分类:A/B/C/D/E 五类 -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`),所以简单"发个标量"的补丁必须跟着对象切换走。 +| 类 | 含义 | 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% | -§5.9 的覆盖生成器**抓不到这一类**:它扫 `MG_Backend/**` 的 READ 面,所以 backend 读 `GetTransformFeedbackCapturedVertices` 会被正常分类并通过,而 MG_Impl 那半个生产者从来没被审计过。**所以 §5.9 必须有第二个生成器**(见 §5.9b)。 +**这张表否定了两种直觉方案:** -### (h) 工作树污染(Phase 0 必须先清) -`DirectGLES.cpp:640-663` 与 `Managers.cpp:875-877` 有**未提交的 per-draw `fprintf(stderr)`**(格式串里还有字面量 `' + NL + '`,且位于 `pendingMutex` 临界区内的 buffer flush 路径上)。`Feat/CS-Delta-IPC` 的 `d96be9f3` 提交过同类东西(`DirectGLES.cpp:+2583-2590`),导致该分支上**每一次测量**(144-failure Windows run、OpenRA `ssim=0.000036` 设备 run)都跑在每 draw 一次 stderr 写的构建上。 +- **"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 门控: + +| 路径 | 稳态实际做的事 | +|---|---| +| `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 因此有了一个可证伪的预测而不只是定性主张。 --- -## 3. 目标架构总览 +## 3. 接口设计:MGPipe + +### 3.1 文件布局与单一真相源 ``` -┌───────────────────────── CLIENT 进程 (libMobileGL.so) ─────────────────────────┐ -│ App / LWJGL │ -│ │ gl* │ -│ ▼ │ -│ MG_Impl (validate → RecordError → 调 MG_State mutator) ← glGetError 本地 │ -│ │ │ -│ ▼ │ -│ MG_State::pGLContext (权威状态 + ShaderCompilePool + glslang) │ -│ │ │ -│ ├─ gBackendFunctionsTable = EmitTable ─┐ │ -│ ├─ pActiveBackendObject = BackendObject_Remote (+ CapsMirror) │ -│ └─ SetBufferBackendOps(&g_emitBufferOps) ─┤ │ -│ ▼ │ -│ MG_Remote::WireMirror │ -│ (① PublishImplicitState:persistent-map 推送、 │ -│ MarkGpuWritten 保守置位、XFB 计数、mip 分配 │ -│ ② 读版本计数器 → 决定发什么 │ -│ ③ 清 dirty flag / 记录 shipped 水位) │ -│ │ │ -└──────────────────────────────────────────────────┼─────────────────────────────┘ - SEG_CMD (SPSC ring, POD 记录) ────────────┤ 写 - SEG_STAGE (bulk 字节 ring, 独立游标) ─────┤ 写 - SEG_SHADOW[n] (P4.5+, client 拥有)────────┤ RW - RingControl (一条 cache line 的 atomics) ◄─┤ 读 watermark(acquire load) - ├─ producerParked ──► server 敲门铃 - SEG_REPLY / SEG_EVENT (server 拥有) ◄─┘ 读(在每个等待循环里排空) - CTRL socket (socketpair / 继承 overlapped pipe): FlatBuffers table - + SCM_RIGHTS + 双向 doorbell -┌──────────────────────────────────────────────────┼─────────────────────────────┐ -│ MobileGLServer (dlopen libMobileGL.so → mobilegl_server_main) │ -│ thread mgl-srv-io : asio,framing,fd 传递,doorbell,控制面 RPC │ -│ thread mgl-srv-apply: 终身持有 EGL/Vulkan context(可绑大核) │ -│ │ │ -│ ▼ Applier::Apply(RecHeader) → MG_State mutator / 共享 helper / │ -│ GLFunctionsTable │ -│ MG_State::pGLContext (replica) │ -│ ▲ │ -│ │ 293 次 pGLContext-> + ~90 getter,**零改动** │ -│ MG_Backend (DirectGLES / DirectVulkan) + MG_Util(SPIRV-Cross, 转译缓存) │ -│ │ │ -│ ▼ 真实 GLES / Vulkan 驱动 │ -└────────────────────────────────────────────────────────────────────────────────┘ +MobileGL/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 必须解析到存在的行 ``` -三种运行模式(`MOBILEGL_TRANSPORT`):`monolith`(默认,编译期折叠)、`inproc`(同进程第二个 `GLContext` + apply 线程,**需要 `MOBILEGL_BUILD_DISAGGREGATED_INPROC`**)、`spawn` / `unix:` / `pipe:`(真跨进程,出货形态)。 +`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 项,完整目录见 §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 +}; +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) | -## 4. 边界定义(每个面变成什么) +**接口规范条款:任何 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` | |---|---| -| **(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 的生成器强制全覆盖。 | +| 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 显式删除、不移植的项 -## 5. 状态 delta 模型 +- `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,绝不阻塞。** -### 5.0 决定:replica `GLContext` vs 重写 backend +### 3.5 关键 payload -**选 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 天然满足。 +#### 3.5.1 `MGPResourceDesc`(判别式,三种 GL 存储类合一) -代价: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"在这个代码库里是伪命题。 +```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 变` | -**replica 模型的边界必须明确写出来(R1 的真正内容)**:replica 只保证"对 backend 可见的状态"与 client 一致。凡是 client 的**入口点**(MG_Impl)在调 table 之外还做过的 MG_State 改动,applier 必须显式复刻——这不是理论风险,是 §2(g) 已经确认的两族实例。§5.9b 把它变成编译期门。 +**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)。 -### 5.1 reconcile 在哪里发生 +**性能诚实注记**: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 速率,两台设备)。 -`MobileGL/MG_Remote/Client/WireMirror.{h,cpp}`,在**发射点**运行:每个 `GLFunctionsTable` 命令、`Present`、任何阻塞请求。 +#### 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。** -**步骤 ①:`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)。 +#### 3.5.4 `SamplerParameters` 与 `MGPSamplerView` / `MGPTextureParams` -**步骤 ②:可达性遍历。** 这就是 `DirectGLES::PrepareForDraw`(`DirectGLES.cpp:2916-2976`)的遍历,把 sync 换成 emit——不是比喻,是同一集合、同一顺序、同一门控: +`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 到底绑不绑这张纹理。 -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`)。 +```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; +}; +``` -**步骤 ③:清消费型状态。** 对本次发射的每个纹理 level 调 `MarkStorageDirty(uploadTarget, level, false)`(§5.6a)。 +`GetViewStorageOwner()`(`TextureObject.h:96-100`,一个 `SharedPtr`,且**它自己永远不是 view**)变成 `resource_create` 的 `viewOf` + server 侧 keep-alive。 -因为 backend 自身这套门控已被证明有界且便宜,reconciler 的每 draw 成本形状是**已知的**,不是估计。 +#### 3.5.5 `MGPProgramDesc`(`create_shader_state` 的 payload) -存储: ```cpp -// MobileGL/MG_Remote/Client/WireMirror.h -struct ShipRecord { // 40 B - Uint64 shippedA, shippedB, shippedC; // 打包版本元组,按 kind 解释 - Uint32 flags; // Created | Published | Deleted | ServerAuthoritative - Uint32 pad; +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; }; -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; +``` + +**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` … | - -### 5.4 对象身份、创建/删除顺序 - -wire handle = `WireHandle { kind:u8, glName:u32, lifetimeId:u64 }`。`GetLifetimeId()` 永不复用(`BufferObject.h:208`、`FramebufferObject.h:158`、`ProgramObject.h:1620`、`VertexArrayObject.h:120`、`SamplerObject.h:141`、`TextureObject.h:83,161`)。 - -**`RenderbufferObject` 既没有 `GetLifetimeId()` 也没有 `GetVersion()`(已在 `MG_State/GLState/RenderbufferState/RenderbufferObject.h` 上确认为零命中)—— Phase 0 两个都补上**,`GetVersion()` 取 `SamplerObject::GetVersion` 的同款形状(`Uint16`,每次 `RenderbufferStorage*` bump),并在 §5.1 步骤②-6 的 per-attachment 遍历里读它。理由:`BackendRenderbufferObject::SyncToBackend`(`Managers.cpp:~8620-8700`)缓存 `{internalFormat,width,height,samples}`,而对一个**已 attach 的** renderbuffer 重新 `glRenderbufferStorageMultisample` 不必然 bump `GetAllFramebufferAttachmentVersions()`,没有 `GetVersion()` 就没有触发器。 - -replica 使用**与 client 相同的 GL name**:applier 直接 `ctx.CreateBufferObject(name)`,绕过 server 自己的 `IndexGenerator`。server 侧维护 `ska::flat_hash_map<(kind,name), {SharedPtr, clientLifetimeId}>`。 - -**若某次 create 的 `lifetimeId` 与记录不符 → `Fatal{IdentityDivergence}`,不做"先销毁再创建"的修复。** 上一版的"先销毁"是错的:replica 上那个对象可能仍被 FBO attachment、binding slot、texture view(`GetViewStorageOwner`)或 XFB capture target 通过 `SharedPtr` 合法持有,GL 保证它活到最后一个引用消失;强行销毁要么留下悬挂引用要么静默 detach,把一个协议 bug 变成一个会被归咎于 backend 的渲染 bug。协议正确时这个分支不可达,所以响亮地停下来严格优于静默的破坏性修复(`MOBILEGL_IPC_RESPAWN=1` 时改为强制 `ResyncSnapshot`)。 - -这就是 packed_pixels 的教训(身份 + 计数器,绝不单靠计数器)在协议层的应用,也是本设计对 name 空间漂移的**结构性预防**(而非事后 checksum 检测)。 - -创建/删除在 `glGen*`/`glDelete*` 时刻**立即**发射,顺序即 ring 顺序。client 的 `~BufferObject` 触发 emit-ops 的 `OnDestroy` 追加 `RecObjDelete`;server 的 replica `~BufferObject` 触发**真实**的 `Ops_OnDestroy`,完成 pooling / 延迟 `glDeleteBuffers`(`Managers.cpp:1271-1300`)——一行不改。 - -### 5.5 合并规则 - -1. **版本门控本身就是合并器。** 两个发射点之间的 N 次 mutation 折叠成一条 delta;改了又改回去的状态永不上线。 -2. **Buffer range** 在 per-buffer `VecRange1D` 里累积(复用 `MG_Util/Math/VectorTypes.h:264` 已调优的 7% span gap 合并),在该 buffer 的下一个发射点 flush。**绝不 union 成整个 buffer**——`Managers.cpp:860-864` 的 postmortem 记录了那样会每帧重拷近乎整个 chunk-mesh arena。 -3. **纹理区域**逐字沿用 `MipmapStorage::GetDirtyRects`,含 `summedArea*4 >= unionArea*3` 回退(`MipmapStorage.cpp:305`)。**注意代价轴是反的**:buffer 按字节计价,texture sub-image 按 **job 数**计价(`Managers.cpp:4311-4319`,~100 rects vs 一个 box 实测 +6ms/frame)。client 下发**区域形状**(union box 或 rect 列表,按 client 自己的 `GetDirtyRects` 判定),server 的 backend 从自己 replica 的 dirty 状态重新推导**上传形状**,让已调优的启发式留在付 GPU 代价的那一侧。 -4. `RecRenderStateBlob`、各类 bind:last-writer-wins,reconciler 只发**当前值**。 -5. **命令永不合并、永不重排。** - -### 5.6 backend→frontend 写:三种归属 - -| 写 | 归属 | -|---|---| -| `SetBackendResource`、`SetBackendHashMemo`/`StateMemo`/`AuxMemo` | **纯 server 本地**,零 wire 流量 | -| `MarkGpuWritten` ×3 + `EnsureGpuResidentStorage` | **client 保守自建**(§5.6b)。server 侧照常在 replica 上置位;`EvGpuWritten{handle, ranges[]}` 仅作为**收窄提示** | -| `MarkStorageDirty(…,false)` ×11 | **client 在发射后自己清**(§5.6a)。server 侧照常在 replica 上清 | -| `MarkStorageDirty(…,true)`(`Managers.cpp:2813` RequireImageBindableStorage 的 re-dirty) | 纯 server 本地:它是 server 的 re-mint 导致的,client 无从预测,也无需知道——重传由 server 自己在 replica 上完成 | -| `WritebackFromBackend`(PBO/XFB) | server 侧写 replica shadow;合并后的 range 变成 `EvBufferWriteback` 回传 client | -| `RecordError` ×2(`DirectGLES.cpp:6319`、`Managers.cpp:8679`)+ DirectVulkan 4 处 | **分两类**(§5.6c):分配类同步 ack,其余走 `EvGlError` 晚一批可见 | -| `AllocateStorage` 生成 mip(`DirectGLES.cpp:6270-6271,6861`)、`MirrorCopyImageIntoDestinationShadow`(`:7144`) | **per-level `serverAuthoritative` 位**(§6.6) | -| `InvalidateCompileEnv`、`SwapchainObject` 改写 default-FBO 占位纹理 | 事件 `EvCompileEnvInvalidate` / `EvDefaultFramebufferInfo` | +理由:`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 模型里累积(§6.3 的发射游标),在**下一个 validate / flush 点**把合并后的形状作为**一条** `resource_subdata`(带 union box + region 列表)发出。`MOBILEGL_PIPE_STATS` 必须把逐帧 `resource_subdata` 发射次数单列一类,并在 MC 动画图集 fixture 上设上限。 + +**稳态成本**:见 §13.2(v2 已按动态口径重写)。 + +### 4.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 直接失败**。这是 B-R6 的第四层。 + +**三个回绕的 `Uint16` 在 tracker 边界加宽。** `m_lastPushed[]` 是 tracker 自己的字段,加宽到 `Uint32`/`Uint64` **不需要改 `MG_State` 一行**;同时 handle 与它同行过线。**回绕在 tracker 本地是无害的**(一次回绕造成一次多余的重推,永不漏推),何况集合 hash 抑制器会把多余重推吞掉。 + +### 4.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 拿不到的异步收益。 + +### 4.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。 -#### 5.6a 纹理 dirty flag:client 必须清(推翻上一版) +**索引绑定的范围必须在 validate 时刻实时解析,不是在 bind 时刻快照。** `BindingSlotRange1D::GetRange()` 对整 buffer 绑定返回 `Range1D(0, object->GetSize())`,因为 `glBindBufferBase` 之后再 `glBufferData` 是普通应用代码。 -上一版写"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);`)本身就是为"有人会清"写的。 +### 4.5 sampler view 在 client 侧解析 -好消息是清是安全的:**MG_Impl 里没有任何 `IsStorageDirty(` / `GetStorageDirtyRects(` / `GetStorageDirtyRegion(` 调用点**(已 grep 确认为零),前端从不读自己的 dirty 状态;它自己也在五处主动清(`GL_Texture.cpp:528,701,5547,5621,5691`)。 +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"就是解析后的形态。** -**规则**: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 的记录。 +**解析留在 client**,并且 client 必须为它保留一个自己的 memo(§2.5 的 ~40 行搬迁项),否则每 draw 重跑完备性规则。**合并单元空间,无 stage 维度**(§3.4.3)。 -#### 5.6b `MarkGpuWritten`:client 保守自建(推翻上一版) +**两处 backend 特定的后处理留在 server**,作用在已解析的集合上:Espryt 的 raw-depth-fetch sampler 替换(`DirectGLES.cpp:3540-3546`)与 Magma 的 feedback-loop 检测(对着 draw FBO,`UniformManager.cpp:554`)。两者都可从已推送的 `set_framebuffer_state` + view 集合判定。 -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`。 +### 4.6 对象生命周期、共享组与 composite pipeline program -拆分后 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` 一整族。 +#### 4.6.1 生命周期 -**规则**:`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 范围,晚到无害。 +`resource_create` 在**前端对象构造**时发,存储由 `resource_respecify` 惰性定义。`resource_destroy` 在前端对象析构时发。三条顺序约束: -同时,§7.4 的事件排空点必须补上 `glMapBuffer` / `glMapBufferRange` / `glGetBufferSubData` / `glGetNamedBufferSubData`——上一版的排空点列表(`glGetError`、`glGetQueryObject*`、`glClientWaitSync`、`eglSwapBuffers`)不含它们。 +- **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.6c GL 错误:分配类同步 ack,其余晚到 +#### 4.6.2 共享组 -上一版把所有 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 从未分配的存储上渲染。 +v1:一个 screen、一个 context、一个扁平 handle 空间、一条 flow。`eglMakeCurrent` 是 flow 所有权转移,在既有 `EGLOperationMutex`(`EGLImpl.cpp:241`)下发射——**顺手修今天不取该锁的两个入口**:`ReleaseThread`(`:341-350`)与 `SwapInterval`(`:435-450`)。 -**规则**:只把**分配类**入口点标 `kNeedsAck`——`glRenderbufferStorage` / `glRenderbufferStorageMultisample` / `glNamedRenderbufferStorage*`、`glTexImage*` / `glTexStorage*` / `glCopyTexImage*` 中 backend 可能失败的形式、`glBufferStorage`。它们本来就罕见且昂贵,ack 几乎免费,换来 OOM 探测精确。其余全部保持晚到。有了这个划分,`MOBILEGL_IPC_STRICT_ERRORS` 从"CTS 专用"降级为纯诊断开关(默认 0,出问题时用来判断某个失败是不是错误时序引起的)。 +#### 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`。 + +tracker 调它,拿到 `SharedPtr`,推**一个 handle**。合成体没有 GL name,但**有 lifetimeId**,slot 从 `ShaderCso` 的保留高位段分配。生命周期:pipeline cache 淘汰该条目时释放 slot、`gen++`、发 `delete_shader_state`——`CompositeResolver.cpp` 里三行。 + +**合成体从不过线、从不被重新实现,server 侧不需要任何"解析后的 draw program"钩子。** 副带收益:阻塞的 `JoinLinkAndSpirv()` 彻底离开 server 的 draw path。 + +### 4.7 program artifacts 与全局 UBO scratch + +**`create_shader_state` 的 payload 是 SPIR-V + 全结构体反射归档**(§3.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 每帧至多一次"。它小、每次 `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` | 同上 | -`glGetError` 本身永远本地(`GL_Getter.cpp:2811-2817`;`Core.cpp:48-49` 的 "GL error state is GL-thread-owned" 不变式)。 +**client 侧需要 reconcile 的那两条的形态**:publish → 等 `appliedSeq` → 排空事件 → 再碰 shadow。跳过它,`maxIndex` 来自陈旧字节,顶点数组被少拷 → 几何缺失,或越界读应用数组。 -### 5.7 composite pipeline program +门:`ClientArrayAfterComputeWriteScenario`(新增),**必须能因它存在的理由变红**。 +门:`create-indirect` fixture 上的 `roundtrips-per-frame` 计数器**必须读零**(P8 验收),这是上面那条"不加 `SyncGpuWrites()`"的绊线。 -`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 改它。 +**另注**:monolith 在 `*IndirectCount` 上不调 `SyncGpuWrites()` 本身可能是一个潜在缺口(compute 写的 indirect buffer)。**那是一个独立的 `dev` 问题,拆分不得借机"顺手修"**——那会改变基线并让逐名对比失去意义。列入开放问题。 + +--- + +## 5. 后端状态机改造 + +### 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 世代 -- **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() 首行先查该槽位 +// 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 ``` -server 因此**永不 link、永不 join compile pool**,`PrepareForDraw` 首条语句照常工作。 -### 5.8 全量快照 / resync +**`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 构建里)完整性变成**构建期事实**:一个从未被写入的字段就是一个编译器能标出来的字段。 -稳态**没有初始状态**:transport 在 `MG_Backend::Init()` 内建立,早于任何 GL 对象存在。 +### 5.3 Track V / Track H 与残余值块 -`ResyncSnapshot` 只服务三件事:**server 重启**、**backend context 丢失**(EGL surface 变更销毁整个原生 context 并 bump `g_backendContextGeneration`/`g_syncContextGeneration`,`DirectGLES.cpp:10664-10676`)、**硬 drain 后的纹理重发**(§5.6a)。实现 = 同一个 reconcile 遍历,关闭"已发送版本"门控。 +- **Track V(值类型)**:`GetRenderStateParameters`、`GetPixelStoreParameters`、`IsCapabilityEnabled(+Indexed)`、`GetStencilState`、`GetColorMaskIndexed`、`GetDepthMask`、`GetScissorBox`、`GetPatchVertices`、`GetCurrentVertexAttribute`、Magma 的 ~22 个标量 getter…… **约占 B 类读点的 55%**。机械,每组 ~1 天。 +- **Track H(对象类型)**:167 个 `SharedPtr` 点。真活。 -**关键纪律:一个 applier、两个 producer**——快照发同样的记录种类,因而被同一套测试覆盖。`Feat/CS-Delta-IPC` 的结构性错误正是有一个与生产路径零共享代码的平行 applier(`StateEmitter.h:312-501` vs `ServerCore.cpp:389-401`)。 +**Track V 的 55% 不需要逐字段接口条目就能跑起来**,所以 P2 发一个**显式临时**调用 `set_residual_value_state(MGPBlobRef)`: -**P7 之后的限制**:adopted store 的字节住在 server,client 无法重建它们。因此 `MOBILEGL_IPC_RESPAWN=1` 与 `MOBILEGL_IPC_ADOPT_TIER != 2` 互斥:要么关采纳换可 resync,要么开采纳并接受 server 死亡 = context lost(不重启)。这条互斥必须在 `ConfigLoader` 里显式检查并 `MGLOG_W`。 +```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); +``` -### 5.9 覆盖度的**编译期**保证 +——**从应用已有的字节起步**,然后只把捕获到的 varying 补进去,"这样 `gl_SkipComponents` 要求的空洞保留应用原本放在那里的东西——**这正是这个特性的全部意义**"(`:889-892` 的注释;`:880-883` 点名 `KHR-GL46.transform_feedback.capture_special_interleaved_test` 是走到这条路径的用例)。server 没有 `MappedData()`,而 `MGPipeCallbacks` 里也没有反向的 buffer 读。照 v1 实施,要么空洞被清零(一致性破坏),要么需要一次 §12.2 没有列出的、发生在 `glEndTransformFeedback` 上的同步反向读。 -#### 5.9a READ 面(backend 读了什么) +**修正(不新增停顿类)**:**scatter 搬到 client。** -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`。 +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()`)。 -#### 5.9b MUTATOR 面(MG_Impl 在 table 调用旁改了什么)—— **本轮新增,是 §2(g) 的门** +副作用:`:906-914` 的"CPU 模型给出 0 顶点 → 整批捕获丢弃"的诊断**落到应用线程**上,比落在 server 上更有用。计入 Espryt 子系统 7(§5.4)。 -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`。 +### 6.3 纹理 dirty 归属反转 -已知必须在第一轮映射的条目(不是穷举,是脚本首次运行时保证不为空的锚点): -- `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`。 +**client** 保留 `MipmapStorage` 的模型(96-rect 级联合并 + `summedArea*4 >= unionArea*3` union-box 回退,`MipmapStorage.cpp:300-305`),维护一份**发射游标**,在发射后清自己的标志。**server 从不碰 client 的标志。** -CI:两个生成器都重新生成 + `git diff --exit-code`。 +这是安全的,且已核实:**`MG_Impl` 里没有任何 `IsStorageDirty(` / `GetStorageDirtyRects(` / `GetStorageDirtyRegion(` 调用点**(前端从不读自己的 dirty 状态),而它自己在五处主动清(`GL_Texture.cpp:528, 701, 5547, 5621, 5691`)。**这一条让"server 侧逐 level 权威位 + 纹理 ack 协议"整套机制不必存在。** -**backend 长出一个 reconciler 走不到的 read,或 MG_Impl 长出一个 applier 没 replay 的 mutation → 编译失败,而不是设备回归。** +**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 边界各一次。 -### 5.10 persistent map:client 侧的推送(本轮新增的独立小节) +**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 对它完全不敏感)。 -**问题**(已在仓库确认):`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。字节丢失。 +**上传形状决策留在 server**:`resource_subdata` 同时带 union box 与 region 列表(§3.5.6),Mali 按作业数计价的悬崖在哪一侧付 GPU 代价,决策就留在哪一侧。 -另外,`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 而跳过整个同步。 +### 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 侧应用。**反向通道需要与正向通道相同的有序保证。** -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 行为。 +**`on_gl_error` 必须对命令流有序**,否则 `glGetError` 答错。`glGetError` 本身永远本地(`GL_Getter.cpp:2811-2817`;不变式 `Core.cpp:48-49`)。 -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 块,只发自上次发送以来被改过的块。 +**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`)里惰性做的。 - "被改过"的判定: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 上跑)。 +**修正后的规则**: +- **纹理分配的 OOM 在 monolith 里就已经推迟到 sync 时刻,拆分不改变任何可观察行为** —— 这批**不标** `kNeedsAck`,并把这条事实写进文档(避免后人以为是遗漏)。 +- **`kNeedsAck` 只标两项**:`glBufferStorage`(真同步)与 `glRenderbufferStorage*`(**若**决定把它的分配提前到 GL 调用时刻以支持 OOM 探测;否则它也不标,同样写明)。**这个"若"由 P0 回答**:查 MC / Iris 语料里有没有真的 `glRenderbufferStorage` OOM 探测惯用法;没有就不标,省掉整条 ack 路径。 +- 其余错误一律晚到,走有序的 `on_gl_error`。 - **这是 §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(这是计划里唯一一个允许因测量结果而改变阶段顺序的地方)。 +**对事件通道的强制条款:`on_log` 必须按严重级分级。** §8.4 的朴素策略把**全部**日志行设为有损(覆盖最旧 + `eventDropped`)。但 §4.7 已确认:**backend program link/compile 失败只以一行日志加一次 bind-program-0 的空 draw 呈现**。统一有损策略下,系统里诊断价值最高的那一行会在日志压力下静默消失。 -3. **P1 就要有门**:新增 `PersistentCoherentMapScenario`(map PERSISTENT|WRITE|COHERENT、写、不做任何其它 GL 调用、draw、readback 校验),列为 P1 验收项。**今天计划里没有任何门能抓到这个 bug。** +**规则**:`on_log(level ≤ WARN)` 有损;**`on_log(level ≥ ERROR)` 无损**,加入触发 `eventRingFull` + 停止 apply 的语义事件集;再加一个**每秒 ERROR 速率限制器**,超限时发一条显式的 "N errors suppressed"。`MGLOG_E_ONCE` 的 latch 变成 per-server。P9 的故障注入门:日志洪泛下注入一次 link 失败,那行 ERROR 必须出现**且**两侧都恢复。 -**与 `MOBILEGL_COHERENT_AS_FLUSH` 的关系**:该开关(`GL_Buffer.cpp:297-305`,默认 false,`Config.h:174` / `ConfigLoader.cpp:185`)把应用请求的 persistent+FLUSH_EXPLICIT 改写成 coherent,从而**制造**上面这个情形。上一版禁止它在拆分模式下生效——但那只处理了"我们自己改写出来的 coherent map",没处理"应用自己就请求 coherent"。有了上面的三件套,两种来源都被覆盖,所以**禁令改为可选**:`MOBILEGL_COHERENT_AS_FLUSH` 在拆分模式下**照常生效**,这样 `tools/trace_replay/trace_cases.json` 里那两个带 `coherent_as_flush: true` 的用例(`minecraft-1.21.1-neoforge-create-indirect-in-world`、`minecraft-1.21.1-neoforge-create-instancing-in-world`)在 split 与 monolith 下走同一条 buffer 路径,P2 的逐名对比才有意义。若 P2 测出保守推送在这两个 fixture 上代价过高,改为"这两个用例在 split 模式下同时关掉该开关,并在报告里标注",而不是让两侧走不同路径还宣称对比通过。 +### 6.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) 从可选升级为强制并拿到真预算。 --- -## 6. 数据面 +## 7. 传输与数据面 + +> 本章与状态模型无关:它规定字节怎么过去、什么时候可以被覆盖、背压怎么升级。§8 规定控制面与同步,§9-§11 规定帧节奏、线程与平台。 -### 6.1 段(segment)布局 +### 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 单一真相源: -```cpp -// MobileGL/MG_Remote/Protocol/Records.def -#define MGL_REC_LIST(X) \ - X(BindBuffer, RecBindBuffer, 24) \ - X(DrawArrays, RecDrawArrays, 32) \ - X(DrawElements, RecDrawElements, 56) \ - X(BufferSubData, RecBufferSubData, 64) \ - X(BufferMap, RecBufferMap, 40) \ - X(BufferUnmap, RecBufferUnmap, 24) \ - X(RenderStateBlob, RecRenderStateBlob, 40) \ - X(XfbAccounting, RecXfbAccounting, 56) \ - X(GenerateMipmapLevels, RecGenerateMipmapLevels, 32) \ - X(RenderbufferStorage, RecRenderbufferStorage, 40) \ - /* … ~95 项 … */ -#define MGL_REC_SIZE_CHECK(name, T, sz) \ - static_assert(sizeof(MobileGL::Wire::T) == (sz), #name " record size drift"); -MGL_REC_LIST(MGL_REC_SIZE_CHECK) -``` -**每种一条 `static_assert`** ——修掉正是 `Feat/CS-Delta-IPC` 中过一次的 bug 类(`b50f3348`:"旧的 off-by-one 让 applier 误读 TexImage 之后的每一条 state delta"),而它那条只断言 union 首成员的 assert(`ServerCore.cpp:31-33`)永远抓不到中间插入。 +**单一真相源是 `PipeCalls.def`,生成器是 G3**(§3.1)。它对**每一个** MGPipe 调用生成三样东西: -**运行期边界纪律(本轮新增)**:`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; +// 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 函数指针 ``` -`kVarTail` 记录额外校验 `定长前缀 + 尾巴自描述长度 == h.size`。违反一律 `Fatal{ProtocolCorruption}`,绝不进入未定义行为。 -变长记录(`RecVaoConfig`、`RecTexSubImage` 的 rect 列表、`RecProgramLinkOp`、`RecMultiDrawArgs`):`kVarTail` + 定长前缀 + 自描述长度的内联尾巴。 +**每种一条 `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` 管不到运行期损坏。`kVarTail` 记录额外校验 `定长前缀 + 尾巴自描述长度 == h.size`;`kHasBlob` 记录额外校验 `BlobRef` 落在它声明的段内。违反一律 `Fatal{ProtocolCorruption}`,绝不进入未定义行为。 -### 6.4 WAR 危害与字节稳定性 +变长记录(`set_sampler_views` 的 view 数组、`resource_subdata` 的 rect 列表、`draw_vbo` 的 `MGPDrawRange[]` 与 `MGHostSpan`、`set_shader_buffers` 的 range 数组):`kVarTail` + 定长前缀 + 自描述长度的内联尾巴。 -**Phase 1-4 规则:GL 调用时刻把字节拷进 ring slot。** slot 从写入到 `stageAppliedTail` 越过它为止不可变,client 拿不回它 → **危害按构造消除**。代价是一次 memcpy,而 `Ops_ResidentSubData`(`Managers.cpp:1165`)和 `StageBlocksIntoUnpackRing` 在 monolith 里已经在付同样的钱。 +### 7.4 WAR 危害与字节稳定性 -**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 1 规则(P5-P8):GL 调用时刻把字节拷进 ring slot。** slot 从写入到 `stageAppliedTail` 越过它为止不可变,client 拿不回它 → **危害按构造消除**。代价是一次 memcpy,而 `Ops_ResidentSubData`(`Managers.cpp:1165`)和 `StageBlocksIntoUnpackRing` 在 monolith 里已经在付同样的钱。 -**该改动必须整段 `#if MOBILEGL_BUILD_DISAGGREGATED` 包裹**:`PipeResource` 与 `MipmapStorage` 住在 `MG_State`,不在 `MG_Remote`,而改一个容器的 allocator 就改了类型;不包裹的话 §12/D8 的 `nm`/`.text` 门会在 P4.5 变红。写法是"分配器特化:option OFF 时逐字折叠成今天的 `MapAlignedAllocator`"。 +**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 推送的脏位来源。 -#### 拷贝账(更正版,MC pan 一帧约 9MB section mesh + ~1MB UBO scratch) +**该改动必须整段 `#if MOBILEGL_BUILD_DISAGGREGATED` 包裹**:`PipeResource` 与 `MipmapStorage` 住在 `MG_State`,不在 `MG_Remote`,而改一个容器的 allocator 就改了类型;不包裹的话 §13.5 的编译期折叠保证不成立。写法是"分配器特化:option OFF 时逐字折叠成今天的 `MapAlignedAllocator`"。 -上一版这张表把 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 字节),验收看**总量**,不是只看 client 一侧的数字。 -无论选哪个,`TracyPlot` 字节计数器必须**装在 wire 两侧**(client 的 emit 字节 + server 的 apply 字节 + server 的 ring/staging 字节),P4.5 的验收看**总量**,不是只看 client 一侧的数字。 +### 7.5 Ring 分配与背压 -### 6.5 Ring 分配与背压 +逐字移植 `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。 -逐字移植 `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 的纹理记录。 +硬 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 水位。 + +### 8.6 三个应先独立落到 `dev` 的 monolith 修复(可二分、monolith 自身受益) -| # | 站点 | 频率 | 为什么 | -|---|---|---|---| -| 1 | 握手 `Hello`/`Welcome` + 段 fd 传递 | 一次 | — | -| 2 | `InitializeEGLDisplay`(写 `major`/`minor`) | 一次 | 出参 | -| 3 | `CreateEGL{Window,Pbuffer}Surface` / `Resize` | 罕见 | 返回 `Bool`;回复顺带 `DefaultFramebufferInfo` | -| 4 | 首次 `MakeEGLCurrent` + `InitCapabilities` → `CapsSnapshot` | 每 surface 一次 | caps 只在那一刻才存在(`BackendObject.cpp:341-347`) | -| 5 | `glReadPixels` → 客户内存 | 罕见(CTS 热) | GL 要求返回时字节已就位 | -| 6 | `glCopyTexSubImage*` / `glClearTexImage`(内含 ReadPixels) | 罕见 | 前端实现本来就借一次 ReadPixels | -| 7 | `glGetTexImage`/`glGetTextureImage`(DirectVulkan;DirectGLES 仅 `serverAuthoritative` level) | 罕见 | — | -| 8 | `glGetBufferSubData` / `glMapBuffer(READ)` on client-pending | 罕见 | monolith 里本来就阻塞;client pending 集合触发 | -| 9 | client 顶点数组的索引扫描 / restart 重写 / indirect-count 读(当 EBO 在 pending 集合里) | 罕见 | monolith 在同一位置调 `SyncGpuWrites()` | -| 10 | `glClientWaitSync(timeout>0)` 超出 watermark | 每帧级 | 应用请求的等待 | -| 11 | `glGetQueryObject*(GL_QUERY_RESULT)` 未完成;`glBeginConditionalRender` | 罕见 | `GL_Query.cpp:300`、`:705-706`(后者注释明说"by WAITING even for the _NO_WAIT modes") | -| 12 | 轮询饥饿升级(连续 N 次无进展) | 极罕见 | 防死锁保险 | -| 13 | **分配类入口点的错误 ack**(`glRenderbufferStorage*`、部分 `glTexImage*`/`glTexStorage*`/`glCopyTexImage*`、`glBufferStorage`) | 罕见 | OOM 探测惯用法(§5.6c) | -| 14 | `AcquirePersistentMap`(仅 P7 T1) | 每 store 一次 | 返回映射 | -| 15 | ring/stage 耗尽、present credit | 节奏 | 非语义 | - -### 变成异步或本地的 - -- 全部 20 个 draw、9 个 clear、blit/copy、`GenerateMipmap`、dispatch、barrier、image bind、7 个 XFB 跨度标记、`PatchParameteri`、`ShaderStorageBlockBinding`(权威状态已在 client,`GL_Program.cpp:3391`)、所有 buffer/texture/program/VAO/FBO delta、`Present`。 -- **`glGetError` 永远本地**(`GL_Getter.cpp:2811-2817`;`Core.cpp:48-49` 的不变式)。 -- **`glFinish`/`glFlush` 保持免费**。 -- **89 个 caps 站点全部本地**(45 `GetDynamicParameters` + 8 `GetRendererInfo` + 4 `GetFormatCapabilities` + 3 `GetBackendType` + `IsTimerQuerySupported` + `PrefersCpuXfbPrimitiveAccounting` + `BeginOcclusionQuery!=nullptr`)。 -- **`glGetIntegeri_v` 全部本地**;`glDispatchCompute` 的三次 per-dispatch 校验查询(`GL_Drawing.cpp:719`)改读 `CompileEnv::maxComputeWorkGroupCount`(`CompileEnv.h:52-54`)。 -- **`GetInteger64i_v`、`GetProgramiv` 删除**。 -- **`FenceSync`、`Begin{TimeElapsed,Occlusion,XfbPrimitives}Query`、`QueryCounterTimestamp` → client 铸造 handle**,fire-and-forget(前端本来就铸造应用可见的名字:`GL_Sync.cpp:61`、`GL_Query.cpp:54`)。 -- **`GetSyncStatus`、`ClientWaitSync(0)`、`IsQueryResultAvailable`、`GetQueryResult64(wait=false)` → 先 publish(§7.2),再从水位一次 acquire load 回答**。miss 返回 `GL_UNSIGNALED` / "未就绪",两处契约明确允许(`BackendObject.h:210-214`、`:236-241`;`GL_Query.cpp:302-311` 已遵守:读 0、**不缓存**、保留 backend handle)。 -- **`glReadPixels` 进 PBO → fire-and-forget**(配 client 侧 `MarkGpuWritten`),比 monolith 更好。 -- **`glEndTransformFeedback` 的无限 fence 等待取消**(配 client 侧对 capture target 置 `MarkGpuWritten`)。 - -**稳态帧:零 round trip**(对不使用 conditional render / 阻塞式 query / 分配类调用的帧而言;见 §15 P3 的门措辞修正)。 - -### fence 完成度必须来自真 fence,不是 present 水位(本轮新增) - -上一版让 `retiredSeq`/`completedFrameSerial` 兜底 fence 语义。但在 DirectGLES 上这两个水位**只在 `Present()` 里前进**(`DirectGLES.cpp:10626-10643` 在 `eglSwapBuffers` 之后轮询 4 深 fence ring),或在 `WaitForFrameSerialCompleted`(`:10583-10607`)里。帧中创建的 fence 于是要等到**下一次 present 退休**才报 signalled,即 fence 完成度退化成帧计数推断。`DirectVulkan.cpp:1120-1128` 恰恰写明这是被修掉的 bug:完成度必须"track the GPU itself rather than the frame-count inference; MC 1.21.5's fence-paced ring buffers depend on this to recycle their space instead of growing without bound",而项目记忆 `magma-mc1215-fence-oom` 记录了它曾导致 native-heap OOM kill。 - -**规则**:`RecFenceSync` 在 server 侧转成一次**真实的 backend `FenceSync()`**;server 用自己已有的逐 fence 轮询(DirectGLES 有 `WaitForFrameSerialCompleted` 的 fence 选择逻辑 `:10586-10600` 可复用;DirectVulkan 有 `IsSubmitIndexComplete`)在**非 present 时刻**也推进,并发 `EvFenceSignaled{handle}`。client 的本地快路径读的是"由真实逐 fence 退休导出的 handle 水位",不是 present 水位。 - -### 三个应先独立落到 `dev` 的 monolith 修复(可二分、monolith 自身受益) 1. `glEndTransformFeedback` 的无条件无限 `ClientWaitSync`(`GL_Drawing.cpp:1326-1337`)→ 用既有 `MarkGpuWritten`/`SyncGpuWrites` 推迟到首次读。 -2. `glDispatchCompute` 三次 `GetIntegeri_v` → `CompileEnv`。 +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 的项 + +| 类 | 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 逐条列了早退位置)。按动态口径重写: -1. **编译期折叠。** `MOBILEGL_BUILD_DISAGGREGATED`(默认 **OFF**)关闭时 `MobileGL/MG_Remote/**` 不进 `SOURCE_FILES`,`MG_Config::Transport` 是 `constexpr Monolith`,`MG_Backend/Init.cpp` 里的分支在编译期消失。**默认构建与今天字节一致。** +| | 今天(动态稳态) | 之后(动态稳态) | +|---|---|---| +| 每 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` 是**写**) | - -一旦 client 装上发射表,`inproc` 里 applier 与 server 侧 MG_Impl 就没有任何路径能拿到真正的 DirectGLES/DirectVulkan 表;而一个进程也不可能同时持有 client 的 default-FBO 描述与 server 的(`SwapchainObject` 直接往里写 server 的视角)。 +| `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 虚函数面 | -**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 密集区开刀。** +两个全局的 shim 只需要 `operator->` / `operator bool` / `get()` / 赋值,而且**都不在 GL 热路径的每次访问上**(pipe 表在每个 MGPipe 调用处取一次,`pActiveBackendObject` 只在 EGL/caps 面)。**这条是 MGPipe 让 `inproc` 从"成本可疑的实验"变成"可交付形态"的直接原因。** -**如果这层隔离的成本被判定过高**,退路是把 `inproc` 降级为**纯测试模式**:applier 通过显式传入的表指针工作,server 侧不跑 MG_Impl(于是 `GenerateMipmap_Backend` 那类回退不可用,需要在 `inproc` 下走另一条路径)。但那样 P2.5 就不再测量它本该测量的"monolith 渲染线程"交付物——**这个取舍必须在 P0 结束前拍板并写进文档,不能悬着**。 +### 13.7 `inproc` 作为产品交付物与运行时选择 -### 12.3 `inproc` 作为产品交付物 +`inproc` 不只是测试脚手架:同进程第二个 apply 线程 = monolith 的渲染线程。今天 `PrepareForDraw`(状态调和、VAO/FBO/纹理/program/render-state sync、UBO ring memcpy)加驱动调用全部同步跑在 `glDrawElements` 里;把它们搬到 apply 线程,对 GL 线程 CPU-bound 的应用(本项目的 profiling 史说 Minecraft 就是)是**手上最大的单一杠杆**,且不需要任何 IPC/shm/平台工作。 -在隔离成本可接受的前提下,`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),它在原分支没有对应物 | +### P0 — 卫生、度量、门与骨架(9-11 天) -### 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 清除) | +**交付物** +- **清工作树 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` 测试同批。**目的是让 P11 的结论在第一周就有方向**:若两台都不行,P11 缩为"记录并回退",省 6 天。 ---- +**验收**:`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 全绿。 -## 15. 分阶段实施计划 +### 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` 绊线(§3.5.5)。 -> 通用纪律(每个 commit 都适用):默认 ALL target 必须能完整构建;禁止提交热路径插桩;每个门必须**能因它存在的理由变红**;**Windows 机器不是正确性门**(其 Vulkan 缺 `vkCreateHeadlessSurfaceEXT`,占该机 567 个基线集成失败中的 423 个);设备对比走 reboot-clean + 同窗口配对 A/B;**每个阶段的出口都跑一次 §12 第 4 层的 `nm`/`.text` monolith 门**(不只是 P0)。 +**验收**:全套现有测试逐名不变(这是一次纯搬移);两条 include 闭包断言绿,且**人为把一个 `MG_State` include 加回 `MGPipeValueTypes.h` 能让它变红**;`nm --defined-only` 与 `.text` 变化可逐符号归因(搬移会改变某些内联决策,允许,但要解释)。 -### P0 — 卫生、骨架与两个 spike(5 天) +### P1 — `PipeInputs` 替换与 verify harness(10-13 天) **交付物** -- 清除工作树 `[IBOTX]`/`[BUFTX]` fprintf(`DirectGLES.cpp:640-663`、`Managers.cpp:875-877`,后者在 `pendingMutex` 临界区内)。 -- `RenderbufferObject::GetLifetimeId()` **与 `GetVersion()`**(§5.4)。 -- 两个 CMake option:`MOBILEGL_BUILD_DISAGGREGATED`(OFF) 与 `MOBILEGL_BUILD_DISAGGREGATED_INPROC`(OFF);`MOBILEGL_TRANSPORT` 解析;§13 的 flatbuffers include-dir guard。 -- `MG_Remote/{Protocol,Transport}` 骨架:`ITransport`、`InProcessTransport`、校验型 `Framing`、`Ring` + `RingControl`(**双 tail、双游标三元组、双向 doorbell**)、`Doorbell`、`ShmSegment`(memfd/ASharedMemory/shm_open/CreateFileMappingW)、**`SCM_RIGHTS` fd 传递(第一优先)**。 -- `protocol.fbs` + 提交的 `protocol_generated.h` + `gen_protocol.py` + CI `flatc-check`;`Records.def` 的 `static_assert` 与**运行期边界检查**生成。 -- `gen_backend_state_surface.py` + `Coverage.def` **和** `gen_impl_mutation_surface.py` + `MutationCoverage.def` + `CoverageAssert.cpp` + CI `coverage-check`。 -- `MG_Test/Wire/` 目录(复制 `MG_Test/Buffer/CMakeLists.txt`)。 -- **`TracyPlot` 字节计数器**,装在 wire **两侧**,按类别分:`cmd-records`、`stage-buffer`、`stage-texture`、`stage-ubo`、`persistent-map-push`、`server-ring`、`server-staging`(树里今天完全没有 per-frame 字节度量:`MG_Util/Metrics` 只是格式算术,Tracy 只有 zone 无 plot,MC 26.3 战役的 PANDIAG 已不在树里)。 -- `mobilegl_server_main` 的 `extern "C" __attribute__((visibility("default")))` 声明(§11.2)。 -- **spike A(Android 交付链,半天)**:从根 CMakeLists 造一个平凡的 `libMobileGLServer.so`(`add_executable` + `PREFIX "lib"/SUFFIX ".so"`),确认 AGP 把它打进 `lib/arm64-v8a/`;让 `TraceReplayActivity` 从 `getApplicationInfo().nativeLibraryDir` **`posix_spawn`** 它并打一行日志——在**应用自身进程(`untrusted_app` 域)**验证 exec,而不是靠 `run-as`。同时把一个通用 env 透传(`--es mobilegl_env "K=V;K=V"`)接进 trace 路径的五个文件(`trace-replay-ci.sh`、`TraceReplayActivity.java`、JNI Request marshalling、`trace_replay_core.cpp`、`run_android_retrace_local.py`),取代逐 knob 加 `--es/--ez`。 -- **spike B(external memory 可行性,半天)**:最小程序,导出一个 `HOST_VISIBLE|HOST_COHERENT` VkBuffer 的 fd,`mmap` 后回读校验,在 `35d0befa`(Adreno 830)与 `3B159D009VZ00000`(Mali)各跑一次。与 `SCM_RIGHTS` 测试同批。**目的是让 P7 的结论在第一周就有方向**:若两台都不行,P7 缩为"记录并回退",省 6 天。 +- `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),作为文档交付物。 -**验收** -- 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` 走"四全局角色隔离"还是"降级为纯测试模式",写进文档。 +**验收(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 上。 -### P1a — 垂直切片(client + inproc applier),Linux 门(6 天) +**★ 第 25 天(低端估计)— 最早可见里程碑:**零产品风险地证明"推送等价于拉取",逐 draw 逐字段。**这不是 GO/NO-GO**(它没有性能数字,也没有 Track H 单位成本)。 -**范围刻意收窄到 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。 +### P2 — 值推送:渲染状态 CSO(双后端)+ 第一片 Track H + 残余值块(18-26 天) -**交付物**:`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` 上跑通。 +**交付物** +- `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. `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 保守版的代价)。 +- 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 设计更慢"分开。 -**明确非目标**:性能。P1-4 双份 glslang,**MC 级负载不在此测**。 +**★ 第 43 天(低端估计)— GO/NO-GO 决策点。** 此刻手上有:verify harness、双后端已推送的渲染状态、真实 CPU 增量与绝对 ns、Blaze3D 微基准、CSO 负面对照、**Track H 在两个 backend 的最便宜子系统上的实测单位成本**。两个出口(继续 / 收缩为 headless 工装用途或重新评估)与沉没成本口径写在 §0.5。 -### P1b — spawn transport,Linux 门(4 天) +### P3a — handle wave 1(Espryt):buffer、VAO(18-23 天) -**交付物**:`SocketTransport`(socketpair + `fork`/`execve` + 显式 envp 剔除 `MOBILEGL_TRANSPORT`/`MOBILEGL_IPC_*`)、`ServerMain`、`MOBILEGL_IPC_SERVER_PATH` 发现链、就绪握手与有界重试、EOF 即时退出。 +> handle 基建(0b)已在 P2 交付。 -**验收** -1. P1a 的全部测试在 `MOBILEGL_TRANSPORT=spawn` 下绿(两个真进程、真 socket、真 `SCM_RIGHTS` 段)。 -2. **fork 链测试**:spawn 一个 server 并断言进程树只多出恰好一个子进程(§11.1)。 -3. **HeadlessGL 预检交互测试**:在开着 fork 预检的 Linux 上跑整套 split 集成用例,断言没有孤儿 server(用 `pgrep` 计数 + 预检结束后 100ms 内归零)。 +**交付物**: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` 分支维护。 -### P2 — 广度:集成套件、trace 语料对齐、设备首跑(9 天) +**验收**:全套门(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 开始之前重定基线。** -**交付物** -- 其余记录种类(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)。 +### P4a — handle wave 2(Espryt):FBO / 纹理 / sampler / program 的身份与描述符(26-34 天) -**验收** -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 分钟。 +**刻意推迟到首帧之后的部分**:memo 重键、dirty 归属反转、跨步描述符改造、program 陈旧性重构(→ P3b/P4b)。 -### P2.5 — inproc 渲染线程:单机收益证伪门(3 天) +**交付物**:`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。 -**交付物**:`add_trace_replay_test` 的 `INPROC` 变体;应用线程与 apply 线程的**逐线程 CPU 时间**插桩(不只是墙钟);`MOBILEGL_IPC_SERVER_AFFINITY` 的大核绑定(复用 `ShaderCompilePool.cpp:73-96`);用现有 `--benchmark --benchmark-tail-frames --benchmark-result` 在全部 fixture 上跑。 +**验收**:全套门;`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 天,同上处理。** -**验收**:`inproc` 与 `monolith` 的应用线程帧时差 + 两侧 CPU 时间在 Create/Flywheel 与 MC fixture 上被**测量并记录**,且带亲和性开/关两组。若不利,整个计划的价值主张在第 6 周(而不是第 15 周)被重新审视。**这是本计划最早的证伪点,也是 §16-R15 排期风险的退火器。** +### P5 — 传输 + inproc applier + 发射表(12 天) -### P3 — sync / query / present 节奏(5 天) +**交付物**:`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)。 -**交付物** -- 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)。 +**v2 规范条款:`InProcessTransport` 必须走与 spawn **完全相同**的 G3 编解码路径**,只在门铃/拷贝机制上不同。否则第 99 天的里程碑证明不了 wire 完整性,而 P6(第 104 天)才在关键路径上发现缺口。**`PipeApplier` 里加一条 debug 断言:任何传输下都不得有 `SharedPtr` 或裸前端指针跨过 applier 边界。** -**验收** -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 被记录。 +**验收**:`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。 -### P4 — 回读与 GPU-written(5 天) +### P6 — spawn transport(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`")。 +**交付物**:`SocketTransport`(socketpair + fork/execve,**显式 envp 剔除 + `mobilegl_server_main` 内强制 Monolith 的双保险**);`ServerMain`;`MOBILEGL_IPC_SERVER_PATH` 为主 + `dladdr` 兜底;就绪握手有界重试;client EOF 即时退出;server 死亡的 device-lost latch。 -**验收** -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`。 +**验收**:P5 全部测试在 `MOBILEGL_TRANSPORT=spawn` 下绿;fork 链测试断言进程树只多一个子进程;`HeadlessGL` 的 fork 预检交互测试无孤儿 server(§11.3);`run_android_retrace_local.py --case OpenRA --backend DirectGLES` 在 `35d0befa` 上 SSIM ≥ 0.99。 +**★ 第 104 天 — 首个跨进程帧(缩减路径)。** -### P4.5 — 零拷贝 shadow-in-shm 前移(4 天) +### P3b / P4b — 深化(Espryt):memo 重键、dirty 反转、跨步描述符、XFB scatter、回读(29-38 天) -(原计划推到 P6;MC pan 每帧 ~9MB 的额外拷贝不该背六个阶段) +**交付物**:重键 `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。 -**交付物**:`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` 开关。 +**验收**:~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)。 -**验收** -1. P2/P4 门在开关两态下均不回归。 -2. **两侧** `TracyPlot` 显示 buffer 上传路径的总拷贝次数从 4 降到 3(或选方案 B 则到 2,§6.4);staged-copy 回退率被记录成数字。 -3. `nm`/`.text` monolith 门仍绿(这一条是本阶段最容易破的)。 -4. 对象删除/重定义与未 apply 记录并发的压力测试不读到别的对象的字节。 +### P7 — DirectVulkan(Magma)全量迁移(80-104 天,可与 P5/P6/P8 并行) -### P5 — `ProgramPublish`,退役 server relink(6 天) +> 子系统 1(pipeline+动态状态)与子系统 4(VertexInput/VaoDrawMemo)已在 P2 交付,所以是 §5.5 的 85-111 减去 5-7。 -**交付物**:`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`)。 +**交付物**:§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)。 -**验收** -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 的预算对表。 +**验收**: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 在单跑道下位于关键路径。 -### P6 — 数据面性能(6 天) +### P8 — emulation 下放 + 索引宿主镜像 + 协议广度(12-16 天) -**交付物**:`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` 的设备调优。 +**交付物**:`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 用例。 -**验收**:两台设备上 `minecraft-1.21.4-fabric-sodium-in-world` 的配对 A/B,每项优化用自己的开关单独可 A/B;P2/P4 门在任意开关组合下不回归;输入延迟直方图不因任何优化恶化。 +**验收**:`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。** -### P7 — persistent map 与 ≥16MiB 采纳(8 天,若 P0 spike B 全否则缩为 2 天) +### P9 — 反向通道(10 天) -**交付物**:`SEG_ADOPT`(server 分配)+ 三档探针(T2/T1/T0)+ 自动回退到 P4.5 路径;阻塞 `AcquirePersistentMap`;client 侧注册 `ResidentSubData`;`MOBILEGL_IPC_RESPAWN` 与 `MOBILEGL_IPC_ADOPT_TIER` 的互斥检查(§5.8)。 +**交付物**:`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)。 -**验收**:`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 已交付并测量。 +**验收**:`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 必须出现**。 -### P8 — XFB / compute / 健壮性 / 多线程(6 天) +### P10 — sync / query / present 节奏(6 天) -**交付物**:XFB capture writeback 与 scatter(全部在 server 对 replica 执行,只有合并后的 range 过线);**`RecXfbAccounting` 与共享 helper 的完整接线**(§2(g)-2;注意它必须跟着 `RecBindTransformFeedback` 的对象切换走,`Core.cpp:1273,1296`);GS strip 顺序修正移到 server;compute dispatch/indirect/barrier/image load-store;`EvGlError` 与 `glGetError` 的顺序 + `MOBILEGL_IPC_STRICT_ERRORS` 诊断开关;server 死亡的 device-lost 闩锁与 client 死亡的 server 拆机;外来线程 sync/query 的 `AuxRequest`;修 `EGLOperationMutex` 既有漏洞(`ReleaseThread`、`SwapInterval`)。 +**交付物**: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 修复。 -**验收** -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` 退出而不崩溃。 +**验收**:`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 天) -**注**:XFB 场景的 `RecXfbAccounting` 骨架其实在 P1a 就要落地(helper + 记录 + applier 分支),只是这里才被真正测到。§5.9b 的生成器会在 P0 就把它标成未映射并让编译失败,从而强制这个顺序。 +**交付物**:`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 开关表接线(**零新增管线**)。 -### P9 — Android 生产窗口路径(10 天) +**验收**:Minecraft 通过 FCL 在 spawn 模式下在 `35d0befa` 上双后端入世界;配对 reboot-clean bench + 输入延迟直方图;杀 server 产生干净的 device-lost latch;SIGKILL 故障注入。 -**交付物**:`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 偏好。 +### P13 — 退役 pull 路径(8-12 天) -**验收**:FCL 在 `35d0befa` 上以 split 模式把 Minecraft 1.21.4 拉到主菜单并进入世界;`bench.sh` 在同一个热窗口内报出 split vs monolith 的游戏内 FPS **与输入延迟**;plugin APK 通过 `.github/scripts/validate-plugin-apks.sh`;旋屏/后台切换的 surface 销毁重建无泄漏无挂起;两个进程的合计 RSS 落在预算内。 +**交付物**:删 `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 报告。 -**合计 ≈ 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)。 +**验收**:**`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` 杠杆),必须据此重新评估是否值得** | + +--- + +## 16. 开放问题 + +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 主张同时削弱。 + +--- + +## 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) | --- -## 17. 开放问题 - -1. **T1/T0 采纳在 Adreno 830 与 Mali-G925 上到底能不能用?** 由 **P0 的 spike B** 在第一周回答(导出 `HOST_VISIBLE|HOST_COHERENT` VkBuffer 的 fd,client `mmap` 后回读),与 `SCM_RIGHTS` 测试同批。若两台设备都不行,P7 缩为"记录并回退",节省 6 天;若可行,还要回答 GLES 侧能否用 `GL_EXT_memory_object_fd` + `glBufferStorageMemEXT` 走同一条路(DirectGLES 的采纳今天走的是 `glBufferStorageEXT` + `glMapBufferRange(PERSISTENT|COHERENT)`,不是外部内存)。 -2. **`glGetError` 的严格性 CTS 到底要求到什么程度?** §5.6c 已把分配类改成同步 ack,剩下的晚到错误里,哪些 CTS case 可能观察到?P8 需要列出清单。若清单为空,`MOBILEGL_IPC_STRICT_ERRORS` 可以永久保持默认关。 -3. **P2.5 的 inproc 数字若为负怎么办?** 需要事先约定:若 inproc 相对 monolith 无收益甚至更慢(含亲和性绑定之后),是继续(因为拆分本身还有内存隔离、崩溃隔离、工装价值)还是收缩到 headless 工装用途?**建议在 P2.5 前由协调者拍板判据**,并同时约定"绑大核后仍无收益"与"未绑核无收益"是两个不同的结论。 -4. **§12.2 的隔离取舍**:`inproc` 做四全局角色隔离(含 133 处非箭头用法的 shim)值不值?若判定不值而把 `inproc` 降级为纯测试模式,P2.5 测的就不再是 monolith 渲染线程交付物——那时 monolith 侧的收益要靠什么证明?**P0 结束前必须有答案。** -5. **§6.4 的拷贝目标选方案 A 还是 B?** 方案 B(replica 的 `PipeResource` 采纳 client 的 `SEG_SHADOW` 只读映射)能把 buffer 上传路径从 3 次降到 2 次并消掉重复 shadow(对 R14 的内存预算意义更大),但要处理 server 侧写(`WritebackFromBackend`、生成 mip、CopyImage 镜像)的 copy-on-write 升级。P4.5 先做 A 并测量,P6 由数据决定是否做 B。 -6. **`SEG_SHADOW` 在 Android 上应该用 `ASharedMemory` 还是 memfd?** 前者是平台正道且有 `setProt` 只读降权(正好匹配"client 拥有、server 只读"),后者有 sealing。大 buffer 频繁重映射的场景需要一次实测。 -7. **client 侧是否需要 `mgl-client-tx` 发送线程?** 只有 P6 的 `TracyPlot` 数据能回答;在此之前不要预先加线程(会引入拷贝或锁)。 -8. **`ResyncSnapshot` 与采纳的互斥能否放松?** §5.8 目前规定 `MOBILEGL_IPC_RESPAWN=1` 与 `MOBILEGL_IPC_ADOPT_TIER != 2` 互斥,因为 adopted store 的字节在 server。是否值得为 adopted buffer 单独做一条"server 死亡时其内容视为丢失、按 `hasDefinedContent=false` 重建"的降级路径?取决于 MC 的 chunk arena 在 respawn 后能否被应用自己重填。 -9. **Windows AF_UNIX-everywhere 是否值得?** asio 的 IOCP `async_accept` 走 `AcceptEx`(AF_UNIX 从不支持);我们用继承 overlapped 句柄绕开 accept,理论上可行但需真编真跑。P6 评估,named pipe 是已知可用的默认。 -10. **P9 的 ART 启动成本具体是多少?** 若不可接受,是否接受"游戏内走 monolith,工装/CTS 走 split"的长期二元形态? -11. **`tools/trace_replay` 的 Android 应用内路径是否从非主线程驱动 GL、是否每重放帧调 `Present`?** 桌面重放器传 `--singlethread`(`trace_replay_core.cpp:430`),Android 应用内路径本次未完整追踪,它决定该工装能否验证节奏模型(尤其是 §9.1 的输入延迟直方图)。 -12. **`MOBILEGL_IPC_PERSISTENT_BLOCK_KB` 的默认值与脏块判定方式**:P1-4 的保守版(整 mapped span 按块重传)在 Create/Flywheel fixture 上的实测代价是多少?精确版用 `memcmp` 还是 mprotect 写屏障?前者对 1MB 块是 ~50µs 量级且只在真正 mapped 的 buffer 上跑,看起来够用,但需要 P2 的数据确认。 -13. **`SEG_STAGE` 的上限该定多少?** R14 要求由实测定而不是默认 256MiB。需要 P2 之后用 MC in-world 与 Create 两类 fixture 的 `stage-*` Tracy 计数器给出 p99 占用。 +## 附 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**) --- -## 附:环境变量与 CMake 选项汇总 +## 附 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 a22becad2..000000000 --- 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 0f19af37b..105c4acc1 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. 综合稿的关键决定 + +- 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. -## 3. 修订记录(综合稿 → 定稿) +## 4. 修订记录(综合稿 v1 → 定稿 v2) -- 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. +- [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. -## 4. 被驳回的审查意见 +## 5. 被驳回或部分驳回的审查意见 -- '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. +- [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. From 9c773182bbb44e2c88b100049c214cf426a70977 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 14:07:33 -0400 Subject: [PATCH 004/529] [Feat] (MGPipe): land the interface skeleton - the complete call catalogue, the payload PODs and the seven generators - Plan B section 4 makes the frontend/backend boundary explicit and gives the backend its own state machine. This is the P0 deliverable of section 11: the whole catalogue exists from day one, placeholders included, because the wire opcode is a call's position in PipeCalls.def and record numbering must never churn. - MG_Pipe/PipeCalls.def is the single source of truth: 68 unique calls as X(Name, Payload, Class, Flags). Its header reconciles that number with the plan's headline counts (section 4.4, appendix A), which double count - "CSO 15" names bind_sampler_states and set_sampler_views that the "set_* 17" list also names, "screen 14" tabulates the query family that section 4.3 assigns to the context, and the "transfer 12" row enumerates 11 calls. Each reconciliation is written down next to the count rather than resolved silently. - MGPipeHandles.h: the 8-byte {slot, gen} pair, dense per-kind slots, the reserved null and default-framebuffer handles, and the ShaderCso composite band (sections 4.2, 5.6.3). The two generations are documented as strictly separate, with the interface rule that no call may require the client to know MGGen. - MGPipeTypes.h: every payload of section 4.5 as a flat POD with explicit padding, a trivial-copyability assertion and an exact sizeof assertion, because the wire records are memcpy'd and a field silently changing width is a protocol break no test would see. MGPCaps embeds DynamicBackendParameters by inclusion so a caps field added there needs no second edit here; its assertion is stated as a composition because that struct still carries SizeT. ResidualValueBlock is pinned at MGL_RESIDUAL_BLOCK_SIZE 1248, the ratchet that only ever goes down and reaches static_assert(... == 0) in P13 (section 6.3). - MGPipeHostSpan.h keeps the one shape that changes with the transport isolated behind one predictable branch, with the kFromServerIndexMirror sentinel D-B7 needs. - MGPipeCallbacks.h names the reverse channel as ten callbacks plus the forward terminator in the context table, replacing 95 poke sites across 17 methods (section 7.1). - scripts/gen_pipe.py runs G1-G7 off those .def files. G1 asserts each table is EXACTLY its call count of function pointers; G3 pads every wire record to the stream's 8-byte granularity and checks size >= sizeof && size <= remaining && size % 8 == 0 before dispatch, fatally; G4 compares field by field (padding excluded, floats by bits) because a comparator with false positives is one nobody reads - DirectGLES.cpp says the same thing about its own memcmp of RenderStateParameters; G5 turns the accessor list into per-verb poison generations rather than a written-once bitmap, which is the only version that can see a field left over from the previous draw (section 6.2.2); G6 joins the 477 read points of the vendored backend_read_inventory.md against Coverage.def and reports 0 UNMAPPED (299 to a call, 167 signatures that become handle parameters, 6 reverse channel, 5 client-resolved); G7 pins the pipeline subset BY MEMBER NAME from what VulkanRenderer::ComputePipelineStateHash hashes today, computing no offsets in python. - The generated files are committed so the build never depends on python; CI regenerates and diffs them. --- CMakeLists.txt | 4 + MobileGL/MG_Pipe/Coverage.def | 106 +++ MobileGL/MG_Pipe/MGPipe.h | 93 ++ MobileGL/MG_Pipe/MGPipeCallbacks.h | 61 ++ MobileGL/MG_Pipe/MGPipeHandles.h | 98 ++ MobileGL/MG_Pipe/MGPipeHostSpan.h | 57 ++ MobileGL/MG_Pipe/MGPipeTypes.h | 754 ++++++++++++++++ MobileGL/MG_Pipe/PipeCalls.def | 148 ++++ MobileGL/MG_Pipe/PipeFields.def | 235 +++++ MobileGL/MG_Pipe/generated/PipeCoverage.inc | 108 +++ MobileGL/MG_Pipe/generated/PipeFilled.inc | 308 +++++++ MobileGL/MG_Pipe/generated/PipeSpanTable.inc | 67 ++ MobileGL/MG_Pipe/generated/PipeTables.inc | 105 +++ MobileGL/MG_Pipe/generated/PipeThunks.inc | 290 ++++++ MobileGL/MG_Pipe/generated/PipeVerify.inc | 565 ++++++++++++ MobileGL/MG_Pipe/generated/PipeWire.inc | 885 +++++++++++++++++++ scripts/data/backend_read_inventory.md | 626 +++++++++++++ scripts/gen_pipe.py | 638 +++++++++++++ 18 files changed, 5148 insertions(+) create mode 100644 MobileGL/MG_Pipe/Coverage.def create mode 100644 MobileGL/MG_Pipe/MGPipe.h create mode 100644 MobileGL/MG_Pipe/MGPipeCallbacks.h create mode 100644 MobileGL/MG_Pipe/MGPipeHandles.h create mode 100644 MobileGL/MG_Pipe/MGPipeHostSpan.h create mode 100644 MobileGL/MG_Pipe/MGPipeTypes.h create mode 100644 MobileGL/MG_Pipe/PipeCalls.def create mode 100644 MobileGL/MG_Pipe/PipeFields.def create mode 100644 MobileGL/MG_Pipe/generated/PipeCoverage.inc create mode 100644 MobileGL/MG_Pipe/generated/PipeFilled.inc create mode 100644 MobileGL/MG_Pipe/generated/PipeSpanTable.inc create mode 100644 MobileGL/MG_Pipe/generated/PipeTables.inc create mode 100644 MobileGL/MG_Pipe/generated/PipeThunks.inc create mode 100644 MobileGL/MG_Pipe/generated/PipeVerify.inc create mode 100644 MobileGL/MG_Pipe/generated/PipeWire.inc create mode 100644 scripts/data/backend_read_inventory.md create mode 100644 scripts/gen_pipe.py diff --git a/CMakeLists.txt b/CMakeLists.txt index eb574dc61..f89dc3879 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -472,6 +472,10 @@ message(STATUS "MOBILEGL_COMPILE_DEF=${MOBILEGL_COMPILE_DEF}") set(MOBILEGL_INCLUDE_DIR ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/MobileGL + # The MGPipe boundary headers. They are reachable as through the + # line above too; this entry lets the client, the backends and MG_Remote spell them + # as once MG_Pipe stops being a leaf of the frontend tree. + ${CMAKE_SOURCE_DIR}/MobileGL/MG_Pipe ${spirv-tools_SOURCE_DIR} ${spirv-tools_SOURCE_DIR}/include ${spirv-tools_BINARY_DIR} diff --git a/MobileGL/MG_Pipe/Coverage.def b/MobileGL/MG_Pipe/Coverage.def new file mode 100644 index 000000000..f5ee58e1d --- /dev/null +++ b/MobileGL/MG_Pipe/Coverage.def @@ -0,0 +1,106 @@ +// MobileGL - MobileGL/MG_Pipe/Coverage.def +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The hand-maintained half of G6 (plan B section 4.7, gate 10.3-5): which MGPipe call +// answers each backend read point in scripts/data/backend_read_inventory.md (477 rows, 57 +// files, generated from the backends by MobileGL-CS's extract_backend_read_inventory.py). +// +// gen_pipe.py joins the inventory's `member` column against MGP_COVERAGE_ACCESSOR_LIST and +// its `delta` column against MGP_COVERAGE_DELTA_LIST, then writes generated/PipeCoverage.inc +// with the per-accessor table and prints the coverage summary. Rows matching neither are +// UNMAPPED: allowed in P0 and merely counted, ZERO from P5 onward, when the gate becomes +// "regenerate and git diff --exit-code with 0 UNMAPPED". +// +// Three pseudo-calls stand for read points that do NOT become a forward call: +// kClientResolved - the frontend answers it itself; the server is never asked +// (section 4.4.6: "the server answers nothing the client can answer"). +// kReverseChannel - it becomes one of the ten MGPipeCallbacks (section 7.1). +// kStructuralHandle - the row is a SIGNATURE carrying SharedPtr, which +// becomes an MGPipeHandle parameter; there is no single call to name. +// +// clang-format off + +// X(Accessor, PipeCall) +#define MGP_COVERAGE_ACCESSOR_LIST(X) \ + X(GetActiveTextureUnit, SetSamplerViews) \ + X(GetBlendColor, SetDynamicState) \ + X(GetBlendEquationIndexed, CreateRenderState) \ + X(GetBlendFuncIndexed, CreateRenderState) \ + X(GetBoundTransformFeedbackName, SetStreamOutputTargets) \ + X(GetBoundVertexArray, BindVertexElements) \ + /* Polymorphic over BufferTarget: its rows split across set_vertex_buffers, */ \ + /* set_index_buffer, set_indirect_buffers and set_shader_buffers once the */ \ + /* inventory carries the target argument (P1). Named for the plan's explicit */ \ + /* replacement of the DrawIndirect/Parameter pair. */ \ + X(GetBufferBindingSlot, SetIndirectBuffers) \ + X(GetBufferBindingPoint, SetShaderBuffers) \ + X(GetBufferBindingPointCount, SetShaderBuffers) \ + X(GetTouchedBufferBindingPointCount, SetShaderBuffers) \ + X(GetClampReadColor, SetDynamicState) \ + X(GetClearColor, SetDynamicState) \ + X(GetClearDepth, SetDynamicState) \ + X(GetClearStencil, SetDynamicState) \ + X(GetColorMaskIndexed, CreateRenderState) \ + X(GetCullFaceMode, CreateRenderState) \ + X(GetCurrentVertexAttribute, SetVertexAttribDefaults) \ + X(GetDepthFunc, CreateRenderState) \ + X(GetDepthMask, CreateRenderState) \ + X(GetDepthRangeIndexed, SetDynamicState) \ + X(GetFramebufferBindingSlot, SetFramebufferState) \ + X(GetImageTextureBinding, SetShaderImages) \ + X(GetLineWidth, SetDynamicState) \ + X(GetLogicOp, CreateRenderState) \ + X(GetMaxTouchedTextureUnit, SetSamplerViews) \ + X(GetMinSampleShadingValue, CreateRenderState) \ + X(GetPatchDefaultInnerLevel, SetPatchState) \ + X(GetPatchDefaultOuterLevel, SetPatchState) \ + X(GetPatchVertices, SetPatchState) \ + X(GetPipelineStateVersion, BindRenderState) \ + X(GetPixelStoreParameters, SetPixelPackState) \ + X(GetPolygonModeFront, CreateRenderState) \ + X(GetPolygonOffsetFactor, SetDynamicState) \ + X(GetPolygonOffsetUnits, SetDynamicState) \ + X(GetPrimitiveRestartIndex, DrawVbo) \ + X(GetProgramForDispatch, SetDispatchProgram) \ + X(GetProgramForDraw, SetDrawProgram) \ + X(GetProgramObject, CreateShaderState) \ + /* Not in ComputePipelineStateHash today even though Vulkan makes it pipeline */ \ + /* state; recorded here so the G7 chunk table has to answer for it before it */ \ + /* freezes (section 10.3-5). */ \ + X(GetProvokingVertexMode, CreateRenderState) \ + X(GetRenderStateParameters, CreateRenderState) \ + X(GetRenderStateParametersVersion, BindRenderState) \ + X(GetSamplingResolutionGeneration, SetSamplerViews) \ + X(GetScissorBox, SetDynamicState) \ + X(GetStencilState, CreateRenderState) \ + X(GetTextureBindGeneration, SetSamplerViews) \ + X(GetTextureContextId, SetSamplerViews) \ + X(GetTextureObject, SetSamplerViews) \ + X(GetTextureUnitObject, SetSamplerViews) \ + X(GetTransformFeedbackCapturedVertices, DrawVbo) \ + X(GetTransformFeedbackGeneration, SetStreamOutputTargets) \ + X(GetTransformFeedbackPausedPrimitiveCounter, EndStreamOutput) \ + X(GetTransformFeedbackProgram, SetStreamOutputTargets) \ + X(GetViewport, SetDynamicState) \ + X(GetViewportIndexed, SetDynamicState) \ + X(IsCapabilityEnabled, CreateRenderState) \ + X(IsCapabilityEnabledIndexed, CreateRenderState) \ + X(IsTransformFeedbackActive, BeginStreamOutput) \ + X(IsTransformFeedbackPaused, PauseStreamOutput) \ + X(InvalidateCompileEnv, kClientResolved) \ + X(ValidateProgramName, kClientResolved) \ + X(RecordError, kReverseChannel) + +// X(DeltaKind, PipeCall) - for inventory rows with no accessor in the member column. +// Read by gen_pipe.py ONLY, never by the C++ preprocessor: the delta kinds are the +// inventory's own free-text labels, not C tokens. +#define MGP_COVERAGE_DELTA_LIST(X) \ + X(handle-ify (wire handle), kStructuralHandle) \ + X(Buffer ops delta, ResourceRespecify) + +// clang-format on diff --git a/MobileGL/MG_Pipe/MGPipe.h b/MobileGL/MG_Pipe/MGPipe.h new file mode 100644 index 000000000..85e26d81a --- /dev/null +++ b/MobileGL/MG_Pipe/MGPipe.h @@ -0,0 +1,93 @@ +// MobileGL - MobileGL/MG_Pipe/MGPipe.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +#include "MGPipeCallbacks.h" +#include "MGPipeHandles.h" +#include "MGPipeHostSpan.h" +#include "MGPipeTypes.h" + +// The MGPipe boundary (plan B section 4). +// +// The two interface tables are FUNCTION-POINTER STRUCTS, not virtual bases. Three reasons +// out of this repository rather than out of gallium: the boundary already is a +// function-pointer struct sitting on one hook point in MG_Backend/Init.cpp; a nullptr entry +// already means "not implemented, frontend falls back", which is exactly what a +// not-yet-migrated subsystem needs to say while it keeps pulling; and MG_Test already +// substitutes this table to mock a backend. The rare EGL and caps surface stays on +// pActiveBackendObject's virtual functions. +namespace MobileGL::MG_Pipe { + // Unscoped on purpose: PipeCalls.def spells these as bare tokens so the same file can + // be read by the C++ preprocessor and by scripts/gen_pipe.py. + enum MGPipeCallClass : Uint8 { + kScreen, + kCtxCso, + kCtxState, + kCtxObject, + kCtxVerb, + kCtxQuery, + kCallClassCount, + }; + + enum MGPipeCallFlags : Uint32 { + kNone = 0, + // The caller must not proceed until the server has acknowledged. Rare by design. + kNeedsAck = 1u << 0, + // Carries an MGPBlobRef. + kHasBlob = 1u << 1, + // Carries a variable-length array after the fixed payload. + kVarTail = 1u << 2, + // Carries an MGHostSpan - the one shape that changes with the transport. + kHostSpan = 1u << 3, + // Answers into an MGPReplySlot; never blocks. + kReplySlot = 1u << 4, + // May be null in a backend's table. A null entry is a real answer ("this backend + // does not implement it"), not an error: DirectVulkan deliberately leaves + // buffer_subdata_resident unregistered, and SetSwapInterval likewise. + kOptional = 1u << 5, + }; + + // The pipeline/dynamic split of RenderStateParameters, defined exactly once (section + // 4.5.2). Generated by G7 from the field list ComputePipelineStateHash already hashes; + // MGPipeRenderStateSpans.cpp and the setter-consistency test land with P2, which is + // when the chunk table can be filled with real offsets. + struct MGPipeRenderStateSpans; + + // The catalogue itself. Only macros, so it is safe to expand inside the namespace, and + // consumers (the unit test, later the transport) get MGP_CALL_LIST from this header. +#include "PipeCalls.def" + + // G1: the two interface tables. A null entry means "not implemented" (section 4.1). +#include "generated/PipeTables.inc" + + // The installed tables. Zero-initialized, so an un-installed MGPipe is every entry + // null - which is precisely the pre-migration state. + inline MGPipeScreen gMGPipeScreen{}; + inline MGPipeContext gMGPipeContext{}; + + // G2: monolith thunks. These are what MG_Impl call sites move onto, replacing + // gBackendFunctionsTable.GL.* one name at a time. +#include "generated/PipeThunks.inc" + + // G3: wire records, their size assertions, and the applier's bounds precondition. +#include "generated/PipeWire.inc" + + // G4: the MOBILEGL_PIPE_VERIFY field-wise comparators. +#include "generated/PipeVerify.inc" + + // G5: PipeInputs field ids and the per-verb poison generations. +#include "generated/PipeFilled.inc" + + // G6: the backend read inventory's coverage table. +#include "generated/PipeCoverage.inc" + + // G7: the render-state pipeline subset, by member name. +#include "generated/PipeSpanTable.inc" +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Pipe/MGPipeCallbacks.h b/MobileGL/MG_Pipe/MGPipeCallbacks.h new file mode 100644 index 000000000..f379aceaa --- /dev/null +++ b/MobileGL/MG_Pipe/MGPipeCallbacks.h @@ -0,0 +1,61 @@ +// MobileGL - MobileGL/MG_Pipe/MGPipeCallbacks.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +#include "MGPipeHandles.h" +#include "MGPipeTypes.h" + +// The backend -> frontend reverse channel, named (plan B section 7.1). +// +// Today this traffic is 95 call sites across 17 methods poked directly into frontend +// objects. gallium has no vocabulary for shadow writeback, GPU-write notification, texture +// re-send requests or default-framebuffer geometry, because in Mesa the state tracker and +// the driver share an address space. Naming them as ten callbacks plus one forward +// terminator (MGPipeContext::ResourceSubDataComplete) is the deliberate deviation (D8). +// +// Installed at context creation. In a monolith these are direct calls; under split they are +// records on the reverse channel, and their ORDER is a correctness requirement rather than +// an optimization (section 7.4). +namespace MobileGL::MG_Pipe { + struct MGPipeCallbacks { + // A driver-detected GL error that only the server could have seen. + void (*OnGlError)(Uint32 code); + // Ranges of a resource the GPU wrote; retires MarkGpuWritten. + void (*OnGpuWritten)(MGPipeHandle res, Uint rangeCount, const MGPRange* ranges); + void (*OnBufferWriteback)(MGPipeHandle res, Uint64 offset, MGPBlobRef bytes); + void (*OnTextureWriteback)(MGPipeHandle res, const MGPBox* box, MGPBlobRef bytes); + // The one new stall class in this design (D-B6): the server recast a texture and + // needs its texels back. The client answers with zero or more ResourceSubData + // records terminated by ResourceSubDataComplete carrying the same pullSerial. + void (*OnTexturePullRequest)(MGPipeHandle res, Uint16 target, Uint16 firstLevel, Uint16 levelCount, + Uint64 pullSerial); + // SHAPE ONLY, never bytes: the client owns the CPU shadow and allocates the levels + // itself. + void (*OnMipLevelsGenerated)(MGPipeHandle res, Uint16 base, Uint16 count); + // Retires the layering inversion where the swapchain writes into MG_Impl's + // pDefaultFramebufferInfo. + void (*OnSurfaceChanged)(const MGPSurfaceInfo* info); + void (*OnCapsInvalidated)(); + // <= WARN is lossy, >= ERROR is lossless and rate limited. + void (*OnLog)(Uint8 level, const char* text); + // The XFB scatter is a read-modify-write of the CLIENT's shadow, so the server + // hands back the packed scratch and the client scatters (section 7.2.1). + void (*OnXfbScatterReady)(MGPipeHandle scratch, Uint64 packedStride, Uint64 vertices); + }; + + // Ten, and the count is asserted so an eleventh cannot be added without touching the + // transport's reverse-channel record table. + inline constexpr SizeT kMGPipeCallbackCount = 10; + static_assert(sizeof(MGPipeCallbacks) == kMGPipeCallbackCount * sizeof(void (*)()), + "MGPipeCallbacks gained or lost a callback"); + + // Null-initialized: a backend that installs nothing sends nothing. + inline MGPipeCallbacks gMGPipeCallbacks{}; +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Pipe/MGPipeHandles.h b/MobileGL/MG_Pipe/MGPipeHandles.h new file mode 100644 index 000000000..586880db3 --- /dev/null +++ b/MobileGL/MG_Pipe/MGPipeHandles.h @@ -0,0 +1,98 @@ +// MobileGL - MobileGL/MG_Pipe/MGPipeHandles.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +// MGPipe object identity (plan B section 4.2). +// +// A handle is a {slot, gen} pair minted by the CLIENT and never by the server: no create_* +// call in the catalogue returns a server-cast handle, which is the deliberate deviation +// from gallium (D1) that lets the whole catalogue be remoted with ZERO creation round +// trips. +// +// Slots are dense and allocated PER KIND, so the server's object table is an array rather +// than a hash map. The allocator is a free list plus a high-water mark and has nothing to +// do with MG_State's IndexGenerator - that container's LIFO name reuse is the very problem +// {slot, gen} exists to close. +namespace MobileGL::MG_Pipe { + enum class MGPipeKind : Uint8 { + None = 0, + Buffer = 1, + Texture, + Renderbuffer, + Framebuffer, + Xfb, + RenderStateCso, + VertexElementsCso, + SamplerCso, + SamplerViewCso, + ShaderCso, + Fence, + Query, + Context, + KindCount, + }; + + // 8 bytes, POD, passed by value in a register pair. + // + // Gen increments only when a SLOT IS REUSED - never on a respecify - so {slot, gen} is + // unique until the same slot has been recycled 2^32 times. That bound is documented + // rather than defended at runtime in release builds: at one recycle per frame at + // 1000 fps a single slot would take ~50 days of continuous churn to wrap, and the + // debug allocator asserts on the wrap. + // + // Two generations exist in this design and they are strictly separate (section 4.2.2): + // this one is the CLIENT's answer to "is this still the same GL object", while MGGen is + // the SERVER's own epoch for "did I recast my driver object". Interface rule: no MGPipe + // call may require the client to supply or know MGGen. + struct MGPipeHandle { + Uint32 Slot; + Uint32 Gen; + + friend constexpr Bool operator==(const MGPipeHandle& a, const MGPipeHandle& b) { + return a.Slot == b.Slot && a.Gen == b.Gen; + } + }; + + static_assert(sizeof(MGPipeHandle) == 8, "MGPipeHandle is the 8-byte {slot, gen} pair"); + static_assert(alignof(MGPipeHandle) == 4, "MGPipeHandle must not gain padding on the wire"); + static_assert(std::is_trivially_copyable_v); + + // Reserved handles (section 4.2.1). + // {0, 0} is null for every kind. + // {0, 1} of kind Framebuffer is the DEFAULT framebuffer. It exists so the four + // pDefaultFramebufferInfo->defaultFBO identity comparisons in DirectGLES retire into + // an ordinary handle compare. + inline constexpr MGPipeHandle kMGPipeNullHandle{0, 0}; + inline constexpr MGPipeHandle kMGPipeDefaultFramebuffer{0, 1}; + + inline constexpr Bool MGPipeHandleIsNull(const MGPipeHandle& handle) { + return handle.Slot == 0 && handle.Gen == 0; + } + + // Slot 0 of every kind is reserved (null, and the default framebuffer for kind + // Framebuffer), so a real allocation starts at 1. + inline constexpr Uint32 kMGPipeFirstAllocatableSlot = 1; + + // ShaderCso slot space. The top 1/16 of it is reserved for PROGRAM PIPELINE COMPOSITES + // (section 5.6.3): a composite is minted client-side out of the stage programs bound to + // a pipeline object, and the server never learns it is a composite - it is just another + // ShaderCso. Reserving a band rather than a flag keeps the composite resolver's + // lifetime bookkeeping out of the ordinary program slot allocator. + inline constexpr Uint32 kMGPipeShaderCsoSlotLimit = 1u << 20; + inline constexpr Uint32 kMGPipeShaderCsoCompositeSlotBase = + kMGPipeShaderCsoSlotLimit - (kMGPipeShaderCsoSlotLimit >> 4); + + inline constexpr Bool MGPipeIsCompositeShaderSlot(Uint32 slot) { + return slot >= kMGPipeShaderCsoCompositeSlotBase && slot < kMGPipeShaderCsoSlotLimit; + } + + static_assert(kMGPipeShaderCsoCompositeSlotBase > kMGPipeFirstAllocatableSlot, + "the composite band must not swallow the ordinary program slots"); +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Pipe/MGPipeHostSpan.h b/MobileGL/MG_Pipe/MGPipeHostSpan.h new file mode 100644 index 000000000..edec2fa49 --- /dev/null +++ b/MobileGL/MG_Pipe/MGPipeHostSpan.h @@ -0,0 +1,57 @@ +// MobileGL - MobileGL/MG_Pipe/MGPipeHostSpan.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +// The ONE thing in MGPipe whose shape changes with the transport (plan B section 4.5.7). +// +// Monolith: Ptr addresses the frontend shadow or the application's own memory and the +// accessor is one predictable branch. Split: Ptr is null and the bytes live in a staging +// segment named by Seg/Offset, or - for the index bytes a server-side primitive-restart +// rewrite or multi-draw flattening consumes - in the server's own index host mirror, which +// costs no wire traffic at all (D-B7). +namespace MobileGL::MG_Pipe { + // Seg sentinels. Anything else is a real SEG_STAGE id assigned by the transport. + inline constexpr Uint32 kMGHostSpanSegNone = 0; + // "The bytes are already on your side": the server reads them out of the index host + // mirror it maintains for every resource created with the ELEMENT_ARRAY bind bit while + // kCapNeedsHostIndexBytes is set. When the mirror is over budget the tracker degrades + // to per-draw staging and counts the bytes in index-bytes-shipped. + inline constexpr Uint32 kMGHostSpanSegFromServerIndexMirror = 0xFFFFFFFFu; + + struct MGHostSpan { + // Field order is chosen so the struct is 32 bytes with natural alignment on both a + // 64-bit and a 32-bit host: the pointer and the two 32-bit words fill the first + // 16-byte block either way. + const void* Ptr; + Uint32 Seg; + Uint32 Pad0; + Uint64 Size; + Uint64 Offset; + }; + + static_assert(sizeof(MGHostSpan) == 32, "MGHostSpan is the 32-byte host-bytes descriptor"); + static_assert(std::is_trivially_copyable_v); + + // Split-mode resolution needs the transport's segment table, which does not exist in a + // monolith build; the hook is a weak-ish indirection installed by MG_Remote when it is + // compiled in. In P0 there is no transport, so a span that names a segment resolves to + // null and every caller is still on the monolith branch. + using MGPipeSegmentResolver = const void* (*)(Uint32 seg, Uint64 offset, Uint64 size); + inline MGPipeSegmentResolver gMGPipeSegmentResolver = nullptr; + + // One predictable branch on the hot path. + inline const void* MGPipeHostBytes(const MGHostSpan& span) { + if (span.Ptr != nullptr) { + return static_cast(span.Ptr) + span.Offset; + } + if (gMGPipeSegmentResolver == nullptr) return nullptr; + return gMGPipeSegmentResolver(span.Seg, span.Offset, span.Size); + } +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Pipe/MGPipeTypes.h b/MobileGL/MG_Pipe/MGPipeTypes.h new file mode 100644 index 000000000..227338d35 --- /dev/null +++ b/MobileGL/MG_Pipe/MGPipeTypes.h @@ -0,0 +1,754 @@ +// MobileGL - MobileGL/MG_Pipe/MGPipeTypes.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +#include "MGPipeHandles.h" +#include "MGPipeHostSpan.h" + +// Every MGPipe payload (plan B section 4.5). Each one is a flat POD with explicit padding, +// carries a static_assert on trivial copyability and one on its exact size, and never +// contains a pointer other than the single MGHostSpan the design isolates on purpose. +// +// Sizes are asserted rather than merely documented because the wire records generated from +// these structs (generated/PipeWire.inc) are memcpy'd; a field silently changing width is a +// protocol break that no test would otherwise see. +// +// P0.5 DEBT, recorded here so it is impossible to miss: two payloads reach into headers +// this directory is eventually forbidden to see - MGPCaps embeds MG_Backend's +// DynamicBackendParameters, and ResidualValueBlock embeds MG_State's RenderStateParameters +// and PixelStoreParameters. Both are deliberate: the caps block IS that struct (section +// 4.4.1) and the residual block is the migration carrier for Track V (section 6.3). P0.5 +// extracts MGPipeValueTypes.h and both includes below go away; until then purity gate A +// (section 10.3) cannot be armed for this header. +#include +#include + +namespace MobileGL::MG_Pipe { + using MG_Backend::DynamicBackendParameters; + // Both live directly in namespace MobileGL today; P0.5 moves them into + // MG_Pipe/MGPipeValueTypes.h. + using MobileGL::PixelStoreParameters; + using MobileGL::RenderStateParameters; + + // A payload must be memcpy-able and its size must be an exact, stated number. +#define MGP_ASSERT_POD(T, Size) \ + static_assert(std::is_trivially_copyable_v, #T " must be trivially copyable"); \ + static_assert(sizeof(T) == (Size), #T " changed size; update the wire format and this assertion") + + // --------------------------------------------------------------------------------- + // Shared primitives + // --------------------------------------------------------------------------------- + + // A run of bytes in the command stream's blob area. Monolith: Seg is + // kMGHostSpanSegNone and Offset is an address into the caller's staging arena. Split: + // Seg names a transport segment. + struct MGPBlobRef { + Uint64 Offset; + Uint64 Size; + Uint32 Seg; + Uint32 Pad0; + }; + MGP_ASSERT_POD(MGPBlobRef, 24); + + struct MGPRange { + Uint64 Offset; + Uint64 Size; + }; + MGP_ASSERT_POD(MGPRange, 16); + + // Destination box in the level's own coordinate system (section 4.5.6). + struct MGPBox { + Int32 X, Y, Z; + Uint32 W, H, D; + }; + MGP_ASSERT_POD(MGPBox, 24); + + // Where an asynchronous answer lands. Every server query in this catalogue is + // async-with-handle; none of them blocks (section 4.4.6, "the total rule"). + struct MGPReplySlot { + Uint64 Id; + }; + MGP_ASSERT_POD(MGPReplySlot, 8); + + // One contiguous run of RenderStateParameters bytes. The pipeline/dynamic split is + // defined exactly once, in MGPipeRenderStateSpans, and generated by G7 from the field + // list VulkanRenderer::ComputePipelineStateHash already hashes (section 4.5.2). + struct MGPStateChunk { + Uint16 Offset; + Uint16 Length; + }; + MGP_ASSERT_POD(MGPStateChunk, 4); + + // The payload of every call that carries nothing but an object identity. + struct MGPHandleOnly { + MGPipeHandle Handle; + Uint32 Kind; // MGPipeKind, widened for a stable wire size + Uint32 Pad0; + }; + MGP_ASSERT_POD(MGPHandleOnly, 16); + + // --------------------------------------------------------------------------------- + // Screen: caps, resources, fences + // --------------------------------------------------------------------------------- + + // Capability bits that replace "is this table slot null" as an implicit feature probe + // (section 4.4.1). The five ownership-switch bits of v1 are deliberately absent: what + // they tried to express - who performs primitive-restart rewriting and multi-draw + // flattening - is not expressible as a capability (D-B7). + enum MGPCapBit : Uint64 { + kCapNone = 0, + kCapViewportArray = 1ull << 0, + kCapFloat64VertexAttrib = 1ull << 1, + kCapResidentSubData = 1ull << 2, + kCapCpuXfbPrimitiveAccounting = 1ull << 3, + kCapTimerQuery = 1ull << 4, + kCapOcclusionQuery = 1ull << 5, + kCapXfbPrimitivesQuery = 1ull << 6, + // The server rewrites restart indices / flattens multi-draws itself and therefore + // needs the index bytes on its side: under split this arms the index host mirror + // (D-B7). + kCapNeedsHostIndexBytes = 1ull << 7, + // The server packs named uniform blocks into its own ring and therefore needs the + // host bytes of a set_shader_buffers(Uniform) range (D-B8). + kCapNeedsHostUboBytes = 1ull << 8, + }; + + struct MGPCaps { + // The ~90 flat scalars the backends already publish, by inclusion rather than by + // restatement: a caps field added there must not need a second edit here. + DynamicBackendParameters Dynamic; + Uint64 CallMask; // MGPCapBit + // The two halves that are not flat PODs travel as blobs: the format capability + // cache holds Vector sample-count lists, and the renderer strings are + // Strings. Their serializers land with the transport (P5). + MGPBlobRef FormatCapabilities; + MGPBlobRef RendererInfo; + }; + static_assert(std::is_trivially_copyable_v, "MGPCaps must be trivially copyable"); + // Stated as a COMPOSITION rather than a literal: DynamicBackendParameters still carries + // SizeT fields, so its literal size is ABI-dependent until P0.5 moves the caps block + // into MGPipeValueTypes.h with fixed-width members. The assertion still fires on any + // padding introduced between the members below. + static_assert(sizeof(MGPCaps) == sizeof(DynamicBackendParameters) + 8 + 24 + 24, + "MGPCaps gained padding or a member; update the wire format"); + + // Discriminated resource descriptor: buffers, every texture target and renderbuffers + // share one create/respecify shape (section 4.5.1). + struct MGPResourceDesc { + MGPipeHandle Resource; + Uint8 Target; // Buffer | Tex1D..TexCubeArray | Tex2DMS.. | Renderbuffer | TexBuffer + Uint8 StorageKind; // == TextureStorageType (Mipmap | Buffer) + // VERTEX|INDEX|CONSTANT|SHADER_BUFFER|INDIRECT|SAMPLER|SHADER_IMAGE|RENDER_TARGET| + // DEPTH_STENCIL|STREAM_OUTPUT|ATOMIC|ELEMENT_ARRAY. The ELEMENT_ARRAY bit is the + // D-B7 switch: with kCapNeedsHostIndexBytes set the server mirrors this resource. + Uint16 BindMask; + Uint32 InternalFormat; // already resolved to an uncompressed fallback by the client + Uint32 Width, Height, Depth; + Uint16 ArrayLayers, Levels, Samples; + Uint8 FixedSampleLocations, Immutable; + Uint32 Usage; // BufferUsage + Uint32 StorageFlags; // glBufferStorage flags + Uint8 HasDefinedContent; // false after a NULL-data respecify + Uint8 ImageBindableHint; // client-side everImageBound; pre-emptive allocation + Uint16 Pad0; + // Diagnostics only. A GL name is NEVER an identity, never a memo key and never part + // of a content hash (section 4.2.1). Widened from the plan's two bytes, which + // cannot hold one. + Uint32 GlNameForDiag; + Uint32 Pad1; + MGPipeHandle ViewOf; // storage owner for a texture view + MGPipeHandle BufferForTexBuffer; // texture-buffer backing store + Uint64 BufOffset, BufSize; // kWholeBuffer == ~0, resolved live + }; + MGP_ASSERT_POD(MGPResourceDesc, 88); + inline constexpr Uint64 kMGPipeWholeBuffer = ~0ull; + + struct MGPFenceWait { + MGPipeHandle Fence; + Uint64 TimeoutNs; + }; + MGP_ASSERT_POD(MGPFenceWait, 16); + + struct MGPQueryDesc { + MGPipeHandle Query; + Uint32 Kind; // GL query target + Uint32 Stream; // indexed query stream, 0 otherwise + }; + MGP_ASSERT_POD(MGPQueryDesc, 16); + + struct MGPQueryResultRequest { + MGPipeHandle Query; + Uint8 Wait; // the two-value contract of GetSyncStatus is preserved verbatim + Uint8 Pad0[3]; + Uint32 Pad1; + }; + MGP_ASSERT_POD(MGPQueryResultRequest, 16); + + // --------------------------------------------------------------------------------- + // CSOs + // --------------------------------------------------------------------------------- + + // create_render_state carries ONLY the pipeline subset's chunk bytes. chunkMask lets an + // incremental create send just the chunks that moved, against baseCso (section 4.5.2). + struct MGPRenderStateDesc { + MGPipeHandle Cso; + MGPipeHandle BaseCso; + Uint32 ChunkMask; // all ones for a brand new CSO + Uint32 Pad0; + MGPBlobRef Blob; + }; + MGP_ASSERT_POD(MGPRenderStateDesc, 48); + + // Steady state: 12 bytes on the wire, no hashing, no blob. + struct MGPBindRenderState { + MGPipeHandle Cso; + Uint16 Version; + Uint16 PipelineVersion; + }; + MGP_ASSERT_POD(MGPBindRenderState, 12); + + // The half of the render state that must NOT mint a CSO: viewport, scissor, depth + // range, blend colour, line width, polygon offset, stencil ref/write mask, clear + // values, sample coverage, hints and the point-size family. This is what keeps + // glViewport from evicting Magma's pipeline memo (D-B1). + struct MGPDynamicState { + Uint32 ChunkMask; + Uint16 Version; + Uint16 Pad0; + MGPBlobRef Blob; + }; + MGP_ASSERT_POD(MGPDynamicState, 32); + + // Both views travel, and neither is derivable from the other: the resolved + // VertexAttribute[32] AND the binding points, because a pointer-call stride of 0 means + // "element size" while a binding-model stride of 0 means "every vertex reads the same + // element" (section 4.5.3). IsLong and Type == Float64 are carried separately. + struct MGPVertexElements { + MGPipeHandle Cso; + Uint32 AttributeCount; + Uint32 BindingPointCount; + MGPBlobRef Blob; // VertexAttribute[] followed by VertexBufferBindingPoint[] + }; + MGP_ASSERT_POD(MGPVertexElements, 40); + + // SamplerParameters crosses byte for byte INCLUDING borderColorForm: without it the + // backend cannot choose between glSamplerParameterIiv and fv, or between the + // VkBorderColor families, because all three representations are always numerically + // populated (section 4.5.4). Carried as a blob until P0.5 gives it a value header. + struct MGPSamplerDesc { + MGPipeHandle Cso; + MGPBlobRef Parameters; + }; + MGP_ASSERT_POD(MGPSamplerDesc, 32); + + // = pipe_sampler_view, and ONLY the view restrictions. Everything a glTexParameter + // writes lives on set_texture_params instead, because a texture that is only an FBO + // attachment, only an image binding or only a glCopyImageSubData endpoint has no + // sampler view to hang it on (section 4.4.3). + struct MGPSamplerView { + MGPipeHandle Cso; + MGPipeHandle Texture; + Uint32 InternalFormat; // aliasing format for glTextureView + Uint8 Target; + Uint8 Pad0[3]; + Uint16 MinLevel, NumLevels, MinLayer, NumLayers; + Uint16 Samples; + Uint8 FixedSampleLocations; + Uint8 Pad1; + }; + MGP_ASSERT_POD(MGPSamplerView, 36); + + // Per texture OBJECT, independent of any view. + struct MGPTextureParams { + MGPipeHandle Res; + Uint16 BaseLevel, MaxLevel; + Uint8 Swizzle[4]; + Uint8 DepthStencilMode; + // Mirrors m_forceTextureParamsResync: the widened-channel carrier needs a swizzle + // override that the frontend params version does not move for. + Uint8 ForceResync; + Uint8 Pad0[2]; + Float MinLod, MaxLod, LodBias; + }; + MGP_ASSERT_POD(MGPTextureParams, 32); + + // create_shader_state. The reflection blob is the whole LinkArtifacts + SpirvArtifacts + // archive; P0.5 extracts those types out of ProgramObject.h so a server can + // deserialize into them without dragging in glslang (section 4.5.5). + struct MGPProgramDesc { + MGPipeHandle Cso; + Uint32 StageMask; // == GetLinkedShaderStages() + Uint32 GlobalUboSize; + Uint32 ReservedNumSamplesOffset; + Uint8 SpirvStatus; + Uint8 NativeFloat64; + Uint8 PointSizeDemoted; + Uint8 EnableSpirvValidation; + MGPBlobRef Spirv[6]; // per stage + MGPBlobRef Reflection; + }; + MGP_ASSERT_POD(MGPProgramDesc, 192); + + // --------------------------------------------------------------------------------- + // set_* + // --------------------------------------------------------------------------------- + + // = pipe_surface. internalFormat is INLINE so the four cross-object masks fall out at + // push time with no lookup (section 4.5.6). + struct MGPSurface { + MGPipeHandle Res; + Uint32 InternalFormat; + Uint8 Kind; // Texture | Renderbuffer | None + Uint8 Layered; + Uint16 Level; + Uint32 Layer; + Uint16 UploadTarget; + Uint16 Pad0; + }; + MGP_ASSERT_POD(MGPSurface, 24); + + struct MGPFramebufferState { + MGPipeHandle Fbo; // kMGPipeDefaultFramebuffer for the default framebuffer + MGPSurface Color[8]; + MGPSurface Depth, Stencil; + // The RESOLVED read surface, not an index. This is what structurally closes the + // read-buffer-shared-FBO defect class. + MGPSurface ReadSurface; + Int8 DrawBuffers[8]; // attachment index, -1 = NONE + Uint16 Width, Height, Layers, Samples; + Uint8 FixedSampleLocations, IsDefault, Complete, Pad0; + Uint32 Pad1; + // Two jobs (section 4.5.6): the server's render-pass memo key, and the CLIENT's + // emission suppressor - an unchanged hash means this record is not sent at all. + // The same pattern is mandatory for every kVarTail set_* below, or 26.2's + // redundant glBindSampler traffic reappears as a variable-length record per batch. + Uint64 ContentHash; + }; + MGP_ASSERT_POD(MGPFramebufferState, 304); + + struct MGPVertexBuffer { + MGPipeHandle Res; + Uint64 Offset; + Uint32 Stride; + Uint32 Divisor; + Uint32 BindingIndex; + Uint32 Pad0; + }; + MGP_ASSERT_POD(MGPVertexBuffer, 32); + + // Var-tail header: MGPVertexBuffer[Count] follows. + struct MGPVertexBuffers { + Uint32 Start, Count; + Uint64 ContentHash; + }; + MGP_ASSERT_POD(MGPVertexBuffers, 16); + + // An independent call, NOT a subset of the VAO configuration version (D5). + struct MGPIndexBuffer { + MGPipeHandle Res; + Uint64 Offset; + Uint32 IndexSize; + Uint32 Pad0; + }; + MGP_ASSERT_POD(MGPIndexBuffer, 24); + + struct MGPIndirectBuffers { + MGPipeHandle DrawIndirect; + MGPipeHandle Parameter; + }; + MGP_ASSERT_POD(MGPIndirectBuffers, 16); + + // One entry of set_sampler_views. No stage dimension: MobileGL's texture unit space is + // MERGED (TextureState::m_textureUnits is one Array of MAX_TEXTURE_IMAGE_UNITS = 192), + // and the same unit may be sampled from two stages (section 4.4.3). + struct MGPBoundView { + MGPipeHandle View; + MGPipeHandle Texture; + Uint32 Unit; + Uint32 Pad0; + }; + MGP_ASSERT_POD(MGPBoundView, 24); + + struct MGPSamplerViews { + Uint32 Start, Count; + Uint64 ContentHash; + }; + MGP_ASSERT_POD(MGPSamplerViews, 16); + + // Var-tail header: MGPipeHandle[Count] of sampler CSOs follows. + struct MGPSamplerStates { + Uint32 Start, Count; + Uint64 ContentHash; + }; + MGP_ASSERT_POD(MGPSamplerStates, 16); + + struct MGPImageView { + MGPipeHandle Res; + Uint32 Unit; + Uint32 InternalFormat; + Uint32 Layer; + Uint16 Level; + Uint8 Layered; + Uint8 Access; + }; + MGP_ASSERT_POD(MGPImageView, 24); + + struct MGPShaderImages { + Uint32 Start, Count; + Uint64 ContentHash; + }; + MGP_ASSERT_POD(MGPShaderImages, 16); + + // One bound buffer range. Payload is populated for the Uniform class only, and only + // while kCapNeedsHostUboBytes is set (D-B8). + struct MGPBufferRange { + MGPipeHandle Res; + Uint64 Offset; + Uint64 Size; + MGHostSpan Payload; + }; + MGP_ASSERT_POD(MGPBufferRange, 56); + + // Var-tail header: MGPBufferRange[Count] follows. + struct MGPShaderBuffers { + Uint32 Class; // Uniform | ShaderStorage | AtomicCounter + Uint32 Start; + Uint32 Count; + Uint32 WritableMask; + Uint64 ContentHash; + }; + MGP_ASSERT_POD(MGPShaderBuffers, 24); + + // Var-tail header: MGPBufferRange[Count] then Uint32 offsets[Count]. + struct MGPStreamOutputTargets { + Uint32 Count; + Uint32 Pad0; + Uint64 Generation; + Uint64 ContentHash; + }; + MGP_ASSERT_POD(MGPStreamOutputTargets, 24); + + // Covers the DEFAULT UNIFORM BLOCK only (D6). + struct MGPGlobalConstants { + MGPipeHandle ShaderCso; + Uint32 Version; + Uint32 Pad0; + MGPBlobRef Blob; + }; + MGP_ASSERT_POD(MGPGlobalConstants, 40); + + // The float/int/uint view is resolved on the CLIENT by ClassifyVertexAttribType. + struct MGPAttribValue { + Uint32 Location; + Uint8 ValueClass; // Float | Int | Uint | Double + Uint8 Pad0[3]; + Uint32 Data[4]; + }; + MGP_ASSERT_POD(MGPAttribValue, 24); + + // Var-tail header: MGPAttribValue[popcount(Mask)] follows. + struct MGPVertexAttribDefaults { + Uint32 Mask; + Uint32 Count; + }; + MGP_ASSERT_POD(MGPVertexAttribDefaults, 8); + + // PACK only. There is deliberately no unpack counterpart: nothing on the far side of + // the boundary reads unpack state (section 4.6 D5), and the staged-repack upload path + // does not even issue glPixelStorei. + struct MGPPixelPackState { + PixelStoreParameters Pack; + }; + static_assert(std::is_trivially_copyable_v); + static_assert(sizeof(MGPPixelPackState) == sizeof(PixelStoreParameters)); + + // Also a shader-variant input: both backends bake these into the synthesized + // pass-through control stage. + struct MGPPatchState { + Uint32 Vertices; + Uint32 Pad0; + Float Outer[4]; + Float Inner[2]; + Uint32 Pad1[2]; + }; + MGP_ASSERT_POD(MGPPatchState, 40); + + // Migration-only (section 6.3). Every stage removes fields and lowers + // MGL_RESIDUAL_BLOCK_SIZE; P13 asserts it is zero, which is the retirement trip wire. + // + // Layout must be asserted MEMBER BY MEMBER, not only by sizeof: a heterogeneous POD + // union is where padding differs across ABIs, and the monolith verify harness is blind + // to it because both sides are the same translation unit. G3 emits the offsetof + // assertions; under split the block is serialized field-wise rather than memcpy'd. + struct ResidualValueBlock { + RenderStateParameters RenderState; // until create/bind_render_state + set_dynamic_state land + PixelStoreParameters Pack; // until set_pixel_pack_state lands + Uint64 CapabilityBits; + Uint32 PatchVertices; + Uint32 Pad0; + Float PatchOuter[4]; + Float PatchInner[2]; + Uint32 Pad1[2]; + }; + static_assert(std::is_trivially_copyable_v); +// The retirement ratchet. This number only ever goes DOWN: every stage that lands a real +// set_* call deletes fields here and lowers it, and P13 replaces it with +// static_assert(sizeof(ResidualValueBlock) == 0), which stays red until the last field is +// gone. Shrinking the block without lowering the number, or growing it at all, is a build +// break - which is the point. +// +// Stable across the ABIs MobileGL ships on: every member of RenderStateParameters and +// PixelStoreParameters is a fixed-width scalar or an array of one, with no pointer and no +// SizeT. +#define MGL_RESIDUAL_BLOCK_SIZE 1248 + static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE, + "the residual value block changed size; lower MGL_RESIDUAL_BLOCK_SIZE if a field " + "retired, and do not raise it"); + + struct MGPResidualValueState { + Uint32 Version; + Uint32 Pad0; + MGPBlobRef Blob; + }; + MGP_ASSERT_POD(MGPResidualValueState, 32); + + // --------------------------------------------------------------------------------- + // Transfer + // --------------------------------------------------------------------------------- + + // Shape copied from the unpack ring's existing UnpackStagingBlock. The source strides + // are CARRIED, not inferred from a pointer comparison: the old + // `uploadData == mipData` test cannot survive a split, where the client neither ships + // the whole level nor keeps a server-side mirror of it (section 4.5.6). + struct MGPSubRegion { + Int32 X, Y, Z; + Uint32 W, H, D; + Uint64 SrcOffset; // into the blob + Uint32 SrcRowStride; // bytes; 0 = tightly packed (w * bpp) + Uint32 SrcSliceStride; // bytes; 0 = tightly packed + }; + MGP_ASSERT_POD(MGPSubRegion, 40); + + // Carries the union box AND the region list so the SERVER picks the upload shape - the + // decision belongs on the side that pays the GPU cost. Mali prices texture upload by + // JOB COUNT: ~100 sprite rects against one union box measured +6 ms/frame. + struct MGPSubData { + MGPipeHandle Res; + Uint16 Target, Level; + // Replaces the backend's `uploadData == mipData` pointer comparison: are these + // bytes an untransformed level shadow? + Uint8 SourceIsVerbatimLevelShadow; + Uint8 Pad0[3]; + MGPBox UnionBox; + Uint32 RegionCount; // MGPSubRegion[] in the variable tail + Uint32 Pad1; + MGPBlobRef Blob; + }; + MGP_ASSERT_POD(MGPSubData, 72); + + // The forward terminator for a server-initiated texture pull (section 7.1). May carry + // zero regions - that is how a pull that needs nothing is answered. + struct MGPSubDataComplete { + MGPipeHandle Res; + Uint16 Target, FirstLevel, LevelCount, Pad0; + Uint64 PullSerial; + }; + MGP_ASSERT_POD(MGPSubDataComplete, 24); + + // Carries the application's REAL access flags, not a normalized subset. + struct MGPFlushRange { + MGPipeHandle Res; + Uint64 Offset, Size; + Uint32 AccessFlags; // Flags + Uint32 Pad0; + }; + MGP_ASSERT_POD(MGPFlushRange, 32); + + struct MGPReadback { + MGPipeHandle Res; + Uint64 Offset, Size; + }; + MGP_ASSERT_POD(MGPReadback, 24); + + struct MGPCopyRegion { + MGPipeHandle Src, Dst; + MGPBox SrcBox; + Int32 DstX, DstY, DstZ; + Uint16 SrcTarget, DstTarget; + Uint16 SrcLevel, DstLevel; + Uint32 Pad0; + }; + MGP_ASSERT_POD(MGPCopyRegion, 64); + + struct MGPBlit { + MGPipeHandle ReadFbo, DrawFbo; + Int32 SrcX0, SrcY0, SrcX1, SrcY1; + Int32 DstX0, DstY0, DstX1, DstY1; + Uint32 Mask; + Uint32 Filter; + }; + MGP_ASSERT_POD(MGPBlit, 56); + + // One discriminated record replacing glClear, the four glClearBuffer* and the four + // glClearNamedFramebuffer* entry points (section 4.4.4). + struct MGPClear { + MGPipeHandle Fbo; + Uint32 Kind; // Whole | Color | Depth | Stencil | DepthStencil + Int32 DrawBufferIndex; + Uint32 BufferMask; // GL_COLOR_BUFFER_BIT etc. for the whole-framebuffer form + Uint32 ValueClass; // Float | Int | Uint + Uint32 ColorValue[4]; + Float DepthValue; + Int32 StencilValue; + }; + MGP_ASSERT_POD(MGPClear, 48); + + struct MGPMipPlan { + MGPipeHandle Res; + Uint16 Target, BaseLevel, LevelCount, Pad0; + }; + MGP_ASSERT_POD(MGPMipPlan, 16); + + // read_pixels and get_texture_image share one shape; both answer into a reply slot. + struct MGPReadbackInfo { + MGPipeHandle Res; // null for read_pixels: the bound read surface answers + MGPBox Box; + Uint32 Format, Type; + Uint16 Target, Level; + Uint32 Pad0; + Uint64 DstOffset, DstSize; + }; + MGP_ASSERT_POD(MGPReadbackInfo, 64); + + // --------------------------------------------------------------------------------- + // Commands + // --------------------------------------------------------------------------------- + + enum MGPDrawFlagBit : Uint8 { + kDrawHasUserIndices = 1u << 0, + kDrawPrimitiveRestart = 1u << 1, + kDrawIndicesAreClient = 1u << 2, + kDrawHasIndexRange = 1u << 3, + kDrawHasXfbCount = 1u << 4, + }; + + // = pipe_draw_info. Today's twenty draw entry points collapse onto this one call, with + // MGPDrawRange[] holding exactly the shape the glMultiDraw* family already has. + // + // minIndex/maxIndex are computed only on the client-memory array path today, and + // xfbCpuCapturedVertices only on the XFB scatter path, so Flags gates the WORK. They + // stay in the fixed head; moving them into the variable tail is a wire-format decision + // that belongs with the transport (P5), where per-draw byte histograms exist to size + // it. userIndices is in the variable tail already, so the VBO path - every Minecraft + // and Sodium draw - never pays the 32 bytes of an MGHostSpan. + struct MGPDrawInfo { + Uint32 Mode; + Uint8 IndexSize; // 0 = arrays, else 1 / 2 / 4 + Uint8 Flags; // MGPDrawFlagBit + Uint16 Pad0; + Uint32 InstanceCount, StartInstance; + Uint32 RestartIndex; + Uint32 DrawIdOffset; + MGPipeHandle IndexResource; + Uint32 MinIndex, MaxIndex; // ~0 = unknown + Uint64 XfbCpuCapturedVertices; + Uint32 NumDraws; // MGPDrawRange[] in the variable tail + Uint32 Pad1; + }; + MGP_ASSERT_POD(MGPDrawInfo, 56); + + // = pipe_draw_start_count_bias. + struct MGPDrawRange { + Uint32 Start, Count; + Int32 IndexBias; + }; + MGP_ASSERT_POD(MGPDrawRange, 12); + + // Present when the draw is indirect. The client resolves the COUNT itself, so the + // server never reads an indirect command block to learn how many draws there are. + struct MGPDrawIndirect { + MGPipeHandle Buffer; + MGPipeHandle ParameterBuffer; + Uint64 Offset, ParameterOffset; + Uint32 Stride, DrawCount; + }; + MGP_ASSERT_POD(MGPDrawIndirect, 40); + + struct MGPGridInfo { + Uint32 GridX, GridY, GridZ; + Uint32 BlockX, BlockY, BlockZ; + MGPipeHandle IndirectBuffer; + Uint64 IndirectOffset; + Uint8 IsIndirect; + Uint8 Pad0[7]; + }; + MGP_ASSERT_POD(MGPGridInfo, 48); + + struct MGPMemoryBarrier { + Uint32 Bits; // GLbitfield + Uint8 ByRegion; + Uint8 Pad0[3]; + }; + MGP_ASSERT_POD(MGPMemoryBarrier, 8); + + struct MGPStreamOutputBegin { + Uint32 PrimitiveMode; + Uint32 Pad0; + }; + MGP_ASSERT_POD(MGPStreamOutputBegin, 8); + + // end_stream_output carries the accounting the client owns; the scatter itself is a + // read-modify-write of the client's shadow and lives there (section 7.2.1). + struct MGPXfbAccounting { + Uint64 CapturedVertices; + Uint64 PrimitivesWritten; + Uint32 PrimitiveMode; + Uint32 Pad0; + }; + MGP_ASSERT_POD(MGPXfbAccounting, 24); + + struct MGPStreamOutputControl { + Uint32 Reserved; + Uint32 Pad0; + }; + MGP_ASSERT_POD(MGPStreamOutputControl, 8); + + struct MGPFlush { + Uint32 Flags; + Uint32 Pad0; + }; + MGP_ASSERT_POD(MGPFlush, 8); + + struct MGPPresent { + Uint64 FrameSerial; + }; + MGP_ASSERT_POD(MGPPresent, 8); + + struct MGPSwapInterval { + Int32 Interval; + Uint32 Pad0; + }; + MGP_ASSERT_POD(MGPSwapInterval, 8); + + // --------------------------------------------------------------------------------- + // Reverse channel payloads (section 7.1) + // --------------------------------------------------------------------------------- + + struct MGPSurfaceInfo { + Uint32 Width, Height; + Uint32 InternalFormat; + Uint16 Samples, Layers; + Uint8 IsDefault; + Uint8 Pad0[7]; + }; + MGP_ASSERT_POD(MGPSurfaceInfo, 24); + +#undef MGP_ASSERT_POD +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Pipe/PipeCalls.def b/MobileGL/MG_Pipe/PipeCalls.def new file mode 100644 index 000000000..799fdea65 --- /dev/null +++ b/MobileGL/MG_Pipe/PipeCalls.def @@ -0,0 +1,148 @@ +// MobileGL - MobileGL/MG_Pipe/PipeCalls.def +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The single source of truth for the MGPipe call catalogue (plan B section 4.1 / 4.4 / +// appendix A). One line per call; seven generators consume this file +// (scripts/gen_pipe.py -> MG_Pipe/generated/*.inc) and one unit test +// (MG_Test/Pipe/PipeCatalogueTest.cpp) pins the arithmetic. +// +// X(Name, PayloadStruct, Class, Flags) +// Class : kScreen | kCtxCso | kCtxState | kCtxObject | kCtxVerb | kCtxQuery +// kScreen lands in struct MGPipeScreen, every other class in struct +// MGPipeContext (plan section 4.3). +// Flags : kNone | kNeedsAck | kHasBlob | kVarTail | kHostSpan | kReplySlot | kOptional +// +// RECORD NUMBERING NEVER CHURNS. Entries that are not implemented yet still occupy their +// line (plan section 11, P0: "the complete call catalogue, placeholders included"). A new +// call is APPENDED to its group; a retired call keeps its slot with a comment. The wire +// opcode is the 1-based position in this list, so reordering is a protocol break. +// +// --------------------------------------------------------------------------------------- +// COUNTS. MGP_CALL_LIST_DOCUMENTED_COUNT below is the authority; PipeCatalogueTest asserts +// that the expansion, the two generated tables and this number agree. +// +// class entries group (as the plan tabulates it) +// kScreen 10 screen: caps 1 + resource 3 + persistent map 2 + fence 4 +// kCtxQuery 6 query object namespace +// kCtxCso 13 CSO create/bind/delete +// kCtxState 17 16 of the 17 set_* calls + the temporary set_residual_value_state +// kCtxObject 9 set_texture_params (the 17th set_*) + 8 object-scoped transfers +// kCtxVerb 13 3 context-reading transfer calls + the 10 commands +// total 68 +// +// Reconciliation with the plan's headline numbers (section 4.4 / appendix A), because they +// do not add up to a set of UNIQUE records and this file has to hold unique records: +// - "screen 14" tabulates the fence and query families together with the screen block. +// Section 4.3 assigns the query NAMESPACE to the context ("VAO / FBO / XFB object / +// query namespaces, the command stream, present"), so the six query calls carry +// kCtxQuery and live in MGPipeContext. Screen keeps 10. The eight EGL lifecycle entry +// points stay virtual functions on pActiveBackendObject and are deliberately NOT calls +// here (section 4.4.1, last row). +// - "CSO 15" is create/bind/delete x 5 kinds. Two of those binds are ALSO named in the +// set_* catalogue as their array forms - bind_sampler_states and set_sampler_views +// (section 4.4.3) - and a call may only exist once, so they are emitted under +// kCtxState and the CSO group holds 13: create/delete x 5 plus the three remaining +// binds (render state, vertex elements, shader). +// - "transfer 12" enumerates 11 calls in section 4.4.4 plus appendix A +// (resource_subdata, buffer_subdata_resident, resource_flush_range, resource_readback, +// resource_copy_region, blit, clear, generate_mipmap, read_pixels, get_texture_image, +// resource_subdata_complete). Eleven is what is emitted; the twelfth is not named +// anywhere in the plan. +// - "about 74 items" in section 4.1 is the sum of those headline numbers, so it inherits +// the same double counting. 68 unique records is the honest total. +// --------------------------------------------------------------------------------------- + +#define MGP_CALL_LIST_DOCUMENTED_COUNT 68 + +// clang-format off +#define MGP_CALL_LIST(X) \ + /* ---- screen: caps, resources, persistent map, fences (plan 4.4.1) ---- */ \ + 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) \ + X(UnmapPersistent, MGPHandleOnly, kScreen, kOptional) \ + X(FenceCreate, MGPHandleOnly, kScreen, kNone) \ + X(FenceStatus, MGPHandleOnly, kScreen, kReplySlot) \ + X(FenceWait, MGPFenceWait, kScreen, kReplySlot) \ + X(FenceDestroy, MGPHandleOnly, kScreen, kNone) \ + /* ---- context: query objects (plan 4.3 gives the namespace to the context) ---- */ \ + X(QueryCreate, MGPQueryDesc, kCtxQuery, kNone) \ + X(QueryBegin, MGPQueryDesc, kCtxQuery, kNone) \ + X(QueryEnd, MGPQueryDesc, kCtxQuery, kNone) \ + X(QueryAvailable, MGPHandleOnly, kCtxQuery, kReplySlot) \ + X(QueryResult, MGPQueryResultRequest, kCtxQuery, kReplySlot) \ + X(QueryDestroy, MGPHandleOnly, kCtxQuery, kNone) \ + /* ---- context: CSO create/bind/delete (plan 4.4.2, 4.5.2-4.5.5) ---- */ \ + X(CreateRenderState, MGPRenderStateDesc, kCtxCso, kHasBlob) \ + X(BindRenderState, MGPBindRenderState, kCtxCso, kNone) \ + X(DeleteRenderState, MGPHandleOnly, kCtxCso, kNone) \ + X(CreateVertexElements, MGPVertexElements, kCtxCso, kHasBlob) \ + X(BindVertexElements, MGPHandleOnly, kCtxCso, kNone) \ + X(DeleteVertexElements, MGPHandleOnly, kCtxCso, kNone) \ + X(CreateSamplerState, MGPSamplerDesc, kCtxCso, kNone) \ + X(DeleteSamplerState, MGPHandleOnly, kCtxCso, kNone) \ + X(CreateSamplerView, MGPSamplerView, kCtxCso, kNone) \ + X(DeleteSamplerView, MGPHandleOnly, kCtxCso, kNone) \ + X(CreateShaderState, MGPProgramDesc, kCtxCso, kHasBlob) \ + X(BindShaderState, MGPHandleOnly, kCtxCso, kNone) \ + X(DeleteShaderState, MGPHandleOnly, kCtxCso, kNone) \ + /* ---- context: set_* (plan 4.4.3) ---- */ \ + X(SetDynamicState, MGPDynamicState, kCtxState, kHasBlob) \ + X(SetFramebufferState, MGPFramebufferState, kCtxState, kNone) \ + X(SetVertexBuffers, MGPVertexBuffers, kCtxState, kVarTail) \ + X(SetIndexBuffer, MGPIndexBuffer, kCtxState, kNone) \ + X(SetIndirectBuffers, MGPIndirectBuffers, kCtxState, kNone) \ + X(SetSamplerViews, MGPSamplerViews, kCtxState, kVarTail) \ + X(BindSamplerStates, MGPSamplerStates, kCtxState, kVarTail) \ + X(SetShaderImages, MGPShaderImages, kCtxState, kVarTail) \ + X(SetShaderBuffers, MGPShaderBuffers, kCtxState, kVarTail|kHostSpan) \ + X(SetStreamOutputTargets, MGPStreamOutputTargets, kCtxState, kVarTail) \ + X(SetGlobalConstants, MGPGlobalConstants, kCtxState, kHasBlob) \ + X(SetVertexAttribDefaults, MGPVertexAttribDefaults, kCtxState, kVarTail) \ + X(SetPixelPackState, MGPPixelPackState, kCtxState, kNone) \ + X(SetPatchState, MGPPatchState, kCtxState, kNone) \ + X(SetDrawProgram, MGPHandleOnly, kCtxState, kNone) \ + X(SetDispatchProgram, MGPHandleOnly, kCtxState, kNone) \ + /* Migration-only carrier for Track V, retired field by field across P2..P13. Its */ \ + /* retirement is a compile error: MGL_RESIDUAL_BLOCK_SIZE only ever goes DOWN and the */ \ + /* final step asserts sizeof(ResidualValueBlock) == 0 (plan 6.3). */ \ + X(SetResidualValueState, MGPResidualValueState, kCtxState, kHasBlob) \ + /* ---- context: per-object state and transfer (plan 4.4.3 set_texture_params, 4.4.4) ---- */ \ + X(SetTextureParams, MGPTextureParams, kCtxObject, kNone) \ + X(ResourceSubData, MGPSubData, kCtxObject, kHasBlob|kVarTail) \ + X(BufferSubDataResident, MGPSubData, kCtxObject, kHasBlob|kOptional) \ + X(ResourceSubDataComplete, MGPSubDataComplete, kCtxObject, kNone) \ + X(ResourceFlushRange, MGPFlushRange, kCtxObject, kNone) \ + X(ResourceReadback, MGPReadback, kCtxObject, kReplySlot) \ + X(ResourceCopyRegion, MGPCopyRegion, kCtxObject, kNone) \ + X(GenerateMipmap, MGPMipPlan, kCtxObject, kNone) \ + X(GetTextureImage, MGPReadbackInfo, kCtxObject, kReplySlot) \ + /* ---- context: transfer calls that read whole-context state, and the commands ---- */ \ + X(Blit, MGPBlit, kCtxVerb, kNone) \ + X(Clear, MGPClear, kCtxVerb, kNone) \ + X(ReadPixels, MGPReadbackInfo, kCtxVerb, kReplySlot) \ + X(DrawVbo, MGPDrawInfo, kCtxVerb, kHostSpan|kVarTail) \ + X(LaunchGrid, MGPGridInfo, kCtxVerb, kNone) \ + X(MemoryBarrier, MGPMemoryBarrier, kCtxVerb, kNone) \ + X(BeginStreamOutput, MGPStreamOutputBegin, kCtxVerb, kNone) \ + X(EndStreamOutput, MGPXfbAccounting, kCtxVerb, kNone) \ + X(PauseStreamOutput, MGPStreamOutputControl, kCtxVerb, kNone) \ + X(ResumeStreamOutput, MGPStreamOutputControl, kCtxVerb, kNone) \ + X(Flush, MGPFlush, kCtxVerb, kNone) \ + X(Present, MGPPresent, kCtxVerb, kNone) \ + X(SetSwapInterval, MGPSwapInterval, kCtxVerb, kOptional) +// clang-format on + +// Explicitly NOT migrated (plan 4.4.6 / appendix A "explicit deletions"): GetIntegeri_v, +// GetInteger64i_v, GetProgramiv (only GL_COMPUTE_WORK_GROUP_SIZE is a real backend answer +// and it lives in MGPCaps), ShaderStorageBlockBinding (folded into MGPProgramDesc's +// reflection archive), set_pixel_unpack_state (no such state crosses the line - plan 4.6 +// D5), a compressed-format concept, pipe_transfer, and the stage dimension of +// set_sampler_views (MobileGL's texture unit space is merged, not per stage - plan 4.4.3). diff --git a/MobileGL/MG_Pipe/PipeFields.def b/MobileGL/MG_Pipe/PipeFields.def new file mode 100644 index 000000000..0aaddd1d4 --- /dev/null +++ b/MobileGL/MG_Pipe/PipeFields.def @@ -0,0 +1,235 @@ +// MobileGL - MobileGL/MG_Pipe/PipeFields.def +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// Field lists for the G4 shadow comparator (plan B section 10.3-2). One macro per payload +// in MGPipeTypes.h, listing the fields that carry MEANING - padding is deliberately absent, +// because MOBILEGL_PIPE_VERIFY has to have ZERO false positives and a padding byte is +// exactly what makes a memcmp of RenderStateParameters false-DIFFER +// (DirectGLES.cpp documents that behaviour where it does the same comparison itself). +// +// Hand maintained alongside MGPipeTypes.h. Adding a field to a payload without adding it +// here makes the comparator blind to it; that gap closes in P1, when the verify harness +// goes live and the comparator's coverage is itself asserted. +// +// clang-format off + +#define MGP_FIELDS_MGPBlobRef(F) \ + F(Offset) F(Size) F(Seg) + +#define MGP_FIELDS_MGPRange(F) \ + F(Offset) F(Size) + +#define MGP_FIELDS_MGPBox(F) \ + F(X) F(Y) F(Z) F(W) F(H) F(D) + +#define MGP_FIELDS_MGPReplySlot(F) \ + F(Id) + +#define MGP_FIELDS_MGPStateChunk(F) \ + F(Offset) F(Length) + +#define MGP_FIELDS_MGPHandleOnly(F) \ + F(Handle) F(Kind) + +#define MGP_FIELDS_MGPCaps(F) \ + F(Dynamic) F(CallMask) F(FormatCapabilities) F(RendererInfo) + +#define MGP_FIELDS_MGPResourceDesc(F) \ + F(Resource) F(Target) F(StorageKind) F(BindMask) F(InternalFormat) F(Width) F(Height) F(Depth) \ + F(ArrayLayers) F(Levels) F(Samples) F(FixedSampleLocations) F(Immutable) F(Usage) F(StorageFlags) \ + F(HasDefinedContent) F(ImageBindableHint) F(GlNameForDiag) F(ViewOf) F(BufferForTexBuffer) \ + F(BufOffset) F(BufSize) + +#define MGP_FIELDS_MGPFenceWait(F) \ + F(Fence) F(TimeoutNs) + +#define MGP_FIELDS_MGPQueryDesc(F) \ + F(Query) F(Kind) F(Stream) + +#define MGP_FIELDS_MGPQueryResultRequest(F) \ + F(Query) F(Wait) + +#define MGP_FIELDS_MGPRenderStateDesc(F) \ + F(Cso) F(BaseCso) F(ChunkMask) F(Blob) + +#define MGP_FIELDS_MGPBindRenderState(F) \ + F(Cso) F(Version) F(PipelineVersion) + +#define MGP_FIELDS_MGPDynamicState(F) \ + F(ChunkMask) F(Version) F(Blob) + +#define MGP_FIELDS_MGPVertexElements(F) \ + F(Cso) F(AttributeCount) F(BindingPointCount) F(Blob) + +#define MGP_FIELDS_MGPSamplerDesc(F) \ + F(Cso) F(Parameters) + +#define MGP_FIELDS_MGPSamplerView(F) \ + F(Cso) F(Texture) F(InternalFormat) F(Target) F(MinLevel) F(NumLevels) F(MinLayer) F(NumLayers) \ + F(Samples) F(FixedSampleLocations) + +#define MGP_FIELDS_MGPTextureParams(F) \ + F(Res) F(BaseLevel) F(MaxLevel) F(Swizzle) F(DepthStencilMode) F(ForceResync) F(MinLod) F(MaxLod) \ + F(LodBias) + +#define MGP_FIELDS_MGPProgramDesc(F) \ + F(Cso) F(StageMask) F(GlobalUboSize) F(ReservedNumSamplesOffset) F(SpirvStatus) F(NativeFloat64) \ + F(PointSizeDemoted) F(EnableSpirvValidation) F(Spirv) F(Reflection) + +#define MGP_FIELDS_MGPSurface(F) \ + F(Res) F(InternalFormat) F(Kind) F(Layered) F(Level) F(Layer) F(UploadTarget) + +#define MGP_FIELDS_MGPFramebufferState(F) \ + F(Fbo) F(Color) F(Depth) F(Stencil) F(ReadSurface) F(DrawBuffers) F(Width) F(Height) F(Layers) \ + F(Samples) F(FixedSampleLocations) F(IsDefault) F(Complete) F(ContentHash) + +#define MGP_FIELDS_MGPVertexBuffer(F) \ + F(Res) F(Offset) F(Stride) F(Divisor) F(BindingIndex) + +#define MGP_FIELDS_MGPVertexBuffers(F) \ + F(Start) F(Count) F(ContentHash) + +#define MGP_FIELDS_MGPIndexBuffer(F) \ + F(Res) F(Offset) F(IndexSize) + +#define MGP_FIELDS_MGPIndirectBuffers(F) \ + F(DrawIndirect) F(Parameter) + +#define MGP_FIELDS_MGPBoundView(F) \ + F(View) F(Texture) F(Unit) + +#define MGP_FIELDS_MGPSamplerViews(F) \ + F(Start) F(Count) F(ContentHash) + +#define MGP_FIELDS_MGPSamplerStates(F) \ + F(Start) F(Count) F(ContentHash) + +#define MGP_FIELDS_MGPImageView(F) \ + F(Res) F(Unit) F(InternalFormat) F(Layer) F(Level) F(Layered) F(Access) + +#define MGP_FIELDS_MGPShaderImages(F) \ + F(Start) F(Count) F(ContentHash) + +#define MGP_FIELDS_MGPBufferRange(F) \ + F(Res) F(Offset) F(Size) F(Payload) + +#define MGP_FIELDS_MGPShaderBuffers(F) \ + F(Class) F(Start) F(Count) F(WritableMask) F(ContentHash) + +#define MGP_FIELDS_MGPStreamOutputTargets(F) \ + F(Count) F(Generation) F(ContentHash) + +#define MGP_FIELDS_MGPGlobalConstants(F) \ + F(ShaderCso) F(Version) F(Blob) + +#define MGP_FIELDS_MGPAttribValue(F) \ + F(Location) F(ValueClass) F(Data) + +#define MGP_FIELDS_MGPVertexAttribDefaults(F) \ + F(Mask) F(Count) + +#define MGP_FIELDS_MGPPixelPackState(F) \ + F(Pack) + +#define MGP_FIELDS_MGPPatchState(F) \ + F(Vertices) F(Outer) F(Inner) + +#define MGP_FIELDS_ResidualValueBlock(F) \ + F(RenderState) F(Pack) F(CapabilityBits) F(PatchVertices) F(PatchOuter) F(PatchInner) + +#define MGP_FIELDS_MGPResidualValueState(F) \ + F(Version) F(Blob) + +#define MGP_FIELDS_MGPSubRegion(F) \ + F(X) F(Y) F(Z) F(W) F(H) F(D) F(SrcOffset) F(SrcRowStride) F(SrcSliceStride) + +#define MGP_FIELDS_MGPSubData(F) \ + F(Res) F(Target) F(Level) F(SourceIsVerbatimLevelShadow) F(UnionBox) F(RegionCount) F(Blob) + +#define MGP_FIELDS_MGPSubDataComplete(F) \ + F(Res) F(Target) F(FirstLevel) F(LevelCount) F(PullSerial) + +#define MGP_FIELDS_MGPFlushRange(F) \ + F(Res) F(Offset) F(Size) F(AccessFlags) + +#define MGP_FIELDS_MGPReadback(F) \ + F(Res) F(Offset) F(Size) + +#define MGP_FIELDS_MGPCopyRegion(F) \ + F(Src) F(Dst) F(SrcBox) F(DstX) F(DstY) F(DstZ) F(SrcTarget) F(DstTarget) F(SrcLevel) F(DstLevel) + +#define MGP_FIELDS_MGPBlit(F) \ + F(ReadFbo) F(DrawFbo) F(SrcX0) F(SrcY0) F(SrcX1) F(SrcY1) F(DstX0) F(DstY0) F(DstX1) F(DstY1) \ + F(Mask) F(Filter) + +#define MGP_FIELDS_MGPClear(F) \ + F(Fbo) F(Kind) F(DrawBufferIndex) F(BufferMask) F(ValueClass) F(ColorValue) F(DepthValue) \ + F(StencilValue) + +#define MGP_FIELDS_MGPMipPlan(F) \ + F(Res) F(Target) F(BaseLevel) F(LevelCount) + +#define MGP_FIELDS_MGPReadbackInfo(F) \ + F(Res) F(Box) F(Format) F(Type) F(Target) F(Level) F(DstOffset) F(DstSize) + +#define MGP_FIELDS_MGPDrawInfo(F) \ + F(Mode) F(IndexSize) F(Flags) F(InstanceCount) F(StartInstance) F(RestartIndex) F(DrawIdOffset) \ + F(IndexResource) F(MinIndex) F(MaxIndex) F(XfbCpuCapturedVertices) F(NumDraws) + +#define MGP_FIELDS_MGPDrawRange(F) \ + F(Start) F(Count) F(IndexBias) + +#define MGP_FIELDS_MGPDrawIndirect(F) \ + F(Buffer) F(ParameterBuffer) F(Offset) F(ParameterOffset) F(Stride) F(DrawCount) + +#define MGP_FIELDS_MGPGridInfo(F) \ + F(GridX) F(GridY) F(GridZ) F(BlockX) F(BlockY) F(BlockZ) F(IndirectBuffer) F(IndirectOffset) \ + F(IsIndirect) + +#define MGP_FIELDS_MGPMemoryBarrier(F) \ + F(Bits) F(ByRegion) + +#define MGP_FIELDS_MGPStreamOutputBegin(F) \ + F(PrimitiveMode) + +#define MGP_FIELDS_MGPXfbAccounting(F) \ + F(CapturedVertices) F(PrimitivesWritten) F(PrimitiveMode) + +#define MGP_FIELDS_MGPStreamOutputControl(F) \ + F(Reserved) + +#define MGP_FIELDS_MGPFlush(F) \ + F(Flags) + +#define MGP_FIELDS_MGPPresent(F) \ + F(FrameSerial) + +#define MGP_FIELDS_MGPSwapInterval(F) \ + F(Interval) + +#define MGP_FIELDS_MGPSurfaceInfo(F) \ + F(Width) F(Height) F(InternalFormat) F(Samples) F(Layers) F(IsDefault) + +// Every payload above, in the order the comparator is generated. Keep in sync with the +// macros; gen_pipe.py reads THIS list to know what to emit. +#define MGP_VERIFY_PAYLOAD_LIST(P) \ + P(MGPBlobRef) P(MGPRange) P(MGPBox) P(MGPReplySlot) P(MGPStateChunk) P(MGPHandleOnly) P(MGPCaps) \ + P(MGPResourceDesc) P(MGPFenceWait) P(MGPQueryDesc) P(MGPQueryResultRequest) P(MGPRenderStateDesc) \ + P(MGPBindRenderState) P(MGPDynamicState) P(MGPVertexElements) P(MGPSamplerDesc) P(MGPSamplerView) \ + P(MGPTextureParams) P(MGPProgramDesc) P(MGPSurface) P(MGPFramebufferState) P(MGPVertexBuffer) \ + P(MGPVertexBuffers) P(MGPIndexBuffer) P(MGPIndirectBuffers) P(MGPBoundView) P(MGPSamplerViews) \ + P(MGPSamplerStates) P(MGPImageView) P(MGPShaderImages) P(MGPBufferRange) P(MGPShaderBuffers) \ + P(MGPStreamOutputTargets) P(MGPGlobalConstants) P(MGPAttribValue) P(MGPVertexAttribDefaults) \ + P(MGPPixelPackState) P(MGPPatchState) P(ResidualValueBlock) P(MGPResidualValueState) \ + P(MGPSubRegion) P(MGPSubData) P(MGPSubDataComplete) P(MGPFlushRange) P(MGPReadback) \ + P(MGPCopyRegion) P(MGPBlit) P(MGPClear) P(MGPMipPlan) P(MGPReadbackInfo) P(MGPDrawInfo) \ + P(MGPDrawRange) P(MGPDrawIndirect) P(MGPGridInfo) P(MGPMemoryBarrier) P(MGPStreamOutputBegin) \ + P(MGPXfbAccounting) P(MGPStreamOutputControl) P(MGPFlush) P(MGPPresent) P(MGPSwapInterval) \ + P(MGPSurfaceInfo) + +// clang-format on diff --git a/MobileGL/MG_Pipe/generated/PipeCoverage.inc b/MobileGL/MG_Pipe/generated/PipeCoverage.inc new file mode 100644 index 000000000..fa451ec76 --- /dev/null +++ b/MobileGL/MG_Pipe/generated/PipeCoverage.inc @@ -0,0 +1,108 @@ +// MobileGL - MobileGL/MG_Pipe/generated/PipeCoverage.inc +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// G6: backend read inventory -> MGPipe call coverage. +// +// GENERATED by scripts/gen_pipe.py from Coverage.def and scripts/data/backend_read_inventory.md - DO NOT EDIT. +// Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result. +// This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe. + +// The acceptance rule (plan B section 10.3-5): regenerate, `git diff --exit-code`, and +// ZERO unmapped rows. P0 permits unmapped rows and only counts them; the count below is +// the number the later gate has to drive to zero. +// +// Three pseudo-calls stand for read points that never become a forward record: +// kClientResolved (the frontend answers it), kReverseChannel (it becomes one of the ten +// MGPipeCallbacks) and kStructuralHandle (the row is a signature carrying a +// SharedPtr that becomes an MGPipeHandle parameter). + +struct MGPipeCoverageEntry { + const char* Accessor; + const char* Call; + Uint32 ReadPoints; +}; + +inline constexpr MGPipeCoverageEntry kMGPipeCoverage[] = { + {"Buffer ops delta", "ResourceRespecify", 17}, + {"GetActiveTextureUnit", "SetSamplerViews", 8}, + {"GetBlendColor", "SetDynamicState", 1}, + {"GetBlendEquationIndexed", "CreateRenderState", 1}, + {"GetBlendFuncIndexed", "CreateRenderState", 1}, + {"GetBoundTransformFeedbackName", "SetStreamOutputTargets", 1}, + {"GetBoundVertexArray", "BindVertexElements", 12}, + {"GetBufferBindingPoint", "SetShaderBuffers", 19}, + {"GetBufferBindingPointCount", "SetShaderBuffers", 3}, + {"GetBufferBindingSlot", "SetIndirectBuffers", 29}, + {"GetClampReadColor", "SetDynamicState", 1}, + {"GetClearColor", "SetDynamicState", 1}, + {"GetClearDepth", "SetDynamicState", 1}, + {"GetClearStencil", "SetDynamicState", 1}, + {"GetColorMaskIndexed", "CreateRenderState", 6}, + {"GetCullFaceMode", "CreateRenderState", 1}, + {"GetCurrentVertexAttribute", "SetVertexAttribDefaults", 2}, + {"GetDepthFunc", "CreateRenderState", 1}, + {"GetDepthMask", "CreateRenderState", 5}, + {"GetDepthRangeIndexed", "SetDynamicState", 1}, + {"GetFramebufferBindingSlot", "SetFramebufferState", 19}, + {"GetImageTextureBinding", "SetShaderImages", 14}, + {"GetLineWidth", "SetDynamicState", 1}, + {"GetLogicOp", "CreateRenderState", 1}, + {"GetMaxTouchedTextureUnit", "SetSamplerViews", 1}, + {"GetMinSampleShadingValue", "CreateRenderState", 1}, + {"GetPatchDefaultInnerLevel", "SetPatchState", 3}, + {"GetPatchDefaultOuterLevel", "SetPatchState", 3}, + {"GetPatchVertices", "SetPatchState", 3}, + {"GetPipelineStateVersion", "BindRenderState", 3}, + {"GetPixelStoreParameters", "SetPixelPackState", 6}, + {"GetPolygonModeFront", "CreateRenderState", 1}, + {"GetPolygonOffsetFactor", "SetDynamicState", 1}, + {"GetPolygonOffsetUnits", "SetDynamicState", 1}, + {"GetPrimitiveRestartIndex", "DrawVbo", 3}, + {"GetProgramForDispatch", "SetDispatchProgram", 3}, + {"GetProgramForDraw", "SetDrawProgram", 7}, + {"GetProgramObject", "CreateShaderState", 3}, + {"GetProvokingVertexMode", "CreateRenderState", 1}, + {"GetRenderStateParameters", "CreateRenderState", 11}, + {"GetRenderStateParametersVersion", "BindRenderState", 2}, + {"GetSamplingResolutionGeneration", "SetSamplerViews", 9}, + {"GetScissorBox", "SetDynamicState", 3}, + {"GetStencilState", "CreateRenderState", 8}, + {"GetTextureBindGeneration", "SetSamplerViews", 5}, + {"GetTextureContextId", "SetSamplerViews", 6}, + {"GetTextureObject", "SetSamplerViews", 1}, + {"GetTextureUnitObject", "SetSamplerViews", 19}, + {"GetTouchedBufferBindingPointCount", "SetShaderBuffers", 2}, + {"GetTransformFeedbackCapturedVertices", "DrawVbo", 1}, + {"GetTransformFeedbackGeneration", "SetStreamOutputTargets", 1}, + {"GetTransformFeedbackPausedPrimitiveCounter", "EndStreamOutput", 2}, + {"GetTransformFeedbackProgram", "SetStreamOutputTargets", 3}, + {"GetViewport", "SetDynamicState", 1}, + {"GetViewportIndexed", "SetDynamicState", 1}, + {"InvalidateCompileEnv", "kClientResolved", 2}, + {"IsCapabilityEnabled", "CreateRenderState", 29}, + {"IsCapabilityEnabledIndexed", "CreateRenderState", 1}, + {"IsTransformFeedbackActive", "BeginStreamOutput", 5}, + {"IsTransformFeedbackPaused", "PauseStreamOutput", 2}, + {"RecordError", "kReverseChannel", 6}, + {"ValidateProgramName", "kClientResolved", 3}, + {"handle-ify (wire handle)", "kStructuralHandle", 167}, +}; + +inline constexpr SizeT kMGPipeCoverageEntryCount = 63; +inline constexpr Uint32 kMGPipeInventoryReadPoints = 477; +inline constexpr Uint32 kMGPipeInventoryMappedToCall = 299; +inline constexpr Uint32 kMGPipeInventoryClientResolved = 5; +inline constexpr Uint32 kMGPipeInventoryReverseChannel = 6; +inline constexpr Uint32 kMGPipeInventoryStructuralHandle = 167; +inline constexpr Uint32 kMGPipeInventoryUnmapped = 0; +static_assert(kMGPipeCoverageEntryCount == sizeof(kMGPipeCoverage) / sizeof(kMGPipeCoverage[0])); +static_assert(kMGPipeInventoryMappedToCall + kMGPipeInventoryClientResolved + + kMGPipeInventoryReverseChannel + kMGPipeInventoryStructuralHandle + + kMGPipeInventoryUnmapped == + kMGPipeInventoryReadPoints, + "every inventory row must land in exactly one bucket"); diff --git a/MobileGL/MG_Pipe/generated/PipeFilled.inc b/MobileGL/MG_Pipe/generated/PipeFilled.inc new file mode 100644 index 000000000..7dec6e867 --- /dev/null +++ b/MobileGL/MG_Pipe/generated/PipeFilled.inc @@ -0,0 +1,308 @@ +// MobileGL - MobileGL/MG_Pipe/generated/PipeFilled.inc +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// G5: PipeInputs field ids and the per-verb poison generations. +// +// GENERATED by scripts/gen_pipe.py from Coverage.def and PipeCalls.def - DO NOT EDIT. +// Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result. +// This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe. + +// One field id per GLContext accessor the backends actually read (plan B section 6.2: +// PipeInputs is organized by MEMO KEY, not by read point, which is why the field set is +// small and stable across the whole migration). +// +// The poison is a per-verb GENERATION, not a bit. A bitmap cannot see the dangerous case: +// a field filled by the previous DRAW and then read by the glTexSubImage that follows is +// stale, and its bit is already set. So every verb bumps CurrentVerbSerial, filling a +// field stamps it with that serial, and reading a non-sticky field whose stamp is older is +// Fatal{UnmigratedPipeInput} (section 6.2.2). +// +// P0 is the skeleton: the enum, the tables and the assertion helper exist, PipeInputs +// itself lands in P1. + +enum class MGPipeInputField : Uint16 { + GetActiveTextureUnit, + GetBlendColor, + GetBlendEquationIndexed, + GetBlendFuncIndexed, + GetBoundTransformFeedbackName, + GetBoundVertexArray, + GetBufferBindingSlot, + GetBufferBindingPoint, + GetBufferBindingPointCount, + GetTouchedBufferBindingPointCount, + GetClampReadColor, + GetClearColor, + GetClearDepth, + GetClearStencil, + GetColorMaskIndexed, + GetCullFaceMode, + GetCurrentVertexAttribute, + GetDepthFunc, + GetDepthMask, + GetDepthRangeIndexed, + GetFramebufferBindingSlot, + GetImageTextureBinding, + GetLineWidth, + GetLogicOp, + GetMaxTouchedTextureUnit, + GetMinSampleShadingValue, + GetPatchDefaultInnerLevel, + GetPatchDefaultOuterLevel, + GetPatchVertices, + GetPipelineStateVersion, + GetPixelStoreParameters, + GetPolygonModeFront, + GetPolygonOffsetFactor, + GetPolygonOffsetUnits, + GetPrimitiveRestartIndex, + GetProgramForDispatch, + GetProgramForDraw, + GetProgramObject, + GetProvokingVertexMode, + GetRenderStateParameters, + GetRenderStateParametersVersion, + GetSamplingResolutionGeneration, + GetScissorBox, + GetStencilState, + GetTextureBindGeneration, + GetTextureContextId, + GetTextureObject, + GetTextureUnitObject, + GetTransformFeedbackCapturedVertices, + GetTransformFeedbackGeneration, + GetTransformFeedbackPausedPrimitiveCounter, + GetTransformFeedbackProgram, + GetViewport, + GetViewportIndexed, + IsCapabilityEnabled, + IsCapabilityEnabledIndexed, + IsTransformFeedbackActive, + IsTransformFeedbackPaused, + InvalidateCompileEnv, + ValidateProgramName, + RecordError, + kFieldCount, +}; + +inline constexpr SizeT kMGPipeInputFieldCount = static_cast(MGPipeInputField::kFieldCount); +static_assert(kMGPipeInputFieldCount == 61, "the PipeInputs field set moved"); + +inline constexpr const char* kMGPipeInputFieldNames[kMGPipeInputFieldCount] = { + "GetActiveTextureUnit", + "GetBlendColor", + "GetBlendEquationIndexed", + "GetBlendFuncIndexed", + "GetBoundTransformFeedbackName", + "GetBoundVertexArray", + "GetBufferBindingSlot", + "GetBufferBindingPoint", + "GetBufferBindingPointCount", + "GetTouchedBufferBindingPointCount", + "GetClampReadColor", + "GetClearColor", + "GetClearDepth", + "GetClearStencil", + "GetColorMaskIndexed", + "GetCullFaceMode", + "GetCurrentVertexAttribute", + "GetDepthFunc", + "GetDepthMask", + "GetDepthRangeIndexed", + "GetFramebufferBindingSlot", + "GetImageTextureBinding", + "GetLineWidth", + "GetLogicOp", + "GetMaxTouchedTextureUnit", + "GetMinSampleShadingValue", + "GetPatchDefaultInnerLevel", + "GetPatchDefaultOuterLevel", + "GetPatchVertices", + "GetPipelineStateVersion", + "GetPixelStoreParameters", + "GetPolygonModeFront", + "GetPolygonOffsetFactor", + "GetPolygonOffsetUnits", + "GetPrimitiveRestartIndex", + "GetProgramForDispatch", + "GetProgramForDraw", + "GetProgramObject", + "GetProvokingVertexMode", + "GetRenderStateParameters", + "GetRenderStateParametersVersion", + "GetSamplingResolutionGeneration", + "GetScissorBox", + "GetStencilState", + "GetTextureBindGeneration", + "GetTextureContextId", + "GetTextureObject", + "GetTextureUnitObject", + "GetTransformFeedbackCapturedVertices", + "GetTransformFeedbackGeneration", + "GetTransformFeedbackPausedPrimitiveCounter", + "GetTransformFeedbackProgram", + "GetViewport", + "GetViewportIndexed", + "IsCapabilityEnabled", + "IsCapabilityEnabledIndexed", + "IsTransformFeedbackActive", + "IsTransformFeedbackPaused", + "InvalidateCompileEnv", + "ValidateProgramName", + "RecordError", +}; + +// Fields whose value is valid ACROSS verbs. Every entry is false in P0 and each +// true has to be argued for in P1 when the fillers land: a sticky field is a field +// the poison cannot protect. +inline constexpr Bool kMGPipeInputFieldSticky[kMGPipeInputFieldCount] = { + false, // GetActiveTextureUnit + false, // GetBlendColor + false, // GetBlendEquationIndexed + false, // GetBlendFuncIndexed + false, // GetBoundTransformFeedbackName + false, // GetBoundVertexArray + false, // GetBufferBindingSlot + false, // GetBufferBindingPoint + false, // GetBufferBindingPointCount + false, // GetTouchedBufferBindingPointCount + false, // GetClampReadColor + false, // GetClearColor + false, // GetClearDepth + false, // GetClearStencil + false, // GetColorMaskIndexed + false, // GetCullFaceMode + false, // GetCurrentVertexAttribute + false, // GetDepthFunc + false, // GetDepthMask + false, // GetDepthRangeIndexed + false, // GetFramebufferBindingSlot + false, // GetImageTextureBinding + false, // GetLineWidth + false, // GetLogicOp + false, // GetMaxTouchedTextureUnit + false, // GetMinSampleShadingValue + false, // GetPatchDefaultInnerLevel + false, // GetPatchDefaultOuterLevel + false, // GetPatchVertices + false, // GetPipelineStateVersion + false, // GetPixelStoreParameters + false, // GetPolygonModeFront + false, // GetPolygonOffsetFactor + false, // GetPolygonOffsetUnits + false, // GetPrimitiveRestartIndex + false, // GetProgramForDispatch + false, // GetProgramForDraw + false, // GetProgramObject + false, // GetProvokingVertexMode + false, // GetRenderStateParameters + false, // GetRenderStateParametersVersion + false, // GetSamplingResolutionGeneration + false, // GetScissorBox + false, // GetStencilState + false, // GetTextureBindGeneration + false, // GetTextureContextId + false, // GetTextureObject + false, // GetTextureUnitObject + false, // GetTransformFeedbackCapturedVertices + false, // GetTransformFeedbackGeneration + false, // GetTransformFeedbackPausedPrimitiveCounter + false, // GetTransformFeedbackProgram + false, // GetViewport + false, // GetViewportIndexed + false, // IsCapabilityEnabled + false, // IsCapabilityEnabledIndexed + false, // IsTransformFeedbackActive + false, // IsTransformFeedbackPaused + false, // InvalidateCompileEnv + false, // ValidateProgramName + false, // RecordError +}; + +// Which call is expected to have filled a field by the time a verb reads it. Names +// come from Coverage.def, so this table and the coverage table cannot disagree. +inline constexpr const char* kMGPipeInputFieldFilledBy[kMGPipeInputFieldCount] = { + "SetSamplerViews", + "SetDynamicState", + "CreateRenderState", + "CreateRenderState", + "SetStreamOutputTargets", + "BindVertexElements", + "SetIndirectBuffers", + "SetShaderBuffers", + "SetShaderBuffers", + "SetShaderBuffers", + "SetDynamicState", + "SetDynamicState", + "SetDynamicState", + "SetDynamicState", + "CreateRenderState", + "CreateRenderState", + "SetVertexAttribDefaults", + "CreateRenderState", + "CreateRenderState", + "SetDynamicState", + "SetFramebufferState", + "SetShaderImages", + "SetDynamicState", + "CreateRenderState", + "SetSamplerViews", + "CreateRenderState", + "SetPatchState", + "SetPatchState", + "SetPatchState", + "BindRenderState", + "SetPixelPackState", + "CreateRenderState", + "SetDynamicState", + "SetDynamicState", + "DrawVbo", + "SetDispatchProgram", + "SetDrawProgram", + "CreateShaderState", + "CreateRenderState", + "CreateRenderState", + "BindRenderState", + "SetSamplerViews", + "SetDynamicState", + "CreateRenderState", + "SetSamplerViews", + "SetSamplerViews", + "SetSamplerViews", + "SetSamplerViews", + "DrawVbo", + "SetStreamOutputTargets", + "EndStreamOutput", + "SetStreamOutputTargets", + "SetDynamicState", + "SetDynamicState", + "CreateRenderState", + "CreateRenderState", + "BeginStreamOutput", + "PauseStreamOutput", + "kClientResolved", // pseudo-call: not filled by a forward record + "kClientResolved", // pseudo-call: not filled by a forward record + "kReverseChannel", // pseudo-call: not filled by a forward record +}; + +struct MGPipeFilledState { + Uint64 CurrentVerbSerial; + Uint64 FilledGen[kMGPipeInputFieldCount]; +}; + +[[noreturn]] inline void MGPipeInputPoisonFatal(MGPipeInputField field, const char* verb) { + MGLOG_F("MGPipe: Fatal{UnmigratedPipeInput, \"%s@%s\"}", + kMGPipeInputFieldNames[static_cast(field)], verb); + std::abort(); +} + +inline Bool MGPipeInputFieldIsFresh(const MGPipeFilledState& state, MGPipeInputField field) { + const SizeT index = static_cast(field); + return kMGPipeInputFieldSticky[index] ? state.FilledGen[index] != 0 + : state.FilledGen[index] == state.CurrentVerbSerial; +} diff --git a/MobileGL/MG_Pipe/generated/PipeSpanTable.inc b/MobileGL/MG_Pipe/generated/PipeSpanTable.inc new file mode 100644 index 000000000..eb94a9b69 --- /dev/null +++ b/MobileGL/MG_Pipe/generated/PipeSpanTable.inc @@ -0,0 +1,67 @@ +// MobileGL - MobileGL/MG_Pipe/generated/PipeSpanTable.inc +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// G7: the render-state pipeline subset, by member name. +// +// GENERATED by scripts/gen_pipe.py from the field list in scripts/gen_pipe.py - DO NOT EDIT. +// Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result. +// This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe. + +// D-B1 rejected three CSOs and demanded this table instead, so the table needs its own +// completeness trip wire: MG_Test walks every public RenderState setter and asserts that +// the pipeline-subset hash moves IF AND ONLY IF m_pipelineStateVersion moves. That test +// and MGPipeRenderStateSpans.cpp land with P2; what P0 pins is the MEMBER LIST, taken from +// what VulkanRenderer::ComputePipelineStateHash hashes today, so the later offsets are +// derived from a list that was reviewed rather than invented. +// +// Deliberately absent, and each absence is a question P2 has to answer before the chunk +// table freezes: +// - FramebufferSrgb and DepthClamp have NO STORAGE at all (RenderState.cpp's SetCapability +// falls to "not supported currently" and IsCapabilityEnabled returns false), so six +// backend read points are constant false today. Pipeline state or dead capability? +// - ProvokingVertexModeSetting is Vulkan pipeline state but is not hashed today. +// - FrontFaceModeSetting, ClipOrigin and ClipDepthMode are pipeline state on Vulkan and +// are handled elsewhere in the payload path rather than in the memo word. +// +// The complement of this list is the DYNAMIC subset - the half whose whole purpose is that +// glViewport must not mint a new CSO. + +inline constexpr const char* const kMGPipePipelineStateMembers[] = { + "CullFaceEnabled", + "DepthTestEnabled", + "PolygonOffsetFillEnabled", + "RasterizerDiscardEnabled", + "ColorLogicOpEnabled", + "StencilTestEnabled", + "PrimitiveRestartEnabled", + "PrimitiveRestartFixedIndexEnabled", + "DepthMask", + "SampleShadingEnabled", + "MultisampleEnabled", + "SampleMaskEnabled", + "SampleMaskValue", + "MinSampleShadingValue", + "PatchVertices", + "PatchDefaultOuterLevel", + "PatchDefaultInnerLevel", + "PolygonModeFront", + "CullFaceModeSetting", + "DepthFunc", + "LogicOp", + "StencilStates", + "BlendStates", + "ColorMasks", +}; +inline constexpr SizeT kMGPipePipelineStateMemberCount = 24; +static_assert(kMGPipePipelineStateMemberCount == + sizeof(kMGPipePipelineStateMembers) / sizeof(kMGPipePipelineStateMembers[0])); + +// Filled in by MG_Pipe/MGPipeRenderStateSpans.cpp (P2), which computes the offsets +// in C++ with offsetof rather than guessing them in python. +extern const MGPStateChunk kMGPipePipelineChunks[]; +extern const MGPStateChunk kMGPipeDynamicChunks[]; diff --git a/MobileGL/MG_Pipe/generated/PipeTables.inc b/MobileGL/MG_Pipe/generated/PipeTables.inc new file mode 100644 index 000000000..cb8a602de --- /dev/null +++ b/MobileGL/MG_Pipe/generated/PipeTables.inc @@ -0,0 +1,105 @@ +// MobileGL - MobileGL/MG_Pipe/generated/PipeTables.inc +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// G1: the two MGPipe interface tables. +// +// GENERATED by scripts/gen_pipe.py from PipeCalls.def - DO NOT EDIT. +// Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result. +// This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe. + +// share group: 10 calls. A null entry means the backend does not implement this +// call and the frontend keeps its own path (plan B section 4.1). +struct MGPipeScreen { + void (*GetCaps)(const MGPCaps* payload, MGPReplySlot* reply); + void (*ResourceCreate)(const MGPResourceDesc* payload); + void (*ResourceRespecify)(const MGPResourceDesc* payload); + void (*ResourceDestroy)(const MGPHandleOnly* payload); + void (*MapPersistent)(const MGPHandleOnly* payload, MGPReplySlot* reply); + void (*UnmapPersistent)(const MGPHandleOnly* payload); + void (*FenceCreate)(const MGPHandleOnly* payload); + void (*FenceStatus)(const MGPHandleOnly* payload, MGPReplySlot* reply); + void (*FenceWait)(const MGPFenceWait* payload, MGPReplySlot* reply); + void (*FenceDestroy)(const MGPHandleOnly* payload); +}; + +// context: 58 calls. A null entry means the backend does not implement this +// call and the frontend keeps its own path (plan B section 4.1). +struct MGPipeContext { + void (*QueryCreate)(const MGPQueryDesc* payload); + void (*QueryBegin)(const MGPQueryDesc* payload); + void (*QueryEnd)(const MGPQueryDesc* payload); + void (*QueryAvailable)(const MGPHandleOnly* payload, MGPReplySlot* reply); + void (*QueryResult)(const MGPQueryResultRequest* payload, MGPReplySlot* reply); + void (*QueryDestroy)(const MGPHandleOnly* payload); + void (*CreateRenderState)(const MGPRenderStateDesc* payload); + void (*BindRenderState)(const MGPBindRenderState* payload); + void (*DeleteRenderState)(const MGPHandleOnly* payload); + void (*CreateVertexElements)(const MGPVertexElements* payload); + void (*BindVertexElements)(const MGPHandleOnly* payload); + void (*DeleteVertexElements)(const MGPHandleOnly* payload); + void (*CreateSamplerState)(const MGPSamplerDesc* payload); + void (*DeleteSamplerState)(const MGPHandleOnly* payload); + void (*CreateSamplerView)(const MGPSamplerView* payload); + void (*DeleteSamplerView)(const MGPHandleOnly* payload); + void (*CreateShaderState)(const MGPProgramDesc* payload); + void (*BindShaderState)(const MGPHandleOnly* payload); + void (*DeleteShaderState)(const MGPHandleOnly* payload); + void (*SetDynamicState)(const MGPDynamicState* payload); + void (*SetFramebufferState)(const MGPFramebufferState* payload); + void (*SetVertexBuffers)(const MGPVertexBuffers* payload, const void* varTail, Uint32 varTailCount); + void (*SetIndexBuffer)(const MGPIndexBuffer* payload); + void (*SetIndirectBuffers)(const MGPIndirectBuffers* payload); + void (*SetSamplerViews)(const MGPSamplerViews* payload, const void* varTail, Uint32 varTailCount); + void (*BindSamplerStates)(const MGPSamplerStates* payload, const void* varTail, Uint32 varTailCount); + void (*SetShaderImages)(const MGPShaderImages* payload, const void* varTail, Uint32 varTailCount); + void (*SetShaderBuffers)(const MGPShaderBuffers* payload, const void* varTail, Uint32 varTailCount); + void (*SetStreamOutputTargets)(const MGPStreamOutputTargets* payload, const void* varTail, Uint32 varTailCount); + void (*SetGlobalConstants)(const MGPGlobalConstants* payload); + void (*SetVertexAttribDefaults)(const MGPVertexAttribDefaults* payload, const void* varTail, Uint32 varTailCount); + void (*SetPixelPackState)(const MGPPixelPackState* payload); + void (*SetPatchState)(const MGPPatchState* payload); + void (*SetDrawProgram)(const MGPHandleOnly* payload); + void (*SetDispatchProgram)(const MGPHandleOnly* payload); + void (*SetResidualValueState)(const MGPResidualValueState* payload); + void (*SetTextureParams)(const MGPTextureParams* payload); + void (*ResourceSubData)(const MGPSubData* payload, const void* varTail, Uint32 varTailCount); + void (*BufferSubDataResident)(const MGPSubData* payload); + void (*ResourceSubDataComplete)(const MGPSubDataComplete* payload); + void (*ResourceFlushRange)(const MGPFlushRange* payload); + void (*ResourceReadback)(const MGPReadback* payload, MGPReplySlot* reply); + void (*ResourceCopyRegion)(const MGPCopyRegion* payload); + void (*GenerateMipmap)(const MGPMipPlan* payload); + void (*GetTextureImage)(const MGPReadbackInfo* payload, MGPReplySlot* reply); + void (*Blit)(const MGPBlit* payload); + void (*Clear)(const MGPClear* payload); + void (*ReadPixels)(const MGPReadbackInfo* payload, MGPReplySlot* reply); + void (*DrawVbo)(const MGPDrawInfo* payload, const void* varTail, Uint32 varTailCount); + void (*LaunchGrid)(const MGPGridInfo* payload); + void (*MemoryBarrier)(const MGPMemoryBarrier* payload); + void (*BeginStreamOutput)(const MGPStreamOutputBegin* payload); + void (*EndStreamOutput)(const MGPXfbAccounting* payload); + void (*PauseStreamOutput)(const MGPStreamOutputControl* payload); + void (*ResumeStreamOutput)(const MGPStreamOutputControl* payload); + void (*Flush)(const MGPFlush* payload); + void (*Present)(const MGPPresent* payload); + void (*SetSwapInterval)(const MGPSwapInterval* payload); +}; + +inline constexpr SizeT kMGPipeScreenCallCount = 10; +inline constexpr SizeT kMGPipeContextCallCount = 58; +inline constexpr SizeT kMGPipeCallCount = 68; + +// A table that is not exactly its call count of function pointers has grown a +// member that no generator knows about. +static_assert(sizeof(MGPipeScreen) == kMGPipeScreenCallCount * sizeof(void (*)()), + "MGPipeScreen is not exactly its catalogue's function pointers"); +static_assert(sizeof(MGPipeContext) == kMGPipeContextCallCount * sizeof(void (*)()), + "MGPipeContext is not exactly its catalogue's function pointers"); +static_assert(kMGPipeScreenCallCount + kMGPipeContextCallCount == kMGPipeCallCount); +static_assert(kMGPipeCallCount == MGP_CALL_LIST_DOCUMENTED_COUNT, + "the catalogue and its documented count disagree"); diff --git a/MobileGL/MG_Pipe/generated/PipeThunks.inc b/MobileGL/MG_Pipe/generated/PipeThunks.inc new file mode 100644 index 000000000..70e999314 --- /dev/null +++ b/MobileGL/MG_Pipe/generated/PipeThunks.inc @@ -0,0 +1,290 @@ +// MobileGL - MobileGL/MG_Pipe/generated/PipeThunks.inc +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// G2: monolith thunks over the two tables. +// +// GENERATED by scripts/gen_pipe.py from PipeCalls.def - DO NOT EDIT. +// Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result. +// This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe. + +// One inline call through the installed table. These are the names MG_Impl call +// sites move onto, replacing gBackendFunctionsTable.GL.* one at a time. An +// unimplemented (null) entry is the caller's business to check, exactly as it is +// with the table this replaces. + +inline void MGP_GetCaps(const MGPCaps* payload, MGPReplySlot* reply) { + gMGPipeScreen.GetCaps(payload, reply); +} + +inline void MGP_ResourceCreate(const MGPResourceDesc* payload) { + gMGPipeScreen.ResourceCreate(payload); +} + +inline void MGP_ResourceRespecify(const MGPResourceDesc* payload) { + gMGPipeScreen.ResourceRespecify(payload); +} + +inline void MGP_ResourceDestroy(const MGPHandleOnly* payload) { + gMGPipeScreen.ResourceDestroy(payload); +} + +inline void MGP_MapPersistent(const MGPHandleOnly* payload, MGPReplySlot* reply) { + gMGPipeScreen.MapPersistent(payload, reply); +} + +inline void MGP_UnmapPersistent(const MGPHandleOnly* payload) { + gMGPipeScreen.UnmapPersistent(payload); +} + +inline void MGP_FenceCreate(const MGPHandleOnly* payload) { + gMGPipeScreen.FenceCreate(payload); +} + +inline void MGP_FenceStatus(const MGPHandleOnly* payload, MGPReplySlot* reply) { + gMGPipeScreen.FenceStatus(payload, reply); +} + +inline void MGP_FenceWait(const MGPFenceWait* payload, MGPReplySlot* reply) { + gMGPipeScreen.FenceWait(payload, reply); +} + +inline void MGP_FenceDestroy(const MGPHandleOnly* payload) { + gMGPipeScreen.FenceDestroy(payload); +} + +inline void MGP_QueryCreate(const MGPQueryDesc* payload) { + gMGPipeContext.QueryCreate(payload); +} + +inline void MGP_QueryBegin(const MGPQueryDesc* payload) { + gMGPipeContext.QueryBegin(payload); +} + +inline void MGP_QueryEnd(const MGPQueryDesc* payload) { + gMGPipeContext.QueryEnd(payload); +} + +inline void MGP_QueryAvailable(const MGPHandleOnly* payload, MGPReplySlot* reply) { + gMGPipeContext.QueryAvailable(payload, reply); +} + +inline void MGP_QueryResult(const MGPQueryResultRequest* payload, MGPReplySlot* reply) { + gMGPipeContext.QueryResult(payload, reply); +} + +inline void MGP_QueryDestroy(const MGPHandleOnly* payload) { + gMGPipeContext.QueryDestroy(payload); +} + +inline void MGP_CreateRenderState(const MGPRenderStateDesc* payload) { + gMGPipeContext.CreateRenderState(payload); +} + +inline void MGP_BindRenderState(const MGPBindRenderState* payload) { + gMGPipeContext.BindRenderState(payload); +} + +inline void MGP_DeleteRenderState(const MGPHandleOnly* payload) { + gMGPipeContext.DeleteRenderState(payload); +} + +inline void MGP_CreateVertexElements(const MGPVertexElements* payload) { + gMGPipeContext.CreateVertexElements(payload); +} + +inline void MGP_BindVertexElements(const MGPHandleOnly* payload) { + gMGPipeContext.BindVertexElements(payload); +} + +inline void MGP_DeleteVertexElements(const MGPHandleOnly* payload) { + gMGPipeContext.DeleteVertexElements(payload); +} + +inline void MGP_CreateSamplerState(const MGPSamplerDesc* payload) { + gMGPipeContext.CreateSamplerState(payload); +} + +inline void MGP_DeleteSamplerState(const MGPHandleOnly* payload) { + gMGPipeContext.DeleteSamplerState(payload); +} + +inline void MGP_CreateSamplerView(const MGPSamplerView* payload) { + gMGPipeContext.CreateSamplerView(payload); +} + +inline void MGP_DeleteSamplerView(const MGPHandleOnly* payload) { + gMGPipeContext.DeleteSamplerView(payload); +} + +inline void MGP_CreateShaderState(const MGPProgramDesc* payload) { + gMGPipeContext.CreateShaderState(payload); +} + +inline void MGP_BindShaderState(const MGPHandleOnly* payload) { + gMGPipeContext.BindShaderState(payload); +} + +inline void MGP_DeleteShaderState(const MGPHandleOnly* payload) { + gMGPipeContext.DeleteShaderState(payload); +} + +inline void MGP_SetDynamicState(const MGPDynamicState* payload) { + gMGPipeContext.SetDynamicState(payload); +} + +inline void MGP_SetFramebufferState(const MGPFramebufferState* payload) { + gMGPipeContext.SetFramebufferState(payload); +} + +inline void MGP_SetVertexBuffers(const MGPVertexBuffers* payload, const void* varTail, Uint32 varTailCount) { + gMGPipeContext.SetVertexBuffers(payload, varTail, varTailCount); +} + +inline void MGP_SetIndexBuffer(const MGPIndexBuffer* payload) { + gMGPipeContext.SetIndexBuffer(payload); +} + +inline void MGP_SetIndirectBuffers(const MGPIndirectBuffers* payload) { + gMGPipeContext.SetIndirectBuffers(payload); +} + +inline void MGP_SetSamplerViews(const MGPSamplerViews* payload, const void* varTail, Uint32 varTailCount) { + gMGPipeContext.SetSamplerViews(payload, varTail, varTailCount); +} + +inline void MGP_BindSamplerStates(const MGPSamplerStates* payload, const void* varTail, Uint32 varTailCount) { + gMGPipeContext.BindSamplerStates(payload, varTail, varTailCount); +} + +inline void MGP_SetShaderImages(const MGPShaderImages* payload, const void* varTail, Uint32 varTailCount) { + gMGPipeContext.SetShaderImages(payload, varTail, varTailCount); +} + +inline void MGP_SetShaderBuffers(const MGPShaderBuffers* payload, const void* varTail, Uint32 varTailCount) { + gMGPipeContext.SetShaderBuffers(payload, varTail, varTailCount); +} + +inline void MGP_SetStreamOutputTargets(const MGPStreamOutputTargets* payload, const void* varTail, Uint32 varTailCount) { + gMGPipeContext.SetStreamOutputTargets(payload, varTail, varTailCount); +} + +inline void MGP_SetGlobalConstants(const MGPGlobalConstants* payload) { + gMGPipeContext.SetGlobalConstants(payload); +} + +inline void MGP_SetVertexAttribDefaults(const MGPVertexAttribDefaults* payload, const void* varTail, Uint32 varTailCount) { + gMGPipeContext.SetVertexAttribDefaults(payload, varTail, varTailCount); +} + +inline void MGP_SetPixelPackState(const MGPPixelPackState* payload) { + gMGPipeContext.SetPixelPackState(payload); +} + +inline void MGP_SetPatchState(const MGPPatchState* payload) { + gMGPipeContext.SetPatchState(payload); +} + +inline void MGP_SetDrawProgram(const MGPHandleOnly* payload) { + gMGPipeContext.SetDrawProgram(payload); +} + +inline void MGP_SetDispatchProgram(const MGPHandleOnly* payload) { + gMGPipeContext.SetDispatchProgram(payload); +} + +inline void MGP_SetResidualValueState(const MGPResidualValueState* payload) { + gMGPipeContext.SetResidualValueState(payload); +} + +inline void MGP_SetTextureParams(const MGPTextureParams* payload) { + gMGPipeContext.SetTextureParams(payload); +} + +inline void MGP_ResourceSubData(const MGPSubData* payload, const void* varTail, Uint32 varTailCount) { + gMGPipeContext.ResourceSubData(payload, varTail, varTailCount); +} + +inline void MGP_BufferSubDataResident(const MGPSubData* payload) { + gMGPipeContext.BufferSubDataResident(payload); +} + +inline void MGP_ResourceSubDataComplete(const MGPSubDataComplete* payload) { + gMGPipeContext.ResourceSubDataComplete(payload); +} + +inline void MGP_ResourceFlushRange(const MGPFlushRange* payload) { + gMGPipeContext.ResourceFlushRange(payload); +} + +inline void MGP_ResourceReadback(const MGPReadback* payload, MGPReplySlot* reply) { + gMGPipeContext.ResourceReadback(payload, reply); +} + +inline void MGP_ResourceCopyRegion(const MGPCopyRegion* payload) { + gMGPipeContext.ResourceCopyRegion(payload); +} + +inline void MGP_GenerateMipmap(const MGPMipPlan* payload) { + gMGPipeContext.GenerateMipmap(payload); +} + +inline void MGP_GetTextureImage(const MGPReadbackInfo* payload, MGPReplySlot* reply) { + gMGPipeContext.GetTextureImage(payload, reply); +} + +inline void MGP_Blit(const MGPBlit* payload) { + gMGPipeContext.Blit(payload); +} + +inline void MGP_Clear(const MGPClear* payload) { + gMGPipeContext.Clear(payload); +} + +inline void MGP_ReadPixels(const MGPReadbackInfo* payload, MGPReplySlot* reply) { + gMGPipeContext.ReadPixels(payload, reply); +} + +inline void MGP_DrawVbo(const MGPDrawInfo* payload, const void* varTail, Uint32 varTailCount) { + gMGPipeContext.DrawVbo(payload, varTail, varTailCount); +} + +inline void MGP_LaunchGrid(const MGPGridInfo* payload) { + gMGPipeContext.LaunchGrid(payload); +} + +inline void MGP_MemoryBarrier(const MGPMemoryBarrier* payload) { + gMGPipeContext.MemoryBarrier(payload); +} + +inline void MGP_BeginStreamOutput(const MGPStreamOutputBegin* payload) { + gMGPipeContext.BeginStreamOutput(payload); +} + +inline void MGP_EndStreamOutput(const MGPXfbAccounting* payload) { + gMGPipeContext.EndStreamOutput(payload); +} + +inline void MGP_PauseStreamOutput(const MGPStreamOutputControl* payload) { + gMGPipeContext.PauseStreamOutput(payload); +} + +inline void MGP_ResumeStreamOutput(const MGPStreamOutputControl* payload) { + gMGPipeContext.ResumeStreamOutput(payload); +} + +inline void MGP_Flush(const MGPFlush* payload) { + gMGPipeContext.Flush(payload); +} + +inline void MGP_Present(const MGPPresent* payload) { + gMGPipeContext.Present(payload); +} + +inline void MGP_SetSwapInterval(const MGPSwapInterval* payload) { + gMGPipeContext.SetSwapInterval(payload); +} diff --git a/MobileGL/MG_Pipe/generated/PipeVerify.inc b/MobileGL/MG_Pipe/generated/PipeVerify.inc new file mode 100644 index 000000000..4b14a829b --- /dev/null +++ b/MobileGL/MG_Pipe/generated/PipeVerify.inc @@ -0,0 +1,565 @@ +// MobileGL - MobileGL/MG_Pipe/generated/PipeVerify.inc +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// G4: the MOBILEGL_PIPE_VERIFY field-wise comparators. +// +// GENERATED by scripts/gen_pipe.py from PipeFields.def - DO NOT EDIT. +// Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result. +// This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe. + +// Field by field, never memcmp over a whole payload: RenderStateParameters is documented +// in DirectGLES.cpp to false-DIFFER on padding under memcmp (harmlessly there, fatally +// here - a comparator with false positives is a comparator nobody reads). Each function +// reports the FIRST differing field by name, which with the draw serial is what the verify +// harness prints. +// +// Floating-point fields are compared by BITS, so a NaN patch level - which +// glPatchParameterfv accepts and ComputePipelineStateHash already hashes bitwise - equals +// itself instead of tripping every draw. + +#include "../PipeFields.def" + +template +struct MGPipeHasFieldVerifier : std::false_type {}; + +inline Bool MGPipeVerify(const MGPBlobRef& a, const MGPBlobRef& b, const char** outField); +inline Bool MGPipeVerify(const MGPRange& a, const MGPRange& b, const char** outField); +inline Bool MGPipeVerify(const MGPBox& a, const MGPBox& b, const char** outField); +inline Bool MGPipeVerify(const MGPReplySlot& a, const MGPReplySlot& b, const char** outField); +inline Bool MGPipeVerify(const MGPStateChunk& a, const MGPStateChunk& b, const char** outField); +inline Bool MGPipeVerify(const MGPHandleOnly& a, const MGPHandleOnly& b, const char** outField); +inline Bool MGPipeVerify(const MGPCaps& a, const MGPCaps& b, const char** outField); +inline Bool MGPipeVerify(const MGPResourceDesc& a, const MGPResourceDesc& b, const char** outField); +inline Bool MGPipeVerify(const MGPFenceWait& a, const MGPFenceWait& b, const char** outField); +inline Bool MGPipeVerify(const MGPQueryDesc& a, const MGPQueryDesc& b, const char** outField); +inline Bool MGPipeVerify(const MGPQueryResultRequest& a, const MGPQueryResultRequest& b, const char** outField); +inline Bool MGPipeVerify(const MGPRenderStateDesc& a, const MGPRenderStateDesc& b, const char** outField); +inline Bool MGPipeVerify(const MGPBindRenderState& a, const MGPBindRenderState& b, const char** outField); +inline Bool MGPipeVerify(const MGPDynamicState& a, const MGPDynamicState& b, const char** outField); +inline Bool MGPipeVerify(const MGPVertexElements& a, const MGPVertexElements& b, const char** outField); +inline Bool MGPipeVerify(const MGPSamplerDesc& a, const MGPSamplerDesc& b, const char** outField); +inline Bool MGPipeVerify(const MGPSamplerView& a, const MGPSamplerView& b, const char** outField); +inline Bool MGPipeVerify(const MGPTextureParams& a, const MGPTextureParams& b, const char** outField); +inline Bool MGPipeVerify(const MGPProgramDesc& a, const MGPProgramDesc& b, const char** outField); +inline Bool MGPipeVerify(const MGPSurface& a, const MGPSurface& b, const char** outField); +inline Bool MGPipeVerify(const MGPFramebufferState& a, const MGPFramebufferState& b, const char** outField); +inline Bool MGPipeVerify(const MGPVertexBuffer& a, const MGPVertexBuffer& b, const char** outField); +inline Bool MGPipeVerify(const MGPVertexBuffers& a, const MGPVertexBuffers& b, const char** outField); +inline Bool MGPipeVerify(const MGPIndexBuffer& a, const MGPIndexBuffer& b, const char** outField); +inline Bool MGPipeVerify(const MGPIndirectBuffers& a, const MGPIndirectBuffers& b, const char** outField); +inline Bool MGPipeVerify(const MGPBoundView& a, const MGPBoundView& b, const char** outField); +inline Bool MGPipeVerify(const MGPSamplerViews& a, const MGPSamplerViews& b, const char** outField); +inline Bool MGPipeVerify(const MGPSamplerStates& a, const MGPSamplerStates& b, const char** outField); +inline Bool MGPipeVerify(const MGPImageView& a, const MGPImageView& b, const char** outField); +inline Bool MGPipeVerify(const MGPShaderImages& a, const MGPShaderImages& b, const char** outField); +inline Bool MGPipeVerify(const MGPBufferRange& a, const MGPBufferRange& b, const char** outField); +inline Bool MGPipeVerify(const MGPShaderBuffers& a, const MGPShaderBuffers& b, const char** outField); +inline Bool MGPipeVerify(const MGPStreamOutputTargets& a, const MGPStreamOutputTargets& b, const char** outField); +inline Bool MGPipeVerify(const MGPGlobalConstants& a, const MGPGlobalConstants& b, const char** outField); +inline Bool MGPipeVerify(const MGPAttribValue& a, const MGPAttribValue& b, const char** outField); +inline Bool MGPipeVerify(const MGPVertexAttribDefaults& a, const MGPVertexAttribDefaults& b, const char** outField); +inline Bool MGPipeVerify(const MGPPixelPackState& a, const MGPPixelPackState& b, const char** outField); +inline Bool MGPipeVerify(const MGPPatchState& a, const MGPPatchState& b, const char** outField); +inline Bool MGPipeVerify(const ResidualValueBlock& a, const ResidualValueBlock& b, const char** outField); +inline Bool MGPipeVerify(const MGPResidualValueState& a, const MGPResidualValueState& b, const char** outField); +inline Bool MGPipeVerify(const MGPSubRegion& a, const MGPSubRegion& b, const char** outField); +inline Bool MGPipeVerify(const MGPSubData& a, const MGPSubData& b, const char** outField); +inline Bool MGPipeVerify(const MGPSubDataComplete& a, const MGPSubDataComplete& b, const char** outField); +inline Bool MGPipeVerify(const MGPFlushRange& a, const MGPFlushRange& b, const char** outField); +inline Bool MGPipeVerify(const MGPReadback& a, const MGPReadback& b, const char** outField); +inline Bool MGPipeVerify(const MGPCopyRegion& a, const MGPCopyRegion& b, const char** outField); +inline Bool MGPipeVerify(const MGPBlit& a, const MGPBlit& b, const char** outField); +inline Bool MGPipeVerify(const MGPClear& a, const MGPClear& b, const char** outField); +inline Bool MGPipeVerify(const MGPMipPlan& a, const MGPMipPlan& b, const char** outField); +inline Bool MGPipeVerify(const MGPReadbackInfo& a, const MGPReadbackInfo& b, const char** outField); +inline Bool MGPipeVerify(const MGPDrawInfo& a, const MGPDrawInfo& b, const char** outField); +inline Bool MGPipeVerify(const MGPDrawRange& a, const MGPDrawRange& b, const char** outField); +inline Bool MGPipeVerify(const MGPDrawIndirect& a, const MGPDrawIndirect& b, const char** outField); +inline Bool MGPipeVerify(const MGPGridInfo& a, const MGPGridInfo& b, const char** outField); +inline Bool MGPipeVerify(const MGPMemoryBarrier& a, const MGPMemoryBarrier& b, const char** outField); +inline Bool MGPipeVerify(const MGPStreamOutputBegin& a, const MGPStreamOutputBegin& b, const char** outField); +inline Bool MGPipeVerify(const MGPXfbAccounting& a, const MGPXfbAccounting& b, const char** outField); +inline Bool MGPipeVerify(const MGPStreamOutputControl& a, const MGPStreamOutputControl& b, const char** outField); +inline Bool MGPipeVerify(const MGPFlush& a, const MGPFlush& b, const char** outField); +inline Bool MGPipeVerify(const MGPPresent& a, const MGPPresent& b, const char** outField); +inline Bool MGPipeVerify(const MGPSwapInterval& a, const MGPSwapInterval& b, const char** outField); +inline Bool MGPipeVerify(const MGPSurfaceInfo& a, const MGPSurfaceInfo& b, const char** outField); + +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; + +template +inline Bool MGPipeFieldEqual(const T& a, const T& b) { + if constexpr (MGPipeHasFieldVerifier::value) { + const char* unusedField = nullptr; + return MGPipeVerify(a, b, &unusedField); + } else if constexpr (std::is_floating_point_v) { + return std::memcmp(&a, &b, sizeof(T)) == 0; + } else if constexpr (std::is_scalar_v || std::is_enum_v) { + return a == b; + } else if constexpr (requires(const T& x, const T& y) { x == y; }) { + return a == b; + } else { + // MEMCMP FALLBACK. Only reached by the payload members that are still MG_State / + // MG_Backend value structs (RenderStateParameters, PixelStoreParameters, + // DynamicBackendParameters) and by MGHostSpan. Those are exactly the types P0.5 + // moves into MGPipeValueTypes.h, at which point they get field lists of their own + // and this branch stops being reachable from any payload. + return std::memcmp(&a, &b, sizeof(T)) == 0; + } +} + +template +inline Bool MGPipeFieldEqual(const T (&a)[N], const T (&b)[N]) { + for (SizeT i = 0; i < N; ++i) { + if (!MGPipeFieldEqual(a[i], b[i])) return false; + } + return true; +} + +#define MGP_VERIFY_FIELD(FieldName) \ + if (!MGPipeFieldEqual(a.FieldName, b.FieldName)) { \ + if (outField != nullptr) *outField = #FieldName; \ + return false; \ + } + +inline Bool MGPipeVerify(const MGPBlobRef& a, const MGPBlobRef& b, const char** outField) { + MGP_FIELDS_MGPBlobRef(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPRange& a, const MGPRange& b, const char** outField) { + MGP_FIELDS_MGPRange(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPBox& a, const MGPBox& b, const char** outField) { + MGP_FIELDS_MGPBox(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPReplySlot& a, const MGPReplySlot& b, const char** outField) { + MGP_FIELDS_MGPReplySlot(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPStateChunk& a, const MGPStateChunk& b, const char** outField) { + MGP_FIELDS_MGPStateChunk(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPHandleOnly& a, const MGPHandleOnly& b, const char** outField) { + MGP_FIELDS_MGPHandleOnly(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPCaps& a, const MGPCaps& b, const char** outField) { + MGP_FIELDS_MGPCaps(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPResourceDesc& a, const MGPResourceDesc& b, const char** outField) { + MGP_FIELDS_MGPResourceDesc(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPFenceWait& a, const MGPFenceWait& b, const char** outField) { + MGP_FIELDS_MGPFenceWait(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPQueryDesc& a, const MGPQueryDesc& b, const char** outField) { + MGP_FIELDS_MGPQueryDesc(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPQueryResultRequest& a, const MGPQueryResultRequest& b, const char** outField) { + MGP_FIELDS_MGPQueryResultRequest(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPRenderStateDesc& a, const MGPRenderStateDesc& b, const char** outField) { + MGP_FIELDS_MGPRenderStateDesc(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPBindRenderState& a, const MGPBindRenderState& b, const char** outField) { + MGP_FIELDS_MGPBindRenderState(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPDynamicState& a, const MGPDynamicState& b, const char** outField) { + MGP_FIELDS_MGPDynamicState(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPVertexElements& a, const MGPVertexElements& b, const char** outField) { + MGP_FIELDS_MGPVertexElements(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPSamplerDesc& a, const MGPSamplerDesc& b, const char** outField) { + MGP_FIELDS_MGPSamplerDesc(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPSamplerView& a, const MGPSamplerView& b, const char** outField) { + MGP_FIELDS_MGPSamplerView(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPTextureParams& a, const MGPTextureParams& b, const char** outField) { + MGP_FIELDS_MGPTextureParams(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPProgramDesc& a, const MGPProgramDesc& b, const char** outField) { + MGP_FIELDS_MGPProgramDesc(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPSurface& a, const MGPSurface& b, const char** outField) { + MGP_FIELDS_MGPSurface(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPFramebufferState& a, const MGPFramebufferState& b, const char** outField) { + MGP_FIELDS_MGPFramebufferState(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPVertexBuffer& a, const MGPVertexBuffer& b, const char** outField) { + MGP_FIELDS_MGPVertexBuffer(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPVertexBuffers& a, const MGPVertexBuffers& b, const char** outField) { + MGP_FIELDS_MGPVertexBuffers(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPIndexBuffer& a, const MGPIndexBuffer& b, const char** outField) { + MGP_FIELDS_MGPIndexBuffer(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPIndirectBuffers& a, const MGPIndirectBuffers& b, const char** outField) { + MGP_FIELDS_MGPIndirectBuffers(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPBoundView& a, const MGPBoundView& b, const char** outField) { + MGP_FIELDS_MGPBoundView(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPSamplerViews& a, const MGPSamplerViews& b, const char** outField) { + MGP_FIELDS_MGPSamplerViews(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPSamplerStates& a, const MGPSamplerStates& b, const char** outField) { + MGP_FIELDS_MGPSamplerStates(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPImageView& a, const MGPImageView& b, const char** outField) { + MGP_FIELDS_MGPImageView(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPShaderImages& a, const MGPShaderImages& b, const char** outField) { + MGP_FIELDS_MGPShaderImages(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPBufferRange& a, const MGPBufferRange& b, const char** outField) { + MGP_FIELDS_MGPBufferRange(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPShaderBuffers& a, const MGPShaderBuffers& b, const char** outField) { + MGP_FIELDS_MGPShaderBuffers(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPStreamOutputTargets& a, const MGPStreamOutputTargets& b, const char** outField) { + MGP_FIELDS_MGPStreamOutputTargets(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPGlobalConstants& a, const MGPGlobalConstants& b, const char** outField) { + MGP_FIELDS_MGPGlobalConstants(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPAttribValue& a, const MGPAttribValue& b, const char** outField) { + MGP_FIELDS_MGPAttribValue(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPVertexAttribDefaults& a, const MGPVertexAttribDefaults& b, const char** outField) { + MGP_FIELDS_MGPVertexAttribDefaults(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPPixelPackState& a, const MGPPixelPackState& b, const char** outField) { + MGP_FIELDS_MGPPixelPackState(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPPatchState& a, const MGPPatchState& b, const char** outField) { + MGP_FIELDS_MGPPatchState(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const ResidualValueBlock& a, const ResidualValueBlock& b, const char** outField) { + MGP_FIELDS_ResidualValueBlock(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPResidualValueState& a, const MGPResidualValueState& b, const char** outField) { + MGP_FIELDS_MGPResidualValueState(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPSubRegion& a, const MGPSubRegion& b, const char** outField) { + MGP_FIELDS_MGPSubRegion(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPSubData& a, const MGPSubData& b, const char** outField) { + MGP_FIELDS_MGPSubData(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPSubDataComplete& a, const MGPSubDataComplete& b, const char** outField) { + MGP_FIELDS_MGPSubDataComplete(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPFlushRange& a, const MGPFlushRange& b, const char** outField) { + MGP_FIELDS_MGPFlushRange(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPReadback& a, const MGPReadback& b, const char** outField) { + MGP_FIELDS_MGPReadback(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPCopyRegion& a, const MGPCopyRegion& b, const char** outField) { + MGP_FIELDS_MGPCopyRegion(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPBlit& a, const MGPBlit& b, const char** outField) { + MGP_FIELDS_MGPBlit(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPClear& a, const MGPClear& b, const char** outField) { + MGP_FIELDS_MGPClear(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPMipPlan& a, const MGPMipPlan& b, const char** outField) { + MGP_FIELDS_MGPMipPlan(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPReadbackInfo& a, const MGPReadbackInfo& b, const char** outField) { + MGP_FIELDS_MGPReadbackInfo(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPDrawInfo& a, const MGPDrawInfo& b, const char** outField) { + MGP_FIELDS_MGPDrawInfo(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPDrawRange& a, const MGPDrawRange& b, const char** outField) { + MGP_FIELDS_MGPDrawRange(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPDrawIndirect& a, const MGPDrawIndirect& b, const char** outField) { + MGP_FIELDS_MGPDrawIndirect(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPGridInfo& a, const MGPGridInfo& b, const char** outField) { + MGP_FIELDS_MGPGridInfo(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPMemoryBarrier& a, const MGPMemoryBarrier& b, const char** outField) { + MGP_FIELDS_MGPMemoryBarrier(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPStreamOutputBegin& a, const MGPStreamOutputBegin& b, const char** outField) { + MGP_FIELDS_MGPStreamOutputBegin(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPXfbAccounting& a, const MGPXfbAccounting& b, const char** outField) { + MGP_FIELDS_MGPXfbAccounting(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPStreamOutputControl& a, const MGPStreamOutputControl& b, const char** outField) { + MGP_FIELDS_MGPStreamOutputControl(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPFlush& a, const MGPFlush& b, const char** outField) { + MGP_FIELDS_MGPFlush(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPPresent& a, const MGPPresent& b, const char** outField) { + MGP_FIELDS_MGPPresent(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPSwapInterval& a, const MGPSwapInterval& b, const char** outField) { + MGP_FIELDS_MGPSwapInterval(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGPSurfaceInfo& a, const MGPSurfaceInfo& b, const char** outField) { + MGP_FIELDS_MGPSurfaceInfo(MGP_VERIFY_FIELD) + return true; +} + +#undef MGP_VERIFY_FIELD + +inline constexpr SizeT kMGPipeVerifiedPayloadCount = 62; diff --git a/MobileGL/MG_Pipe/generated/PipeWire.inc b/MobileGL/MG_Pipe/generated/PipeWire.inc new file mode 100644 index 000000000..426d3e14e --- /dev/null +++ b/MobileGL/MG_Pipe/generated/PipeWire.inc @@ -0,0 +1,885 @@ +// MobileGL - MobileGL/MG_Pipe/generated/PipeWire.inc +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// G3: wire records, size assertions and the applier's bounds gate. +// +// GENERATED by scripts/gen_pipe.py from PipeCalls.def - DO NOT EDIT. +// Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result. +// This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe. + +// Every record is a fixed header plus its payload, padded to the stream's 8-byte +// granularity. The size assertion is stated as a COMPOSITION so it fires on any padding +// the compiler inserts between the header and the payload while staying honest about the +// tail padding the alignment requires. +// +// The applier's precondition is checked BEFORE dispatch, on every record, in every build: +// a record that is shorter than its own type, longer than what is left in the buffer, or +// not a multiple of 8 is protocol corruption and is fatal. There is no recovery path - +// silently applying a truncated record is how a corrupt stream becomes a wrong picture. + +struct MGPWireRecHeader { + Uint16 Op; // MGPWireOp + Uint16 Flags; // MGPipeCallFlags of the call, for asserts and tracing + Uint32 Size; // bytes of this record including the header and the variable tail +}; +static_assert(sizeof(MGPWireRecHeader) == 8, "the wire header is 8 bytes"); +static_assert(std::is_trivially_copyable_v); + +// The opcode is the call's position in PipeCalls.def. Reordering that file is a protocol +// break; appending to it is not. +enum class MGPWireOp : Uint16 { + kInvalid = 0, + GetCaps = 1, + ResourceCreate = 2, + ResourceRespecify = 3, + ResourceDestroy = 4, + MapPersistent = 5, + UnmapPersistent = 6, + FenceCreate = 7, + FenceStatus = 8, + FenceWait = 9, + FenceDestroy = 10, + QueryCreate = 11, + QueryBegin = 12, + QueryEnd = 13, + QueryAvailable = 14, + QueryResult = 15, + QueryDestroy = 16, + CreateRenderState = 17, + BindRenderState = 18, + DeleteRenderState = 19, + CreateVertexElements = 20, + BindVertexElements = 21, + DeleteVertexElements = 22, + CreateSamplerState = 23, + DeleteSamplerState = 24, + CreateSamplerView = 25, + DeleteSamplerView = 26, + CreateShaderState = 27, + BindShaderState = 28, + DeleteShaderState = 29, + SetDynamicState = 30, + SetFramebufferState = 31, + SetVertexBuffers = 32, + SetIndexBuffer = 33, + SetIndirectBuffers = 34, + SetSamplerViews = 35, + BindSamplerStates = 36, + SetShaderImages = 37, + SetShaderBuffers = 38, + SetStreamOutputTargets = 39, + SetGlobalConstants = 40, + SetVertexAttribDefaults = 41, + SetPixelPackState = 42, + SetPatchState = 43, + SetDrawProgram = 44, + SetDispatchProgram = 45, + SetResidualValueState = 46, + SetTextureParams = 47, + ResourceSubData = 48, + BufferSubDataResident = 49, + ResourceSubDataComplete = 50, + ResourceFlushRange = 51, + ResourceReadback = 52, + ResourceCopyRegion = 53, + GenerateMipmap = 54, + GetTextureImage = 55, + Blit = 56, + Clear = 57, + ReadPixels = 58, + DrawVbo = 59, + LaunchGrid = 60, + MemoryBarrier = 61, + BeginStreamOutput = 62, + EndStreamOutput = 63, + PauseStreamOutput = 64, + ResumeStreamOutput = 65, + Flush = 66, + Present = 67, + SetSwapInterval = 68, + kOpCount = 69, +}; + +struct alignas(8) MGPWireRec_GetCaps { + MGPWireRecHeader Header; + MGPCaps Payload; +}; +static_assert(sizeof(MGPWireRec_GetCaps) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPCaps) + 7u) & ~SizeT(7u)), + "MGPWireRec_GetCaps gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_ResourceCreate { + MGPWireRecHeader Header; + MGPResourceDesc Payload; +}; +static_assert(sizeof(MGPWireRec_ResourceCreate) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPResourceDesc) + 7u) & ~SizeT(7u)), + "MGPWireRec_ResourceCreate gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_ResourceRespecify { + MGPWireRecHeader Header; + MGPResourceDesc Payload; +}; +static_assert(sizeof(MGPWireRec_ResourceRespecify) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPResourceDesc) + 7u) & ~SizeT(7u)), + "MGPWireRec_ResourceRespecify gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_ResourceDestroy { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_ResourceDestroy) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_ResourceDestroy gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_MapPersistent { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_MapPersistent) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_MapPersistent gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_UnmapPersistent { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_UnmapPersistent) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_UnmapPersistent gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_FenceCreate { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_FenceCreate) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_FenceCreate gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_FenceStatus { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_FenceStatus) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_FenceStatus gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_FenceWait { + MGPWireRecHeader Header; + MGPFenceWait Payload; +}; +static_assert(sizeof(MGPWireRec_FenceWait) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPFenceWait) + 7u) & ~SizeT(7u)), + "MGPWireRec_FenceWait gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_FenceDestroy { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_FenceDestroy) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_FenceDestroy gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_QueryCreate { + MGPWireRecHeader Header; + MGPQueryDesc Payload; +}; +static_assert(sizeof(MGPWireRec_QueryCreate) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPQueryDesc) + 7u) & ~SizeT(7u)), + "MGPWireRec_QueryCreate gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_QueryBegin { + MGPWireRecHeader Header; + MGPQueryDesc Payload; +}; +static_assert(sizeof(MGPWireRec_QueryBegin) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPQueryDesc) + 7u) & ~SizeT(7u)), + "MGPWireRec_QueryBegin gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_QueryEnd { + MGPWireRecHeader Header; + MGPQueryDesc Payload; +}; +static_assert(sizeof(MGPWireRec_QueryEnd) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPQueryDesc) + 7u) & ~SizeT(7u)), + "MGPWireRec_QueryEnd gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_QueryAvailable { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_QueryAvailable) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_QueryAvailable gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_QueryResult { + MGPWireRecHeader Header; + MGPQueryResultRequest Payload; +}; +static_assert(sizeof(MGPWireRec_QueryResult) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPQueryResultRequest) + 7u) & ~SizeT(7u)), + "MGPWireRec_QueryResult gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_QueryDestroy { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_QueryDestroy) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_QueryDestroy gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_CreateRenderState { + MGPWireRecHeader Header; + MGPRenderStateDesc Payload; +}; +static_assert(sizeof(MGPWireRec_CreateRenderState) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPRenderStateDesc) + 7u) & ~SizeT(7u)), + "MGPWireRec_CreateRenderState gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_BindRenderState { + MGPWireRecHeader Header; + MGPBindRenderState Payload; +}; +static_assert(sizeof(MGPWireRec_BindRenderState) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPBindRenderState) + 7u) & ~SizeT(7u)), + "MGPWireRec_BindRenderState gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_DeleteRenderState { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_DeleteRenderState) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_DeleteRenderState gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_CreateVertexElements { + MGPWireRecHeader Header; + MGPVertexElements Payload; +}; +static_assert(sizeof(MGPWireRec_CreateVertexElements) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPVertexElements) + 7u) & ~SizeT(7u)), + "MGPWireRec_CreateVertexElements gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_BindVertexElements { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_BindVertexElements) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_BindVertexElements gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_DeleteVertexElements { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_DeleteVertexElements) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_DeleteVertexElements gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_CreateSamplerState { + MGPWireRecHeader Header; + MGPSamplerDesc Payload; +}; +static_assert(sizeof(MGPWireRec_CreateSamplerState) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPSamplerDesc) + 7u) & ~SizeT(7u)), + "MGPWireRec_CreateSamplerState gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_DeleteSamplerState { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_DeleteSamplerState) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_DeleteSamplerState gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_CreateSamplerView { + MGPWireRecHeader Header; + MGPSamplerView Payload; +}; +static_assert(sizeof(MGPWireRec_CreateSamplerView) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPSamplerView) + 7u) & ~SizeT(7u)), + "MGPWireRec_CreateSamplerView gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_DeleteSamplerView { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_DeleteSamplerView) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_DeleteSamplerView gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_CreateShaderState { + MGPWireRecHeader Header; + MGPProgramDesc Payload; +}; +static_assert(sizeof(MGPWireRec_CreateShaderState) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPProgramDesc) + 7u) & ~SizeT(7u)), + "MGPWireRec_CreateShaderState gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_BindShaderState { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_BindShaderState) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_BindShaderState gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_DeleteShaderState { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_DeleteShaderState) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_DeleteShaderState gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetDynamicState { + MGPWireRecHeader Header; + MGPDynamicState Payload; +}; +static_assert(sizeof(MGPWireRec_SetDynamicState) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPDynamicState) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetDynamicState gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetFramebufferState { + MGPWireRecHeader Header; + MGPFramebufferState Payload; +}; +static_assert(sizeof(MGPWireRec_SetFramebufferState) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPFramebufferState) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetFramebufferState gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetVertexBuffers { + MGPWireRecHeader Header; + MGPVertexBuffers Payload; +}; +static_assert(sizeof(MGPWireRec_SetVertexBuffers) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPVertexBuffers) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetVertexBuffers gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetIndexBuffer { + MGPWireRecHeader Header; + MGPIndexBuffer Payload; +}; +static_assert(sizeof(MGPWireRec_SetIndexBuffer) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPIndexBuffer) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetIndexBuffer gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetIndirectBuffers { + MGPWireRecHeader Header; + MGPIndirectBuffers Payload; +}; +static_assert(sizeof(MGPWireRec_SetIndirectBuffers) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPIndirectBuffers) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetIndirectBuffers gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetSamplerViews { + MGPWireRecHeader Header; + MGPSamplerViews Payload; +}; +static_assert(sizeof(MGPWireRec_SetSamplerViews) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPSamplerViews) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetSamplerViews gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_BindSamplerStates { + MGPWireRecHeader Header; + MGPSamplerStates Payload; +}; +static_assert(sizeof(MGPWireRec_BindSamplerStates) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPSamplerStates) + 7u) & ~SizeT(7u)), + "MGPWireRec_BindSamplerStates gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetShaderImages { + MGPWireRecHeader Header; + MGPShaderImages Payload; +}; +static_assert(sizeof(MGPWireRec_SetShaderImages) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPShaderImages) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetShaderImages gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetShaderBuffers { + MGPWireRecHeader Header; + MGPShaderBuffers Payload; +}; +static_assert(sizeof(MGPWireRec_SetShaderBuffers) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPShaderBuffers) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetShaderBuffers gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetStreamOutputTargets { + MGPWireRecHeader Header; + MGPStreamOutputTargets Payload; +}; +static_assert(sizeof(MGPWireRec_SetStreamOutputTargets) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPStreamOutputTargets) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetStreamOutputTargets gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetGlobalConstants { + MGPWireRecHeader Header; + MGPGlobalConstants Payload; +}; +static_assert(sizeof(MGPWireRec_SetGlobalConstants) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPGlobalConstants) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetGlobalConstants gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetVertexAttribDefaults { + MGPWireRecHeader Header; + MGPVertexAttribDefaults Payload; +}; +static_assert(sizeof(MGPWireRec_SetVertexAttribDefaults) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPVertexAttribDefaults) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetVertexAttribDefaults gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetPixelPackState { + MGPWireRecHeader Header; + MGPPixelPackState Payload; +}; +static_assert(sizeof(MGPWireRec_SetPixelPackState) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPPixelPackState) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetPixelPackState gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetPatchState { + MGPWireRecHeader Header; + MGPPatchState Payload; +}; +static_assert(sizeof(MGPWireRec_SetPatchState) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPPatchState) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetPatchState gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetDrawProgram { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_SetDrawProgram) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetDrawProgram gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetDispatchProgram { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_SetDispatchProgram) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetDispatchProgram gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetResidualValueState { + MGPWireRecHeader Header; + MGPResidualValueState Payload; +}; +static_assert(sizeof(MGPWireRec_SetResidualValueState) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPResidualValueState) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetResidualValueState gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetTextureParams { + MGPWireRecHeader Header; + MGPTextureParams Payload; +}; +static_assert(sizeof(MGPWireRec_SetTextureParams) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPTextureParams) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetTextureParams gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_ResourceSubData { + MGPWireRecHeader Header; + MGPSubData Payload; +}; +static_assert(sizeof(MGPWireRec_ResourceSubData) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPSubData) + 7u) & ~SizeT(7u)), + "MGPWireRec_ResourceSubData gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_BufferSubDataResident { + MGPWireRecHeader Header; + MGPSubData Payload; +}; +static_assert(sizeof(MGPWireRec_BufferSubDataResident) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPSubData) + 7u) & ~SizeT(7u)), + "MGPWireRec_BufferSubDataResident gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_ResourceSubDataComplete { + MGPWireRecHeader Header; + MGPSubDataComplete Payload; +}; +static_assert(sizeof(MGPWireRec_ResourceSubDataComplete) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPSubDataComplete) + 7u) & ~SizeT(7u)), + "MGPWireRec_ResourceSubDataComplete gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_ResourceFlushRange { + MGPWireRecHeader Header; + MGPFlushRange Payload; +}; +static_assert(sizeof(MGPWireRec_ResourceFlushRange) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPFlushRange) + 7u) & ~SizeT(7u)), + "MGPWireRec_ResourceFlushRange gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_ResourceReadback { + MGPWireRecHeader Header; + MGPReadback Payload; +}; +static_assert(sizeof(MGPWireRec_ResourceReadback) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPReadback) + 7u) & ~SizeT(7u)), + "MGPWireRec_ResourceReadback gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_ResourceCopyRegion { + MGPWireRecHeader Header; + MGPCopyRegion Payload; +}; +static_assert(sizeof(MGPWireRec_ResourceCopyRegion) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPCopyRegion) + 7u) & ~SizeT(7u)), + "MGPWireRec_ResourceCopyRegion gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_GenerateMipmap { + MGPWireRecHeader Header; + MGPMipPlan Payload; +}; +static_assert(sizeof(MGPWireRec_GenerateMipmap) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPMipPlan) + 7u) & ~SizeT(7u)), + "MGPWireRec_GenerateMipmap gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_GetTextureImage { + MGPWireRecHeader Header; + MGPReadbackInfo Payload; +}; +static_assert(sizeof(MGPWireRec_GetTextureImage) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPReadbackInfo) + 7u) & ~SizeT(7u)), + "MGPWireRec_GetTextureImage gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_Blit { + MGPWireRecHeader Header; + MGPBlit Payload; +}; +static_assert(sizeof(MGPWireRec_Blit) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPBlit) + 7u) & ~SizeT(7u)), + "MGPWireRec_Blit gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_Clear { + MGPWireRecHeader Header; + MGPClear Payload; +}; +static_assert(sizeof(MGPWireRec_Clear) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPClear) + 7u) & ~SizeT(7u)), + "MGPWireRec_Clear gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_ReadPixels { + MGPWireRecHeader Header; + MGPReadbackInfo Payload; +}; +static_assert(sizeof(MGPWireRec_ReadPixels) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPReadbackInfo) + 7u) & ~SizeT(7u)), + "MGPWireRec_ReadPixels gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_DrawVbo { + MGPWireRecHeader Header; + MGPDrawInfo Payload; +}; +static_assert(sizeof(MGPWireRec_DrawVbo) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPDrawInfo) + 7u) & ~SizeT(7u)), + "MGPWireRec_DrawVbo gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_LaunchGrid { + MGPWireRecHeader Header; + MGPGridInfo Payload; +}; +static_assert(sizeof(MGPWireRec_LaunchGrid) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPGridInfo) + 7u) & ~SizeT(7u)), + "MGPWireRec_LaunchGrid gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_MemoryBarrier { + MGPWireRecHeader Header; + MGPMemoryBarrier Payload; +}; +static_assert(sizeof(MGPWireRec_MemoryBarrier) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPMemoryBarrier) + 7u) & ~SizeT(7u)), + "MGPWireRec_MemoryBarrier gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_BeginStreamOutput { + MGPWireRecHeader Header; + MGPStreamOutputBegin Payload; +}; +static_assert(sizeof(MGPWireRec_BeginStreamOutput) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPStreamOutputBegin) + 7u) & ~SizeT(7u)), + "MGPWireRec_BeginStreamOutput gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_EndStreamOutput { + MGPWireRecHeader Header; + MGPXfbAccounting Payload; +}; +static_assert(sizeof(MGPWireRec_EndStreamOutput) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPXfbAccounting) + 7u) & ~SizeT(7u)), + "MGPWireRec_EndStreamOutput gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_PauseStreamOutput { + MGPWireRecHeader Header; + MGPStreamOutputControl Payload; +}; +static_assert(sizeof(MGPWireRec_PauseStreamOutput) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPStreamOutputControl) + 7u) & ~SizeT(7u)), + "MGPWireRec_PauseStreamOutput gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_ResumeStreamOutput { + MGPWireRecHeader Header; + MGPStreamOutputControl Payload; +}; +static_assert(sizeof(MGPWireRec_ResumeStreamOutput) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPStreamOutputControl) + 7u) & ~SizeT(7u)), + "MGPWireRec_ResumeStreamOutput gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_Flush { + MGPWireRecHeader Header; + MGPFlush Payload; +}; +static_assert(sizeof(MGPWireRec_Flush) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPFlush) + 7u) & ~SizeT(7u)), + "MGPWireRec_Flush gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_Present { + MGPWireRecHeader Header; + MGPPresent Payload; +}; +static_assert(sizeof(MGPWireRec_Present) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPPresent) + 7u) & ~SizeT(7u)), + "MGPWireRec_Present gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_SetSwapInterval { + MGPWireRecHeader Header; + MGPSwapInterval Payload; +}; +static_assert(sizeof(MGPWireRec_SetSwapInterval) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPSwapInterval) + 7u) & ~SizeT(7u)), + "MGPWireRec_SetSwapInterval gained padding; the wire format moved"); + +[[noreturn]] inline void MGPipeWireProtocolFatal(const char* call, Uint64 size, Uint64 remaining) { + MGLOG_F("MGPipe: protocol corruption applying %s: size=%llu remaining=%llu", call, + static_cast(size), static_cast(remaining)); + std::abort(); +} + +#define MGP_WIRE_CHECK_BOUNDS(RecType, CallName) \ + do { \ + if (!(size >= sizeof(RecType) && size <= remaining && (size % 8) == 0)) { \ + MGPipeWireProtocolFatal(CallName, size, remaining); \ + } \ + } while (0) + +// Returns whether the record was applied. P0 is a SKELETON: every case validates its +// bounds and then reports "not applied", because no applier exists until P5 wires +// MG_Remote/Server/PipeApplier.cpp to the real backend tables. The switch and the opcode +// enum come from the same list, so a call added to the catalogue cannot be forgotten here; +// the default arm is for the opcode that never came from this catalogue at all - a byte +// off a corrupt stream - and it is fatal for the same reason the bounds check is. +inline Bool MGPipeApplyWireRecord(MGPWireOp op, const void* record, Uint64 size, Uint64 remaining) { + (void)record; + switch (op) { + case MGPWireOp::GetCaps: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_GetCaps, "GetCaps"); + return false; + case MGPWireOp::ResourceCreate: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceCreate, "ResourceCreate"); + return false; + case MGPWireOp::ResourceRespecify: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceRespecify, "ResourceRespecify"); + return false; + case MGPWireOp::ResourceDestroy: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceDestroy, "ResourceDestroy"); + return false; + case MGPWireOp::MapPersistent: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_MapPersistent, "MapPersistent"); + return false; + case MGPWireOp::UnmapPersistent: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_UnmapPersistent, "UnmapPersistent"); + return false; + case MGPWireOp::FenceCreate: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_FenceCreate, "FenceCreate"); + return false; + case MGPWireOp::FenceStatus: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_FenceStatus, "FenceStatus"); + return false; + case MGPWireOp::FenceWait: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_FenceWait, "FenceWait"); + return false; + case MGPWireOp::FenceDestroy: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_FenceDestroy, "FenceDestroy"); + return false; + case MGPWireOp::QueryCreate: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryCreate, "QueryCreate"); + return false; + case MGPWireOp::QueryBegin: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryBegin, "QueryBegin"); + return false; + case MGPWireOp::QueryEnd: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryEnd, "QueryEnd"); + return false; + case MGPWireOp::QueryAvailable: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryAvailable, "QueryAvailable"); + return false; + case MGPWireOp::QueryResult: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryResult, "QueryResult"); + return false; + case MGPWireOp::QueryDestroy: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryDestroy, "QueryDestroy"); + return false; + case MGPWireOp::CreateRenderState: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_CreateRenderState, "CreateRenderState"); + return false; + case MGPWireOp::BindRenderState: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BindRenderState, "BindRenderState"); + return false; + case MGPWireOp::DeleteRenderState: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DeleteRenderState, "DeleteRenderState"); + return false; + case MGPWireOp::CreateVertexElements: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_CreateVertexElements, "CreateVertexElements"); + return false; + case MGPWireOp::BindVertexElements: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BindVertexElements, "BindVertexElements"); + return false; + case MGPWireOp::DeleteVertexElements: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DeleteVertexElements, "DeleteVertexElements"); + return false; + case MGPWireOp::CreateSamplerState: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_CreateSamplerState, "CreateSamplerState"); + return false; + case MGPWireOp::DeleteSamplerState: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DeleteSamplerState, "DeleteSamplerState"); + return false; + case MGPWireOp::CreateSamplerView: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_CreateSamplerView, "CreateSamplerView"); + return false; + case MGPWireOp::DeleteSamplerView: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DeleteSamplerView, "DeleteSamplerView"); + return false; + case MGPWireOp::CreateShaderState: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_CreateShaderState, "CreateShaderState"); + return false; + case MGPWireOp::BindShaderState: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BindShaderState, "BindShaderState"); + return false; + case MGPWireOp::DeleteShaderState: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DeleteShaderState, "DeleteShaderState"); + return false; + case MGPWireOp::SetDynamicState: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetDynamicState, "SetDynamicState"); + return false; + case MGPWireOp::SetFramebufferState: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetFramebufferState, "SetFramebufferState"); + return false; + case MGPWireOp::SetVertexBuffers: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetVertexBuffers, "SetVertexBuffers"); + return false; + case MGPWireOp::SetIndexBuffer: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetIndexBuffer, "SetIndexBuffer"); + return false; + case MGPWireOp::SetIndirectBuffers: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetIndirectBuffers, "SetIndirectBuffers"); + return false; + case MGPWireOp::SetSamplerViews: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetSamplerViews, "SetSamplerViews"); + return false; + case MGPWireOp::BindSamplerStates: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BindSamplerStates, "BindSamplerStates"); + return false; + case MGPWireOp::SetShaderImages: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetShaderImages, "SetShaderImages"); + return false; + case MGPWireOp::SetShaderBuffers: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetShaderBuffers, "SetShaderBuffers"); + return false; + case MGPWireOp::SetStreamOutputTargets: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetStreamOutputTargets, "SetStreamOutputTargets"); + return false; + case MGPWireOp::SetGlobalConstants: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetGlobalConstants, "SetGlobalConstants"); + return false; + case MGPWireOp::SetVertexAttribDefaults: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetVertexAttribDefaults, "SetVertexAttribDefaults"); + return false; + case MGPWireOp::SetPixelPackState: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetPixelPackState, "SetPixelPackState"); + return false; + case MGPWireOp::SetPatchState: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetPatchState, "SetPatchState"); + return false; + case MGPWireOp::SetDrawProgram: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetDrawProgram, "SetDrawProgram"); + return false; + case MGPWireOp::SetDispatchProgram: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetDispatchProgram, "SetDispatchProgram"); + return false; + case MGPWireOp::SetResidualValueState: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetResidualValueState, "SetResidualValueState"); + return false; + case MGPWireOp::SetTextureParams: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetTextureParams, "SetTextureParams"); + return false; + case MGPWireOp::ResourceSubData: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceSubData, "ResourceSubData"); + return false; + case MGPWireOp::BufferSubDataResident: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BufferSubDataResident, "BufferSubDataResident"); + return false; + case MGPWireOp::ResourceSubDataComplete: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceSubDataComplete, "ResourceSubDataComplete"); + return false; + case MGPWireOp::ResourceFlushRange: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceFlushRange, "ResourceFlushRange"); + return false; + case MGPWireOp::ResourceReadback: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceReadback, "ResourceReadback"); + return false; + case MGPWireOp::ResourceCopyRegion: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceCopyRegion, "ResourceCopyRegion"); + return false; + case MGPWireOp::GenerateMipmap: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_GenerateMipmap, "GenerateMipmap"); + return false; + case MGPWireOp::GetTextureImage: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_GetTextureImage, "GetTextureImage"); + return false; + case MGPWireOp::Blit: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_Blit, "Blit"); + return false; + case MGPWireOp::Clear: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_Clear, "Clear"); + return false; + case MGPWireOp::ReadPixels: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ReadPixels, "ReadPixels"); + return false; + case MGPWireOp::DrawVbo: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DrawVbo, "DrawVbo"); + return false; + case MGPWireOp::LaunchGrid: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_LaunchGrid, "LaunchGrid"); + return false; + case MGPWireOp::MemoryBarrier: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_MemoryBarrier, "MemoryBarrier"); + return false; + case MGPWireOp::BeginStreamOutput: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BeginStreamOutput, "BeginStreamOutput"); + return false; + case MGPWireOp::EndStreamOutput: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_EndStreamOutput, "EndStreamOutput"); + return false; + case MGPWireOp::PauseStreamOutput: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_PauseStreamOutput, "PauseStreamOutput"); + return false; + case MGPWireOp::ResumeStreamOutput: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResumeStreamOutput, "ResumeStreamOutput"); + return false; + case MGPWireOp::Flush: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_Flush, "Flush"); + return false; + case MGPWireOp::Present: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_Present, "Present"); + return false; + case MGPWireOp::SetSwapInterval: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetSwapInterval, "SetSwapInterval"); + return false; + case MGPWireOp::kInvalid: + case MGPWireOp::kOpCount: + default: + MGPipeWireProtocolFatal("", size, remaining); + } +} + +#undef MGP_WIRE_CHECK_BOUNDS diff --git a/scripts/data/backend_read_inventory.md b/scripts/data/backend_read_inventory.md new file mode 100644 index 000000000..140d8fe09 --- /dev/null +++ b/scripts/data/backend_read_inventory.md @@ -0,0 +1,626 @@ +# Backend read inventory -> delta catalog matrix + +> GENERATED by `scripts/extract_backend_read_inventory.py` - do not edit by hand. +> Acceptance rule: zero UNMAPPED rows before the delta path goes live (P5). + +## Summary + +- scanned files: 57 (MG_Backend/**.cpp|.h) +- total read points: 477 +- pGLContext accesses: 293 +- SharedPtr Texture;` | +| 35 | SharedPtr Renderbuffer;` | +| 158 | SharedPtr& framebuffer,` | +| 160 | SharedPtr& framebuffer,` | +| 162 | SharedPtr& framebuffer,` | +| 164 | SharedPtr& framebuffer,` | +| 168 | SharedPtr& readFramebuffer,` | +| 169 | SharedPtr& drawFramebuffer,` | +| 186 | SharedPtr& texture,` | + +### `MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp` (1 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 849 | BufferBackendOps | - | Buffer ops delta | `BufferImpl::RegisterBufferBackendOps();` | + +### `MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp` (142 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 55 | SharedPtr g_rawDepthFetchSamplerState;` | +| 142 | pGLContext | GetFramebufferBindingSlot | FboAttach | `std::remove_reference_tGetFramebufferBindingSlot(FramebufferTarget::Draw))>;` | +| 182 | SharedPtr& samplerObject,` | +| 260 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();` | +| 356 | pGLContext | GetTouchedBufferBindingPointCount | ObjectBind(BufferRange) | `auto bindingPointCnt = MG_State::pGLContext->GetTouchedBufferBindingPointCount(target);` | +| 369 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& point = MG_State::pGLContext->GetBufferBindingPoint(target, i);` | +| 428 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::TransformFeedback, i);` | +| 461 | pGLContext | GetTouchedBufferBindingPointCount | ObjectBind(BufferRange) | `MG_State::pGLContext->GetTouchedBufferBindingPointCount(BufferTarget::ShaderStorage);` | +| 464 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, i).GetBoundObject();` | +| 473 | pGLContext | GetBufferBindingPointCount | ObjectBind(BufferRange) | `const SizeT pointCount = MG_State::pGLContext->GetBufferBindingPointCount(BufferTarget::AtomicCounter);` | +| 480 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::AtomicCounter,` | +| 517 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `auto& bufferObject = MG_State::pGLContext->GetBufferBindingSlot(target).GetBoundObject();` | +| 537 | SharedPtr& currentVAOObject,` | +| 661 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();` | +| 712 | SharedPtr buffer;` | +| 739 | SharedPtr scatterProgram;` | +| 891 | pGLContext | GetTransformFeedbackCapturedVertices | XfbOp | `static_cast(MG_State::pGLContext->GetTransformFeedbackCapturedVertices());` | +| 986 | pGLContext | GetTransformFeedbackProgram | XfbOp | `const auto& program = MG_State::pGLContext->GetTransformFeedbackProgram();` | +| 1006 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::TransformFeedback,` | +| 1199 | SharedPtr& vao) {` | +| 1215 | SharedPtr& currentVAOObject,` | +| 1237 | SharedPtr& program) {` | +| 1243 | pGLContext | GetBoundVertexArray | ObjectBind(VAO) | `const auto& vao = MG_State::pGLContext->GetBoundVertexArray();` | +| 1274 | pGLContext | GetCurrentVertexAttribute | CurrentAttrib | `const auto& currentValue = MG_State::pGLContext->GetCurrentVertexAttribute(location);` | +| 1298 | SharedPtr& textureObject,` | +| 1372 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);` | +| 1385 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);` | +| 1413 | pGLContext | GetTextureContextId | Texture state | `const Uint64 contextId = MG_State::pGLContext->GetTextureContextId();` | +| 1414 | pGLContext | GetTextureBindGeneration | ObjectBind(Texture) | `const Uint64 bindGeneration = MG_State::pGLContext->GetTextureBindGeneration();` | +| 1457 | SharedPtr* slot = nullptr;` | +| 1503 | pGLContext | GetTextureContextId | Texture state | `keys.contextId = MG_State::pGLContext->GetTextureContextId();` | +| 1505 | pGLContext | GetMaxTouchedTextureUnit | ObjectBind(Texture) | `keys.maxTouchedUnit = MG_State::pGLContext->GetMaxTouchedTextureUnit();` | +| 1506 | pGLContext | GetSamplingResolutionGeneration | TexParam | `keys.samplingGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration();` | +| 1552 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `auto& unit = MG_State::pGLContext->GetTextureUnitObject(index);` | +| 1700 | pGLContext | GetImageTextureBinding | ObjectBind(Image) | `auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(unit));` | +| 1792 | pGLContext | GetImageTextureBinding | ObjectBind(Image) | `const auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(unit));` | +| 1998 | pGLContext | GetRenderStateParametersVersion | RenderStateBlob | `Uint16 currentRenderStateVersion = MG_State::pGLContext->GetRenderStateParametersVersion();` | +| 2012 | pGLContext | GetRenderStateParameters | RenderStateBlob | `const auto& parameters = MG_State::pGLContext->GetRenderStateParameters();` | +| 2041 | pGLContext | GetViewport | RenderStateBlob | `IntVec4 backendViewport = MG_State::pGLContext->GetViewport();` | +| 2124 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `const Bool srgbWrites = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::FramebufferSrgb);` | +| 2680 | SharedPtr& currentProgram) {` | +| 2800 | pGLContext | GetPatchVertices | RenderStateBlob | `static_cast(MG_State::pGLContext->GetPatchVertices()) \|\|` | +| 2802 | pGLContext | GetPatchDefaultOuterLevel | RenderStateBlob | `MG_State::pGLContext->GetPatchDefaultOuterLevel()) \|\|` | +| 2804 | pGLContext | GetPatchDefaultInnerLevel | RenderStateBlob | `MG_State::pGLContext->GetPatchDefaultInnerLevel())))) {` | +| 2853 | SharedPtr& framebuffer,` | +| 2902 | SharedPtr& currentProgram,` | +| 2905 | SharedPtr& currentProgram);` | +| 2915 | pGLContext | GetBoundVertexArray | ObjectBind(VAO) | `const auto& currentVAO = MG_State::pGLContext->GetBoundVertexArray();` | +| 2926 | pGLContext | GetProgramForDraw | ObjectBind(Program) | `const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw();` | +| 2973 | SharedPtr& currentProgram,` | +| 2998 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);` | +| 3113 | SharedPtr& samplerObject) {` | +| 3162 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `const auto& samplerObject = MG_State::pGLContext->GetTextureUnitObject(unit).GetSamplerObject();` | +| 3250 | SharedPtr& currentProgram) {` | +| 3312 | pGLContext | GetProgramForDraw | ObjectBind(Program) | `BindCurrentTextures(TextureImpl::CaptureDrawTextureSyncKeys(), MG_State::pGLContext->GetProgramForDraw());` | +| 3322 | SharedPtr& currentProgram,` | +| 3423 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::Uniform, binding);` | +| 3521 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);` | +| 3547 | SharedPtr* rawDepthSamplerObject =` | +| 3603 | pGLContext | GetProgramForDraw | ObjectBind(Program) | `const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw();` | +| 3653 | pGLContext | GetProgramForDraw | ObjectBind(Program) | `const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw();` | +| 3762 | pGLContext | IsTransformFeedbackActive | XfbOp | `if (MG_State::pGLContext->IsTransformFeedbackActive() \|\|` | +| 3763 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) {` | +| 3767 | pGLContext | GetRenderStateParameters | RenderStateBlob | `const auto& parameters = MG_State::pGLContext->GetRenderStateParameters();` | +| 3862 | SharedPtr& drawIndirectBuffer,` | +| 3941 | SharedPtr& drawIndirectBuffer,` | +| 4003 | pGLContext | GetProgramForDispatch | ObjectBind(Program) | `const auto& currentProgram = MG_State::pGLContext->GetProgramForDispatch();` | +| 4029 | pGLContext | ValidateProgramName | client-resolved (validation) | `if (!MG_State::pGLContext->ValidateProgramName(program)) {` | +| 4034 | pGLContext | GetProgramObject | ProgramPublish | `auto& programObject = MG_State::pGLContext->GetProgramObject(program);` | +| 4097 | pGLContext | GetRenderStateParameters | RenderStateBlob | `const FloatVec4& cc = MG_State::pGLContext->GetRenderStateParameters().ClearColor;` | +| 4156 | pGLContext | GetFramebufferBindingSlot | FboAttach | `MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();` | +| 4313 | SharedPtr& BoundElementArrayBuffer() {` | +| 4314 | SharedPtr none;` | +| 4315 | pGLContext | GetBoundVertexArray | ObjectBind(VAO) | `const auto& vao = MG_State::pGLContext->GetBoundVertexArray();` | +| 4331 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `if (!MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) \|\|` | +| 4332 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex)) {` | +| 4337 | pGLContext | GetPrimitiveRestartIndex | RenderStateBlob | `const Uint32 restartIndex = MG_State::pGLContext->GetPrimitiveRestartIndex();` | +| 4377 | pGLContext | GetPrimitiveRestartIndex | RenderStateBlob | `const Uint32 applicationRestartIndex = MG_State::pGLContext->GetPrimitiveRestartIndex();` | +| 4505 | pGLContext | GetBoundVertexArray | ObjectBind(VAO) | `const auto& currentVAO = MG_State::pGLContext->GetBoundVertexArray();` | +| 4544 | pGLContext | GetBoundVertexArray | ObjectBind(VAO) | `const auto& currentVAO = MG_State::pGLContext->GetBoundVertexArray();` | +| 4615 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();` | +| 4646 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();` | +| 4647 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `auto parameterBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject();` | +| 4717 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();` | +| 4748 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();` | +| 4749 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `auto parameterBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject();` | +| 4908 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();` | +| 4949 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();` | +| 5194 | pGLContext | IsTransformFeedbackActive | XfbOp | `if (MG_State::pGLContext->IsTransformFeedbackActive() &&` | +| 5195 | pGLContext | IsTransformFeedbackPaused | XfbOp | `!MG_State::pGLContext->IsTransformFeedbackPaused() && g_GLESFuncs.glPauseTransformFeedback) {` | +| 5811 | SharedPtr& readFramebuffer,` | +| 5812 | SharedPtr& drawFramebuffer, GLint srcX0, GLint srcY0,` | +| 5979 | pGLContext | GetFramebufferBindingSlot | FboAttach | `MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(),` | +| 5980 | pGLContext | GetFramebufferBindingSlot | FboAttach | `MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(), srcX0, srcY0,` | +| 5990 | SharedPtr& readFramebuffer,` | +| 5991 | SharedPtr& drawFramebuffer,` | +| 6042 | pGLContext | GetActiveTextureUnit | ObjectBind(Texture) | `auto unit = MG_State::pGLContext->GetActiveTextureUnit();` | +| 6043 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);` | +| 6120 | pGLContext | GetPixelStoreParameters | PixelStoreBlob | `const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false);` | +| 6267 | SharedPtr& texture) {` | +| 6310 | pGLContext | RecordError | client-resolved (error queue) | `MG_State::pGLContext->RecordError(` | +| 6335 | SharedPtr& texture) {` | +| 6561 | SharedPtr& texture,` | +| 6586 | SharedPtr& texture,` | +| 6634 | pGLContext | GetActiveTextureUnit | ObjectBind(Texture) | `Uint activeTextureUnit = MG_State::pGLContext->GetActiveTextureUnit();` | +| 6635 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `const auto& textureObject = MG_State::pGLContext->GetTextureUnitObject((Int)activeTextureUnit)` | +| 6729 | pGLContext | GetActiveTextureUnit | ObjectBind(Texture) | `auto activeTextureUnit = MG_State::pGLContext->GetActiveTextureUnit();` | +| 6730 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `const auto& textureObject = MG_State::pGLContext->GetTextureUnitObject(activeTextureUnit)` | +| 6794 | SharedPtr& texture) {` | +| 6867 | pGLContext | GetActiveTextureUnit | ObjectBind(Texture) | `auto unitIndex = MG_State::pGLContext->GetActiveTextureUnit();` | +| 6868 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `auto& unit = MG_State::pGLContext->GetTextureUnitObject(unitIndex);` | +| 6994 | SharedPtr& renderbufferObject) {` | +| 7059 | SharedPtr& texture) {` | +| 7260 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index);` | +| 7266 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index);` | +| 7271 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index);` | +| 7288 | pGLContext | GetImageTextureBinding | ObjectBind(Image) | `auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index));` | +| 7297 | pGLContext | GetImageTextureBinding | ObjectBind(Image) | `auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index));` | +| 7306 | pGLContext | GetImageTextureBinding | ObjectBind(Image) | `auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index));` | +| 7315 | pGLContext | GetImageTextureBinding | ObjectBind(Image) | `auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index));` | +| 7324 | pGLContext | GetImageTextureBinding | ObjectBind(Image) | `auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index));` | +| 7333 | pGLContext | GetImageTextureBinding | ObjectBind(Image) | `auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index));` | +| 7352 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index);` | +| 7357 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index);` | +| 7408 | pGLContext | ValidateProgramName | client-resolved (validation) | `if (!MG_State::pGLContext->ValidateProgramName(program)) return;` | +| 7409 | pGLContext | GetProgramObject | ProgramPublish | `auto& programObject = MG_State::pGLContext->GetProgramObject(program);` | +| 7456 | SharedPtr& framebuffer,` | +| 7514 | SharedPtr& framebuffer,` | +| 7534 | SharedPtr& framebuffer,` | +| 7551 | SharedPtr& framebuffer,` | +| 7571 | SharedPtr& framebuffer,` | +| 7605 | pGLContext | GetPixelStoreParameters | PixelStoreBlob | `const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false);` | +| 7613 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject();` | +| 8595 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject();` | +| 8825 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject();` | +| 9092 | pGLContext | GetPixelStoreParameters | PixelStoreBlob | `const Bool packSwapBytes = MG_State::pGLContext->GetPixelStoreParameters(false).SwapBytes;` | +| 9135 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject();` | +| 9245 | pGLContext | GetActiveTextureUnit | ObjectBind(Texture) | `auto activeTextureUnit = MG_State::pGLContext->GetActiveTextureUnit();` | +| 9248 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `const auto& textureObject = MG_State::pGLContext->GetTextureUnitObject(activeTextureUnit)` | +| 9471 | pGLContext | GetPixelStoreParameters | PixelStoreBlob | `const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false);` | +| 9561 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject();` | +| 10114 | BufferBackendOps | - | Buffer ops delta | `BufferImpl::RegisterBufferBackendOps();` | + +### `MobileGL/MG_Backend/DirectGLES/DirectGLES.h` (6 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 60 | SharedPtr& framebuffer,` | +| 62 | SharedPtr& framebuffer,` | +| 64 | SharedPtr& framebuffer,` | +| 66 | SharedPtr& framebuffer,` | +| 70 | SharedPtr& readFramebuffer,` | +| 71 | SharedPtr& drawFramebuffer,` | + +### `MobileGL/MG_Backend/DirectGLES/Managers.cpp` (39 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 443 | SharedPtr& stateProgramObject) {` | +| 495 | BufferBackendOps | - | Buffer ops delta | `using MG_State::GLState::BufferBackendOps;` | +| 1333 | BufferBackendOps | - | Buffer ops delta | `const BufferBackendOps g_glesBufferBackendOps = {` | +| 1356 | BufferBackendOps | - | Buffer ops delta | `void RegisterBufferBackendOps() {` | +| 1357 | BufferBackendOps | - | Buffer ops delta | `MG_State::GLState::SetBufferBackendOps(&g_glesBufferBackendOps);` | +| 1363 | BufferBackendOps | - | Buffer ops delta | `void UnregisterBufferBackendOps() {` | +| 1364 | BufferBackendOps | - | Buffer ops delta | `if (MG_State::GLState::GetBufferBackendOps() == &g_glesBufferBackendOps) {` | +| 1365 | BufferBackendOps | - | Buffer ops delta | `MG_State::GLState::SetBufferBackendOps(nullptr);` | +| 1379 | BufferBackendOps | - | Buffer ops delta | `UnregisterBufferBackendOps(); // also bumps the buffer-mutation epoch` | +| 1453 | SharedPtr& bufferObject) {` | +| 2264 | SharedPtr& stateVAOObject) {` | +| 2496 | SharedPtr& stateVAOObject, GLint first, GLsizei count) {` | +| 2787 | SharedPtr& stateTextureObject) {` | +| 3604 | SharedPtr& stateTextureObject) {` | +| 3606 | pGLContext | GetTextureContextId | Texture state | `m_syncedShapeContextId = MG_State::pGLContext->GetTextureContextId();` | +| 3607 | pGLContext | GetSamplingResolutionGeneration | TexParam | `m_syncedShapeGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration();` | +| 3614 | SharedPtr& stateTextureObject) {` | +| 3707 | SharedPtr& stateTextureObject) {` | +| 3735 | pGLContext | GetTextureContextId | Texture state | `m_syncedShapeContextId == MG_State::pGLContext->GetTextureContextId() &&` | +| 3736 | pGLContext | GetSamplingResolutionGeneration | TexParam | `m_syncedShapeGeneration == MG_State::pGLContext->GetSamplingResolutionGeneration() &&` | +| 3806 | pGLContext | GetTextureContextId | Texture state | `m_syncedShapeContextId = MG_State::pGLContext->GetTextureContextId();` | +| 3807 | pGLContext | GetSamplingResolutionGeneration | TexParam | `m_syncedShapeGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration();` | +| 4661 | pGLContext | GetTextureContextId | Texture state | `m_syncedShapeContextId = MG_State::pGLContext->GetTextureContextId();` | +| 4662 | pGLContext | GetSamplingResolutionGeneration | TexParam | `m_syncedShapeGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration();` | +| 4670 | SharedPtr& stateTextureObject) {` | +| 4781 | SharedPtr& stateTextureObject) {` | +| 5280 | pGLContext | GetFramebufferBindingSlot | FboAttach | `MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject();` | +| 5385 | pGLContext | GetFramebufferBindingSlot | FboAttach | `MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject();` | +| 5409 | SharedPtr& stateFBOObject) {` | +| 5520 | SharedPtr& stateFBOObject, FramebufferTarget asTarget) {` | +| 6155 | pGLContext | GetImageTextureBinding | ObjectBind(Image) | `return static_cast(MG_State::pGLContext->GetImageTextureBinding(unit).Format);` | +| 7118 | pGLContext | GetPatchVertices | RenderStateBlob | `? MG_State::pGLContext->GetPatchVertices()` | +| 7126 | pGLContext | GetPatchDefaultOuterLevel | RenderStateBlob | `? MG_State::pGLContext->GetPatchDefaultOuterLevel()` | +| 7129 | pGLContext | GetPatchDefaultInnerLevel | RenderStateBlob | `? MG_State::pGLContext->GetPatchDefaultInnerLevel()` | +| 7229 | SharedPtr& stateProgramObject) {` | +| 8262 | SharedPtr& stateProgramObject) {` | +| 8450 | SharedPtr& stateSamplerObject) {` | +| 8620 | SharedPtr& stateRBOObject) {` | +| 8676 | pGLContext | RecordError | client-resolved (error queue) | `MG_State::pGLContext->RecordError(` | + +### `MobileGL/MG_Backend/DirectGLES/Managers.h` (18 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 501 | BufferBackendOps | - | Buffer ops delta | `void RegisterBufferBackendOps();` | +| 502 | BufferBackendOps | - | Buffer ops delta | `void UnregisterBufferBackendOps();` | +| 511 | SharedPtr& bufferObject);` | +| 679 | SharedPtr& stateVAOObject);` | +| 681 | SharedPtr& stateVAOObject, GLint first, GLsizei count);` | +| 953 | SharedPtr& stateTextureObject);` | +| 959 | SharedPtr& stateTextureObject);` | +| 960 | SharedPtr& stateTextureObject);` | +| 966 | SharedPtr& stateTextureObject);` | +| 967 | SharedPtr& stateTextureObject);` | +| 973 | SharedPtr& stateTextureObject);` | +| 1127 | SharedPtr& textureObject,` | +| 1151 | SharedPtr& stateFBOObject,` | +| 1156 | SharedPtr& stateFBOObject);` | +| 1531 | SharedPtr& stateProgramObject);` | +| 1639 | SharedPtr& stateProgramObject);` | +| 1817 | SharedPtr& stateSamplerObject);` | +| 1846 | SharedPtr& stateRBOObject);` | + +### `MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp` (8 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 44 | pGLContext | GetPrimitiveRestartIndex | RenderStateBlob | `return MG_State::pGLContext->GetPrimitiveRestartIndex();` | +| 50 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `return MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) \|\|` | +| 51 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex);` | +| 86 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();` | +| 92 | SharedPtr& BoundIndexBuffer() {` | +| 93 | SharedPtr none;` | +| 94 | pGLContext | GetBoundVertexArray | ObjectBind(VAO) | `const auto& vao = MG_State::pGLContext->GetBoundVertexArray();` | +| 355 | SharedPtr& indexBuffer,` | + +### `MobileGL/MG_Backend/DirectGLES/Utils.cpp` (2 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 2297 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject();` | +| 2301 | pGLContext | GetPixelStoreParameters | PixelStoreBlob | `const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false);` | + +### `MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp` (2 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 389 | pGLContext | InvalidateCompileEnv | client-resolved (compile env) | `MG_State::pGLContext->InvalidateCompileEnv();` | +| 789 | pGLContext | InvalidateCompileEnv | client-resolved (compile env) | `MG_State::pGLContext->InvalidateCompileEnv();` | + +### `MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp` (25 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 280 | pGLContext | ValidateProgramName | client-resolved (validation) | `if (!MG_State::pGLContext->ValidateProgramName(program)) {` | +| 283 | pGLContext | GetProgramObject | ProgramPublish | `auto& programObject = MG_State::pGLContext->GetProgramObject(program);` | +| 288 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();` | +| 369 | SharedPtr& framebuffer, GLenum buffer,` | +| 376 | SharedPtr& framebuffer, GLenum buffer,` | +| 383 | SharedPtr& framebuffer, GLenum buffer,` | +| 390 | SharedPtr& framebuffer, GLenum buffer,` | +| 412 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();` | +| 475 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `auto parameterBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject();` | +| 543 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();` | +| 596 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();` | +| 706 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index);` | +| 712 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index);` | +| 717 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index);` | +| 739 | pGLContext | GetImageTextureBinding | ObjectBind(Image) | `auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index));` | +| 765 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index);` | +| 770 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index);` | +| 816 | pGLContext | RecordError | client-resolved (error queue) | `MG_State::pGLContext->RecordError(` | +| 849 | SharedPtr& texture, TextureUploadTarget uploadTarget,` | +| 886 | pGLContext | GetBoundVertexArray | ObjectBind(VAO) | `const auto& vao = *MG_State::pGLContext->GetBoundVertexArray();` | +| 1010 | pGLContext | GetBoundVertexArray | ObjectBind(VAO) | `const auto& vao = *MG_State::pGLContext->GetBoundVertexArray();` | +| 1112 | SharedPtr& readFramebuffer,` | +| 1113 | SharedPtr& drawFramebuffer,` | +| 1337 | pGLContext | GetTransformFeedbackPausedPrimitiveCounter | XfbOp | `primitives += MG_State::pGLContext->GetTransformFeedbackPausedPrimitiveCounter() -` | +| 1384 | pGLContext | GetTransformFeedbackPausedPrimitiveCounter | XfbOp | `MG_State::pGLContext ? MG_State::pGLContext->GetTransformFeedbackPausedPrimitiveCounter() : 0;` | + +### `MobileGL/MG_Backend/DirectVulkan/DirectVulkan.h` (7 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 36 | SharedPtr& framebuffer, GLenum buffer,` | +| 38 | SharedPtr& framebuffer, GLenum buffer,` | +| 40 | SharedPtr& framebuffer, GLenum buffer,` | +| 42 | SharedPtr& framebuffer, GLenum buffer,` | +| 76 | SharedPtr& readFramebuffer,` | +| 77 | SharedPtr& drawFramebuffer,` | +| 103 | SharedPtr& texture, TextureUploadTarget uploadTarget,` | + +### `MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp` (23 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 161 | SharedPtr MakePlaceholderTextureObject(TextureTarget target,` | +| 505 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);` | +| 508 | SharedPtr fallbackHolder;` | +| 554 | pGLContext | GetFramebufferBindingSlot | FboAttach | `auto drawFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();` | +| 781 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `const auto& samplerOverride = MG_State::pGLContext->GetTextureUnitObject(unit).GetSamplerObject();` | +| 809 | SharedPtr& outTexture) {` | +| 820 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);` | +| 846 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);` | +| 873 | SharedPtr texture;` | +| 1015 | pGLContext | GetImageTextureBinding | ObjectBind(Image) | `auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(imageUnit);` | +| 1188 | pGLContext | GetBufferBindingPointCount | ObjectBind(BufferRange) | `static_cast(MG_State::pGLContext->GetBufferBindingPointCount(bufferTarget));` | +| 1193 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& bindingPoint = MG_State::pGLContext->GetBufferBindingPoint(bufferTarget, frontendBinding);` | +| 1294 | pGLContext | GetImageTextureBinding | ObjectBind(Image) | `auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(imageUnit);` | +| 1303 | SharedPtr placeholder;` | +| 1392 | SharedPtr UniformManager::GetFallbackTexture(` | +| 1433 | SharedPtr UniformManager::GetFallbackMultisampleTexture(` | +| 1477 | SharedPtr texture;` | +| 1597 | SharedPtr UniformManager::GetUnboundStorageImageTexture(` | +| 1661 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);` | +| 1849 | pGLContext | GetImageTextureBinding | ObjectBind(Image) | `auto* texture = MG_State::pGLContext->GetImageTextureBinding(imageUnit).Texture.get();` | +| 1937 | pGLContext | GetImageTextureBinding | ObjectBind(Image) | `const auto& image = MG_State::pGLContext->GetImageTextureBinding(imageUnit);` | +| 2012 | pGLContext | GetBufferBindingPointCount | ObjectBind(BufferRange) | `static_cast(MG_State::pGLContext->GetBufferBindingPointCount(BufferTarget::Uniform));` | +| 2017 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& bindingPoint = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::Uniform, frontendBinding);` | + +### `MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h` (7 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 165 | SharedPtr& outTexture);` | +| 184 | SharedPtr GetFallbackTexture(` | +| 191 | SharedPtr GetFallbackMultisampleTexture(` | +| 213 | SharedPtr GetUnboundStorageImageTexture(TextureTarget target,` | +| 305 | SharedPtr m_fallbackTexture2D;` | +| 308 | SharedPtr> m_fallbackMultisampleTextures;` | +| 317 | SharedPtr> m_unboundStorageImageTextures;` | + +### `MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp` (9 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 50 | BufferBackendOps | - | Buffer ops delta | `using MG_State::GLState::BufferBackendOps;` | +| 104 | BufferBackendOps | - | Buffer ops delta | `const BufferBackendOps g_vulkanBufferBackendOps = {` | +| 130 | BufferBackendOps | - | Buffer ops delta | `MG_State::GLState::SetBufferBackendOps(&g_vulkanBufferBackendOps);` | +| 137 | BufferBackendOps | - | Buffer ops delta | `if (MG_State::GLState::GetBufferBackendOps() == &g_vulkanBufferBackendOps) {` | +| 138 | BufferBackendOps | - | Buffer ops delta | `MG_State::GLState::SetBufferBackendOps(nullptr);` | +| 255 | SharedPtr& bufferObject) {` | +| 496 | SharedPtr&& resource) {` | +| 566 | SharedPtr& bufferObject,` | +| 614 | SharedPtr& bufferObject,` | + +### `MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.h` (4 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 146 | SharedPtr& bufferObject,` | +| 151 | SharedPtr& bufferObject,` | +| 165 | SharedPtr&& resource);` | +| 184 | SharedPtr& bufferObject);` | + +### `MobileGL/MG_Backend/DirectVulkan/Renderer/VkClearManager.cpp` (11 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 57 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `if (MG_State::pGLContext->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb)) return;` | +| 238 | SharedPtr& outTexture) {` | +| 261 | SharedPtr& outTexture) {` | +| 319 | SharedPtr& texture) {` | +| 325 | SharedPtr& storageTexture = storageOwner ? storageOwner : texture;` | +| 350 | SharedPtr& storageTexture = storageOwner ? storageOwner : texture;` | +| 372 | SharedPtr liveTexture;` | +| 392 | SharedPtr liveTexture;` | +| 404 | SharedPtr liveTexture;` | +| 409 | SharedPtr& outTexture) {` | +| 461 | SharedPtr liveTexture;` | + +### `MobileGL/MG_Backend/DirectVulkan/Renderer/VkClearManager.h` (4 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 127 | SharedPtr& texture);` | +| 135 | SharedPtr& outTexture);` | +| 148 | SharedPtr& outTexture);` | +| 150 | SharedPtr& outTexture);` | + +### `MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp` (5 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 342 | SharedPtr& renderbuffer) {` | +| 613 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `MG_State::pGLContext->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb);` | +| 965 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `MG_State::pGLContext->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb);` | +| 1111 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `MG_State::pGLContext->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb));` | +| 1595 | SharedPtr liveTexture;` | + +### `MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h` (1 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 353 | SharedPtr& renderbuffer);` | + +### `MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp` (2 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 808 | pGLContext | GetTextureObject | Texture state | `const auto& liveTexture = MG_State::pGLContext->GetTextureObject(texture.GetExternalIndex());` | +| 951 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `MG_State::pGLContext->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb);` | + +### `MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp` (140 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 462 | pGLContext | GetViewportIndexed | RenderStateBlob | `const FloatVec4& stored = MG_State::pGLContext->GetViewportIndexed(index);` | +| 467 | pGLContext | GetDepthRangeIndexed | RenderStateBlob | `const FloatVec2& depthRange = MG_State::pGLContext->GetDepthRangeIndexed(index);` | +| 522 | pGLContext | GetBlendColor | RenderStateBlob | `const FloatVec4& blendColor = MG_State::pGLContext->GetBlendColor();` | +| 555 | pGLContext | GetPolygonOffsetUnits | RenderStateBlob | `const Float constantFactor = MG_State::pGLContext->GetPolygonOffsetUnits();` | +| 556 | pGLContext | GetPolygonOffsetFactor | RenderStateBlob | `const Float slopeFactor = MG_State::pGLContext->GetPolygonOffsetFactor();` | +| 569 | pGLContext | GetLineWidth | RenderStateBlob | `Float lineWidth = MG_State::pGLContext->GetLineWidth();` | +| 648 | pGLContext | GetStencilState | RenderStateBlob | `const StencilFaceState& frontStencil = MG_State::pGLContext->GetStencilState(StencilFace::Front);` | +| 649 | pGLContext | GetStencilState | RenderStateBlob | `const StencilFaceState& backStencil = MG_State::pGLContext->GetStencilState(StencilFace::Back);` | +| 1242 | pGLContext | RecordError | client-resolved (error queue) | `MG_State::pGLContext->RecordError(code, MakeUnique("DirectVulkan", func, message));` | +| 1246 | pGLContext | RecordError | client-resolved (error queue) | `MG_State::pGLContext->RecordError(code, MakeUnique("DirectVulkan", func, message));` | +| 1302 | pGLContext | RecordError | client-resolved (error queue) | `MG_State::pGLContext->RecordError(` | +| 2770 | pGLContext | GetClampReadColor | RenderStateBlob | `const GLenum clampMode = MG_State::pGLContext->GetClampReadColor();` | +| 2987 | pGLContext | GetFramebufferBindingSlot | FboAttach | `MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();` | +| 3419 | SharedPtr{}` | +| 3446 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) \|\|` | +| 3447 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex);` | +| 3924 | pGLContext | GetCurrentVertexAttribute | CurrentAttrib | `const auto& currentValue = MG_State::pGLContext->GetCurrentVertexAttribute(location);` | +| 4058 | pGLContext | GetRenderStateParameters | RenderStateBlob | `const RenderStateParameters& rsp = MG_State::pGLContext->GetRenderStateParameters();` | +| 4074 | SharedPtr& indexBufferShared =` | +| 4813 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `if (!MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::Multisample)) return kFullCoverage;` | +| 4814 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `if (!MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::SampleMask)) return kFullCoverage;` | +| 4815 | pGLContext | GetRenderStateParameters | RenderStateBlob | `return MG_State::pGLContext->GetRenderStateParameters().SampleMaskValue;` | +| 4826 | pGLContext | GetRenderStateParameters | RenderStateBlob | `const RenderStateParameters& p = MG_State::pGLContext->GetRenderStateParameters();` | +| 4938 | pGLContext | GetRenderStateParameters | RenderStateBlob | `const RenderStateParameters& rsp = MG_State::pGLContext->GetRenderStateParameters();` | +| 4982 | pGLContext | GetPipelineStateVersion | RenderStateBlob | `const Uint renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion();` | +| 5160 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `auto cullFaceEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::CullFace);` | +| 5161 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `auto depthTestEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::DepthTest);` | +| 5163 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PolygonOffsetFill) &&` | +| 5166 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard);` | +| 5168 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::ColorLogicOp) && m_logicOpFeatureEnabled;` | +| 5169 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `auto stencilTestEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::StencilTest);` | +| 5175 | pGLContext | GetFramebufferBindingSlot | FboAttach | `MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();` | +| 5187 | pGLContext | GetStencilState | RenderStateBlob | `const StencilFaceState& frontStencil = MG_State::pGLContext->GetStencilState(StencilFace::Front);` | +| 5188 | pGLContext | GetStencilState | RenderStateBlob | `const StencilFaceState& backStencil = MG_State::pGLContext->GetStencilState(StencilFace::Back);` | +| 5190 | pGLContext | GetPolygonModeFront | RenderStateBlob | `MG_Util::ConvertPolygonModeToVkEnum(MG_State::pGLContext->GetPolygonModeFront());` | +| 5257 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::SampleShading),` | +| 5258 | pGLContext | GetMinSampleShadingValue | RenderStateBlob | `.minSampleShading = MG_State::pGLContext->GetMinSampleShadingValue(),` | +| 5264 | pGLContext | GetPatchVertices | RenderStateBlob | `.patchControlPoints = static_cast(MG_State::pGLContext->GetPatchVertices()),` | +| 5268 | pGLContext | GetCullFaceMode | RenderStateBlob | `? MG_Util::ConvertCullFaceModeToVkEnum(MG_State::pGLContext->GetCullFaceMode(), invertClockwise)` | +| 5282 | pGLContext | GetDepthMask | RenderStateBlob | `.depthWriteEnable = depthTestEnabled && MG_State::pGLContext->GetDepthMask(),` | +| 5287 | pGLContext | GetDepthFunc | RenderStateBlob | `.depthCompareOp = MG_Util::ConvertDepthTestFuncToVkEnum(MG_State::pGLContext->GetDepthFunc()),` | +| 5288 | pGLContext | GetLogicOp | RenderStateBlob | `.logicOp = MG_Util::ConvertLogicOperationToVkEnum(MG_State::pGLContext->GetLogicOp()),` | +| 5324 | pGLContext | GetPatchDefaultOuterLevel | RenderStateBlob | `const FloatVec4& defaultOuterLevel = MG_State::pGLContext->GetPatchDefaultOuterLevel();` | +| 5325 | pGLContext | GetPatchDefaultInnerLevel | RenderStateBlob | `const FloatVec2& defaultInnerLevel = MG_State::pGLContext->GetPatchDefaultInnerLevel();` | +| 5377 | pGLContext | GetFramebufferBindingSlot | FboAttach | `MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();` | +| 5405 | pGLContext | GetBlendFuncIndexed | RenderStateBlob | `MG_State::pGLContext->GetBlendFuncIndexed(i, srcRGB, dstRGB, srcAlpha, dstAlpha);` | +| 5406 | pGLContext | GetBlendEquationIndexed | RenderStateBlob | `MG_State::pGLContext->GetBlendEquationIndexed(i, colorEquation, alphaEquation);` | +| 5407 | pGLContext | IsCapabilityEnabledIndexed | RenderStateBlob | `const Bool blendEnabled = MG_State::pGLContext->IsCapabilityEnabledIndexed(CapabilityInput::Blend, i);` | +| 5412 | pGLContext | GetColorMaskIndexed | RenderStateBlob | `MG_State::pGLContext->GetColorMaskIndexed(m_independentBlendFeatureEnabled ? i : 0);` | +| 5828 | pGLContext | GetRenderStateParameters | RenderStateBlob | `const auto& parameters = MG_State::pGLContext->GetRenderStateParameters();` | +| 5889 | pGLContext | GetRenderStateParametersVersion | RenderStateBlob | `const Uint paramsVersion = MG_State::pGLContext->GetRenderStateParametersVersion();` | +| 5903 | pGLContext | GetRenderStateParameters | RenderStateBlob | `const RenderStateParameters& p = MG_State::pGLContext->GetRenderStateParameters();` | +| 5979 | pGLContext | GetFramebufferBindingSlot | FboAttach | `MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();` | +| 6003 | pGLContext | GetProgramForDraw | ObjectBind(Program) | `const auto& program = *MG_State::pGLContext->GetProgramForDraw();` | +| 6051 | pGLContext | IsTransformFeedbackActive | XfbOp | `MG_State::pGLContext->IsTransformFeedbackActive() &&` | +| 6065 | pGLContext | GetBoundVertexArray | ObjectBind(VAO) | `const auto& vao = *MG_State::pGLContext->GetBoundVertexArray();` | +| 6070 | pGLContext | GetFramebufferBindingSlot | FboAttach | `MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();` | +| 6081 | pGLContext | GetPipelineStateVersion | RenderStateBlob | `const Uint renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion();` | +| 6082 | pGLContext | GetTextureBindGeneration | ObjectBind(Texture) | `const Uint64 bindGeneration = MG_State::pGLContext->GetTextureBindGeneration();` | +| 6090 | pGLContext | GetRenderStateParameters | RenderStateBlob | `const RenderStateParameters& rsp = MG_State::pGLContext->GetRenderStateParameters();` | +| 6264 | pGLContext | GetSamplingResolutionGeneration | TexParam | `const Uint64 samplingResolutionGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration();` | +| 6385 | pGLContext | GetProgramForDraw | ObjectBind(Program) | `const auto& drawProgram = *MG_State::pGLContext->GetProgramForDraw();` | +| 6397 | pGLContext | GetFramebufferBindingSlot | FboAttach | `MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();` | +| 6404 | pGLContext | GetBoundVertexArray | ObjectBind(VAO) | `const auto& vao = *MG_State::pGLContext->GetBoundVertexArray();` | +| 6405 | pGLContext | GetProgramForDraw | ObjectBind(Program) | `const auto& program = *MG_State::pGLContext->GetProgramForDraw();` | +| 6445 | pGLContext | IsTransformFeedbackActive | XfbOp | `if (m_transformFeedbackFeatureEnabled && MG_State::pGLContext->IsTransformFeedbackActive() &&` | +| 6459 | pGLContext | GetTextureBindGeneration | ObjectBind(Texture) | `const Uint64 lodBindGeneration = MG_State::pGLContext->GetTextureBindGeneration();` | +| 6465 | pGLContext | GetSamplingResolutionGeneration | TexParam | `const Uint64 lodSamplingGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration();` | +| 6583 | pGLContext | GetTextureBindGeneration | ObjectBind(Texture) | `const Uint64 bindGeneration = MG_State::pGLContext->GetTextureBindGeneration();` | +| 6602 | pGLContext | GetSamplingResolutionGeneration | TexParam | `const Uint64 samplingGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration();` | +| 6763 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::DepthTest) \|\|` | +| 6764 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::StencilTest);` | +| 6905 | pGLContext | GetPipelineStateVersion | RenderStateBlob | `snap.renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion();` | +| 6906 | pGLContext | GetTextureBindGeneration | ObjectBind(Texture) | `snap.bindGeneration = MG_State::pGLContext->GetTextureBindGeneration();` | +| 6932 | pGLContext | GetSamplingResolutionGeneration | TexParam | `snap.samplingResolutionGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration();` | +| 6969 | pGLContext | GetProgramForDispatch | ObjectBind(Program) | `const auto& program = *MG_State::pGLContext->GetProgramForDispatch();` | +| 7021 | pGLContext | GetProgramForDispatch | ObjectBind(Program) | `const auto& program = *MG_State::pGLContext->GetProgramForDispatch();` | +| 7065 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `auto indirectBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DispatchIndirect).GetBoundObject();` | +| 7139 | pGLContext | GetScissorBox | RenderStateBlob | `? MakeDefaultFramebufferScissorRect(MG_State::pGLContext->GetScissorBox(),` | +| 7142 | pGLContext | GetScissorBox | RenderStateBlob | `: MakeClampedScissorRect(MG_State::pGLContext->GetScissorBox(), renderPassEntry->extent);` | +| 7190 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `if (MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) {` | +| 7193 | pGLContext | GetFramebufferBindingSlot | FboAttach | `auto* fbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject().get();` | +| 7201 | pGLContext | GetClearColor | RenderStateBlob | `.color = MG_State::pGLContext->GetClearColor(),` | +| 7202 | pGLContext | GetClearDepth | RenderStateBlob | `.depth = MG_State::pGLContext->GetClearDepth(),` | +| 7203 | pGLContext | GetClearStencil | RenderStateBlob | `.stencil = MG_State::pGLContext->GetClearStencil()` | +| 7210 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `if (MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::ScissorTest)) {` | +| 7233 | pGLContext | GetColorMaskIndexed | RenderStateBlob | `const BoolVec4 colorMask = MG_State::pGLContext->GetColorMaskIndexed(drawBufferIndex);` | +| 7260 | pGLContext | GetDepthMask | RenderStateBlob | `if ((mask & GL_DEPTH_BUFFER_BIT) != 0 && MG_State::pGLContext->GetDepthMask()) {` | +| 7273 | pGLContext | GetStencilState | RenderStateBlob | `MG_State::pGLContext->GetStencilState(StencilFace::Front).WriteMask;` | +| 7302 | pGLContext | GetDepthMask | RenderStateBlob | `if ((deferredMask & GL_DEPTH_BUFFER_BIT) != 0 && !MG_State::pGLContext->GetDepthMask()) {` | +| 7306 | pGLContext | GetStencilState | RenderStateBlob | `const Uint32 stencilWriteMask = MG_State::pGLContext->GetStencilState(StencilFace::Front).WriteMask;` | +| 7322 | pGLContext | GetColorMaskIndexed | RenderStateBlob | `const BoolVec4 colorMask = MG_State::pGLContext->GetColorMaskIndexed(drawBufferIndex);` | +| 7343 | pGLContext | GetColorMaskIndexed | RenderStateBlob | `const BoolVec4 colorMask = MG_State::pGLContext->GetColorMaskIndexed(drawBufferIndex);` | +| 7372 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `if (MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) {` | +| 7414 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `if (MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::ScissorTest)) {` | +| 7445 | pGLContext | GetDepthMask | RenderStateBlob | `const auto depthClearAllowed = [&]() -> Bool { return MG_State::pGLContext->GetDepthMask(); };` | +| 7447 | pGLContext | GetStencilState | RenderStateBlob | `const Uint32 stencilWriteMask = MG_State::pGLContext->GetStencilState(StencilFace::Front).WriteMask;` | +| 7459 | pGLContext | GetColorMaskIndexed | RenderStateBlob | `const BoolVec4 colorMask = MG_State::pGLContext->GetColorMaskIndexed(static_cast(drawbuffer));` | +| 7516 | pGLContext | GetColorMaskIndexed | RenderStateBlob | `const BoolVec4 colorMask = MG_State::pGLContext->GetColorMaskIndexed(static_cast(drawbuffer));` | +| 7534 | pGLContext | GetDepthMask | RenderStateBlob | `if ((clearPayload.mask & GL_DEPTH_BUFFER_BIT) != 0 && MG_State::pGLContext->GetDepthMask() &&` | +| 7542 | pGLContext | GetStencilState | RenderStateBlob | `MG_State::pGLContext->GetStencilState(StencilFace::Front).WriteMask;` | +| 7561 | pGLContext | GetFramebufferBindingSlot | FboAttach | `auto* fbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject().get();` | +| 7598 | SharedPtr& framebuffer, GLenum buffer, GLint drawbuffer,` | +| 7622 | SharedPtr& framebuffer, GLenum buffer, GLint drawbuffer,` | +| 7645 | SharedPtr& framebuffer, GLenum buffer, GLint drawbuffer,` | +| 7660 | SharedPtr& framebuffer, GLenum buffer, GLint drawbuffer,` | +| 8042 | SharedPtr& renderbuffer) {` | +| 8519 | pGLContext | GetFramebufferBindingSlot | FboAttach | `auto readFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject();` | +| 8520 | pGLContext | GetFramebufferBindingSlot | FboAttach | `auto drawFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();` | +| 8524 | SharedPtr& readFbo,` | +| 8525 | SharedPtr& drawFbo,` | +| 8552 | pGLContext | IsCapabilityEnabled | RenderStateBlob | `if (MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::ScissorTest)) {` | +| 8553 | pGLContext | GetScissorBox | RenderStateBlob | `const IntVec4& scissor = MG_State::pGLContext->GetScissorBox();` | +| 9147 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(MG_State::pGLContext->GetActiveTextureUnit());` | +| 9147 | pGLContext | GetActiveTextureUnit | ObjectBind(Texture) | `auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(MG_State::pGLContext->GetActiveTextureUnit());` | +| 9155 | pGLContext | GetFramebufferBindingSlot | FboAttach | `auto readFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject();` | +| 9866 | pGLContext | GetFramebufferBindingSlot | FboAttach | `auto readFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject();` | +| 10621 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject();` | +| 10622 | pGLContext | GetPixelStoreParameters | PixelStoreBlob | `const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false);` | +| 10652 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `auto& activeUnit = MG_State::pGLContext->GetTextureUnitObject(MG_State::pGLContext->GetActiveTextureUnit());` | +| 10652 | pGLContext | GetActiveTextureUnit | ObjectBind(Texture) | `auto& activeUnit = MG_State::pGLContext->GetTextureUnitObject(MG_State::pGLContext->GetActiveTextureUnit());` | +| 10657 | SharedPtr& textureObject,` | +| 10896 | pGLContext | GetTextureUnitObject | ObjectBind(Texture) | `auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(MG_State::pGLContext->GetActiveTextureUnit());` | +| 10896 | pGLContext | GetActiveTextureUnit | ObjectBind(Texture) | `auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(MG_State::pGLContext->GetActiveTextureUnit());` | +| 11137 | pGLContext | GetBoundTransformFeedbackName | XfbOp | `const Uint name = MG_State::pGLContext->GetBoundTransformFeedbackName();` | +| 11151 | pGLContext | IsTransformFeedbackActive | XfbOp | `!MG_State::pGLContext->IsTransformFeedbackActive()) {` | +| 11157 | pGLContext | IsTransformFeedbackPaused | XfbOp | `if (MG_State::pGLContext->IsTransformFeedbackPaused()) {` | +| 11160 | pGLContext | GetTransformFeedbackProgram | XfbOp | `const auto& program = MG_State::pGLContext->GetTransformFeedbackProgram();` | +| 11199 | pGLContext | GetBufferBindingPoint | ObjectBind(BufferRange) | `auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::TransformFeedback,` | +| 11230 | pGLContext | GetTransformFeedbackGeneration | XfbOp | `const Uint64 generation = MG_State::pGLContext->GetTransformFeedbackGeneration();` | +| 11253 | pGLContext | GetTransformFeedbackProgram | XfbOp | `const auto& program = MG_State::pGLContext->GetTransformFeedbackProgram();` | +| 11901 | pGLContext | GetRenderStateParameters | RenderStateBlob | `const RenderStateParameters& rsp = MG_State::pGLContext->GetRenderStateParameters();` | +| 11979 | pGLContext | GetBoundVertexArray | ObjectBind(VAO) | `const auto& vao = *MG_State::pGLContext->GetBoundVertexArray();` | +| 11989 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();` | +| 11995 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `auto parameterBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject();` | +| 12080 | pGLContext | GetBoundVertexArray | ObjectBind(VAO) | `const auto& vao = *MG_State::pGLContext->GetBoundVertexArray();` | +| 12090 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();` | +| 12160 | pGLContext | GetBufferBindingSlot | ObjectBind(Buffer) | `auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();` | +| 12650 | pGLContext | GetProvokingVertexMode | RenderStateBlob | `MG_State::pGLContext->GetProvokingVertexMode() == ProvokingVertexMode::FirstVertex)` | +| 14813 | SharedPtr liveTexture;` | + +### `MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h` (12 hits) + +| line | kind | member | delta | snippet | +|---|---|---|---|---| +| 184 | SharedPtr& framebuffer,` | +| 186 | SharedPtr& framebuffer,` | +| 188 | SharedPtr& framebuffer,` | +| 190 | SharedPtr& framebuffer,` | +| 195 | SharedPtr& readFbo,` | +| 196 | SharedPtr& drawFbo,` | +| 256 | SharedPtr& texture,` | +| 378 | SharedPtr program;` | +| 379 | SharedPtr nearestSampler;` | +| 380 | SharedPtr linearSampler;` | +| 388 | SharedPtr program;` | +| 1390 | SharedPtr& renderbuffer);` | diff --git a/scripts/gen_pipe.py b/scripts/gen_pipe.py new file mode 100644 index 000000000..21788eb6b --- /dev/null +++ b/scripts/gen_pipe.py @@ -0,0 +1,638 @@ +#!/usr/bin/env python3 +# MobileGL - scripts/gen_pipe.py +# Copyright (c) 2025-2026 MobileGL-Dev +# Licensed under the GNU Lesser General Public License v3.0: +# https://www.gnu.org/licenses/gpl-3.0.txt +# https://www.gnu.org/licenses/lgpl-3.0.txt +# SPDX-License-Identifier: LGPL-3.0-only +# End of Source File Header +"""The seven MGPipe generators, G1..G7 (plan B section 4.1). + +Reads the three hand-maintained sources of truth + + MobileGL/MG_Pipe/PipeCalls.def the call catalogue + MobileGL/MG_Pipe/PipeFields.def per-payload field lists for the verify comparator + MobileGL/MG_Pipe/Coverage.def accessor -> call mapping for the read inventory + +plus the vendored copy of the backend read inventory + + scripts/data/backend_read_inventory.md + +and writes MobileGL/MG_Pipe/generated/*.inc. The outputs are COMMITTED; CI regenerates +them and fails on a diff, which is what keeps the seven generators from drifting apart +from the catalogue (they all consume the same .def). + + python3 scripts/gen_pipe.py # write the generated files, print the summary + python3 scripts/gen_pipe.py --check # fail if regenerating would change anything +""" + +import argparse +import os +import re +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PIPE_DIR = os.path.join(REPO_ROOT, "MobileGL", "MG_Pipe") +GENERATED_DIR = os.path.join(PIPE_DIR, "generated") +INVENTORY = os.path.join(REPO_ROOT, "scripts", "data", "backend_read_inventory.md") + +GENERATED_BANNER = """// MobileGL - MobileGL/MG_Pipe/generated/{name} +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// {title} +// +// GENERATED by scripts/gen_pipe.py from {sources} - DO NOT EDIT. +// Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result. +// This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe. +""" + +# G7. The pipeline subset of RenderStateParameters, BY MEMBER NAME, taken from the fields +# VulkanRenderer::ComputePipelineStateHash hashes today +# (MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp:4805-4906 at dev@81b17c0b, +# including ResolveEffectiveSampleMask, which the hash folds in twice - once as the +# effective enable bit and once as the mask word). +# +# Names only: offsets are NOT computed here. The chunk table with real offsets is +# MGPipeRenderStateSpans.cpp, built in C++ with offsetof, because a python guess at the +# layout of a struct it cannot see is exactly the kind of drift the G7 setter-consistency +# test exists to catch (plan B section 4.5.2). +PIPELINE_STATE_MEMBERS = [ + "CullFaceEnabled", + "DepthTestEnabled", + "PolygonOffsetFillEnabled", + "RasterizerDiscardEnabled", + "ColorLogicOpEnabled", + "StencilTestEnabled", + "PrimitiveRestartEnabled", + "PrimitiveRestartFixedIndexEnabled", + "DepthMask", + "SampleShadingEnabled", + "MultisampleEnabled", + "SampleMaskEnabled", + "SampleMaskValue", + "MinSampleShadingValue", + "PatchVertices", + "PatchDefaultOuterLevel", + "PatchDefaultInnerLevel", + "PolygonModeFront", + "CullFaceModeSetting", + "DepthFunc", + "LogicOp", + "StencilStates", + "BlendStates", + "ColorMasks", +] + + +def read(path): + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + + +class Call(object): + def __init__(self, index, name, payload, cls, flags): + self.Index = index # 1-based; this is the wire opcode + self.Name = name + self.Payload = payload + self.Class = cls + self.Flags = flags + + @property + def IsScreen(self): + return self.Class == "kScreen" + + @property + def Signature(self): + """(parameter declaration list, argument list) for this call.""" + params = ["const %s* payload" % self.Payload] + args = ["payload"] + if "kVarTail" in self.Flags: + params.append("const void* varTail") + params.append("Uint32 varTailCount") + args.append("varTail") + args.append("varTailCount") + if "kReplySlot" in self.Flags: + params.append("MGPReplySlot* reply") + args.append("reply") + return ", ".join(params), ", ".join(args) + + +CALL_RE = re.compile(r"^\s*X\(\s*(\w+)\s*,\s*(\w+)\s*,\s*(\w+)\s*,\s*([\w|]+?)\s*\)\s*\\?\s*$") + + +def parse_calls(): + text = read(os.path.join(PIPE_DIR, "PipeCalls.def")) + documented = re.search(r"#define MGP_CALL_LIST_DOCUMENTED_COUNT (\d+)", text) + if not documented: + sys.exit("PipeCalls.def: MGP_CALL_LIST_DOCUMENTED_COUNT is missing") + calls = [] + inside = False + for line in text.splitlines(): + if line.startswith("#define MGP_CALL_LIST(X)"): + inside = True + continue + if not inside: + continue + match = CALL_RE.match(line) + if match: + calls.append(Call(len(calls) + 1, match.group(1), match.group(2), match.group(3), + match.group(4).split("|"))) + # The macro ends at the first line without a continuation backslash. + if not line.rstrip().endswith("\\"): + inside = False + count = int(documented.group(1)) + if len(calls) != count: + sys.exit("PipeCalls.def: parsed %d calls but MGP_CALL_LIST_DOCUMENTED_COUNT says %d" + % (len(calls), count)) + seen = set() + for call in calls: + if call.Name in seen: + sys.exit("PipeCalls.def: duplicate call %s" % call.Name) + seen.add(call.Name) + return calls + + +def parse_verify_payloads(): + text = read(os.path.join(PIPE_DIR, "PipeFields.def")) + match = re.search(r"#define MGP_VERIFY_PAYLOAD_LIST\(P\)(.*?)\n\n", text, re.S) + if not match: + sys.exit("PipeFields.def: MGP_VERIFY_PAYLOAD_LIST is missing") + payloads = re.findall(r"P\((\w+)\)", match.group(1)) + for payload in payloads: + if ("#define MGP_FIELDS_%s(F)" % payload) not in text: + sys.exit("PipeFields.def: %s is in the payload list with no field macro" % payload) + return payloads + + +def parse_coverage(): + text = read(os.path.join(PIPE_DIR, "Coverage.def")) + accessors = [] + block = re.search(r"#define MGP_COVERAGE_ACCESSOR_LIST\(X\)(.*?)\n\n", text, re.S) + if not block: + sys.exit("Coverage.def: MGP_COVERAGE_ACCESSOR_LIST is missing") + for name, call in re.findall(r"X\((\w+)\s*,\s*(\w+)\)", block.group(1)): + accessors.append((name, call)) + deltas = [] + block = re.search(r"#define MGP_COVERAGE_DELTA_LIST\(X\)(.*?)\n\n", text, re.S) + if not block: + sys.exit("Coverage.def: MGP_COVERAGE_DELTA_LIST is missing") + for kind, call in re.findall(r"X\(([^,]+),\s*(\w+)\)", block.group(1)): + deltas.append((kind.strip(), call)) + return accessors, deltas + + +INVENTORY_ROW_RE = re.compile(r"^\|\s*(\d+)\s*\|([^|]*)\|([^|]*)\|([^|]*)\|") + + +def parse_inventory(): + if not os.path.exists(INVENTORY): + sys.exit("missing %s - copy it from MobileGL-CS/docs/CS_Refactor/" % INVENTORY) + rows = [] + current_file = None + for line in read(INVENTORY).splitlines(): + heading = re.match(r"^### `([^`]+)`", line) + if heading: + current_file = heading.group(1) + continue + match = INVENTORY_ROW_RE.match(line) + if match and current_file: + rows.append({ + "file": current_file, + "line": int(match.group(1)), + "kind": match.group(2).strip(), + "member": match.group(3).strip(), + "delta": match.group(4).strip(), + }) + return rows + + +def banner(name, title, sources): + return GENERATED_BANNER.format(name=name, title=title, sources=sources) + + +def gen_tables(calls): + screen = [c for c in calls if c.IsScreen] + context = [c for c in calls if not c.IsScreen] + out = [banner("PipeTables.inc", "G1: the two MGPipe interface tables.", "PipeCalls.def")] + for struct_name, group, what in (("MGPipeScreen", screen, "share group"), + ("MGPipeContext", context, "context")): + out.append("// %s: %d calls. A null entry means the backend does not implement this\n" + "// call and the frontend keeps its own path (plan B section 4.1).\n" + "struct %s {" % (what, len(group), struct_name)) + for call in group: + params, _ = call.Signature + out.append(" void (*%s)(%s);" % (call.Name, params)) + out.append("};\n") + out.append("inline constexpr SizeT kMGPipeScreenCallCount = %d;" % len(screen)) + out.append("inline constexpr SizeT kMGPipeContextCallCount = %d;" % len(context)) + out.append("inline constexpr SizeT kMGPipeCallCount = %d;" % len(calls)) + out.append("") + out.append("// A table that is not exactly its call count of function pointers has grown a") + out.append("// member that no generator knows about.") + out.append("static_assert(sizeof(MGPipeScreen) == kMGPipeScreenCallCount * sizeof(void (*)()),") + out.append(" \"MGPipeScreen is not exactly its catalogue's function pointers\");") + out.append("static_assert(sizeof(MGPipeContext) == kMGPipeContextCallCount * sizeof(void (*)()),") + out.append(" \"MGPipeContext is not exactly its catalogue's function pointers\");") + out.append("static_assert(kMGPipeScreenCallCount + kMGPipeContextCallCount == kMGPipeCallCount);") + out.append("static_assert(kMGPipeCallCount == MGP_CALL_LIST_DOCUMENTED_COUNT,") + out.append(" \"the catalogue and its documented count disagree\");") + return "\n".join(out) + "\n" + + +def gen_thunks(calls): + out = [banner("PipeThunks.inc", "G2: monolith thunks over the two tables.", "PipeCalls.def")] + out.append("// One inline call through the installed table. These are the names MG_Impl call") + out.append("// sites move onto, replacing gBackendFunctionsTable.GL.* one at a time. An") + out.append("// unimplemented (null) entry is the caller's business to check, exactly as it is") + out.append("// with the table this replaces.\n") + for call in calls: + params, args = call.Signature + table = "gMGPipeScreen" if call.IsScreen else "gMGPipeContext" + out.append("inline void MGP_%s(%s) {" % (call.Name, params)) + out.append(" %s.%s(%s);" % (table, call.Name, args)) + out.append("}") + out.append("") + return "\n".join(out) + + +def gen_wire(calls): + out = [banner("PipeWire.inc", "G3: wire records, size assertions and the applier's bounds gate.", + "PipeCalls.def")] + out.append("""// Every record is a fixed header plus its payload, padded to the stream's 8-byte +// granularity. The size assertion is stated as a COMPOSITION so it fires on any padding +// the compiler inserts between the header and the payload while staying honest about the +// tail padding the alignment requires. +// +// The applier's precondition is checked BEFORE dispatch, on every record, in every build: +// a record that is shorter than its own type, longer than what is left in the buffer, or +// not a multiple of 8 is protocol corruption and is fatal. There is no recovery path - +// silently applying a truncated record is how a corrupt stream becomes a wrong picture. + +struct MGPWireRecHeader { + Uint16 Op; // MGPWireOp + Uint16 Flags; // MGPipeCallFlags of the call, for asserts and tracing + Uint32 Size; // bytes of this record including the header and the variable tail +}; +static_assert(sizeof(MGPWireRecHeader) == 8, "the wire header is 8 bytes"); +static_assert(std::is_trivially_copyable_v); + +// The opcode is the call's position in PipeCalls.def. Reordering that file is a protocol +// break; appending to it is not. +enum class MGPWireOp : Uint16 { + kInvalid = 0,""") + for call in calls: + out.append(" %s = %d," % (call.Name, call.Index)) + out.append(" kOpCount = %d," % (len(calls) + 1)) + out.append("};\n") + for call in calls: + out.append("struct alignas(8) MGPWireRec_%s {" % call.Name) + out.append(" MGPWireRecHeader Header;") + out.append(" %s Payload;" % call.Payload) + out.append("};") + out.append("static_assert(sizeof(MGPWireRec_%s) ==" % call.Name) + out.append(" ((sizeof(MGPWireRecHeader) + sizeof(%s) + 7u) & ~SizeT(7u))," % call.Payload) + out.append(" \"MGPWireRec_%s gained padding; the wire format moved\");" % call.Name) + out.append("") + out.append("""[[noreturn]] inline void MGPipeWireProtocolFatal(const char* call, Uint64 size, Uint64 remaining) { + MGLOG_F("MGPipe: protocol corruption applying %s: size=%llu remaining=%llu", call, + static_cast(size), static_cast(remaining)); + std::abort(); +} + +#define MGP_WIRE_CHECK_BOUNDS(RecType, CallName) \\ + do { \\ + if (!(size >= sizeof(RecType) && size <= remaining && (size % 8) == 0)) { \\ + MGPipeWireProtocolFatal(CallName, size, remaining); \\ + } \\ + } while (0) + +// Returns whether the record was applied. P0 is a SKELETON: every case validates its +// bounds and then reports "not applied", because no applier exists until P5 wires +// MG_Remote/Server/PipeApplier.cpp to the real backend tables. The switch and the opcode +// enum come from the same list, so a call added to the catalogue cannot be forgotten here; +// the default arm is for the opcode that never came from this catalogue at all - a byte +// off a corrupt stream - and it is fatal for the same reason the bounds check is. +inline Bool MGPipeApplyWireRecord(MGPWireOp op, const void* record, Uint64 size, Uint64 remaining) { + (void)record; + switch (op) {""") + for call in calls: + out.append(" case MGPWireOp::%s:" % call.Name) + out.append(" MGP_WIRE_CHECK_BOUNDS(MGPWireRec_%s, \"%s\");" % (call.Name, call.Name)) + out.append(" return false;") + out.append(""" case MGPWireOp::kInvalid: + case MGPWireOp::kOpCount: + default: + MGPipeWireProtocolFatal("", size, remaining); + } +} + +#undef MGP_WIRE_CHECK_BOUNDS""") + return "\n".join(out) + "\n" + + +def gen_verify(payloads): + out = [banner("PipeVerify.inc", "G4: the MOBILEGL_PIPE_VERIFY field-wise comparators.", + "PipeFields.def")] + out.append("""// Field by field, never memcmp over a whole payload: RenderStateParameters is documented +// in DirectGLES.cpp to false-DIFFER on padding under memcmp (harmlessly there, fatally +// here - a comparator with false positives is a comparator nobody reads). Each function +// reports the FIRST differing field by name, which with the draw serial is what the verify +// harness prints. +// +// Floating-point fields are compared by BITS, so a NaN patch level - which +// glPatchParameterfv accepts and ComputePipelineStateHash already hashes bitwise - equals +// itself instead of tripping every draw. + +#include "../PipeFields.def" +""") + out.append("template ") + out.append("struct MGPipeHasFieldVerifier : std::false_type {};\n") + for payload in payloads: + out.append("inline Bool MGPipeVerify(const %s& a, const %s& b, const char** outField);" + % (payload, payload)) + out.append("") + for payload in payloads: + out.append("template <>") + out.append("struct MGPipeHasFieldVerifier<%s> : std::true_type {};" % payload) + out.append("") + out.append("""template +inline Bool MGPipeFieldEqual(const T& a, const T& b) { + if constexpr (MGPipeHasFieldVerifier::value) { + const char* unusedField = nullptr; + return MGPipeVerify(a, b, &unusedField); + } else if constexpr (std::is_floating_point_v) { + return std::memcmp(&a, &b, sizeof(T)) == 0; + } else if constexpr (std::is_scalar_v || std::is_enum_v) { + return a == b; + } else if constexpr (requires(const T& x, const T& y) { x == y; }) { + return a == b; + } else { + // MEMCMP FALLBACK. Only reached by the payload members that are still MG_State / + // MG_Backend value structs (RenderStateParameters, PixelStoreParameters, + // DynamicBackendParameters) and by MGHostSpan. Those are exactly the types P0.5 + // moves into MGPipeValueTypes.h, at which point they get field lists of their own + // and this branch stops being reachable from any payload. + return std::memcmp(&a, &b, sizeof(T)) == 0; + } +} + +template +inline Bool MGPipeFieldEqual(const T (&a)[N], const T (&b)[N]) { + for (SizeT i = 0; i < N; ++i) { + if (!MGPipeFieldEqual(a[i], b[i])) return false; + } + return true; +} + +#define MGP_VERIFY_FIELD(FieldName) \\ + if (!MGPipeFieldEqual(a.FieldName, b.FieldName)) { \\ + if (outField != nullptr) *outField = #FieldName; \\ + return false; \\ + } +""") + for payload in payloads: + out.append("inline Bool MGPipeVerify(const %s& a, const %s& b, const char** outField) {" + % (payload, payload)) + out.append(" MGP_FIELDS_%s(MGP_VERIFY_FIELD)" % payload) + out.append(" return true;") + out.append("}") + out.append("") + out.append("#undef MGP_VERIFY_FIELD") + out.append("") + out.append("inline constexpr SizeT kMGPipeVerifiedPayloadCount = %d;" % len(payloads)) + return "\n".join(out) + "\n" + + +def gen_filled(accessors, calls): + call_names = set(c.Name for c in calls) + out = [banner("PipeFilled.inc", "G5: PipeInputs field ids and the per-verb poison generations.", + "Coverage.def and PipeCalls.def")] + out.append("""// One field id per GLContext accessor the backends actually read (plan B section 6.2: +// PipeInputs is organized by MEMO KEY, not by read point, which is why the field set is +// small and stable across the whole migration). +// +// The poison is a per-verb GENERATION, not a bit. A bitmap cannot see the dangerous case: +// a field filled by the previous DRAW and then read by the glTexSubImage that follows is +// stale, and its bit is already set. So every verb bumps CurrentVerbSerial, filling a +// field stamps it with that serial, and reading a non-sticky field whose stamp is older is +// Fatal{UnmigratedPipeInput} (section 6.2.2). +// +// P0 is the skeleton: the enum, the tables and the assertion helper exist, PipeInputs +// itself lands in P1. +""") + out.append("enum class MGPipeInputField : Uint16 {") + for name, _ in accessors: + out.append(" %s," % name) + out.append(" kFieldCount,") + out.append("};") + out.append("") + out.append("inline constexpr SizeT kMGPipeInputFieldCount = static_cast(MGPipeInputField::kFieldCount);") + out.append("static_assert(kMGPipeInputFieldCount == %d, \"the PipeInputs field set moved\");" % len(accessors)) + out.append("") + out.append("inline constexpr const char* kMGPipeInputFieldNames[kMGPipeInputFieldCount] = {") + for name, _ in accessors: + out.append(" \"%s\"," % name) + out.append("};") + out.append("") + out.append("// Fields whose value is valid ACROSS verbs. Every entry is false in P0 and each") + out.append("// true has to be argued for in P1 when the fillers land: a sticky field is a field") + out.append("// the poison cannot protect.") + out.append("inline constexpr Bool kMGPipeInputFieldSticky[kMGPipeInputFieldCount] = {") + for name, _ in accessors: + out.append(" false, // %s" % name) + out.append("};") + out.append("") + out.append("// Which call is expected to have filled a field by the time a verb reads it. Names") + out.append("// come from Coverage.def, so this table and the coverage table cannot disagree.") + out.append("inline constexpr const char* kMGPipeInputFieldFilledBy[kMGPipeInputFieldCount] = {") + for name, call in accessors: + marker = "" if call in call_names else " // pseudo-call: not filled by a forward record" + out.append(" \"%s\",%s" % (call, marker)) + out.append("};") + out.append("") + out.append("""struct MGPipeFilledState { + Uint64 CurrentVerbSerial; + Uint64 FilledGen[kMGPipeInputFieldCount]; +}; + +[[noreturn]] inline void MGPipeInputPoisonFatal(MGPipeInputField field, const char* verb) { + MGLOG_F("MGPipe: Fatal{UnmigratedPipeInput, \\"%s@%s\\"}", + kMGPipeInputFieldNames[static_cast(field)], verb); + std::abort(); +} + +inline Bool MGPipeInputFieldIsFresh(const MGPipeFilledState& state, MGPipeInputField field) { + const SizeT index = static_cast(field); + return kMGPipeInputFieldSticky[index] ? state.FilledGen[index] != 0 + : state.FilledGen[index] == state.CurrentVerbSerial; +}""") + return "\n".join(out) + "\n" + + +def gen_coverage(accessors, deltas, rows, calls): + call_names = set(c.Name for c in calls) + pseudo = {"kClientResolved", "kReverseChannel", "kStructuralHandle"} + accessor_map = dict(accessors) + delta_map = dict(deltas) + for _, call in accessors + deltas: + if call not in call_names and call not in pseudo: + sys.exit("Coverage.def: %s is not a call in PipeCalls.def and not a pseudo-call" % call) + + mapped = 0 + by_pseudo = {name: 0 for name in pseudo} + unmapped_rows = [] + per_accessor = {} + for row in rows: + call = None + if row["member"] and row["member"] != "-": + call = accessor_map.get(row["member"]) + if call is None: + call = delta_map.get(row["delta"]) + if call is None: + unmapped_rows.append(row) + continue + if call in pseudo: + by_pseudo[call] += 1 + else: + mapped += 1 + key = row["member"] if row["member"] and row["member"] != "-" else row["delta"] + per_accessor.setdefault(key, [call, 0])[1] += 1 + + out = [banner("PipeCoverage.inc", "G6: backend read inventory -> MGPipe call coverage.", + "Coverage.def and scripts/data/backend_read_inventory.md")] + out.append("""// The acceptance rule (plan B section 10.3-5): regenerate, `git diff --exit-code`, and +// ZERO unmapped rows. P0 permits unmapped rows and only counts them; the count below is +// the number the later gate has to drive to zero. +// +// Three pseudo-calls stand for read points that never become a forward record: +// kClientResolved (the frontend answers it), kReverseChannel (it becomes one of the ten +// MGPipeCallbacks) and kStructuralHandle (the row is a signature carrying a +// SharedPtr that becomes an MGPipeHandle parameter). +""") + out.append("struct MGPipeCoverageEntry {") + out.append(" const char* Accessor;") + out.append(" const char* Call;") + out.append(" Uint32 ReadPoints;") + out.append("};") + out.append("") + out.append("inline constexpr MGPipeCoverageEntry kMGPipeCoverage[] = {") + for key in sorted(per_accessor): + call, count = per_accessor[key] + out.append(" {\"%s\", \"%s\", %d}," % (key, call, count)) + out.append("};") + out.append("") + out.append("inline constexpr SizeT kMGPipeCoverageEntryCount = %d;" % len(per_accessor)) + out.append("inline constexpr Uint32 kMGPipeInventoryReadPoints = %d;" % len(rows)) + out.append("inline constexpr Uint32 kMGPipeInventoryMappedToCall = %d;" % mapped) + out.append("inline constexpr Uint32 kMGPipeInventoryClientResolved = %d;" % by_pseudo["kClientResolved"]) + out.append("inline constexpr Uint32 kMGPipeInventoryReverseChannel = %d;" % by_pseudo["kReverseChannel"]) + out.append("inline constexpr Uint32 kMGPipeInventoryStructuralHandle = %d;" + % by_pseudo["kStructuralHandle"]) + out.append("inline constexpr Uint32 kMGPipeInventoryUnmapped = %d;" % len(unmapped_rows)) + out.append("static_assert(kMGPipeCoverageEntryCount == sizeof(kMGPipeCoverage) / sizeof(kMGPipeCoverage[0]));") + out.append("static_assert(kMGPipeInventoryMappedToCall + kMGPipeInventoryClientResolved +") + out.append(" kMGPipeInventoryReverseChannel + kMGPipeInventoryStructuralHandle +") + out.append(" kMGPipeInventoryUnmapped ==") + out.append(" kMGPipeInventoryReadPoints,") + out.append(" \"every inventory row must land in exactly one bucket\");") + return "\n".join(out) + "\n", unmapped_rows, mapped, by_pseudo + + +def gen_span_table(): + out = [banner("PipeSpanTable.inc", "G7: the render-state pipeline subset, by member name.", + "the field list in scripts/gen_pipe.py")] + out.append("""// D-B1 rejected three CSOs and demanded this table instead, so the table needs its own +// completeness trip wire: MG_Test walks every public RenderState setter and asserts that +// the pipeline-subset hash moves IF AND ONLY IF m_pipelineStateVersion moves. That test +// and MGPipeRenderStateSpans.cpp land with P2; what P0 pins is the MEMBER LIST, taken from +// what VulkanRenderer::ComputePipelineStateHash hashes today, so the later offsets are +// derived from a list that was reviewed rather than invented. +// +// Deliberately absent, and each absence is a question P2 has to answer before the chunk +// table freezes: +// - FramebufferSrgb and DepthClamp have NO STORAGE at all (RenderState.cpp's SetCapability +// falls to "not supported currently" and IsCapabilityEnabled returns false), so six +// backend read points are constant false today. Pipeline state or dead capability? +// - ProvokingVertexModeSetting is Vulkan pipeline state but is not hashed today. +// - FrontFaceModeSetting, ClipOrigin and ClipDepthMode are pipeline state on Vulkan and +// are handled elsewhere in the payload path rather than in the memo word. +// +// The complement of this list is the DYNAMIC subset - the half whose whole purpose is that +// glViewport must not mint a new CSO. + +inline constexpr const char* const kMGPipePipelineStateMembers[] = {""") + for member in PIPELINE_STATE_MEMBERS: + out.append(" \"%s\"," % member) + out.append("};") + out.append("inline constexpr SizeT kMGPipePipelineStateMemberCount = %d;" % len(PIPELINE_STATE_MEMBERS)) + out.append("static_assert(kMGPipePipelineStateMemberCount ==") + out.append(" sizeof(kMGPipePipelineStateMembers) / sizeof(kMGPipePipelineStateMembers[0]));") + out.append("") + out.append("// Filled in by MG_Pipe/MGPipeRenderStateSpans.cpp (P2), which computes the offsets") + out.append("// in C++ with offsetof rather than guessing them in python.") + out.append("extern const MGPStateChunk kMGPipePipelineChunks[];") + out.append("extern const MGPStateChunk kMGPipeDynamicChunks[];") + return "\n".join(out) + "\n" + + +def write(path, text, check, changed): + existing = read(path) if os.path.exists(path) else None + if existing == text: + return + changed.append(os.path.relpath(path, REPO_ROOT)) + if not check: + with open(path, "w", encoding="utf-8", newline="\n") as handle: + handle.write(text) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", + help="do not write; exit 1 if regenerating would change anything") + args = parser.parse_args() + + calls = parse_calls() + payloads = parse_verify_payloads() + accessors, deltas = parse_coverage() + rows = parse_inventory() + + if not os.path.isdir(GENERATED_DIR): + os.makedirs(GENERATED_DIR) + + coverage_text, unmapped, mapped, pseudo = gen_coverage(accessors, deltas, rows, calls) + changed = [] + write(os.path.join(GENERATED_DIR, "PipeTables.inc"), gen_tables(calls), args.check, changed) + write(os.path.join(GENERATED_DIR, "PipeThunks.inc"), gen_thunks(calls), args.check, changed) + write(os.path.join(GENERATED_DIR, "PipeWire.inc"), gen_wire(calls), args.check, changed) + write(os.path.join(GENERATED_DIR, "PipeVerify.inc"), gen_verify(payloads), args.check, changed) + write(os.path.join(GENERATED_DIR, "PipeFilled.inc"), gen_filled(accessors, calls), args.check, changed) + write(os.path.join(GENERATED_DIR, "PipeCoverage.inc"), coverage_text, args.check, changed) + write(os.path.join(GENERATED_DIR, "PipeSpanTable.inc"), gen_span_table(), args.check, changed) + + screen = sum(1 for c in calls if c.IsScreen) + print("gen_pipe: %d calls (%d screen, %d context), %d verify payloads, %d PipeInputs fields" + % (len(calls), screen, len(calls) - screen, len(payloads), len(accessors))) + print("gen_pipe: inventory %d rows: %d -> call, %d client-resolved, %d reverse-channel, " + "%d structural handle, %d UNMAPPED" + % (len(rows), mapped, pseudo["kClientResolved"], pseudo["kReverseChannel"], + pseudo["kStructuralHandle"], len(unmapped))) + for row in unmapped: + print("gen_pipe: UNMAPPED %s:%d %s %s" % (row["file"], row["line"], row["kind"], row["member"])) + + if changed: + if args.check: + print("gen_pipe: OUT OF DATE: %s" % ", ".join(changed), file=sys.stderr) + return 1 + print("gen_pipe: wrote %s" % ", ".join(changed)) + else: + print("gen_pipe: generated files are up to date") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 33632589085d585a30a1217e91e786891d649a64 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 14:07:33 -0400 Subject: [PATCH 005/529] [Test] (MGPipe): pin the catalogue arithmetic, the wire opcodes and the comparator - Thirteen cases that need no GL context and no driver, so the fact that PipeCalls.def and the seven generated files agree is checked on every unit run rather than at review time. - The catalogue count is one fact stated three times - the macro expansion, MGP_CALL_LIST_DOCUMENTED_COUNT and the two generated tables - and the per-class counts documented in the .def header are asserted individually, which is what caught kCtxState being 17 rather than 18 (set_texture_params carries kCtxObject, per the plan's own example in section 4.1). - An uninstalled pipe must be all-null: that is what "this subsystem has not been migrated, keep pulling" means (section 4.1), so it is asserted rather than assumed. - The comparator test pins the two properties the verify harness depends on: the FIRST differing field is named, and padding is not a field - two payloads that differ only in padding compare equal, while a nested array element does not. - The poison test pins the per-verb behaviour a bitmap cannot express: a field filled for verb N is stale at verb N+1. --- MobileGL/MG_Test/CMakeLists.txt | 3 + MobileGL/MG_Test/Pipe/CMakeLists.txt | 28 +++ MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp | 220 ++++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 MobileGL/MG_Test/Pipe/CMakeLists.txt create mode 100644 MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp diff --git a/MobileGL/MG_Test/CMakeLists.txt b/MobileGL/MG_Test/CMakeLists.txt index a981087de..14615b54e 100644 --- a/MobileGL/MG_Test/CMakeLists.txt +++ b/MobileGL/MG_Test/CMakeLists.txt @@ -76,6 +76,9 @@ add_subdirectory(VertexArray) add_subdirectory(Program) add_subdirectory(Query) add_subdirectory(Pipeline) +# The MGPipe catalogue arithmetic: no GL context and no driver, just the .def, the seven +# generated files and the payload layouts. +add_subdirectory(Pipe) add_subdirectory(ShaderTranspiler) add_subdirectory(Util) add_subdirectory(SelfTest) diff --git a/MobileGL/MG_Test/Pipe/CMakeLists.txt b/MobileGL/MG_Test/Pipe/CMakeLists.txt new file mode 100644 index 000000000..19dc5e4b6 --- /dev/null +++ b/MobileGL/MG_Test/Pipe/CMakeLists.txt @@ -0,0 +1,28 @@ +cmake_minimum_required(VERSION 3.14) + +add_executable( + PipeCatalogueTest + PipeCatalogueTest.cpp +) + +target_include_directories(PipeCatalogueTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/MobileGL/MG_Pipe + ${MGL_ROOT}/3rdparty/xxHash + ${MGL_ROOT}/3rdparty/Vulkan-Headers/include + ${MGL_ROOT}/3rdparty/SPIRV-Reflect +) + +target_link_libraries( + PipeCatalogueTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + +if (MSVC) + target_compile_options(PipeCatalogueTest PRIVATE /Zc:preprocessor) +endif() + +include(GoogleTest) +gtest_discover_tests(PipeCatalogueTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp new file mode 100644 index 000000000..3d63eb71a --- /dev/null +++ b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp @@ -0,0 +1,220 @@ +// MobileGL - MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The arithmetic of the MGPipe catalogue (plan B section 4.4, appendix A). Everything here +// is cheap on purpose: it is the test that fails when PipeCalls.def and the seven generated +// files stop agreeing, and it must not need a GL context to say so. + +#include + +#include "Includes.h" +#include + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + // Counting expansions of the catalogue. The Class parameter is a real enumerator, so a + // per-class count is a constant expression too. +#define MGP_COUNT_ONE(Name, Payload, Class, Flags) +1 +#define MGP_COUNT_CLASS(Name, Payload, Class, Flags) +((Class) == countedClass ? 1 : 0) + + constexpr SizeT kExpandedCallCount = 0 MGP_CALL_LIST(MGP_COUNT_ONE); + + template + constexpr SizeT ClassCount() { + return 0 MGP_CALL_LIST(MGP_COUNT_CLASS); + } + + // Every payload named in the catalogue must be a memcpy-able POD, and so must every + // payload the verify comparator knows about. +#define MGP_ASSERT_CALL_PAYLOAD_POD(Name, Payload, Class, Flags) \ + static_assert(std::is_trivially_copyable_v, #Name "'s payload " #Payload " is not trivially copyable"); + MGP_CALL_LIST(MGP_ASSERT_CALL_PAYLOAD_POD) + +#define MGP_ASSERT_VERIFY_PAYLOAD_POD(Payload) \ + static_assert(std::is_trivially_copyable_v, #Payload " is not trivially copyable"); + MGP_VERIFY_PAYLOAD_LIST(MGP_ASSERT_VERIFY_PAYLOAD_POD) +} // namespace + +// The handle is the whole object model. Eight bytes, a register pair, no padding. +TEST(PipeCatalogue, HandleIsEightBytes) { + static_assert(sizeof(MGPipeHandle) == 8); + static_assert(alignof(MGPipeHandle) == 4); + static_assert(std::is_trivially_copyable_v); + EXPECT_EQ(sizeof(MGPipeHandle), 8u); + + // The two reserved handles, and the composite band that the program-pipeline resolver + // allocates out of. + EXPECT_TRUE(MGPipeHandleIsNull(kMGPipeNullHandle)); + EXPECT_FALSE(MGPipeHandleIsNull(kMGPipeDefaultFramebuffer)); + EXPECT_FALSE(MGPipeIsCompositeShaderSlot(kMGPipeFirstAllocatableSlot)); + EXPECT_TRUE(MGPipeIsCompositeShaderSlot(kMGPipeShaderCsoCompositeSlotBase)); + EXPECT_FALSE(MGPipeIsCompositeShaderSlot(kMGPipeShaderCsoSlotLimit)); +} + +// The catalogue, the number documented in its header, and the two generated tables are one +// fact stated three times. This is the test that notices when they stop being. +TEST(PipeCatalogue, EntryCountMatchesTheDocumentedCount) { + static_assert(kExpandedCallCount == MGP_CALL_LIST_DOCUMENTED_COUNT); + static_assert(kExpandedCallCount == kMGPipeCallCount); + EXPECT_EQ(kExpandedCallCount, static_cast(MGP_CALL_LIST_DOCUMENTED_COUNT)); + EXPECT_EQ(kMGPipeCallCount, kExpandedCallCount); +} + +TEST(PipeCatalogue, GeneratedTablesHoldTheWholeCatalogue) { + static_assert(ClassCount() == kMGPipeScreenCallCount); + static_assert(ClassCount() + ClassCount() + ClassCount() + + ClassCount() + ClassCount() + ClassCount() == + kMGPipeCallCount); + // The tables ARE their function pointers: a struct that is bigger than its call count + // has grown a member no generator knows about. + static_assert(sizeof(MGPipeScreen) == kMGPipeScreenCallCount * sizeof(void (*)())); + static_assert(sizeof(MGPipeContext) == kMGPipeContextCallCount * sizeof(void (*)())); + + EXPECT_EQ(kMGPipeScreenCallCount, ClassCount()); + EXPECT_EQ(kMGPipeContextCallCount, kMGPipeCallCount - ClassCount()); + + // The per-class counts PipeCalls.def documents in its header. + EXPECT_EQ(ClassCount(), 10u); + EXPECT_EQ(ClassCount(), 6u); + EXPECT_EQ(ClassCount(), 13u); + EXPECT_EQ(ClassCount(), 17u); + EXPECT_EQ(ClassCount(), 9u); + EXPECT_EQ(ClassCount(), 13u); +} + +// An uninstalled pipe is every entry null - which is exactly what "this subsystem has not +// been migrated, keep pulling" means (plan B section 4.1). +TEST(PipeCatalogue, UninstalledTablesAreAllNull) { + const void* const* screen = reinterpret_cast(&gMGPipeScreen); + for (SizeT i = 0; i < kMGPipeScreenCallCount; ++i) { + EXPECT_EQ(screen[i], nullptr) << "screen entry " << i; + } + const void* const* context = reinterpret_cast(&gMGPipeContext); + for (SizeT i = 0; i < kMGPipeContextCallCount; ++i) { + EXPECT_EQ(context[i], nullptr) << "context entry " << i; + } +} + +// The retirement ratchet of the migration carrier (section 6.3): the constant and the +// struct must agree, and the constant only ever goes down. +TEST(PipeCatalogue, ResidualBlockSizeIsPinned) { + static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE); + EXPECT_EQ(sizeof(ResidualValueBlock), static_cast(MGL_RESIDUAL_BLOCK_SIZE)); + // It carries the whole of both value structs today; that is what the later stages eat. + EXPECT_GE(sizeof(ResidualValueBlock), sizeof(RenderStateParameters) + sizeof(PixelStoreParameters)); +} + +// G3's opcode numbering is the wire protocol. Position in PipeCalls.def, 1-based, no holes. +TEST(PipeCatalogue, WireOpcodesAreThePositionsInTheCatalogue) { + EXPECT_EQ(static_cast(MGPWireOp::GetCaps), 1); + EXPECT_EQ(static_cast(MGPWireOp::kOpCount), kMGPipeCallCount + 1); + EXPECT_EQ(sizeof(MGPWireRecHeader), 8u); + // Every record is a multiple of the stream's 8-byte granularity, which is half of the + // applier's precondition. + EXPECT_EQ(sizeof(MGPWireRec_DrawVbo) % 8, 0u); + EXPECT_EQ(sizeof(MGPWireRec_BindRenderState) % 8, 0u); + EXPECT_EQ(sizeof(MGPWireRec_SetResidualValueState) % 8, 0u); +} + +// A well-formed record passes the applier's bounds gate. P0 has no applier, so "accepted" +// is reported as "not applied" rather than "fatal". +TEST(PipeCatalogue, ApplierAcceptsAWellFormedRecord) { + MGPWireRec_Present record{}; + record.Header.Op = static_cast(MGPWireOp::Present); + record.Header.Size = sizeof(record); + record.Payload.FrameSerial = 42; + EXPECT_FALSE(MGPipeApplyWireRecord(MGPWireOp::Present, &record, sizeof(record), sizeof(record))); +} + +// G4 reports the FIRST differing field by name, and compares field by field so that +// padding cannot produce a difference that does not exist. +TEST(PipeCatalogue, VerifyComparatorNamesTheDifferingField) { + MGPDrawInfo a{}; + MGPDrawInfo b{}; + const char* field = nullptr; + EXPECT_TRUE(MGPipeVerify(a, b, &field)); + + b.InstanceCount = 7; + EXPECT_FALSE(MGPipeVerify(a, b, &field)); + EXPECT_STREQ(field, "InstanceCount"); + + // Padding bytes are not fields: writing to them cannot make two payloads differ. + MGPBindRenderState c{}; + MGPBindRenderState d{}; + c.Cso = MGPipeHandle{3, 1}; + d.Cso = MGPipeHandle{3, 1}; + field = nullptr; + EXPECT_TRUE(MGPipeVerify(c, d, &field)); + + // Nested payloads recurse, and arrays compare element-wise. + MGPFramebufferState left{}; + MGPFramebufferState right{}; + right.Color[3].Level = 2; + EXPECT_FALSE(MGPipeVerify(left, right, &field)); + EXPECT_STREQ(field, "Color"); +} + +// G6's join over the backend read inventory. P0 allows unmapped rows; from P5 the gate is +// zero, so the numbers are asserted here to make a regression visible the day it happens. +TEST(PipeCatalogue, CoverageAccountsForEveryInventoryRow) { + EXPECT_EQ(kMGPipeInventoryReadPoints, 477u); + EXPECT_EQ(kMGPipeInventoryUnmapped, 0u); + EXPECT_EQ(kMGPipeInventoryMappedToCall + kMGPipeInventoryClientResolved + + kMGPipeInventoryReverseChannel + kMGPipeInventoryStructuralHandle + + kMGPipeInventoryUnmapped, + kMGPipeInventoryReadPoints); + EXPECT_GT(kMGPipeCoverageEntryCount, 0u); +} + +// G5's field ids come from the same accessor list as the coverage table, and every field +// starts un-filled: reading one before its verb fills it is the poison's whole job. +TEST(PipeCatalogue, PipeInputFieldsStartUnfilled) { + EXPECT_EQ(kMGPipeInputFieldCount, 61u); + MGPipeFilledState state{}; + state.CurrentVerbSerial = 1; + EXPECT_FALSE(MGPipeInputFieldIsFresh(state, MGPipeInputField::GetRenderStateParameters)); + state.FilledGen[static_cast(MGPipeInputField::GetRenderStateParameters)] = 1; + EXPECT_TRUE(MGPipeInputFieldIsFresh(state, MGPipeInputField::GetRenderStateParameters)); + // The next verb makes the same value stale, which a written-once bitmap could not see. + state.CurrentVerbSerial = 2; + EXPECT_FALSE(MGPipeInputFieldIsFresh(state, MGPipeInputField::GetRenderStateParameters)); +} + +// G7 pins the member list the pipeline/dynamic split is derived from. +TEST(PipeCatalogue, PipelineSubsetMembersArePinned) { + EXPECT_EQ(kMGPipePipelineStateMemberCount, 24u); + EXPECT_STREQ(kMGPipePipelineStateMembers[0], "CullFaceEnabled"); + EXPECT_STREQ(kMGPipePipelineStateMembers[kMGPipePipelineStateMemberCount - 1], "ColorMasks"); +} + +// The reverse channel is exactly ten callbacks (section 7.1). +TEST(PipeCatalogue, ReverseChannelHasTenCallbacks) { + EXPECT_EQ(kMGPipeCallbackCount, 10u); + EXPECT_EQ(sizeof(MGPipeCallbacks), kMGPipeCallbackCount * sizeof(void (*)())); +} + +// The one shape that changes with the transport. In a monolith it resolves to the pointer +// it was given; with no transport installed a segment-backed span resolves to nothing +// rather than to garbage. +TEST(PipeCatalogue, HostSpanResolvesTheMonolithPointer) { + static_assert(sizeof(MGHostSpan) == 32); + const Uint8 bytes[8] = {0, 1, 2, 3, 4, 5, 6, 7}; + MGHostSpan span{}; + span.Ptr = bytes; + span.Size = sizeof(bytes); + span.Offset = 2; + EXPECT_EQ(MGPipeHostBytes(span), bytes + 2); + + MGHostSpan staged{}; + staged.Seg = 4; + staged.Size = 16; + EXPECT_EQ(gMGPipeSegmentResolver, nullptr); + EXPECT_EQ(MGPipeHostBytes(staged), nullptr); +} From 2f8d0f0d51828fe64017a5109405330cacee4069 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 14:07:33 -0400 Subject: [PATCH 006/529] [Feat] (Config): parse the six MOBILEGL_PIPE_* switches - Appendix B of plan B. All six default to today's behaviour: PipePush 0 is "pull everything", verify and stats off, legacy memos ON so the first handle waves keep a real old-versus-new arm (B-R16), texel retain 0 because MipmapStorage already holds a complete CPU shadow so that cache buys latency and never correctness (section 7.5c), index mirror 64 MiB. - No allow-list edit is needed and the header says why: InitializeAcceptedEnvVariables accepts every MOBILEGL_ / LIBGL_ prefixed variable found in the environment, so a name with that prefix is visible to these queries by construction - the failure mode of a hand-maintained accepted list does not exist here. - PipeLegacyMemos defaults ON, so it is read as a tri-state (QueryEnvQuirkOverride != ForceOff) rather than as a plain truthy flag: unset must keep the memos and only an explicitly falsy value may drop them. Reading it with QueryEnvFlag would have inverted the default silently. - QueryEnvUint64 is added alongside QueryEnvUint32 for the subsystem bitmask, accepting a 0x prefix - a bitmask written in decimal is unreadable - and warning and falling back to the default on anything unparseable, exactly like its 32-bit sibling. --- MobileGL/Config.h | 31 +++++++++++++++++++++++++++++++ MobileGL/ConfigLoader.cpp | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/MobileGL/Config.h b/MobileGL/Config.h index 8e9962f03..a6ab28504 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -316,6 +316,37 @@ namespace MobileGL::MG_Config { // immune to the probe's verdict moving), and ForceOff is the negative control that // replays the driver's silence. QuirkOverride MagmaPrimGenQueryReroute = QuirkOverride::Auto; + // --- MGPipe (the disaggregation plan's explicit frontend/backend boundary) --- + // MOBILEGL_PIPE_PUSH: per-subsystem bitmask selecting which state the frontend + // PUSHES over MGPipe instead of leaving the backend to pull it out of GLContext. + // 0 - the default and the only shipped value until the migration lands - is "pull + // everything", i.e. exactly today's behaviour. One bit of it also turns OFF + // client-side content addressing of CSOs, which is the negative control the CSO + // design is measured against. Accepts decimal or 0x-prefixed hex. + Uint64 PipePush = 0; + // MOBILEGL_PIPE_VERIFY: per-draw, per-FIELD shadow comparison of the pushed state + // against a snapshot taken from GLContext the old way, printing the first field + // that differs and the draw serial. Roughly 5-10x slower and never shipped; it is + // the semantic gate that replaces byte identity, and it catches the dangerous + // direction - a dirty bit that fires too RARELY - which no purity gate can see. + Bool PipeVerify = false; + // MOBILEGL_PIPE_STATS: dump the boundary counters (bytes, calls, roundtrips, + // texture pulls, upload shapes, residual-block bytes, index mirror bytes). + Bool PipeStats = false; + // MOBILEGL_PIPE_LEGACY_MEMOS: keep the pre-handle registries and TwinLookupMemos + // alive so the first handle waves have a real old-versus-new arm to be compared + // against. ON by default for the whole migration window, deleted with the pull + // path itself. + Bool PipeLegacyMemos = true; + // MOBILEGL_PIPE_TEXEL_RETAIN_MB: LRU budget for texels retained against a + // server-initiated texture re-send. Default 0, i.e. OFF: MipmapStorage already + // holds a complete CPU shadow, so this cache buys latency, never correctness. + Uint32 PipeTexelRetainMb = 0; + // MOBILEGL_PIPE_INDEX_MIRROR_MB: budget for the server-side index host mirror, + // which is what lets primitive-restart rewriting and multi-draw flattening stay on + // the server without shipping index bytes per draw. Over budget it degrades to + // per-draw staging, counted separately in the stats. + Uint32 PipeIndexMirrorMb = 64; }; extern FeaturesTable Features; } // namespace MobileGL::MG_Config diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index 478093846..b760b1444 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -159,6 +159,28 @@ namespace MobileGL::MG_ConfigLoader { return static_cast(parsedValue); } + // Same contract as QueryEnvUint32, over 64 bits and accepting a 0x prefix: the one + // consumer is a subsystem BITMASK, and a bitmask written in decimal is unreadable. + inline Uint64 QueryEnvUint64(const String& key, Uint64 defaultValue) { + auto it = acceptedEnvVariablesMap->find(key); + if (it == acceptedEnvVariablesMap->end()) { + return defaultValue; + } + + const String& value = it->second; + char* parseEnd = nullptr; + errno = 0; + const unsigned long long parsedValue = std::strtoull(value.c_str(), &parseEnd, 0); + if (parseEnd == value.c_str() || *parseEnd != '\0' || errno == ERANGE) { + MGLOG_W("Config: Ignoring invalid env variable %s='%s'; expected an integer (decimal or " + "0x-prefixed), using default %llu", + key.c_str(), value.c_str(), static_cast(defaultValue)); + return defaultValue; + } + + return static_cast(parsedValue); + } + inline void InitFeatures() { auto& features = MG_Config::Features; features.DisableTimerQuery = QueryEnvFlag("MOBILEGL_DISABLE_TIMERQUERY"); @@ -207,6 +229,19 @@ namespace MobileGL::MG_ConfigLoader { features.EsprytWidenPacked16Storage = QueryEnvQuirkOverride("MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE"); features.MagmaPrimGenQueryReroute = QueryEnvQuirkOverride("MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE"); + // MGPipe. Nothing here needs adding to an allow-list: InitializeAcceptedEnvVariables + // accepts every MOBILEGL_ / LIBGL_ prefixed variable in the environment, so a name + // that starts with MOBILEGL_ is visible to these queries by construction. + features.PipePush = QueryEnvUint64("MOBILEGL_PIPE_PUSH", 0); + features.PipeVerify = QueryEnvFlag("MOBILEGL_PIPE_VERIFY"); + features.PipeStats = QueryEnvFlag("MOBILEGL_PIPE_STATS"); + // Defaults ON, so the flag has to be read as a tri-state rather than as a plain + // truthy check: unset must keep the memos, and only an explicitly falsy value may + // drop them. + features.PipeLegacyMemos = + QueryEnvQuirkOverride("MOBILEGL_PIPE_LEGACY_MEMOS") != MG_Config::QuirkOverride::ForceOff; + features.PipeTexelRetainMb = QueryEnvUint32("MOBILEGL_PIPE_TEXEL_RETAIN_MB", 0, 0, 4096); + features.PipeIndexMirrorMb = QueryEnvUint32("MOBILEGL_PIPE_INDEX_MIRROR_MB", 64, 0, 4096); } inline void InitBackendType() { From 9bbf71990cf178f1080b5d36f88bedc978484791 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 14:07:33 -0400 Subject: [PATCH 007/529] [Build] (CI): add the pipe-gen check, the stdio-instrumentation gate and the citation lint - Three P0 gates from plan B section 11, in one job that needs no build: a broken build must not be able to hide a drifted interface. - pipe-gen-check regenerates G1-G7 and runs git diff --exit-code over MobileGL/MG_Pipe/generated. Since all seven generators read the same .def files, this is what makes drift between them impossible rather than merely unlikely. - The stdio gate refuses fprintf(stderr and printf( under MG_Backend and MG_State. Nothing there matches today, so it lands with NO whitelist - verified with a negative control that fprintf(stderr and std::printf both trip it while snprintf does not. Per-draw instrumentation has been committed by accident before, once inside a mutex critical section, and MGLOG_D is the channel these trees are allowed to use because it compiles out in INFO builds. - scripts/gen_pipe_dirty_surface.py reports the frontend mutation surface corollary 4 needs covered: 926 mutator calls under MG_Impl/GLImpl over 73 distinct mutators, of which only 92 sit in a function that also reaches the backend. The other 834 are published by the NEXT verb, which is precisely the population that needs an aggregate generation. Informational in P0; it becomes a gate in P1 when there is a mapping file to diff against. - scripts/check_doc_citations.py resolves every `path:line` citation against a git revision. It reproduces the failure that motivated it - the plan's first draft cited SamplerObject.h:468-492 in a 160-line file - and reports 84 unresolved citations out of 1028 in docs/Disaggregated today, which is why CI runs it warning-only until those documents settle. --strict exits 1, verified in both directions. --- .github/workflows/test.yml | 52 ++++++++ scripts/check_doc_citations.py | 123 ++++++++++++++++++ scripts/gen_pipe_dirty_surface.py | 202 ++++++++++++++++++++++++++++++ 3 files changed, 377 insertions(+) create mode 100644 scripts/check_doc_citations.py create mode 100644 scripts/gen_pipe_dirty_surface.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c343614da..fa7b37771 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -778,3 +778,55 @@ jobs: ) echo "Deleted ${deleted} intermediate Linux artifact(s); retained ${retained} failed-retrace fixture(s)." + + pipe-gates: + name: MGPipe generators and hygiene gates + runs-on: ubuntu-latest + # Deliberately independent of build-linux: these are source-level gates, they take + # seconds, and a broken build must not hide a drifted interface. + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + # The seven generators all read MG_Pipe/*.def, so regenerating and diffing is what + # keeps the two interface tables, the wire records, the verify comparators, the + # PipeInputs field ids, the read-inventory coverage and the render-state member list + # from drifting apart from the catalogue. The generated files are committed + # deliberately: the build must not depend on python. + - name: Regenerate the MGPipe interface (G1-G7) + run: | + python3 scripts/gen_pipe.py + git diff --exit-code -- MobileGL/MG_Pipe/generated + + # Per-draw fprintf/printf instrumentation has repeatedly been committed by accident, + # once inside a mutex critical section. Nothing under these two trees prints to a + # stdio stream today - MGLOG_D compiles out in INFO builds and is the only channel + # they are allowed to use - so this gate starts with no exceptions, and any addition + # to it needs a reason in the pull request rather than a quiet whitelist entry. + - name: No stdio instrumentation in MG_Backend or MG_State + run: | + if grep -rnE 'fprintf[[:space:]]*\(stderr|(^|[^[:alnum:]_>.])printf[[:space:]]*\(' \ + MobileGL/MG_Backend MobileGL/MG_State; then + echo "::error::stdio instrumentation found; use MGLOG_D (compiled out in INFO builds)" + exit 1 + fi + echo "no fprintf(stderr / printf( under MobileGL/MG_Backend or MobileGL/MG_State" + + # Informational: the frontend mutation surface an MGPipe aggregate generation has to + # cover. It becomes a gate in P1, when the mapping file exists to diff against. + - name: MGPipe dirty-surface report + run: python3 scripts/gen_pipe_dirty_surface.py --summary + + # Warning only for now: the disaggregation documents are still being written, and a + # lint that fails a rewrite in progress teaches people to ignore it. It becomes + # --strict when the documents settle. + - name: Documentation citation lint + run: | + shopt -s nullglob + documents=(docs/Disaggregated/*.md) + if [ ${#documents[@]} -eq 0 ]; then + echo "no disaggregation documents to check" + exit 0 + fi + python3 scripts/check_doc_citations.py "${documents[@]}" || true diff --git a/scripts/check_doc_citations.py b/scripts/check_doc_citations.py new file mode 100644 index 000000000..d53659b40 --- /dev/null +++ b/scripts/check_doc_citations.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# MobileGL - scripts/check_doc_citations.py +# Copyright (c) 2025-2026 MobileGL-Dev +# Licensed under the GNU Lesser General Public License v3.0: +# https://www.gnu.org/licenses/gpl-3.0.txt +# https://www.gnu.org/licenses/lgpl-3.0.txt +# SPDX-License-Identifier: LGPL-3.0-only +# End of Source File Header +"""Resolve every `path:line` citation in a set of markdown documents. + +A design document that cites the code is only as good as its line numbers, and a wrong one +is worse than none: it sends the next reader to a function that does something else. The +disaggregation plan's first draft cited SamplerObject.h:468-492 for a struct that lives at +:72-96 in a 160-line file, and nothing caught it. + +So every `File.h:123` and `File.cpp:123-456` in the given documents is resolved against a +git revision - the file must exist there and must have at least that many lines. Bare file +names are resolved by basename, which is how the plan spells most of its citations; an +ambiguous basename is reported rather than guessed. + + python3 scripts/check_doc_citations.py docs/Disaggregated/*.md + python3 scripts/check_doc_citations.py --rev 81b17c0b --strict docs/Disaggregated/*.md + +Exits non-zero only with --strict, so it can be wired into CI as a warning first. +""" + +import argparse +import os +import re +import subprocess +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# `Managers.cpp:4340-4390`, `MobileGL/MG_State/.../RenderState.h:263`, `:12-13` is NOT +# matched on purpose: a citation with no file name cannot be checked, only guessed. +CITATION_RE = re.compile( + r"(? 1 and "/" in cited: + candidates = [p for p in candidates if p.endswith(cited)] or candidates + return candidates + + def LineCount(self, path): + if path not in self.LineCounts: + blob = git(["show", "%s:%s" % (self.Rev, path)]) + # A file with no trailing newline still has that last line. + count = blob.count("\n") + (1 if blob and not blob.endswith("\n") else 0) + self.LineCounts[path] = count + return self.LineCounts[path] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("documents", nargs="+", help="markdown files to check") + parser.add_argument("--rev", default="HEAD", help="git revision the citations point into") + parser.add_argument("--strict", action="store_true", help="exit 1 when a citation does not resolve") + args = parser.parse_args() + + tree = Tree(args.rev) + checked = 0 + problems = [] + for document in args.documents: + if not os.path.exists(document): + problems.append("%s: no such document" % document) + continue + with open(document, "r", encoding="utf-8", errors="replace") as handle: + lines = handle.read().splitlines() + for number, line in enumerate(lines, start=1): + for match in CITATION_RE.finditer(line): + cited, first, last = match.group(1), int(match.group(2)), match.group(3) + last = int(last) if last else first + checked += 1 + where = "%s:%d: `%s`" % (document, number, match.group(0)) + candidates = tree.Resolve(cited) + if not candidates: + problems.append("%s -> no such file at %s" % (where, args.rev)) + continue + if len(candidates) > 1: + problems.append("%s -> ambiguous: %s" % (where, ", ".join(sorted(candidates)))) + continue + if last < first: + problems.append("%s -> inverted line range" % where) + continue + count = tree.LineCount(candidates[0]) + if last > count: + problems.append("%s -> %s has %d lines at %s" + % (where, candidates[0], count, args.rev)) + + print("check_doc_citations: %d citations in %d document(s) against %s, %d problem(s)" + % (checked, len(args.documents), args.rev, len(problems))) + for problem in problems: + print(" %s" % problem) + if problems and args.strict: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/gen_pipe_dirty_surface.py b/scripts/gen_pipe_dirty_surface.py new file mode 100644 index 000000000..36deee1cd --- /dev/null +++ b/scripts/gen_pipe_dirty_surface.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +# MobileGL - scripts/gen_pipe_dirty_surface.py +# Copyright (c) 2025-2026 MobileGL-Dev +# Licensed under the GNU Lesser General Public License v3.0: +# https://www.gnu.org/licenses/gpl-3.0.txt +# https://www.gnu.org/licenses/lgpl-3.0.txt +# SPDX-License-Identifier: LGPL-3.0-only +# End of Source File Header +"""The dirty-surface scanner (plan B corollary 4, section 5.2). + +MGPipe replaces "the backend rediscovers what changed" with "the frontend says what +changed", which only works if EVERY frontend mutation that a backend can observe bumps an +aggregate generation. The failure mode is silent and one-directional: a mutation that +forgets to bump renders stale, and no purity gate can see it. + +So the mutation surface has to be enumerated mechanically rather than by memory. This +script reports every place in MG_Impl/GLImpl where a GL entry point BOTH mutates frontend +state through pGLContext AND reaches the backend in the same function - those are the +publish points, the ones that must map onto an aggregate generation. + +P0 is the skeleton: it reports. P1 adds the mapping file and CI regenerates it with +`git diff --exit-code` and zero unmapped mutators, the same shape as gen_pipe.py's G6. + + python3 scripts/gen_pipe_dirty_surface.py # human-readable report + python3 scripts/gen_pipe_dirty_surface.py --summary # counts only +""" + +import argparse +import os +import re +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SCAN_ROOT = os.path.join(REPO_ROOT, "MobileGL", "MG_Impl", "GLImpl") + +# The mutating half of GLContext's surface. Prefix-matched, per the plan's list. +MUTATOR_PREFIXES = ("Add", "Set", "Mark", "Bump", "Allocate", "Truncate", "Record", "Notify", + "Begin", "End") + +MUTATOR_RE = re.compile(r"pGLContext->\s*((?:%s)\w*)\s*\(" % "|".join(MUTATOR_PREFIXES)) +BACKEND_RE = re.compile(r"gBackendFunctionsTable\.GL\.(\w+)|pActiveBackendObject->\s*(\w+)") +FUNCTION_RE = re.compile(r"(?:^|\n)[ \t]*(?:[A-Za-z_][\w:<>,&*\s]*?)\b(\w+)\s*\([^;{}]*\)\s*" + r"(?:const\s*)?(?:noexcept\s*)?\{") + + +def mask_comments_and_strings(text): + """Replace comment and string-literal bodies with spaces, keeping every offset and + newline, so the regexes below cannot match inside a comment or a literal.""" + out = list(text) + i = 0 + n = len(text) + while i < n: + c = text[i] + if c == "/" and i + 1 < n and text[i + 1] == "/": + while i < n and text[i] != "\n": + out[i] = " " + i += 1 + elif c == "/" and i + 1 < n and text[i + 1] == "*": + out[i] = out[i + 1] = " " + i += 2 + while i < n and not (text[i] == "*" and i + 1 < n and text[i + 1] == "/"): + if text[i] != "\n": + out[i] = " " + i += 1 + if i < n: + out[i] = " " + if i + 1 < n: + out[i + 1] = " " + i += 2 + elif c in "\"'": + quote = c + i += 1 + while i < n and text[i] != quote: + if text[i] == "\\": + out[i] = " " + i += 1 + if i < n and text[i] != "\n": + out[i] = " " + i += 1 + if i < n: + out[i] = " " + i += 1 + else: + i += 1 + return "".join(out) + + +def function_bodies(masked): + """Yield (name, start_offset, end_offset) for every braced function body.""" + for match in FUNCTION_RE.finditer(masked): + name = match.group(1) + start = masked.index("{", match.end() - 1) if masked[match.end() - 1] != "{" else match.end() - 1 + depth = 0 + i = start + while i < len(masked): + if masked[i] == "{": + depth += 1 + elif masked[i] == "}": + depth -= 1 + if depth == 0: + yield name, start, i + break + i += 1 + + +def line_of(text, offset): + return text.count("\n", 0, offset) + 1 + + +def scan_file(path): + with open(path, "r", encoding="utf-8", errors="replace") as handle: + text = handle.read() + masked = mask_comments_and_strings(text) + findings = [] + # Every mutator in the file, whether or not it shares a function with a backend call. + # The difference between this and the publish points below is the whole point of the + # report: a mutation that does NOT reach the backend in the same function is published + # by the NEXT verb, and it is exactly those that need an aggregate generation rather + # than an inline push. + all_mutators = [(m.group(1), line_of(masked, m.start())) for m in MUTATOR_RE.finditer(masked)] + for name, start, end in function_bodies(masked): + body = masked[start:end] + mutators = [(m.group(1), line_of(masked, start + m.start())) for m in MUTATOR_RE.finditer(body)] + if not mutators: + continue + backend = sorted(set(m.group(1) or m.group(2) for m in BACKEND_RE.finditer(body))) + if not backend: + continue + findings.append({ + "function": name, + "line": line_of(masked, start), + "mutators": mutators, + "backend": backend, + }) + return findings, all_mutators + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--summary", action="store_true", help="print the counts only") + args = parser.parse_args() + + if not os.path.isdir(SCAN_ROOT): + sys.exit("missing %s" % SCAN_ROOT) + + sources = [] + for root, _, files in os.walk(SCAN_ROOT): + for name in sorted(files): + if name.endswith((".cpp", ".h")): + sources.append(os.path.join(root, name)) + sources.sort() + + total_functions = 0 + total_mutators = 0 + deferred_mutators = 0 + distinct_mutators = {} + distinct_all = {} + for path in sources: + findings, all_mutators = scan_file(path) + for mutator, _ in all_mutators: + distinct_all[mutator] = distinct_all.get(mutator, 0) + 1 + deferred_mutators += len(all_mutators) + if not findings: + continue + relative = os.path.relpath(path, REPO_ROOT).replace(os.sep, "/") + if not args.summary: + print("\n%s" % relative) + for finding in findings: + total_functions += 1 + total_mutators += len(finding["mutators"]) + for mutator, _ in finding["mutators"]: + distinct_mutators[mutator] = distinct_mutators.get(mutator, 0) + 1 + if args.summary: + continue + print(" %s (line %d) -> backend: %s" % (finding["function"], finding["line"], + ", ".join(finding["backend"][:4]))) + for mutator, line in finding["mutators"]: + print(" %-44s :%d UNMAPPED" % (mutator, line)) + + print("\ndirty-surface: %d files scanned under MG_Impl/GLImpl" % len(sources)) + print("dirty-surface: %d mutator calls in total, %d distinct mutators" % (deferred_mutators, + len(distinct_all))) + print("dirty-surface: %d of them sit in %d IMMEDIATE PUBLISH POINTS - functions that also " + "reach the backend - across %d distinct mutators" + % (total_mutators, total_functions, len(distinct_mutators))) + print("dirty-surface: the remaining %d are DEFERRED: nothing reaches the backend in the same " + "function, so the next verb publishes them, and each one needs an aggregate generation" + % (deferred_mutators - total_mutators)) + print("dirty-surface: distinct mutators, by call count") + for mutator in sorted(distinct_all, key=lambda k: (-distinct_all[k], k)): + print(" %5d %s%s" % (distinct_all[mutator], mutator, + " (immediate)" if mutator in distinct_mutators else "")) + print("dirty-surface: every mutator above is UNMAPPED - the aggregate-generation mapping file " + "lands in P1, and this report is what it has to cover.") + print("dirty-surface: known limits of this scanner - it matches braced function bodies " + "textually, so a mutator inside a lambda is attributed to the enclosing function, and a " + "mutation published through a helper the entry point calls reads as deferred here.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 42e0f47ebb1174515622069d96a64800a6c84de1 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 14:39:55 -0400 Subject: [PATCH 008/529] [Feat] (Metrics): land the MGPipe boundary counters - bytes, dynamic accessor calls and the six memo gates, off unless MOBILEGL_PIPE_STATS is set - Plan B section 11 P0 asks for TracyPlot per-frame counters on both sides of the boundary, and section 2.3.1 adds the deliverable v1 did not have: DYNAMIC call counters. The static call-site counts everyone quotes (Espryt 124 / Magma 169) are not the per-draw cost, because every one of those paths is memo-gated; without a dynamic counter P2's verdict stays a guess. The tree had no per-frame byte or call measurement at all - MG_Util/Metrics is format arithmetic, and Tracy has zones but no plots. - Cost when off: g_pipeStatsEnabled is a plain global Bool latched once in Initialize() right after MG_ConfigLoader::Init(), and every counting site is `if (PipeStats::Enabled()) ...` - one load of a hot global and one never-taken branch. The counters are relaxed atomics because buffer and texture staging are reachable from more than one thread; the off path never touches them. - Eight byte classes (stage-buffer, stage-texture, stage-ubo-global, stage-ubo-named, stage-vertex-client, stage-index-client, persistent-map-push and the residual-value-block placeholder of section 6.3), six call classes and the six memo gates of section 2.3.1, each as a hit/miss pair. Names are minted now, including the two that stay 0 in P0, so no recorded baseline is invalidated by a later rename - which is why the name set is pinned by a test. - Reporting: TracyPlot per counter per frame when TRACY_ENABLE; with MOBILEGL_PIPE_STATS=1 one MGLOG_I summary line every 120 frames and at teardown, plus a JSON dump to MOBILEGL_PIPE_STATS_FILE when that is set. MGLOG_I against the usual "MGLOG_D for non-critical" rule on purpose: the line has to survive an INFO build - the only build a device runs - it is at most one line per 120 frames, and it exists only when the operator asked for it. - Summary lines report disjoint WINDOWS, not run totals: a run total over a workload that changes shape (load, then steady state) averages away the very number section 2.3.1 wants an absolute value for. - MOBILEGL_PIPE_STATS_FILE joins the six switches parsed in e48a3582; like them it needs no allow-list entry because InitializeAcceptedEnvVariables accepts every MOBILEGL_-prefixed variable by construction. --- CMakeLists.txt | 2 + MobileGL/Config.h | 4 + MobileGL/ConfigLoader.cpp | 1 + MobileGL/Init.cpp | 10 + MobileGL/MG_Test/Util/CMakeLists.txt | 19 ++ MobileGL/MG_Test/Util/PipeStatsTest.cpp | 213 +++++++++++++ MobileGL/MG_Util/Metrics/PipeStats.cpp | 403 ++++++++++++++++++++++++ MobileGL/MG_Util/Metrics/PipeStats.h | 171 ++++++++++ 8 files changed, 823 insertions(+) create mode 100644 MobileGL/MG_Test/Util/PipeStatsTest.cpp create mode 100644 MobileGL/MG_Util/Metrics/PipeStats.cpp create mode 100644 MobileGL/MG_Util/Metrics/PipeStats.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f89dc3879..0caa8a7cf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -238,6 +238,8 @@ set(SOURCE_FILES MobileGL/MG_Util/Metrics/BufferMetrics.cpp + MobileGL/MG_Util/Metrics/PipeStats.cpp + MobileGL/MG_Util/Converters/GLToStr/GLEnumConverter.cpp MobileGL/MG_Util/Converters/EGLToStr/EGLEnumConverter.cpp MobileGL/MG_Util/Converters/MGToStr/DataTypeConverter.cpp diff --git a/MobileGL/Config.h b/MobileGL/Config.h index a6ab28504..3ceb3c9df 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -347,6 +347,10 @@ namespace MobileGL::MG_Config { // the server without shipping index bytes per draw. Over budget it degrades to // per-draw staging, counted separately in the stats. Uint32 PipeIndexMirrorMb = 64; + // MOBILEGL_PIPE_STATS_FILE: where the boundary counters' teardown JSON dump goes. + // Empty (the default) means no dump; the per-120-frame summary line still goes to + // the log whenever PipeStats is on, so a device run needs no writable path. + String PipeStatsFile; }; extern FeaturesTable Features; } // namespace MobileGL::MG_Config diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index b760b1444..94558a222 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -242,6 +242,7 @@ namespace MobileGL::MG_ConfigLoader { QueryEnvQuirkOverride("MOBILEGL_PIPE_LEGACY_MEMOS") != MG_Config::QuirkOverride::ForceOff; features.PipeTexelRetainMb = QueryEnvUint32("MOBILEGL_PIPE_TEXEL_RETAIN_MB", 0, 0, 4096); features.PipeIndexMirrorMb = QueryEnvUint32("MOBILEGL_PIPE_INDEX_MIRROR_MB", 64, 0, 4096); + QueryEnvVariable("MOBILEGL_PIPE_STATS_FILE", features.PipeStatsFile, ""); } inline void InitBackendType() { diff --git a/MobileGL/Init.cpp b/MobileGL/Init.cpp index 68870532f..480286256 100644 --- a/MobileGL/Init.cpp +++ b/MobileGL/Init.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -42,6 +43,11 @@ namespace MobileGL { if (logLifecycle) { MGLOG_I("MobileGL closing..."); } + // Before any subsystem the counters name goes away, and before the last frame's + // numbers can be lost: emits the final summary line and, when + // MOBILEGL_PIPE_STATS_FILE is set, the JSON dump. A no-op when the counters are + // off, and idempotent. + MG_Util::PipeStats::Shutdown(); // First, before anything else is torn down. In-flight compile/link jobs own // their own inputs and are safe against everything below EXCEPT glslang's // process globals and the TShader/TProgram objects hanging off pGLContext, @@ -102,6 +108,10 @@ namespace MobileGL { MGLOG_I("Initializing MobileGL..."); MG_ConfigLoader::Init(); MGLOG_I("Config loaded"); + // Immediately after the config load and before anything can count: the MGPipe + // boundary counters latch their enable flag here, so every counting site in the + // two backends is a load of an already-settled global for the rest of the run. + MG_Util::PipeStats::Init(); MG_State::Init(); MGLOG_D("MG_State initialized"); MG_Backend::Init(); diff --git a/MobileGL/MG_Test/Util/CMakeLists.txt b/MobileGL/MG_Test/Util/CMakeLists.txt index ffaf927a5..df33624f8 100644 --- a/MobileGL/MG_Test/Util/CMakeLists.txt +++ b/MobileGL/MG_Test/Util/CMakeLists.txt @@ -35,6 +35,25 @@ target_link_libraries( ${LINK_LIBRARIES} ) +# The MGPipe boundary counters: the enable latch, the byte/call/gate arithmetic, the +# payload histogram's bucketing and the two report formats. No GL context, no driver. +add_executable( + PipeStatsTest + PipeStatsTest.cpp +) + +target_include_directories(PipeStatsTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL +) + +target_link_libraries( + PipeStatsTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + include(GoogleTest) gtest_discover_tests(JobNodeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(LogLevelTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +gtest_discover_tests(PipeStatsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/Util/PipeStatsTest.cpp b/MobileGL/MG_Test/Util/PipeStatsTest.cpp new file mode 100644 index 000000000..97e8bbcfc --- /dev/null +++ b/MobileGL/MG_Test/Util/PipeStatsTest.cpp @@ -0,0 +1,213 @@ +// MobileGL - MobileGL/MG_Test/Util/PipeStatsTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The MGPipe boundary counters (plan B section 11 P0, corollary in section 2.3.1). +// No GL context and no driver: the module is arithmetic over a fixed set of counters, +// which is exactly what has to be pinned before anyone reads a number off a device. + +#include + +#include + +#include +#include +#include + +namespace { + namespace PS = MobileGL::MG_Util::PipeStats; + using MobileGL::String; + using MobileGL::Uint32; + using MobileGL::Uint64; + + class PipeStatsTest : public ::testing::Test { + protected: + void SetUp() override { + PS::ResetForTesting(); + PS::SetEnabledForTesting(true); + } + void TearDown() override { + PS::SetEnabledForTesting(false); + PS::ResetForTesting(); + } + }; + + // The off latch is the whole cost argument: every counting site in the two backends is + // written as `if (Enabled()) ...`, so a false latch has to mean "nothing is counted". + TEST_F(PipeStatsTest, EnabledLatchIsTheOnlyGate) { + PS::SetEnabledForTesting(false); + EXPECT_FALSE(PS::Enabled()); + PS::SetEnabledForTesting(true); + EXPECT_TRUE(PS::Enabled()); + } + + TEST_F(PipeStatsTest, ByteClassesAccumulateIndependently) { + PS::AddBytes(PS::ByteClass::StageBuffer, 100); + PS::AddBytes(PS::ByteClass::StageBuffer, 40); + PS::AddBytes(PS::ByteClass::StageTexture, 7); + + EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageBuffer), 140u); + EXPECT_EQ(PS::FrameBytes(PS::ByteClass::StageBuffer), 140u); + EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageTexture), 7u); + // Every other class untouched, the residual-value-block placeholder included. + EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageUboGlobal), 0u); + EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageUboNamed), 0u); + EXPECT_EQ(PS::TotalBytes(PS::ByteClass::ResidualValueBlock), 0u); + } + + // The frame accumulator is what feeds TracyPlot; the run total is what feeds the JSON + // dump. A present must clear the first and keep the second. + TEST_F(PipeStatsTest, PresentClearsTheFrameButKeepsTheTotal) { + PS::AddBytes(PS::ByteClass::StageTexture, 512); + PS::AddCalls(PS::CallClass::Draws, 3); + PS::CountGate(PS::Gate::EsprytRenderState, /*hit=*/true); + + PS::OnPresent(); + + EXPECT_EQ(PS::FrameBytes(PS::ByteClass::StageTexture), 0u); + EXPECT_EQ(PS::FrameCalls(PS::CallClass::Draws), 0u); + EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageTexture), 512u); + EXPECT_EQ(PS::TotalCalls(PS::CallClass::Draws), 3u); + EXPECT_EQ(PS::TotalGateHits(PS::Gate::EsprytRenderState), 1u); + EXPECT_EQ(PS::FrameCount(), 1u); + } + + TEST_F(PipeStatsTest, GateHitsAndMissesAreSeparateCounters) { + for (Uint32 i = 0; i < 5; ++i) { + PS::CountGate(PS::Gate::MagmaPipelineMemo, /*hit=*/true); + } + PS::CountGate(PS::Gate::MagmaPipelineMemo, /*hit=*/false); + PS::CountGate(PS::Gate::MagmaDrawFastPath, /*hit=*/false); + + EXPECT_EQ(PS::TotalGateHits(PS::Gate::MagmaPipelineMemo), 5u); + EXPECT_EQ(PS::TotalGateMisses(PS::Gate::MagmaPipelineMemo), 1u); + EXPECT_EQ(PS::TotalGateHits(PS::Gate::MagmaDrawFastPath), 0u); + EXPECT_EQ(PS::TotalGateMisses(PS::Gate::MagmaDrawFastPath), 1u); + } + + // Bucket 0 is "no payload"; bucket n>0 is [2^(n-1), 2^n). The placeholder histogram is + // the SEG_CMD sizing input (section 4.5.7), so its bucketing is pinned now rather than + // when a generator first calls it. + TEST_F(PipeStatsTest, PayloadHistogramBucketsByPowerOfTwo) { + PS::RecordDrawPayloadBytes(0); + PS::RecordDrawPayloadBytes(1); // [1, 2) -> bucket 1 + PS::RecordDrawPayloadBytes(2); // [2, 4) -> bucket 2 + PS::RecordDrawPayloadBytes(3); // [2, 4) -> bucket 2 + PS::RecordDrawPayloadBytes(48); // [32, 64) -> bucket 6 + PS::RecordDrawPayloadBytes(64); // [64, 128)-> bucket 7 + + EXPECT_EQ(PS::TotalPayloadBucket(0), 1u); + EXPECT_EQ(PS::TotalPayloadBucket(1), 1u); + EXPECT_EQ(PS::TotalPayloadBucket(2), 2u); + EXPECT_EQ(PS::TotalPayloadBucket(6), 1u); + EXPECT_EQ(PS::TotalPayloadBucket(7), 1u); + } + + // A record far larger than the last bucket must land in the last bucket, not past the + // end of the array. + TEST_F(PipeStatsTest, PayloadHistogramSaturatesInsteadOfOverflowing) { + PS::RecordDrawPayloadBytes(~Uint64{0}); + EXPECT_EQ(PS::TotalPayloadBucket(PS::kPayloadHistogramBuckets - 1), 1u); + EXPECT_EQ(PS::TotalPayloadBucket(PS::kPayloadHistogramBuckets), 0u); + } + + // The summary line's shape is what an operator greps and what the smoke check in this + // package matches, so it is pinned here rather than left to the log reader's memory. + TEST_F(PipeStatsTest, SummaryLineCarriesEveryClassAndGate) { + PS::AddCalls(PS::CallClass::Draws, 4); + PS::AddCalls(PS::CallClass::AccessorCalls, 50); + PS::AddBytes(PS::ByteClass::StageBuffer, 4096); + PS::OnPresent(); + + const String line = PS::FormatSummaryLine(); + EXPECT_NE(line.find("MGPipe stats:"), String::npos) << line; + EXPECT_NE(line.find("draws=4"), String::npos) << line; + // 50 accessor calls over 4 draws, two decimals, no . + EXPECT_NE(line.find("acc/draw=12.50"), String::npos) << line; + EXPECT_NE(line.find("buf=4096"), String::npos) << line; + for (Uint32 i = 0; i < static_cast(PS::Gate::Count); ++i) { + EXPECT_NE(line.find("="), String::npos); + } + EXPECT_NE(line.find("gates["), String::npos) << line; + EXPECT_NE(line.find("tex[emit="), String::npos) << line; + } + + // Successive summaries report WINDOWS, not run totals: a run total over a workload that + // changes shape (load, then steady state) averages away the very number section 2.3.1 + // wants. + TEST_F(PipeStatsTest, SummaryLinesReportDisjointWindows) { + PS::AddCalls(PS::CallClass::Draws, 10); + PS::OnPresent(); + const String first = PS::FormatSummaryLine(); + EXPECT_NE(first.find("draws=10"), String::npos) << first; + + PS::AddCalls(PS::CallClass::Draws, 3); + PS::OnPresent(); + const String second = PS::FormatSummaryLine(); + EXPECT_NE(second.find("draws=3"), String::npos) << second; + EXPECT_NE(second.find("frames=2"), String::npos) << second; + } + + TEST_F(PipeStatsTest, SummaryLineSurvivesZeroDraws) { + PS::OnPresent(); + const String line = PS::FormatSummaryLine(); + EXPECT_NE(line.find("acc/draw=0.00"), String::npos) << line; + } + + TEST_F(PipeStatsTest, JsonDumpNamesEveryCounter) { + PS::AddBytes(PS::ByteClass::StageUboNamed, 256); + PS::CountGate(PS::Gate::MagmaDynamicTail, /*hit=*/false); + PS::RecordDrawPayloadBytes(9); + PS::OnPresent(); + + const String json = PS::FormatJson(); + for (Uint32 i = 0; i < static_cast(PS::ByteClass::Count); ++i) { + const String name = PS::NameOf(static_cast(i)); + EXPECT_NE(json.find("\"" + name + "\""), String::npos) << name << " missing from " << json; + } + for (Uint32 i = 0; i < static_cast(PS::CallClass::Count); ++i) { + const String name = PS::NameOf(static_cast(i)); + EXPECT_NE(json.find("\"" + name + "\""), String::npos) << name << " missing from " << json; + } + for (Uint32 i = 0; i < static_cast(PS::Gate::Count); ++i) { + const String name = PS::NameOf(static_cast(i)); + EXPECT_NE(json.find("\"" + name + "\""), String::npos) << name << " missing from " << json; + } + EXPECT_NE(json.find("\"stage-ubo-named\": 256"), String::npos) << json; + EXPECT_NE(json.find("\"frames\": 1"), String::npos) << json; + EXPECT_NE(json.find("cmd-bytes-per-draw-histogram"), String::npos) << json; + } + + // The counter names are the TracyPlot series names and the JSON keys; a rename is a + // breaking change for every recorded baseline, so the whole set is pinned. + TEST_F(PipeStatsTest, CounterNamesAreStable) { + EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageBuffer), "stage-buffer"); + EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageTexture), "stage-texture"); + EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageUboGlobal), "stage-ubo-global"); + EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageUboNamed), "stage-ubo-named"); + EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageVertexClient), "stage-vertex-client"); + EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageIndexClient), "stage-index-client"); + EXPECT_STREQ(PS::NameOf(PS::ByteClass::PersistentMapPush), "persistent-map-push"); + EXPECT_STREQ(PS::NameOf(PS::ByteClass::ResidualValueBlock), "residual-value-block"); + EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytRenderState), "espryt-render-state"); + EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytTextureSyncList), "espryt-texture-sync-list"); + EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytUnitBindingsEpoch), "espryt-unit-bindings-epoch"); + EXPECT_STREQ(PS::NameOf(PS::Gate::MagmaDrawFastPath), "magma-draw-fastpath"); + EXPECT_STREQ(PS::NameOf(PS::Gate::MagmaPipelineMemo), "magma-pipeline-memo"); + EXPECT_STREQ(PS::NameOf(PS::Gate::MagmaDynamicTail), "magma-dynamic-tail"); + } + + // A summary is emitted every kSummaryFramePeriod presents. The period is a constant the + // smoke check depends on, so a change to it has to break a test. + TEST_F(PipeStatsTest, SummaryPeriodIsOneHundredAndTwentyFrames) { + EXPECT_EQ(PS::kSummaryFramePeriod, 120u); + for (Uint64 i = 0; i < PS::kSummaryFramePeriod; ++i) { + PS::OnPresent(); + } + EXPECT_EQ(PS::FrameCount(), PS::kSummaryFramePeriod); + } +} // namespace diff --git a/MobileGL/MG_Util/Metrics/PipeStats.cpp b/MobileGL/MG_Util/Metrics/PipeStats.cpp new file mode 100644 index 000000000..1fada1b15 --- /dev/null +++ b/MobileGL/MG_Util/Metrics/PipeStats.cpp @@ -0,0 +1,403 @@ +// MobileGL - MobileGL/MG_Util/Metrics/PipeStats.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#include "PipeStats.h" + +#include + +#include + +// --------------------------------------------------------------------------------------- +// SITE INVENTORY - what these counters DO and DO NOT cover. +// +// Byte classes +// stage-buffer DirectGLES Managers.cpp: RespecifyStorageNow's glBufferData, +// FlushPendingRangesNow's three shapes (map-write, glBufferSubData, +// upload-ring stage). Covers every byte Espryt hands the driver for +// a buffer object's contents. +// NOT covered: DirectVulkan's own buffer staging (its buffer bytes +// reach the GPU through a persistent map the frontend already owns, +// so there is no second copy to count) - see the note on +// persistent-map-push. +// stage-texture DirectGLES Managers.cpp texture upload: the bytes of whichever of +// the three upload shapes ran (rect list / union box / whole level). +// NOT covered: the DirectVulkan texture staging path, and Espryt's +// compressed-texture and readback paths. +// stage-ubo-global DirectGLES.cpp default-uniform-block image, both the UBO-ring +// memcpy and the glBufferSubData fallback. +// stage-ubo-named DirectVulkan UniformManager::ResolveUniformBufferPayload - the +// bytes Magma repacks into its own UBO ring. Espryt contributes +// nothing by construction (D-B8). +// stage-vertex-client DirectGLES BackendVertexArrayObject::SyncClientSideAttributesFor- +// DrawArrays, both the Float64-narrowing and the verbatim shapes. +// NOT covered: the DirectVulkan converted-vertex-stream cache. +// stage-index-client DirectGLES index rewriting (the primitive-restart substitution +// buffer). +// NOT covered: DirectVulkan's index staging. +// persistent-map-push Not wired in P0: today a persistent map is a permanent address +// space donation (D4/D-B4) that survives the whole monolith track, +// so there is no push to count until the IPC track breaks it. +// residual-value-block Placeholder, always 0 until P2 (plan section 6.3). +// +// Call classes +// draws DirectGLES PrepareForDraw and DirectVulkan TrySetupDrawFastPath's +// caller-visible entry. A dispatch is not a draw and is not counted. +// accessor-calls STATIC TALLIES at the instrumented entry points, NOT a wrapper +// around all 293 pGLContext-> sites. Each instrumented function adds +// the number of GLContext accessor calls that its OWN body executed +// on the path taken. Covered: PrepareForDraw's own reads, +// SyncRenderState, CaptureDrawTextureSyncKeys/CurrentUnitBindings- +// Epoch, SyncNeccessaryTextures' walk, TrySetupDrawFastPath, +// GetOrCreatePipeline and ApplyDynamicDrawStateTail. NOT covered: +// the reads inside the callees those functions invoke (buffer/VAO/ +// FBO/program sync, the pipeline payload builder's ~40 reads on a +// memo miss), and every non-draw entry point. The number is +// therefore a LOWER BOUND on the per-draw accessor count, and it is +// the bound over exactly the six gates section 2.3.1 tabulates. +// texture-* DirectGLES texture upload, per (target, level) emission. +// +// Gates: the six of section 2.3.1, each counted exactly once per probe. +// +// READING acc/draw. The accessor tally covers the instrumented functions wherever they +// run, and three of them (SyncRenderState, the texture-key capture, SyncNeccessaryTextures) +// are also reached from NON-draw call sites - Clear, readbacks, the DSA by-name entry +// points - which the `draws` counter deliberately does not count. So acc/draw is the +// per-draw steady-state number section 2.3.1 asks for only in a DRAW-DOMINATED window; in a +// window dominated by clears and readbacks it is inflated by exactly those non-draw +// probes, and the gate hit/miss pairs are the honest reading there. +// --------------------------------------------------------------------------------------- + +namespace MobileGL::MG_Util::PipeStats { + + Bool g_pipeStatsEnabled = false; + + namespace { + constexpr Uint32 kByteClassCount = static_cast(ByteClass::Count); + constexpr Uint32 kCallClassCount = static_cast(CallClass::Count); + constexpr Uint32 kGateCount = static_cast(Gate::Count); + + using Counter = std::atomic; + + Counter g_frameBytes[kByteClassCount]; + Counter g_totalBytes[kByteClassCount]; + Counter g_frameCalls[kCallClassCount]; + Counter g_totalCalls[kCallClassCount]; + Counter g_frameGateHit[kGateCount]; + Counter g_totalGateHit[kGateCount]; + Counter g_frameGateMiss[kGateCount]; + Counter g_totalGateMiss[kGateCount]; + Counter g_totalPayloadBuckets[kPayloadHistogramBuckets]; + Counter g_frameCount{0}; + + // Window bases: the run totals as of the previous summary line. Only ever touched + // from OnPresent()/Shutdown() (the present thread), so plain integers. + Uint64 g_windowBaseBytes[kByteClassCount] = {}; + Uint64 g_windowBaseCalls[kCallClassCount] = {}; + Uint64 g_windowBaseGateHit[kGateCount] = {}; + Uint64 g_windowBaseGateMiss[kGateCount] = {}; + Uint64 g_windowBaseFrames = 0; + Bool g_shutdownDone = false; + + inline void Bump(Counter& counter, Uint64 amount) { + counter.fetch_add(amount, std::memory_order_relaxed); + } + + inline Uint64 Read(const Counter& counter) { return counter.load(std::memory_order_relaxed); } + + // Bucket 0 is "0 bytes", bucket n>0 holds [2^(n-1), 2^n). Saturates at the last + // bucket so a pathological record cannot index out of the array. + Uint32 PayloadBucketOf(Uint64 bytes) { + if (bytes == 0) { + return 0; + } + Uint32 bucket = 1; + while (bucket + 1 < kPayloadHistogramBuckets && bytes >= (Uint64{1} << bucket)) { + ++bucket; + } + return bucket; + } + + const char* const kByteClassNames[kByteClassCount] = { + "stage-buffer", "stage-texture", "stage-ubo-global", "stage-ubo-named", + "stage-vertex-client", "stage-index-client", "persistent-map-push", "residual-value-block", + }; + const char* const kCallClassNames[kCallClassCount] = { + "draws", "accessor-calls", "tex-upload-emissions", "tex-upload-box", "tex-upload-rect", + "tex-upload-jobs", + }; + const char* const kGateNames[kGateCount] = { + "espryt-render-state", "espryt-texture-sync-list", "espryt-unit-bindings-epoch", + "magma-draw-fastpath", "magma-pipeline-memo", "magma-dynamic-tail", + }; + // Short forms, so the per-120-frame line stays one terminal line wide. + const char* const kByteClassShort[kByteClassCount] = {"buf", "tex", "ubog", "ubon", + "vtxc", "idxc", "pmap", "resid"}; + const char* const kGateShort[kGateCount] = {"ers", "etl", "eub", "mfp", "mpm", "mdt"}; + + void EmitSummaryLine() { + const String line = FormatSummaryLine(); + // MGLOG_I on purpose, against the project's usual "MGLOG_D for anything + // non-critical" rule: the line has to survive an INFO build (that is the only + // build a device ever runs), it is emitted at most once per 120 frames, and it + // exists at all only when the operator set MOBILEGL_PIPE_STATS=1. It is an + // opt-in measurement channel, not per-frame noise. + MGLOG_I("%s", line.c_str()); + } + + void WriteJsonDump() { + const String& path = MG_Config::Features.PipeStatsFile; + if (path.empty()) { + return; + } + std::ofstream out(path, std::ios::out | std::ios::trunc); + if (!out) { + MGLOG_W("PipeStats: could not open MOBILEGL_PIPE_STATS_FILE='%s' for writing", path.c_str()); + return; + } + out << FormatJson(); + out.flush(); + if (!out) { + MGLOG_W("PipeStats: failed writing MOBILEGL_PIPE_STATS_FILE='%s'", path.c_str()); + return; + } + MGLOG_I("MGPipe stats: wrote JSON dump to %s", path.c_str()); + } + } // namespace + + void Init() { + ResetForTesting(); + g_shutdownDone = false; + g_pipeStatsEnabled = MG_Config::Features.PipeStats; + if (g_pipeStatsEnabled) { + MGLOG_I("MGPipe stats: counters ON (MOBILEGL_PIPE_STATS), summary every %llu frames%s%s", + static_cast(kSummaryFramePeriod), + MG_Config::Features.PipeStatsFile.empty() ? "" : ", JSON dump to ", + MG_Config::Features.PipeStatsFile.c_str()); + } + } + + void Shutdown() { + if (!g_pipeStatsEnabled || g_shutdownDone) { + return; + } + g_shutdownDone = true; + EmitSummaryLine(); + WriteJsonDump(); + } + + void AddBytes(ByteClass byteClass, Uint64 bytes) { + const Uint32 index = static_cast(byteClass); + Bump(g_frameBytes[index], bytes); + Bump(g_totalBytes[index], bytes); + } + + void AddCalls(CallClass callClass, Uint64 count) { + const Uint32 index = static_cast(callClass); + Bump(g_frameCalls[index], count); + Bump(g_totalCalls[index], count); + } + + void CountGate(Gate gate, Bool hit) { + const Uint32 index = static_cast(gate); + if (hit) { + Bump(g_frameGateHit[index], 1); + Bump(g_totalGateHit[index], 1); + } else { + Bump(g_frameGateMiss[index], 1); + Bump(g_totalGateMiss[index], 1); + } + } + + void RecordDrawPayloadBytes(Uint64 bytes) { Bump(g_totalPayloadBuckets[PayloadBucketOf(bytes)], 1); } + + void OnPresent() { +#ifdef TRACY_ENABLE + // One plot per counter, the frame's value. Tracy keeps the series by name, and the + // names are the static literals above, which is what TracyPlot requires. + for (Uint32 i = 0; i < kByteClassCount; ++i) { + TracyPlot(kByteClassNames[i], static_cast(Read(g_frameBytes[i]))); + } + for (Uint32 i = 0; i < kCallClassCount; ++i) { + TracyPlot(kCallClassNames[i], static_cast(Read(g_frameCalls[i]))); + } + for (Uint32 i = 0; i < kGateCount; ++i) { + TracyPlot(kGateNames[i], static_cast(Read(g_frameGateMiss[i]))); + } +#endif + for (Uint32 i = 0; i < kByteClassCount; ++i) { + g_frameBytes[i].store(0, std::memory_order_relaxed); + } + for (Uint32 i = 0; i < kCallClassCount; ++i) { + g_frameCalls[i].store(0, std::memory_order_relaxed); + } + for (Uint32 i = 0; i < kGateCount; ++i) { + g_frameGateHit[i].store(0, std::memory_order_relaxed); + g_frameGateMiss[i].store(0, std::memory_order_relaxed); + } + const Uint64 frames = g_frameCount.fetch_add(1, std::memory_order_relaxed) + 1; + if (frames % kSummaryFramePeriod == 0) { + EmitSummaryLine(); + } + } + + Uint64 FrameBytes(ByteClass byteClass) { return Read(g_frameBytes[static_cast(byteClass)]); } + Uint64 TotalBytes(ByteClass byteClass) { return Read(g_totalBytes[static_cast(byteClass)]); } + Uint64 FrameCalls(CallClass callClass) { return Read(g_frameCalls[static_cast(callClass)]); } + Uint64 TotalCalls(CallClass callClass) { return Read(g_totalCalls[static_cast(callClass)]); } + Uint64 TotalGateHits(Gate gate) { return Read(g_totalGateHit[static_cast(gate)]); } + Uint64 TotalGateMisses(Gate gate) { return Read(g_totalGateMiss[static_cast(gate)]); } + Uint64 TotalPayloadBucket(Uint32 bucket) { + return bucket < kPayloadHistogramBuckets ? Read(g_totalPayloadBuckets[bucket]) : 0; + } + Uint64 FrameCount() { return Read(g_frameCount); } + + const char* NameOf(ByteClass byteClass) { return kByteClassNames[static_cast(byteClass)]; } + const char* NameOf(CallClass callClass) { return kCallClassNames[static_cast(callClass)]; } + const char* NameOf(Gate gate) { return kGateNames[static_cast(gate)]; } + + String FormatSummaryLine() { + // Window values: everything since the previous summary. A run total over a workload + // whose shape changes (load, then steady state) hides exactly the number P2 wants. + const Uint64 frames = Read(g_frameCount); + const Uint64 windowFrames = frames - g_windowBaseFrames; + const Uint64 divisorFrames = windowFrames == 0 ? 1 : windowFrames; + + Uint64 bytes[kByteClassCount]; + for (Uint32 i = 0; i < kByteClassCount; ++i) { + bytes[i] = Read(g_totalBytes[i]) - g_windowBaseBytes[i]; + } + Uint64 calls[kCallClassCount]; + for (Uint32 i = 0; i < kCallClassCount; ++i) { + calls[i] = Read(g_totalCalls[i]) - g_windowBaseCalls[i]; + } + Uint64 gateHit[kGateCount]; + Uint64 gateMiss[kGateCount]; + for (Uint32 i = 0; i < kGateCount; ++i) { + gateHit[i] = Read(g_totalGateHit[i]) - g_windowBaseGateHit[i]; + gateMiss[i] = Read(g_totalGateMiss[i]) - g_windowBaseGateMiss[i]; + } + + const Uint64 draws = calls[static_cast(CallClass::Draws)]; + const Uint64 accessorCalls = calls[static_cast(CallClass::AccessorCalls)]; + + String line = "MGPipe stats:"; + line += " frames=" + std::to_string(frames); + line += " window=" + std::to_string(windowFrames); + line += " draws=" + std::to_string(draws); + line += " draws/f=" + std::to_string(draws / divisorFrames); + line += " acc=" + std::to_string(accessorCalls); + // Two decimals without : the per-draw accessor count is the number section + // 2.3.1 wants to an integer's worth of precision, and it is small (10-25). + const Uint64 accPerDrawHundredths = draws == 0 ? 0 : (accessorCalls * 100 + draws / 2) / draws; + line += " acc/draw=" + std::to_string(accPerDrawHundredths / 100) + "." + + (accPerDrawHundredths % 100 < 10 ? "0" : "") + std::to_string(accPerDrawHundredths % 100); + line += " bytes/f["; + for (Uint32 i = 0; i < kByteClassCount; ++i) { + if (i != 0) { + line += " "; + } + line += kByteClassShort[i]; + line += "="; + line += std::to_string(bytes[i] / divisorFrames); + } + line += "] tex[emit=" + std::to_string(calls[static_cast(CallClass::TextureUploadEmissions)]); + line += " box=" + std::to_string(calls[static_cast(CallClass::TextureUploadBoxEmissions)]); + line += " rect=" + std::to_string(calls[static_cast(CallClass::TextureUploadRectEmissions)]); + line += " jobs=" + std::to_string(calls[static_cast(CallClass::TextureUploadJobs)]); + line += "] gates["; + for (Uint32 i = 0; i < kGateCount; ++i) { + if (i != 0) { + line += " "; + } + line += kGateShort[i]; + line += "="; + line += std::to_string(gateHit[i]); + line += "/"; + line += std::to_string(gateMiss[i]); + } + line += "]"; + + for (Uint32 i = 0; i < kByteClassCount; ++i) { + g_windowBaseBytes[i] = Read(g_totalBytes[i]); + } + for (Uint32 i = 0; i < kCallClassCount; ++i) { + g_windowBaseCalls[i] = Read(g_totalCalls[i]); + } + for (Uint32 i = 0; i < kGateCount; ++i) { + g_windowBaseGateHit[i] = Read(g_totalGateHit[i]); + g_windowBaseGateMiss[i] = Read(g_totalGateMiss[i]); + } + g_windowBaseFrames = frames; + return line; + } + + String FormatJson() { + String json = "{\n"; + json += " \"frames\": " + std::to_string(Read(g_frameCount)) + ",\n"; + json += " \"bytes\": {\n"; + for (Uint32 i = 0; i < kByteClassCount; ++i) { + json += " \""; + json += kByteClassNames[i]; + json += "\": " + std::to_string(Read(g_totalBytes[i])); + json += (i + 1 == kByteClassCount) ? "\n" : ",\n"; + } + json += " },\n \"calls\": {\n"; + for (Uint32 i = 0; i < kCallClassCount; ++i) { + json += " \""; + json += kCallClassNames[i]; + json += "\": " + std::to_string(Read(g_totalCalls[i])); + json += (i + 1 == kCallClassCount) ? "\n" : ",\n"; + } + json += " },\n \"gates\": {\n"; + for (Uint32 i = 0; i < kGateCount; ++i) { + json += " \""; + json += kGateNames[i]; + json += "\": {\"hit\": " + std::to_string(Read(g_totalGateHit[i])) + + ", \"miss\": " + std::to_string(Read(g_totalGateMiss[i])) + "}"; + json += (i + 1 == kGateCount) ? "\n" : ",\n"; + } + json += " },\n \"cmd-bytes-per-draw-histogram\": ["; + for (Uint32 i = 0; i < kPayloadHistogramBuckets; ++i) { + if (i != 0) { + json += ", "; + } + json += std::to_string(Read(g_totalPayloadBuckets[i])); + } + json += "]\n}\n"; + return json; + } + + void SetEnabledForTesting(Bool enabled) { g_pipeStatsEnabled = enabled; } + + void ResetForTesting() { + for (Uint32 i = 0; i < kByteClassCount; ++i) { + g_frameBytes[i].store(0, std::memory_order_relaxed); + g_totalBytes[i].store(0, std::memory_order_relaxed); + g_windowBaseBytes[i] = 0; + } + for (Uint32 i = 0; i < kCallClassCount; ++i) { + g_frameCalls[i].store(0, std::memory_order_relaxed); + g_totalCalls[i].store(0, std::memory_order_relaxed); + g_windowBaseCalls[i] = 0; + } + for (Uint32 i = 0; i < kGateCount; ++i) { + g_frameGateHit[i].store(0, std::memory_order_relaxed); + g_totalGateHit[i].store(0, std::memory_order_relaxed); + g_frameGateMiss[i].store(0, std::memory_order_relaxed); + g_totalGateMiss[i].store(0, std::memory_order_relaxed); + g_windowBaseGateHit[i] = 0; + g_windowBaseGateMiss[i] = 0; + } + for (Uint32 i = 0; i < kPayloadHistogramBuckets; ++i) { + g_totalPayloadBuckets[i].store(0, std::memory_order_relaxed); + } + g_frameCount.store(0, std::memory_order_relaxed); + g_windowBaseFrames = 0; + } + +} // namespace MobileGL::MG_Util::PipeStats diff --git a/MobileGL/MG_Util/Metrics/PipeStats.h b/MobileGL/MG_Util/Metrics/PipeStats.h new file mode 100644 index 000000000..6b2348f23 --- /dev/null +++ b/MobileGL/MG_Util/Metrics/PipeStats.h @@ -0,0 +1,171 @@ +// MobileGL - MobileGL/MG_Util/Metrics/PipeStats.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +// MGPipe boundary counters (plan B section 11 "P0 - hygiene, measurement, gates and +// skeleton", and the corollary in section 2.3.1). +// +// WHAT THIS IS FOR. The disaggregation plan has to size two things it cannot size by +// reading the tree: how many BYTES cross the frontend/backend boundary per frame (that +// sizes SEG_STAGE and the command segment), and how many accessor CALLS and memo-gate +// probes the backends actually execute per draw (that decides whether pushing state is +// cheaper than pulling it at all). Section 2.3.1 makes the second one the load-bearing +// number: the static call-site counts everyone quoted - Espryt 124 / Magma 169 - are NOT +// the dynamic per-draw cost, because every one of those paths is memo-gated, and the real +// steady state is believed to be 10-25 accessor calls per backend per draw. Without a +// dynamic counter the P2 verdict stays a guess. +// +// COST WHEN OFF. g_pipeStatsEnabled is a plain global Bool latched once at Init() from +// MG_Config::Features.PipeStats (MOBILEGL_PIPE_STATS). Every counting site in the two +// backends is written as +// +// if (MG_Util::PipeStats::Enabled()) MG_Util::PipeStats::Add...(...); +// +// so with the feature off a site costs one load of a hot global plus one never-taken, +// perfectly-predicted branch, and none of the counter state is touched. The counters +// themselves are relaxed atomics rather than plain integers because texture and buffer +// staging can be reached from more than one thread; relaxed adds cost nothing extra on the +// off path, which never reaches them. +// +// WHAT IS COUNTED AND WHAT IS NOT: see the site inventory in PipeStats.cpp. +namespace MobileGL::MG_Util::PipeStats { + + // Byte classes. Every one of these names a population of bytes that would have to be + // MOVED across the boundary once the backend no longer shares an address space with + // the frontend, which is why they are grouped this way rather than by call site. + enum class ByteClass : Uint32 { + // Buffer object contents flushed to the driver: glBufferData / glBufferSubData / + // map-write ranges / the persistent upload ring. + StageBuffer = 0, + // Texel bytes handed to glTexSubImage & friends, whichever upload shape was chosen. + StageTexture, + // The default-uniform-block ("global UBO") image, uploaded at most once per program + // per frame. + StageUboGlobal, + // Named uniform-block bytes that a backend has to repack itself, i.e. Magma's UBO + // ring. Espryt binds the frontend buffer straight to the driver and contributes + // nothing here - which is exactly the asymmetry D-B8 is about. + StageUboNamed, + // Client-memory vertex arrays uploaded into a scratch VBO on the draw path. + StageVertexClient, + // Client-memory / rewritten index data staged on the draw path. + StageIndexClient, + // Bytes pushed because a persistently mapped range was published to the backend. + PersistentMapPush, + // PLACEHOLDER (plan section 6.3): the residual value block does not exist yet. The + // class is minted now so the counter names never churn; it stays at 0 until P2. + ResidualValueBlock, + Count + }; + + // Call classes: the dynamic per-draw cost section 2.3.1 says P2 cannot be decided + // without. + enum class CallClass : Uint32 { + // Draws that reached an instrumented backend draw-preparation entry point. The + // denominator for every "per draw" number below. + Draws = 0, + // GLContext accessor calls actually EXECUTED on the instrumented paths. Counted in + // static tallies at the ~10 hot entry points, not by wrapping all 293 call sites - + // see the inventory in PipeStats.cpp for exactly what is and is not in this number. + AccessorCalls, + // Texture upload emissions: one per (upload target, level) that actually shipped + // texels. The eventual resource_subdata record count. + TextureUploadEmissions, + // Emissions that took the union-box shape (one driver upload job). + TextureUploadBoxEmissions, + // Emissions that took the refined rect-list shape (N driver upload jobs). The + // box/rect split is the thing SSIM cannot see and the +6 ms/frame Mali cliff came + // from, so it is counted separately from the byte total. + TextureUploadRectEmissions, + // Driver upload jobs issued by those emissions: 1 per box emission, N per rect-list + // emission. + TextureUploadJobs, + Count + }; + + // Memo gates. Each is a place where a backend decides "nothing moved, skip the work". + // Hit == the gate short-circuited; Miss == it fell through and did the work. The six + // are exactly the ones section 2.3.1 tabulates. + enum class Gate : Uint32 { + // DirectGLES.cpp SyncRenderState: the render-state-version early-out. + EsprytRenderState = 0, + // DirectGLES.cpp SyncNeccessaryTextures: the six-value sync-list key compare. + EsprytTextureSyncList, + // DirectGLES.cpp CurrentUnitBindingsEpoch: the (context, max unit, bind generation) + // shutter over the unit walk. + EsprytUnitBindingsEpoch, + // VulkanRenderer.cpp TrySetupDrawFastPath: the whole snapshot fast path. + MagmaDrawFastPath, + // VulkanRenderer.cpp GetOrCreatePipeline: the pipeline memo. + MagmaPipelineMemo, + // VulkanRenderer.cpp ApplyDynamicDrawStateTail: the version+extent tail gate. + MagmaDynamicTail, + Count + }; + + // Per-draw command payload size histogram (plan section 4.5.7: SEG_CMD has to be sized + // off the DISTRIBUTION, not off a per-frame total). PLACEHOLDER in P0: MGPipe emits no + // records yet, so nothing in the backends calls RecordDrawPayloadBytes. The bucketing + // and the reporting are implemented and unit-tested so that the first generator to + // emit records only has to add the one call. + inline constexpr Uint32 kPayloadHistogramBuckets = 24; + + // Frames between two summary lines when MOBILEGL_PIPE_STATS=1. + inline constexpr Uint64 kSummaryFramePeriod = 120; + + // The latch. Read directly by Enabled() so the off path is a global load and a + // predicted branch - do not turn this into a function call. + extern Bool g_pipeStatsEnabled; + + inline Bool Enabled() { return g_pipeStatsEnabled; } + + // Latches g_pipeStatsEnabled from MG_Config::Features.PipeStats and resets every + // counter. Called from MobileGL::Initialize() right after the config load. + void Init(); + + // Final summary line plus, if MOBILEGL_PIPE_STATS_FILE names a path, the JSON dump. + // Called from MobileGL's teardown. Idempotent. + void Shutdown(); + + void AddBytes(ByteClass byteClass, Uint64 bytes); + void AddCalls(CallClass callClass, Uint64 count); + void CountGate(Gate gate, Bool hit); + void RecordDrawPayloadBytes(Uint64 bytes); + + // Frame boundary: publishes the frame's values to Tracy (when TRACY_ENABLE), folds them + // into the run totals, clears the frame accumulators, and every kSummaryFramePeriod + // frames emits the summary line. Called from each backend's Present(). + void OnPresent(); + + // --- introspection, for the unit test and the JSON dump ------------------------- + Uint64 FrameBytes(ByteClass byteClass); + Uint64 TotalBytes(ByteClass byteClass); + Uint64 FrameCalls(CallClass callClass); + Uint64 TotalCalls(CallClass callClass); + Uint64 TotalGateHits(Gate gate); + Uint64 TotalGateMisses(Gate gate); + Uint64 TotalPayloadBucket(Uint32 bucket); + Uint64 FrameCount(); + + const char* NameOf(ByteClass byteClass); + const char* NameOf(CallClass callClass); + const char* NameOf(Gate gate); + + // The compact fixed-format one-liner MGLOG_I prints. Same text in the log and in the + // test, so the format is pinned by a test rather than by the log reader's memory. + String FormatSummaryLine(); + // The teardown dump. Run totals only: a per-frame JSON stream is a different tool. + String FormatJson(); + + // Test hooks. Not used by any shipping path. + void SetEnabledForTesting(Bool enabled); + void ResetForTesting(); + +} // namespace MobileGL::MG_Util::PipeStats From 7566a0b0027d622df58a6979b14fc6cf584b63f7 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 14:40:17 -0400 Subject: [PATCH 009/529] [Feat] (DirectGLES, DirectVulkan): instrument the six memo gates, the staging byte paths and both Present hooks with the MGPipe counters - The sites of plan B section 2.3.1, verified against dev@81b17c0b (the plan's own line numbers for DirectGLES drift by 4-9 lines; the DirectVulkan ones are exact): SyncRenderState is DirectGLES.cpp:1994 with the version read at :1998 and the early-out at :2007-2010 (plan says 2003 / 2007 / 2016-2018); SyncNeccessaryTextures at :1511 (plan :1520); CurrentUnitBindingsEpoch at :1412-1435 (plan :1418-1436); PrepareForDraw at :2907-2968 (plan :2916-2976); the global-UBO upload at :3355-3397 (plan :3369-3392); TrySetupDrawFastPath :5994, GetOrCreatePipeline :4948-4993, ApplyDynamicDrawStateTail :5871-5893 and UniformManager ResolveUniformBufferPayload :2022/:2052 all as cited. - Accessor counting is STATIC TALLIES at ten hot entry points, not a wrapper around the 293 pGLContext-> sites: each instrumented function adds the number of accessor calls its own body made on the path taken. Reads inside callees, and every conditional read (the sRGB capability in SyncRenderState, the XFB probe and the version-gated parameter fetch in TrySetupDrawFastPath, the cull-mode/logic-op/tessellation reads in the pipeline payload builder) are excluded, so the number is a consistent LOWER bound. The full inventory of what is and is not counted is the header comment of PipeStats.cpp. - The Magma fast-path gate is counted from SetupDraw, not from inside TrySetupDrawFastPath: that function has 27 decline returns and one success return, and counting at the caller is the only shape that cannot miss one. - The texture upload counts the SHAPE (union box vs N-rect list) separately from the bytes, because SSIM is blind to the shape and the +6 ms/frame Mali regression of section 7.3 was a shape regression, not a byte one. - Frame boundary: DirectGLES::Present after the ring upkeep, and DirectVulkan's backend Present rather than VulkanRenderer::Present - the latter has an early return for the no-usable-swapchain case, and a suspended frame is still a frame the counters close. - Measured on lavapipe/llvmpipe with MOBILEGL_PIPE_STATS=1, GuiBatchScenario, 14 frames and 26 draws: Espryt 20.65 accessor calls per draw (gates ers 22/18, etl 71/9, eub 71/9), Magma 15.54 (mfp 12/14, mpm 0/14, mdt 12/14). Both land inside the 10-25 band section 2.3.1 predicted and far below the 124/169 static counts, which is the correction that section was written to force. --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 71 +++++++++++++++++++ MobileGL/MG_Backend/DirectGLES/Managers.cpp | 62 ++++++++++++++++ .../MG_Backend/DirectVulkan/DirectVulkan.cpp | 8 +++ .../DirectVulkan/Renderer/UniformManager.cpp | 9 +++ .../DirectVulkan/Renderer/VulkanRenderer.cpp | 59 +++++++++++++++ 5 files changed, 209 insertions(+) diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 726cbf8d2..6c26f1f6e 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -1412,10 +1413,21 @@ namespace MobileGL::MG_Backend::DirectGLES { static Uint64 CurrentUnitBindingsEpoch(Int maxTouchedUnit) { const Uint64 contextId = MG_State::pGLContext->GetTextureContextId(); const Uint64 bindGeneration = MG_State::pGLContext->GetTextureBindGeneration(); + if (MG_Util::PipeStats::Enabled()) { + // Two accessor calls whichever way the shutter goes; only the unit WALK is + // gated, and that walk reads no GLContext accessor of its own. + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 2); + } if (g_observedUnitBindingsContextId == contextId && g_observedUnitBindingsMaxUnit == maxTouchedUnit && g_observedUnitBindingsGeneration == bindGeneration) { + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::EsprytUnitBindingsEpoch, /*hit=*/true); + } return g_unitBindingsEpoch; } + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::EsprytUnitBindingsEpoch, /*hit=*/false); + } if (g_observedUnitBindingsContextId != contextId || g_observedUnitBindingsMaxUnit != maxTouchedUnit || !UnitBindingsUnchanged(maxTouchedUnit, g_observedUnitBindings)) { CaptureUnitBindings(maxTouchedUnit, g_observedUnitBindings); @@ -1505,6 +1517,10 @@ namespace MobileGL::MG_Backend::DirectGLES { keys.maxTouchedUnit = MG_State::pGLContext->GetMaxTouchedTextureUnit(); keys.samplingGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); keys.unitBindingsEpoch = CurrentUnitBindingsEpoch(keys.maxTouchedUnit); + if (MG_Util::PipeStats::Enabled()) { + // The three reads above; CurrentUnitBindingsEpoch counts its own two. + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 3); + } return keys; } @@ -1534,6 +1550,11 @@ namespace MobileGL::MG_Backend::DirectGLES { g_unitTextureSyncListEpoch == unitBindingsEpoch && g_unitTextureSyncListSamplingGeneration == samplingGeneration && PairingsIntact(g_unitTextureSyncList)) { + if (MG_Util::PipeStats::Enabled()) { + // Gate 2 of section 2.3.1. The served path walks the memoised entries + // and reads no GLContext accessor at all. + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::EsprytTextureSyncList, /*hit=*/true); + } for (const auto& entry : g_unitTextureSyncList) { // Aggregate gate == the conjunction of the three callees' own // early-outs (see IsDrawSyncClean); skipping on true is @@ -1546,6 +1567,13 @@ namespace MobileGL::MG_Backend::DirectGLES { entry.backend->SyncMipmapsToBackend(*entry.slot); } } else { + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::EsprytTextureSyncList, /*hit=*/false); + // One GetTextureUnitObject per touched unit in the rebuild walk below. + MG_Util::PipeStats::AddCalls( + MG_Util::PipeStats::CallClass::AccessorCalls, + maxTouchedUnit >= 0 ? static_cast(maxTouchedUnit) + 1u : 0u); + } g_unitTextureSyncListValid = false; g_unitTextureSyncList.clear(); for (Int index = 0; index <= maxTouchedUnit; ++index) { @@ -2006,8 +2034,22 @@ namespace MobileGL::MG_Backend::DirectGLES { const Bool colorMaskWidenDirty = appliedWidenMask != g_syncedColorMaskAlphaWidenMask; if (!forceFullPush && !colorMaskWidenDirty && g_hasSyncedRenderState && currentRenderStateVersion == g_syncedRenderStateVersion) { + // Gate 1 of section 2.3.1: the steady-state cost of this whole function is + // the one Uint16 read above plus this compare. + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::EsprytRenderState, /*hit=*/true); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 1); + } return; } + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::EsprytRenderState, /*hit=*/false); + // The version read above, the parameter-block fetch and the viewport fetch + // below - the three accessor calls this function makes unconditionally on a + // miss. The conditional sRGB capability read further down is deliberately + // NOT counted (see the inventory in PipeStats.cpp). + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 3); + } const auto& parameters = MG_State::pGLContext->GetRenderStateParameters(); @@ -2924,6 +2966,14 @@ namespace MobileGL::MG_Backend::DirectGLES { // cross-TU call with a guarded static inside - repeating it per stage showed // up in draw-loop profiles. const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw(); + if (MG_Util::PipeStats::Enabled()) { + // THE per-draw denominator for Espryt, plus this function's own two accessor + // calls (the VAO and the draw program). Everything the callees below read is + // counted by the callees that are instrumented; the rest is not counted (see + // the inventory in PipeStats.cpp). + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::Draws, 1); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 2); + } const TextureImpl::DrawTextureSyncKeys textureKeys = TextureImpl::CaptureDrawTextureSyncKeys(); BufferImpl::SyncNeccessaryBuffers(currentVAO, vaoTwin, vaoConfigVersion, @@ -3370,6 +3420,10 @@ namespace MobileGL::MG_Backend::DirectGLES { if (BufferImpl::UboRingAllocate(bindSize, offset)) { std::memcpy(static_cast(BufferImpl::UboRingMappedPtr()) + offset, currentProgram->MapUBO(), uboSize); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageUboGlobal, + static_cast(uboSize)); + } ringSlot = {uboContentVersion, BufferImpl::UboRingGeneration(), frameSerial, offset}; slotValid = true; @@ -3389,6 +3443,11 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glBindBuffer(GL_UNIFORM_BUFFER, backendProgram.GetBackendGlobalUBOId()); g_GLESFuncs.glBufferSubData(GL_UNIFORM_BUFFER, 0, currentProgram->GetUBOSize(), currentProgram->MapUBO()); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes( + MG_Util::PipeStats::ByteClass::StageUboGlobal, + static_cast(currentProgram->GetUBOSize())); + } g_GLESFuncs.glBindBuffer(GL_UNIFORM_BUFFER, 0); backendProgram.SetLastUploadedGlobalUboVersion(uboContentVersion); } @@ -4306,6 +4365,12 @@ namespace MobileGL::MG_Backend::DirectGLES { g_restartIndices.capacity = capacity; if (data != nullptr && bytes != 0) { g_GLESFuncs.glBufferSubData(BufferImpl::TempBufferTarget, 0, static_cast(bytes), data); + if (MG_Util::PipeStats::Enabled()) { + // Rewritten index list staged on the draw path: in a split build these + // bytes are the index-mirror-versus-ship decision of section 8. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageIndexClient, + static_cast(bytes)); + } } return true; } @@ -10638,6 +10703,12 @@ namespace MobileGL::MG_Backend::DirectGLES { BufferImpl::UnpackRingOnPresent(); BufferImpl::UploadRingOnPresent(); BufferImpl::TrimBufferPool(); + + // THE frame boundary for the MGPipe counters: publish this frame's plots, fold the + // frame into the run totals and, every 120th frame, emit the summary line. + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::OnPresent(); + } } void DestroyEGLContext() { diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index f18f0f507..30b60aadc 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -779,6 +780,12 @@ namespace MobileGL::MG_Backend::DirectGLES { const void* initialData = (size > 0 && bufferObject.HasDefinedContent()) ? bufferObject.MappedData() : nullptr; g_GLESFuncs.glBufferData(TempBufferTarget, (GLsizeiptr)size, initialData, usage); + if (MG_Util::PipeStats::Enabled() && initialData != nullptr) { + // An ORPHANING respecify passes NULL and moves nothing, which is exactly + // why the test is on initialData rather than on size. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); + } resource.storageSize = size; resource.storageInitialized = true; resource.pendingRespecify = false; @@ -886,6 +893,13 @@ namespace MobileGL::MG_Backend::DirectGLES { const SizeT start = std::min(range.start, end); const SizeT size = end - start; if (size == 0) continue; + if (MG_Util::PipeStats::Enabled()) { + // Counted once per queued range, before the three delivery shapes + // below diverge: all three move exactly these bytes, and it is the + // byte count - not the shape - that sizes SEG_STAGE. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); + } // The invalidating map's fast path is SHAPE-dependent on this Mali // driver: a whole-buffer invalidation renames the store outright, // and a large range gets fresh pages - but a small unaligned range @@ -953,6 +967,10 @@ namespace MobileGL::MG_Backend::DirectGLES { if (write.offset >= limit) continue; const SizeT size = std::min(write.bytes.size(), limit - write.offset); if (size == 0) continue; + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); + } SizeT ringOffset = 0; if (ringUsable && size <= kUploadRingMaxBytes && RingAllocate(g_uploadRing, size, ringOffset)) { @@ -2545,6 +2563,10 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glBufferData(GL_ARRAY_BUFFER, static_cast(converted.size() * sizeof(Float)), converted.data(), GL_STREAM_DRAW); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageVertexClient, + static_cast(converted.size() * sizeof(Float))); + } // GL ignores `normalized` for floating-point array types, so it is not // forwarded here either. g_GLESFuncs.glVertexAttribPointer(attribIndex, attrib.Size, GL_FLOAT, GL_FALSE, @@ -2575,6 +2597,10 @@ namespace MobileGL::MG_Backend::DirectGLES { BufferImpl::BindBufferId(GL_ARRAY_BUFFER, bufferId); g_GLESFuncs.glBufferData(GL_ARRAY_BUFFER, static_cast(uploadSize), clientData, GL_STREAM_DRAW); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageVertexClient, + static_cast(uploadSize)); + } if (!attrib.IsInteger) { const GLint glSize = attrib.IsBgra ? static_cast(GL_BGRA) : attrib.Size; @@ -4391,6 +4417,42 @@ namespace MobileGL::MG_Backend::DirectGLES { if (ringStaged) { BufferImpl::BindPixelUnpackBufferId(BufferImpl::UnpackRingBufferId()); } + if (MG_Util::PipeStats::Enabled()) { + // One emission per (upload target, level) that ships texels; + // the switch below turns it into either one union-box job or + // dirtyRectCount rect jobs. The box/rect split is counted + // separately from the bytes on purpose: SSIM is blind to it + // and the +6 ms/frame Mali cliff was a shape regression, not + // a byte regression (plan section 7.3). + const Bool rectShape = subRectEligible && dirtyRectCount >= 2; + Uint64 shippedBytes = 0; + if (rectShape) { + for (SizeT r = 0; r < dirtyRectCount; ++r) { + const auto& rect = dirtyRects[r]; + shippedBytes += static_cast(rect.hi.x() - rect.lo.x()) * + static_cast(rect.hi.y() - rect.lo.y()) * + static_cast(std::max(rect.hi.z() - rect.lo.z(), 1)) * + static_cast(bpp); + } + } else if (subRectEligible) { + shippedBytes = static_cast(regionSize.x()) * + static_cast(regionSize.y()) * + static_cast(std::max(regionSize.z(), 1)) * + static_cast(bpp); + } else { + shippedBytes = static_cast(byteSize); + } + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageTexture, + shippedBytes); + MG_Util::PipeStats::AddCalls( + MG_Util::PipeStats::CallClass::TextureUploadEmissions, 1); + MG_Util::PipeStats::AddCalls( + rectShape ? MG_Util::PipeStats::CallClass::TextureUploadRectEmissions + : MG_Util::PipeStats::CallClass::TextureUploadBoxEmissions, + 1); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::TextureUploadJobs, + rectShape ? static_cast(dirtyRectCount) : 1u); + } switch (MapToBackendTextureTarget(stateTextureObject->GetTarget())) { case TextureTarget::Texture2D: case TextureTarget::TextureCubeMap: diff --git a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp index f135a4aad..16cc7263c 100644 --- a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp @@ -13,6 +13,7 @@ #include "MG_State/GLState/ErrorState/ErrorInfo.h" #include "MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h" #include "MG_Util/Converters/GLToMG/TextureEnumConverter.h" +#include "MG_Util/Metrics/PipeStats.h" #include "MG_Util/Metrics/TextureMetrics.h" #include "MG_Util/Miscellany/IndexGenerator.h" #include @@ -1430,5 +1431,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { void Present() { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::Present called with null VulkanRenderer"); pVulkanRenderer->Present(); + // THE frame boundary for the MGPipe counters, at the backend entry point rather + // than inside VulkanRenderer::Present: that function has an early return for the + // no-usable-swapchain case, and a suspended frame is still a frame the counters + // must close. + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::OnPresent(); + } } } // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp index a2465a582..a34a5c9f4 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp @@ -20,6 +20,7 @@ #include "MG_Util/Converters/GLToMG/TextureEnumConverter.h" #include "MG_Util/Converters/MGToStr/FramebufferEnumConverter.h" #include "MG_Util/Converters/MGToVk/TextureEnumConverter.h" +#include "MG_Util/Metrics/PipeStats.h" #include "MG_Util/Metrics/TextureMetrics.h" #include "MG_Util/ShaderTranspiler/Types.h" #include @@ -2058,6 +2059,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { } out.payload = outData; out.payloadSize = outSize; + if (MG_Util::PipeStats::Enabled()) { + // D-B8: these are the bytes Magma repacks into its own UBO ring, i.e. exactly + // the host payload a split build would have to ship with set_shader_buffers. + // Espryt binds the frontend buffer to the driver and contributes nothing here, + // which is why the class is named for the payload and not for the call. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageUboNamed, + static_cast(outSize)); + } // Zero-copy direct bind: for a persistent-mapped coherent app buffer whose full reflected // block fits within the aligned bound range, point the descriptor straight at the app's diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index b600511c2..80d69b492 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -27,6 +27,7 @@ #include "MG_Util/Converters/MGToVk/RenderStateEnumConverter.h" #include "MG_Util/Converters/MGToVk/TextureEnumConverter.h" #include "MG_Util/Math/HalfFloat.h" +#include "MG_Util/Metrics/PipeStats.h" #include "MG_Util/Metrics/TextureMetrics.h" #include "MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.h" #include "MG_Util/Texture/PixelStoreProcessor.h" @@ -4999,9 +5000,19 @@ void main() { entry.pipelineStateHash == pipelineStateHash && entry.primitiveRestartEnable == primitiveRestartEnable && entry.transformFlags == transformFlags) { + if (MG_Util::PipeStats::Enabled()) { + // Gate 5 of section 2.3.1. On a hit this whole function cost the one + // GetPipelineStateVersion read above plus this value compare. + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::MagmaPipelineMemo, /*hit=*/true); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 1); + } return entry.pipeline; } } + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::MagmaPipelineMemo, /*hit=*/false); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 1); + } // Shape gate. Behind the memo probe deliberately: only a pipeline that was created // successfully is ever memoized, so a program refused here can never be sitting in the @@ -5157,6 +5168,17 @@ void main() { syntheticVertexInputState.pNext = vis.state.pNext; pipelineVertexInputState = &syntheticVertexInputState; } + if (MG_Util::PipeStats::Enabled()) { + // THE payload-builder walk section 2.3.1 says only runs on a pipeline memo + // miss. Counted as a constant: the unconditional accessor reads between here + // and the end of the payload build (the six capability reads, the draw-FBO + // slot, the two stencil faces, the polygon mode, sample shading + min sample + // shading, patch vertices, the depth mask and the depth func, and the second + // draw-FBO slot read). Reads that are themselves conditional - the cull-mode + // ternary, the logic-op fetch, the two tessellation default-level reads - are + // deliberately excluded, so this stays a LOWER bound like every other tally. + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 15); + } auto cullFaceEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::CullFace); auto depthTestEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::DepthTest); auto polygonOffsetFillEnabled = @@ -5890,8 +5912,18 @@ void main() { if (shadow.dynamicTailValid && shadow.dynamicTailParamsVersion == paramsVersion && shadow.dynamicTailExtentX == extent.x() && shadow.dynamicTailExtentY == extent.y() && shadow.dynamicTailIsDefaultFbo == isDefaultFbo) { + // Gate 6 of section 2.3.1: one version read plus a four-integer compare. + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::MagmaDynamicTail, /*hit=*/true); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 1); + } return; } + if (MG_Util::PipeStats::Enabled()) { + // The version read above and the bulk parameter fetch that builds the value key. + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::MagmaDynamicTail, /*hit=*/false); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 2); + } const VkSurfaceTransformFlagBitsKHR preTransform = m_swapchainObject.GetPreTransform(); // Second-level VALUE gate: the version moved, but RenderState's version counts // every parameter, most of which this tail never reads. Build the key over @@ -6365,6 +6397,15 @@ void main() { MOBILEGL_ASSERT(idxUploadOk, "SetupDraw fast path: failed to upload index buffer"); } ApplyDynamicDrawStateTail(frame, snap.renderPassExtent, snap.drawFboIsDefault, snap.viewportCount); + if (MG_Util::PipeStats::Enabled()) { + // The six accessor reads this function makes unconditionally on the path that + // reaches here: the draw program, the VAO, the draw-FBO slot, the pipeline + // state version, the texture bind generation and the sampling-resolution + // generation. The XFB-active probe is elided on a device without the feature + // and the parameter-block fetch only runs when the pipeline state version + // moved, so neither is counted (lower bound, as everywhere else). + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 6); + } return true; } @@ -6390,9 +6431,27 @@ void main() { return false; } } + if (MG_Util::PipeStats::Enabled()) { + // THE per-draw denominator for Magma, plus the draw-program read above. Placed + // here rather than inside TrySetupDrawFastPath because the fast path has 27 + // decline returns and one success return: counting the gate from the caller is + // the only shape that cannot miss one. + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::Draws, 1); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 1); + } if (TrySetupDrawFastPath(frame, mode, aspects, drawParams, pIndexBufferView)) { + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::MagmaDrawFastPath, /*hit=*/true); + } return true; } + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::MagmaDrawFastPath, /*hit=*/false); + // The three reads the full path makes immediately below (draw FBO, VAO, + // program). The accessor reads the declined fast path had already made before + // it turned back are NOT counted. + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 3); + } const auto& drawFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); if (drawFbo != nullptr && IsUnsupportedFramebufferForDirectVulkan(*drawFbo)) { From d380a01f32a483c1035b49efd0dd949beba06736 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 15:20:13 -0400 Subject: [PATCH 010/529] [Fix] (Metrics, DirectGLES, DirectVulkan): stop the summary line printing window totals under a per-frame label, and wire the six staging paths the site inventory claimed were covered or absent - A window with no Present divided by a faked 1 and printed the window TOTALS under "bytes/f[...]": the *MultiDraw* slice (47 draws, no present) reported 1,404,550 bytes as a PER-FRAME figure, a 47x overstatement of exactly the SEG_STAGE sizing input plan B section 8.2 / section 11 P0 asks this package to produce. FormatWindowLine now relabels the bracket to "bytes[...]" and prints draws/f=n/a when the window holds no frame; acc/draw=n/a follows the same rule, because "0.00" beside a non-zero acc= is the same lie. Pinned by PipeStatsTest.SummaryLineSurvivesZeroFrames and the reworked SummaryLineSurvivesZeroDraws. - Every per-frame and per-draw field now goes through one FormatFixed2 helper. draws/f read 1 for 26 draws over 14 frames (1.86) and buf read 97 for 1360 bytes (97.14): a systematic downward truncation of up to a whole unit on the figures the package exists to produce. Pinned by PipeStatsTest.PerFrameFieldsKeepTwoDecimals. - FormatSummaryLine rewrote the window bases as a side effect of formatting, so any second reader silently zeroed the next window. Split into a pure FormatWindowLine() and an explicit AdvanceSummaryWindow(); EmitSummaryLine calls both. Pinned by PipeStatsTest.FormattingTwiceDoesNotConsumeTheWindow. - Init() called ResetForTesting(), against the header's own "not used by any shipping path". Both now forward to an internal ResetCounters(). - TracyPlot published only the MISS half of each gate under the gate's unqualified name, so the headline output channel of section 11 P0 carried no denominator. Two series per gate now ("...-hit" / "...-miss") from static literal arrays. The payload histogram stays unplotted and says why: it is a run-total distribution over draws (section 4.5.7), not a per-frame scalar, and it reaches the operator through the JSON dump. - GetOrCreatePipeline's 15-call tally sat ABOVE the list-topology primitive-restart refusal, so a declined draw added ten reads it never made - an OVER-count, which breaks the lower bound contract every other tally keeps. Moved to immediately before the payload build, and the enumeration reconciled with the constant: the excluded read is the sample-shading capability, short-circuited by m_sampleRateShadingFeatureEnabled. - Six real staging paths were uncounted while the inventory claimed coverage. The inventory claim "DirectVulkan's own buffer staging ... has no second copy to count" was simply false. Now wired: MultiDraw.cpp's UploadScratch/UploadScratchRing (indirect commands, the compute tier's draw-info array, the rebased index stream - the class is passed in, so a new tier cannot forget it); Managers.cpp's pool-recycle reseed and the VBO-backed Float64 narrowing; VkBufferManager's eight host->device copies of buffer contents plus UploadTransient, the single chokepoint for Magma's per-draw vertex/index/indirect staging; VkTextureManager's packed staging slice, with the same box/rect SHAPE split Espryt already reported. - New byte class stage-indirect-cmd, for draw PARAMETER bytes a backend synthesises and stages. Kept out of stage-index-client because these are the population that becomes MGPipe command-record payload (section 4.5.7), not resource bytes. A name addition, not a rename: no recorded baseline is invalidated. - stage-ubo-named moved past the zero-copy direct-bind decision in ResolveUniformBufferPayload (a direct bind repacks nothing, so counting it there reported a copy that never happened), and Magma's default-uniform-block image now feeds stage-ubo-global the way Espryt's does, counted after the per-frame slice memo. - The site inventory in PipeStats.cpp is rewritten to name every unwired path by file and function. An inventory that overstates coverage is worse than a missing counter, because the zero is then read as an answer. - Evidence, lavapipe/llvmpipe, MOBILEGL_PIPE_STATS=1: the MultiDraw slice now prints "window=0 draws/f=n/a bytes[buf=1404550 ...]"; the indirect tiers (MOBILEGL_ESPRYT_MULTIDRAW_MODE=indirect|multiindirect) move icmd 0 -> 660; Magma's GuiBatch line moves from buf=0 tex=0 ubog=0 to buf=685.71 tex=41.14 ubog=157.71 with tex[emit=9 box=9 rect=0 jobs=9]. Off-path A/B against the base tree with the env unset, 9 runs each of a 40-scenario draw slice, sorted totals in ms: Espryt base 389..794 (median 399) vs branch 384..403 (median 394); Magma base 406..472 (median 410) vs branch 408..761 (median 414) - the always-compiled guard is below this harness's noise on both backends. - 1410 unit tests green (15 PipeStatsTest). integration-gpu 860/860, 860/860, 859/860; the one failure is DirectVulkan.PointSizeDemotion...TheDemotionIsActuallyArmedWhenTheEnvironmentPins- ItOn, a member of the load-dependent *IsActuallyArmed* flake family already present in the untouched base tree, and it passes 8/8 standalone here. stdio gate and gen_pipe check green. --- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 13 ++ MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp | 31 ++- .../DirectVulkan/Renderer/UniformManager.cpp | 25 ++- .../DirectVulkan/Renderer/VkBufferManager.cpp | 66 +++++- .../Renderer/VkTextureManager.cpp | 27 +++ .../DirectVulkan/Renderer/VulkanRenderer.cpp | 29 ++- MobileGL/MG_Test/Util/PipeStatsTest.cpp | 72 +++++- MobileGL/MG_Util/Metrics/PipeStats.cpp | 207 ++++++++++++------ MobileGL/MG_Util/Metrics/PipeStats.h | 38 +++- 9 files changed, 395 insertions(+), 113 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 30b60aadc..56eb9298c 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -1538,6 +1538,13 @@ namespace MobileGL::MG_Backend::DirectGLES { BindBufferId(TempBufferTarget, reused); g_GLESFuncs.glBufferSubData(TempBufferTarget, 0, (GLsizeiptr)poolSize, bufferObject->MappedData()); + if (MG_Util::PipeStats::Enabled()) { + // The pool-recycle reseed is a whole-buffer upload on the hot path, + // not a bookkeeping detail: it moves the same bytes a fresh + // glBufferData would. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(poolSize)); + } { const std::lock_guard lock(resource->pendingMutex); resource->pendingRanges.clear(); @@ -2695,6 +2702,12 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glBufferData(GL_ARRAY_BUFFER, static_cast(converted.size() * sizeof(Float)), converted.data(), GL_STREAM_DRAW); + if (MG_Util::PipeStats::Enabled()) { + // The VBO-backed half of the 64-bit narrowing. Same population as the + // client-array half above: a stream the backend synthesises per draw. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageVertexClient, + static_cast(converted.size() * sizeof(Float))); + } stream.valid = true; stream.sourceLifetimeId = sourceLifetimeId; stream.sourceChangeSerial = sourceChangeSerial; diff --git a/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp b/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp index d3dde2ce0..9d9f012d4 100644 --- a/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp +++ b/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp @@ -9,6 +9,7 @@ #include "MultiDraw.h" #include "Managers.h" #include +#include #include #include @@ -156,7 +157,10 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { // are bound as storage blocks. Respecifies rather than sub-updates: glBufferData // orphans the previous store, so the upload never waits on a dispatch still reading // the old contents out of the same name. - Bool UploadScratch(ScratchBuffer& buffer, SizeT bytes, const void* data) { + // statsClass: which MGPipe byte population these bytes belong to. Counted here + // rather than at the four call sites so a new tier cannot forget it. + Bool UploadScratch(ScratchBuffer& buffer, SizeT bytes, const void* data, + MG_Util::PipeStats::ByteClass statsClass) { if (bytes == 0) return true; if (!EnsureScratchName(buffer)) return false; BufferImpl::BindBufferId(BufferImpl::TempBufferTarget, buffer.id); @@ -169,6 +173,9 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { buffer.cursor = 0; if (data) { g_GLESFuncs.glBufferSubData(BufferImpl::TempBufferTarget, 0, static_cast(bytes), data); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(statsClass, static_cast(bytes)); + } } return true; } @@ -183,7 +190,8 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { constexpr SizeT kRingAlignment = 16; // >= 4, so both command and uint32-index offsets stay legal constexpr SizeT kMinRingBytes = 1u << 16; - Bool UploadScratchRing(ScratchBuffer& buffer, SizeT bytes, const void* data, SizeT& outOffset) { + Bool UploadScratchRing(ScratchBuffer& buffer, SizeT bytes, const void* data, + MG_Util::PipeStats::ByteClass statsClass, SizeT& outOffset) { outOffset = 0; if (bytes == 0) return true; if (!EnsureScratchName(buffer)) return false; @@ -207,6 +215,9 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { if (data) { g_GLESFuncs.glBufferSubData(BufferImpl::TempBufferTarget, static_cast(outOffset), static_cast(bytes), data); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(statsClass, static_cast(bytes)); + } } buffer.cursor += aligned; return true; @@ -417,7 +428,8 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { const SizeT commandBytes = g_commandStaging.size() * sizeof(DrawElementsIndirectCommand); SizeT commandBase = 0; - if (!UploadScratchRing(g_indirectCommands, commandBytes, g_commandStaging.data(), commandBase)) { + if (!UploadScratchRing(g_indirectCommands, commandBytes, g_commandStaging.data(), + MG_Util::PipeStats::ByteClass::StageIndirectCmd, commandBase)) { return false; } @@ -532,7 +544,8 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { } SizeT indexBase = 0; - if (!UploadScratchRing(g_rebasedIndices, total * sizeof(Uint32), g_indexStaging.data(), indexBase)) { + if (!UploadScratchRing(g_rebasedIndices, total * sizeof(Uint32), g_indexStaging.data(), + MG_Util::PipeStats::ByteClass::StageIndexClient, indexBase)) { return false; } @@ -737,10 +750,16 @@ void main() { if (total == 0) return; // nothing to draw; the ordinary tiers no-op just as well if (!EnsureComputeProgram()) return; - if (!UploadScratch(g_drawInfo, g_drawInfoStaging.size() * sizeof(Uint32), g_drawInfoStaging.data())) { + if (!UploadScratch(g_drawInfo, g_drawInfoStaging.size() * sizeof(Uint32), g_drawInfoStaging.data(), + MG_Util::PipeStats::ByteClass::StageIndirectCmd)) { + return; + } + // data == nullptr: pure respecify, the compute pass writes the contents, so no + // host bytes cross here and nothing is counted. + if (!UploadScratch(g_flattenedIndices, total * sizeof(Uint32), nullptr, + MG_Util::PipeStats::ByteClass::StageIndexClient)) { return; } - if (!UploadScratch(g_flattenedIndices, total * sizeof(Uint32), nullptr)) return; BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, 0, sourceResource->id); BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, 1, g_drawInfo.id); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp index a34a5c9f4..de8163012 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp @@ -2059,14 +2059,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { } out.payload = outData; out.payloadSize = outSize; - if (MG_Util::PipeStats::Enabled()) { - // D-B8: these are the bytes Magma repacks into its own UBO ring, i.e. exactly - // the host payload a split build would have to ship with set_shader_buffers. - // Espryt binds the frontend buffer to the driver and contributes nothing here, - // which is why the class is named for the payload and not for the call. - MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageUboNamed, - static_cast(outSize)); - } // Zero-copy direct bind: for a persistent-mapped coherent app buffer whose full reflected // block fits within the aligned bound range, point the descriptor straight at the app's @@ -2085,6 +2077,16 @@ namespace MobileGL::MG_Backend::DirectVulkan { out.dynamicOffset = rangeStart; } } + if (MG_Util::PipeStats::Enabled() && !out.directBindable) { + // D-B8: the bytes Magma repacks into its own UBO ring, i.e. exactly the host + // payload a split build would have to ship with set_shader_buffers. Espryt binds + // the frontend buffer to the driver and contributes nothing here, which is why + // the class is named for the payload and not for the call. Counted AFTER the + // zero-copy direct-bind decision: a direct bind repacks nothing, and counting it + // here reported a copy that never happened. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageUboNamed, + static_cast(outSize)); + } return true; } @@ -2292,6 +2294,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { outBuffer = slice.buffer; outRange = ubo.payloadSize; outDynamicOffset = static_cast(slice.offset); + if (isGlobalUbo && MG_Util::PipeStats::Enabled()) { + // Magma's half of stage-ubo-global, so the class means the same on both + // backends. The memo hit above returns before this, so a frame that reuses the + // slice correctly contributes nothing. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageUboGlobal, + static_cast(ubo.payloadSize)); + } if (isGlobalUbo) { m_globalUboMemo[m_globalUboMemoNext] = GlobalUboSliceMemo{uboProgramLifetimeId, uboFrameSerial, uboContentVersion, diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp index 29f214d72..47394b3f0 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp @@ -10,6 +10,8 @@ #include "../DirectVulkan.h" #include "VulkanRenderer.h" +#include "MG_Util/Metrics/PipeStats.h" + namespace MobileGL::MG_Backend::DirectVulkan { namespace { constexpr VmaAllocationCreateFlags kResidentBufferAllocationFlags = @@ -229,8 +231,38 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool VkBufferManager::UploadTransient(BufferKind kind, Uint32 frameIndex, const void* data, VkDeviceSize size, VkDeviceSize alignment, BufferSlice& outSlice) { - (void)kind; - return m_transientUploadArena.Upload(frameIndex, data, size, alignment, outSlice); + if (!m_transientUploadArena.Upload(frameIndex, data, size, alignment, outSlice)) { + return false; + } + if (MG_Util::PipeStats::Enabled()) { + // The single chokepoint for Magma's per-draw staging. Uniform is deliberately + // absent: its bytes are counted by the caller, which is the only place that + // knows whether the payload is the default block (stage-ubo-global) or a named + // one repacked into the ring (stage-ubo-named), and counting here as well would + // double every uniform byte. + switch (kind) { + case BufferKind::Vertex: + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageVertexClient, + static_cast(size)); + break; + case BufferKind::Index: + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageIndexClient, + static_cast(size)); + break; + case BufferKind::Indirect: + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageIndirectCmd, + static_cast(size)); + break; + case BufferKind::TextureBuffer: + case BufferKind::ShaderStorage: + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); + break; + case BufferKind::Uniform: + break; + } + } + return true; } Bool VkBufferManager::InitializeTransientArenas() { @@ -339,6 +371,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { resource.pendingFullUpload = true; return false; } + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, static_cast(size)); + } resource.pendingFullUpload = false; return true; } @@ -353,6 +388,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { static_cast(size), 16, staging)) { return false; } + if (MG_Util::PipeStats::Enabled()) { + // The staging fill is the host copy; the vkCmdCopyBuffer below is the device + // half of the same bytes and is not counted twice. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, static_cast(size)); + } VkCommandBuffer commandBuffer = m_copyProvider->AcquireBufferCopyCommandBuffer(); if (commandBuffer == VK_NULL_HANDLE) { return false; @@ -422,6 +462,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { if (!resource->buffer.Upload(bufferObject.MappedData(), size, 0)) { MGLOG_E_ONCE("VkBufferManager::OnRespecify: in-place upload failed"); resource->pendingFullUpload = true; + } else if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, static_cast(size)); } } @@ -447,6 +489,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { static_cast(size), static_cast(offset))) { MGLOG_E_ONCE("VkBufferManager::OnSubData: host upload failed"); resource->pendingFullUpload = true; + } else if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); } return; } @@ -484,6 +529,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { static_cast(size), static_cast(offset))) { MGLOG_E_ONCE("VkBufferManager::OnFlushMappedRange: host upload failed"); resource->pendingFullUpload = true; + } else if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); } return; } @@ -554,6 +602,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { const Uint8* seed = bufferObject.MappedData(); if (seed != nullptr) { resource->buffer.Upload(seed, size, 0); + if (MG_Util::PipeStats::Enabled()) { + // The one-time seed of a persistent map. Everything the app writes AFTER + // this goes straight through the mapping and is persistent-map-push + // territory (unwired, D4/D-B4), not this class. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); + } } resource->persistentMapped = true; resource->pendingFullUpload = false; @@ -602,6 +657,10 @@ namespace MobileGL::MG_Backend::DirectVulkan { resource->usageFlags = 0; return false; } + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); + } resource->pendingFullUpload = false; } @@ -681,6 +740,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { outSlice)) { return false; } + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, static_cast(size)); + } resource->transientSlice = outSlice; resource->transientFrameSerial = m_frameSerial; resource->transientChangeSerial = changeSerial; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp index 222e7cd90..0335f23b3 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp @@ -13,6 +13,7 @@ #include "MG_State/GLState/Core.h" #include "MG_Util/Converters/MGToStr/TextureEnumConverter.h" #include "MG_Util/Converters/MGToVk/TextureEnumConverter.h" +#include "MG_Util/Metrics/PipeStats.h" #include #include @@ -3150,6 +3151,32 @@ namespace MobileGL::MG_Backend::DirectVulkan { packBox(dst, item.regionLo, item.regionSize); } + if (MG_Util::PipeStats::Enabled()) { + // Same shape split as Espryt's: one union box per item, or one job per rect of + // a refined rect list. The box/rect decision is invisible to SSIM and is what + // the +6 ms/frame Mali cliff of section 7.3 was, so it is counted apart from + // the bytes. + Uint64 boxEmissions = 0; + Uint64 rectEmissions = 0; + Uint64 jobs = 0; + for (const auto& item : uploadItems) { + if (item.rects.empty()) { + ++boxEmissions; + jobs += isCombinedDepthStencil ? 2u : 1u; + } else { + ++rectEmissions; + jobs += static_cast(item.rects.size()); + } + } + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageTexture, + static_cast(stagingSize)); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::TextureUploadEmissions, + static_cast(uploadItems.size())); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::TextureUploadBoxEmissions, boxEmissions); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::TextureUploadRectEmissions, rectEmissions); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::TextureUploadJobs, jobs); + } + const VkImageAspectFlags aspectMask = GetAspectMaskForFormat(outResource.format); VkPipelineStageFlags uploadSrcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; VkAccessFlags uploadSrcAccessMask = 0; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 80d69b492..e365603d1 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -5168,17 +5168,6 @@ void main() { syntheticVertexInputState.pNext = vis.state.pNext; pipelineVertexInputState = &syntheticVertexInputState; } - if (MG_Util::PipeStats::Enabled()) { - // THE payload-builder walk section 2.3.1 says only runs on a pipeline memo - // miss. Counted as a constant: the unconditional accessor reads between here - // and the end of the payload build (the six capability reads, the draw-FBO - // slot, the two stencil faces, the polygon mode, sample shading + min sample - // shading, patch vertices, the depth mask and the depth func, and the second - // draw-FBO slot read). Reads that are themselves conditional - the cull-mode - // ternary, the logic-op fetch, the two tessellation default-level reads - are - // deliberately excluded, so this stays a LOWER bound like every other tally. - MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 15); - } auto cullFaceEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::CullFace); auto depthTestEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::DepthTest); auto polygonOffsetFillEnabled = @@ -5264,6 +5253,24 @@ void main() { return VK_NULL_HANDLE; } + if (MG_Util::PipeStats::Enabled()) { + // THE payload-builder walk section 2.3.1 says only runs on a pipeline memo miss. + // Counted as a constant, and counted HERE rather than at the top of the walk: + // the list-topology primitive-restart refusal above returns VK_NULL_HANDLE after + // only ten of these reads have run, and a tally that fires before an early return + // is an OVER-count, which breaks the lower-bound contract every other tally keeps. + // + // The 15 are: the six capability reads (cull face, depth test, polygon offset + // fill, rasterizer discard, colour logic op, stencil test), the draw-FBO slot + // read that gates depth/stencil, the two stencil face states, the polygon mode, + // the min sample shading value, the patch vertex count, the depth mask, the depth + // func, and the second draw-FBO slot read below. The sample-shading CAPABILITY + // read is the one excluded: it sits behind && on m_sampleRateShadingFeatureEnabled + // and does not run on a device without the feature. The other conditional reads - + // the cull-mode ternary, the logic-op fetch, the two tessellation default-level + // reads - are excluded for the same reason, so this stays a LOWER bound. + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 15); + } PipelineFactory::PipelineCreatePayload payload { .programHash = programObj.hash, .vertexInputHash = vertexLayoutHash, diff --git a/MobileGL/MG_Test/Util/PipeStatsTest.cpp b/MobileGL/MG_Test/Util/PipeStatsTest.cpp index 97e8bbcfc..4d329ed7b 100644 --- a/MobileGL/MG_Test/Util/PipeStatsTest.cpp +++ b/MobileGL/MG_Test/Util/PipeStatsTest.cpp @@ -56,6 +56,7 @@ namespace { // Every other class untouched, the residual-value-block placeholder included. EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageUboGlobal), 0u); EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageUboNamed), 0u); + EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageIndirectCmd), 0u); EXPECT_EQ(PS::TotalBytes(PS::ByteClass::ResidualValueBlock), 0u); } @@ -123,12 +124,12 @@ namespace { PS::AddBytes(PS::ByteClass::StageBuffer, 4096); PS::OnPresent(); - const String line = PS::FormatSummaryLine(); + const String line = PS::FormatWindowLine(); EXPECT_NE(line.find("MGPipe stats:"), String::npos) << line; EXPECT_NE(line.find("draws=4"), String::npos) << line; // 50 accessor calls over 4 draws, two decimals, no . EXPECT_NE(line.find("acc/draw=12.50"), String::npos) << line; - EXPECT_NE(line.find("buf=4096"), String::npos) << line; + EXPECT_NE(line.find("buf=4096.00"), String::npos) << line; for (Uint32 i = 0; i < static_cast(PS::Gate::Count); ++i) { EXPECT_NE(line.find("="), String::npos); } @@ -136,26 +137,82 @@ namespace { EXPECT_NE(line.find("tex[emit="), String::npos) << line; } + // Per-frame fields carry two decimals for the same reason acc/draw does: they are small + // and load-bearing (bytes/f sizes SEG_STAGE), and integer division silently rounds a + // whole unit off each of them. 26 draws over 14 frames is 1.86, not 1. + TEST_F(PipeStatsTest, PerFrameFieldsKeepTwoDecimals) { + PS::AddCalls(PS::CallClass::Draws, 26); + PS::AddBytes(PS::ByteClass::StageBuffer, 1360); + for (Uint32 i = 0; i < 14; ++i) { + PS::OnPresent(); + } + + const String line = PS::FormatWindowLine(); + EXPECT_NE(line.find("draws/f=1.86"), String::npos) << line; + EXPECT_NE(line.find("buf=97.14"), String::npos) << line; + } + // Successive summaries report WINDOWS, not run totals: a run total over a workload that // changes shape (load, then steady state) averages away the very number section 2.3.1 - // wants. + // wants. Advancing the window is an explicit call, not a side effect of formatting. TEST_F(PipeStatsTest, SummaryLinesReportDisjointWindows) { PS::AddCalls(PS::CallClass::Draws, 10); PS::OnPresent(); - const String first = PS::FormatSummaryLine(); + const String first = PS::FormatWindowLine(); EXPECT_NE(first.find("draws=10"), String::npos) << first; + PS::AdvanceSummaryWindow(); PS::AddCalls(PS::CallClass::Draws, 3); PS::OnPresent(); - const String second = PS::FormatSummaryLine(); + const String second = PS::FormatWindowLine(); EXPECT_NE(second.find("draws=3"), String::npos) << second; EXPECT_NE(second.find("frames=2"), String::npos) << second; } + // FormatWindowLine is pure. It used to rewrite the window bases as a side effect of + // formatting, so any second reader - a probe, a test, a second reporting channel - + // silently zeroed the next window. + TEST_F(PipeStatsTest, FormattingTwiceDoesNotConsumeTheWindow) { + PS::AddCalls(PS::CallClass::Draws, 7); + PS::OnPresent(); + + const String first = PS::FormatWindowLine(); + const String second = PS::FormatWindowLine(); + EXPECT_EQ(first, second) << first << "\n" << second; + EXPECT_NE(second.find("draws=7"), String::npos) << second; + + // ...and advancing explicitly does close it. + PS::AdvanceSummaryWindow(); + const String third = PS::FormatWindowLine(); + EXPECT_NE(third.find("draws=0"), String::npos) << third; + } + TEST_F(PipeStatsTest, SummaryLineSurvivesZeroDraws) { + PS::AddCalls(PS::CallClass::AccessorCalls, 12); PS::OnPresent(); - const String line = PS::FormatSummaryLine(); - EXPECT_NE(line.find("acc/draw=0.00"), String::npos) << line; + const String line = PS::FormatWindowLine(); + // No draw in the window means there is no per-draw number - and "0.00" beside a + // non-zero acc= would read as one. + EXPECT_NE(line.find("acc/draw=n/a"), String::npos) << line; + EXPECT_NE(line.find("acc=12"), String::npos) << line; + } + + // A window with no Present in it has no per-frame reading at all. This used to divide by + // a faked 1 and print the window TOTALS under a "/f" label: a scenario slice that draws + // 47 times and never presents reported 1,404,550 staged bytes as a per-frame figure, + // which is a 47x overstatement of the SEG_STAGE sizing input this package exists to + // produce. + TEST_F(PipeStatsTest, SummaryLineSurvivesZeroFrames) { + PS::AddCalls(PS::CallClass::Draws, 47); + PS::AddBytes(PS::ByteClass::StageBuffer, 1404550); + + const String line = PS::FormatWindowLine(); + EXPECT_EQ(PS::FrameCount(), 0u); + EXPECT_NE(line.find("window=0"), String::npos) << line; + EXPECT_NE(line.find("draws/f=n/a"), String::npos) << line; + // The bracket is relabelled rather than divided: totals, and marked as totals. + EXPECT_EQ(line.find("bytes/f["), String::npos) << line; + EXPECT_NE(line.find("bytes[buf=1404550"), String::npos) << line; } TEST_F(PipeStatsTest, JsonDumpNamesEveryCounter) { @@ -191,6 +248,7 @@ namespace { EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageUboNamed), "stage-ubo-named"); EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageVertexClient), "stage-vertex-client"); EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageIndexClient), "stage-index-client"); + EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageIndirectCmd), "stage-indirect-cmd"); EXPECT_STREQ(PS::NameOf(PS::ByteClass::PersistentMapPush), "persistent-map-push"); EXPECT_STREQ(PS::NameOf(PS::ByteClass::ResidualValueBlock), "residual-value-block"); EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytRenderState), "espryt-render-state"); diff --git a/MobileGL/MG_Util/Metrics/PipeStats.cpp b/MobileGL/MG_Util/Metrics/PipeStats.cpp index 1fada1b15..e12123150 100644 --- a/MobileGL/MG_Util/Metrics/PipeStats.cpp +++ b/MobileGL/MG_Util/Metrics/PipeStats.cpp @@ -15,42 +15,69 @@ // --------------------------------------------------------------------------------------- // SITE INVENTORY - what these counters DO and DO NOT cover. // +// This list is the contract. A byte class that reads 0 while a real copy runs uncounted is +// worse than a missing counter, because the zero is then read as an answer, so every path +// that moves bytes and is NOT wired is named here by file and function. +// // Byte classes -// stage-buffer DirectGLES Managers.cpp: RespecifyStorageNow's glBufferData, -// FlushPendingRangesNow's three shapes (map-write, glBufferSubData, -// upload-ring stage). Covers every byte Espryt hands the driver for -// a buffer object's contents. -// NOT covered: DirectVulkan's own buffer staging (its buffer bytes -// reach the GPU through a persistent map the frontend already owns, -// so there is no second copy to count) - see the note on +// stage-buffer ESPRYT (DirectGLES Managers.cpp): RespecifyStorageNow's +// glBufferData, FlushPendingRangesNow's three shapes (map-write, +// glBufferSubData, upload-ring stage), and the pool-recycle reseed +// in SyncBufferObject. +// MAGMA (DirectVulkan VkBufferManager.cpp): every host->device copy +// of a buffer object's contents - SwapStorageAndUploadAll, the +// StagedRangeCopy staging fill, the in-place uploads in OnRespecify / +// OnSubData / OnFlushMappedRange, the AcquirePersistentMap seed, the +// AcquireResidentSlice initial upload and the AcquireStreamedSlice +// arena fill. +// NOT covered: bytes an app writes THROUGH a persistent map. Those +// never pass through either backend (D4/D-B4) - see // persistent-map-push. -// stage-texture DirectGLES Managers.cpp texture upload: the bytes of whichever of +// stage-texture ESPRYT (Managers.cpp texture upload): the bytes of whichever of // the three upload shapes ran (rect list / union box / whole level). -// NOT covered: the DirectVulkan texture staging path, and Espryt's -// compressed-texture and readback paths. -// stage-ubo-global DirectGLES.cpp default-uniform-block image, both the UBO-ring -// memcpy and the glBufferSubData fallback. +// MAGMA (VkTextureManager.cpp): the packed staging slice of an +// upload batch item set. +// NOT covered: Espryt's compressed-texture path, and both backends' +// readback (device->host) paths, which are a different direction and +// want their own class when the reverse channel of section 7 exists. +// stage-ubo-global ESPRYT (DirectGLES.cpp): the default-uniform-block image, both the +// UBO-ring memcpy and the glBufferSubData fallback. +// MAGMA (UniformManager::ResolveDynamicUboDescriptor): the same +// image, counted after the per-frame slice memo, so a frame that +// re-uses the slice correctly contributes nothing. // stage-ubo-named DirectVulkan UniformManager::ResolveUniformBufferPayload - the -// bytes Magma repacks into its own UBO ring. Espryt contributes -// nothing by construction (D-B8). -// stage-vertex-client DirectGLES BackendVertexArrayObject::SyncClientSideAttributesFor- -// DrawArrays, both the Float64-narrowing and the verbatim shapes. -// NOT covered: the DirectVulkan converted-vertex-stream cache. -// stage-index-client DirectGLES index rewriting (the primitive-restart substitution -// buffer). -// NOT covered: DirectVulkan's index staging. +// bytes Magma repacks into its own UBO ring, counted AFTER the +// zero-copy direct-bind decision (a direct bind repacks nothing). +// Espryt contributes nothing by construction (D-B8). +// stage-vertex-client ESPRYT: BackendVertexArrayObject::SyncClientSideAttributesFor- +// DrawArrays (both the Float64-narrowing and the verbatim shapes) +// and the VBO-backed Float64->Float32 narrowing scratch upload. +// MAGMA: VkBufferManager::UploadTransient(BufferKind::Vertex), which +// is the single chokepoint for the converted-vertex-stream and +// client-array staging. +// stage-index-client ESPRYT: the primitive-restart substitution buffer, and MultiDraw's +// rewritten (rebased) index stream. +// MAGMA: VkBufferManager::UploadTransient(BufferKind::Index). +// stage-indirect-cmd ESPRYT MultiDraw.cpp: the DrawElementsIndirectCommand array staged +// for the indirect tiers, and the compute tier's per-draw info +// array. Kept out of stage-index-client because these are draw +// PARAMETERS - the population that becomes MGPipe command-record +// payload, not resource bytes. +// NOT covered: Magma builds no such array (it issues one vkCmdDraw* +// per sub-draw), so this class is Espryt-only by construction. // persistent-map-push Not wired in P0: today a persistent map is a permanent address // space donation (D4/D-B4) that survives the whole monolith track, // so there is no push to count until the IPC track breaks it. // residual-value-block Placeholder, always 0 until P2 (plan section 6.3). // // Call classes -// draws DirectGLES PrepareForDraw and DirectVulkan TrySetupDrawFastPath's -// caller-visible entry. A dispatch is not a draw and is not counted. +// draws DirectGLES PrepareForDraw and DirectVulkan SetupDraw's entry. A +// dispatch is not a draw and is not counted. // accessor-calls STATIC TALLIES at the instrumented entry points, NOT a wrapper // around all 293 pGLContext-> sites. Each instrumented function adds // the number of GLContext accessor calls that its OWN body executed -// on the path taken. Covered: PrepareForDraw's own reads, +// on the path taken, and each tally sits AFTER the last early return +// that would skip those reads. Covered: PrepareForDraw's own reads, // SyncRenderState, CaptureDrawTextureSyncKeys/CurrentUnitBindings- // Epoch, SyncNeccessaryTextures' walk, TrySetupDrawFastPath, // GetOrCreatePipeline and ApplyDynamicDrawStateTail. NOT covered: @@ -59,7 +86,7 @@ // memo miss), and every non-draw entry point. The number is // therefore a LOWER BOUND on the per-draw accessor count, and it is // the bound over exactly the six gates section 2.3.1 tabulates. -// texture-* DirectGLES texture upload, per (target, level) emission. +// texture-* Per (target, level) emission, both backends. // // Gates: the six of section 2.3.1, each counted exactly once per probe. // @@ -122,9 +149,24 @@ namespace MobileGL::MG_Util::PipeStats { return bucket; } + // Two decimals without . Every per-frame and per-draw field in the summary + // goes through this: the numbers are small (a per-draw accessor count in the 10-25 + // band, a per-frame byte count that sizes SEG_STAGE), so truncating integer division + // loses up to a whole unit on exactly the figures the package exists to produce. + // A zero denominator is "n/a" rather than a division by a faked 1. + String FormatFixed2(Uint64 numerator, Uint64 denominator) { + if (denominator == 0) { + return "n/a"; + } + const Uint64 hundredths = (numerator * 100 + denominator / 2) / denominator; + return std::to_string(hundredths / 100) + "." + (hundredths % 100 < 10 ? "0" : "") + + std::to_string(hundredths % 100); + } + const char* const kByteClassNames[kByteClassCount] = { - "stage-buffer", "stage-texture", "stage-ubo-global", "stage-ubo-named", - "stage-vertex-client", "stage-index-client", "persistent-map-push", "residual-value-block", + "stage-buffer", "stage-texture", "stage-ubo-global", + "stage-ubo-named", "stage-vertex-client", "stage-index-client", + "stage-indirect-cmd", "persistent-map-push", "residual-value-block", }; const char* const kCallClassNames[kCallClassCount] = { "draws", "accessor-calls", "tex-upload-emissions", "tex-upload-box", "tex-upload-rect", @@ -134,19 +176,57 @@ namespace MobileGL::MG_Util::PipeStats { "espryt-render-state", "espryt-texture-sync-list", "espryt-unit-bindings-epoch", "magma-draw-fastpath", "magma-pipeline-memo", "magma-dynamic-tail", }; + // Tracy needs a stable string literal per series, and a gate is TWO series: plotting + // only the misses (which is what the first cut did) hides the denominator, and a + // gate's whole point is the ratio. + const char* const kGateHitPlotNames[kGateCount] = { + "espryt-render-state-hit", "espryt-texture-sync-list-hit", "espryt-unit-bindings-epoch-hit", + "magma-draw-fastpath-hit", "magma-pipeline-memo-hit", "magma-dynamic-tail-hit", + }; + const char* const kGateMissPlotNames[kGateCount] = { + "espryt-render-state-miss", "espryt-texture-sync-list-miss", "espryt-unit-bindings-epoch-miss", + "magma-draw-fastpath-miss", "magma-pipeline-memo-miss", "magma-dynamic-tail-miss", + }; // Short forms, so the per-120-frame line stays one terminal line wide. - const char* const kByteClassShort[kByteClassCount] = {"buf", "tex", "ubog", "ubon", - "vtxc", "idxc", "pmap", "resid"}; + const char* const kByteClassShort[kByteClassCount] = {"buf", "tex", "ubog", "ubon", "vtxc", + "idxc", "icmd", "pmap", "resid"}; const char* const kGateShort[kGateCount] = {"ers", "etl", "eub", "mfp", "mpm", "mdt"}; + void ResetCounters() { + for (Uint32 i = 0; i < kByteClassCount; ++i) { + g_frameBytes[i].store(0, std::memory_order_relaxed); + g_totalBytes[i].store(0, std::memory_order_relaxed); + g_windowBaseBytes[i] = 0; + } + for (Uint32 i = 0; i < kCallClassCount; ++i) { + g_frameCalls[i].store(0, std::memory_order_relaxed); + g_totalCalls[i].store(0, std::memory_order_relaxed); + g_windowBaseCalls[i] = 0; + } + for (Uint32 i = 0; i < kGateCount; ++i) { + g_frameGateHit[i].store(0, std::memory_order_relaxed); + g_totalGateHit[i].store(0, std::memory_order_relaxed); + g_frameGateMiss[i].store(0, std::memory_order_relaxed); + g_totalGateMiss[i].store(0, std::memory_order_relaxed); + g_windowBaseGateHit[i] = 0; + g_windowBaseGateMiss[i] = 0; + } + for (Uint32 i = 0; i < kPayloadHistogramBuckets; ++i) { + g_totalPayloadBuckets[i].store(0, std::memory_order_relaxed); + } + g_frameCount.store(0, std::memory_order_relaxed); + g_windowBaseFrames = 0; + } + void EmitSummaryLine() { - const String line = FormatSummaryLine(); + const String line = FormatWindowLine(); // MGLOG_I on purpose, against the project's usual "MGLOG_D for anything // non-critical" rule: the line has to survive an INFO build (that is the only // build a device ever runs), it is emitted at most once per 120 frames, and it // exists at all only when the operator set MOBILEGL_PIPE_STATS=1. It is an // opt-in measurement channel, not per-frame noise. MGLOG_I("%s", line.c_str()); + AdvanceSummaryWindow(); } void WriteJsonDump() { @@ -170,7 +250,7 @@ namespace MobileGL::MG_Util::PipeStats { } // namespace void Init() { - ResetForTesting(); + ResetCounters(); g_shutdownDone = false; g_pipeStatsEnabled = MG_Config::Features.PipeStats; if (g_pipeStatsEnabled) { @@ -218,7 +298,13 @@ namespace MobileGL::MG_Util::PipeStats { void OnPresent() { #ifdef TRACY_ENABLE // One plot per counter, the frame's value. Tracy keeps the series by name, and the - // names are the static literals above, which is what TracyPlot requires. + // names are the static literals above, which is what TracyPlot requires. A gate is + // two series - hits and misses - because the ratio is the deliverable and a miss + // count alone cannot be read. + // + // The payload histogram is deliberately NOT plotted: it is a run-total distribution + // over draws (section 4.5.7), not a per-frame scalar, and Tracy has no histogram + // series. It reaches the operator through the JSON dump. for (Uint32 i = 0; i < kByteClassCount; ++i) { TracyPlot(kByteClassNames[i], static_cast(Read(g_frameBytes[i]))); } @@ -226,7 +312,8 @@ namespace MobileGL::MG_Util::PipeStats { TracyPlot(kCallClassNames[i], static_cast(Read(g_frameCalls[i]))); } for (Uint32 i = 0; i < kGateCount; ++i) { - TracyPlot(kGateNames[i], static_cast(Read(g_frameGateMiss[i]))); + TracyPlot(kGateHitPlotNames[i], static_cast(Read(g_frameGateHit[i]))); + TracyPlot(kGateMissPlotNames[i], static_cast(Read(g_frameGateMiss[i]))); } #endif for (Uint32 i = 0; i < kByteClassCount; ++i) { @@ -260,12 +347,16 @@ namespace MobileGL::MG_Util::PipeStats { const char* NameOf(CallClass callClass) { return kCallClassNames[static_cast(callClass)]; } const char* NameOf(Gate gate) { return kGateNames[static_cast(gate)]; } - String FormatSummaryLine() { + String FormatWindowLine() { // Window values: everything since the previous summary. A run total over a workload // whose shape changes (load, then steady state) hides exactly the number P2 wants. const Uint64 frames = Read(g_frameCount); const Uint64 windowFrames = frames - g_windowBaseFrames; - const Uint64 divisorFrames = windowFrames == 0 ? 1 : windowFrames; + // A window with no Present in it (teardown before the first frame, or a slice whose + // whole workload runs off-screen) has NO per-frame reading. Printing the window + // totals under a "/f" label there is how a 47x overstatement of the SEG_STAGE sizing + // input got printed as a per-frame figure; the label changes instead. + const Bool perFrame = windowFrames != 0; Uint64 bytes[kByteClassCount]; for (Uint32 i = 0; i < kByteClassCount; ++i) { @@ -289,21 +380,21 @@ namespace MobileGL::MG_Util::PipeStats { line += " frames=" + std::to_string(frames); line += " window=" + std::to_string(windowFrames); line += " draws=" + std::to_string(draws); - line += " draws/f=" + std::to_string(draws / divisorFrames); + line += " draws/f=" + FormatFixed2(draws, windowFrames); line += " acc=" + std::to_string(accessorCalls); - // Two decimals without : the per-draw accessor count is the number section - // 2.3.1 wants to an integer's worth of precision, and it is small (10-25). - const Uint64 accPerDrawHundredths = draws == 0 ? 0 : (accessorCalls * 100 + draws / 2) / draws; - line += " acc/draw=" + std::to_string(accPerDrawHundredths / 100) + "." + - (accPerDrawHundredths % 100 < 10 ? "0" : "") + std::to_string(accPerDrawHundredths % 100); - line += " bytes/f["; + // Same rule as the per-frame fields: a window with no draw in it has no per-draw + // number, and "0.00" next to a non-zero acc= is the same lie in a smaller font. + line += " acc/draw=" + FormatFixed2(accessorCalls, draws); + // "bytes/f[...]" only when there IS a frame to divide by; otherwise the bracket is + // labelled "bytes[...]" and carries the window totals verbatim. + line += perFrame ? " bytes/f[" : " bytes["; for (Uint32 i = 0; i < kByteClassCount; ++i) { if (i != 0) { line += " "; } line += kByteClassShort[i]; line += "="; - line += std::to_string(bytes[i] / divisorFrames); + line += perFrame ? FormatFixed2(bytes[i], windowFrames) : std::to_string(bytes[i]); } line += "] tex[emit=" + std::to_string(calls[static_cast(CallClass::TextureUploadEmissions)]); line += " box=" + std::to_string(calls[static_cast(CallClass::TextureUploadBoxEmissions)]); @@ -321,7 +412,10 @@ namespace MobileGL::MG_Util::PipeStats { line += std::to_string(gateMiss[i]); } line += "]"; + return line; + } + void AdvanceSummaryWindow() { for (Uint32 i = 0; i < kByteClassCount; ++i) { g_windowBaseBytes[i] = Read(g_totalBytes[i]); } @@ -332,8 +426,7 @@ namespace MobileGL::MG_Util::PipeStats { g_windowBaseGateHit[i] = Read(g_totalGateHit[i]); g_windowBaseGateMiss[i] = Read(g_totalGateMiss[i]); } - g_windowBaseFrames = frames; - return line; + g_windowBaseFrames = Read(g_frameCount); } String FormatJson() { @@ -374,30 +467,6 @@ namespace MobileGL::MG_Util::PipeStats { void SetEnabledForTesting(Bool enabled) { g_pipeStatsEnabled = enabled; } - void ResetForTesting() { - for (Uint32 i = 0; i < kByteClassCount; ++i) { - g_frameBytes[i].store(0, std::memory_order_relaxed); - g_totalBytes[i].store(0, std::memory_order_relaxed); - g_windowBaseBytes[i] = 0; - } - for (Uint32 i = 0; i < kCallClassCount; ++i) { - g_frameCalls[i].store(0, std::memory_order_relaxed); - g_totalCalls[i].store(0, std::memory_order_relaxed); - g_windowBaseCalls[i] = 0; - } - for (Uint32 i = 0; i < kGateCount; ++i) { - g_frameGateHit[i].store(0, std::memory_order_relaxed); - g_totalGateHit[i].store(0, std::memory_order_relaxed); - g_frameGateMiss[i].store(0, std::memory_order_relaxed); - g_totalGateMiss[i].store(0, std::memory_order_relaxed); - g_windowBaseGateHit[i] = 0; - g_windowBaseGateMiss[i] = 0; - } - for (Uint32 i = 0; i < kPayloadHistogramBuckets; ++i) { - g_totalPayloadBuckets[i].store(0, std::memory_order_relaxed); - } - g_frameCount.store(0, std::memory_order_relaxed); - g_windowBaseFrames = 0; - } + void ResetForTesting() { ResetCounters(); } } // namespace MobileGL::MG_Util::PipeStats diff --git a/MobileGL/MG_Util/Metrics/PipeStats.h b/MobileGL/MG_Util/Metrics/PipeStats.h index 6b2348f23..367a323e6 100644 --- a/MobileGL/MG_Util/Metrics/PipeStats.h +++ b/MobileGL/MG_Util/Metrics/PipeStats.h @@ -32,9 +32,12 @@ // perfectly-predicted branch, and none of the counter state is touched. The counters // themselves are relaxed atomics rather than plain integers because texture and buffer // staging can be reached from more than one thread; relaxed adds cost nothing extra on the -// off path, which never reaches them. +// off path, which never reaches them. The off-path cost is not a guess: see the paired +// A/B in the branch's evidence. // -// WHAT IS COUNTED AND WHAT IS NOT: see the site inventory in PipeStats.cpp. +// WHAT IS COUNTED AND WHAT IS NOT: see the site inventory in PipeStats.cpp. That inventory +// is the contract - it names every path that is NOT wired, because a byte class that reads +// zero while a real copy runs uncounted is worse than a missing counter. namespace MobileGL::MG_Util::PipeStats { // Byte classes. Every one of these names a population of bytes that would have to be @@ -42,9 +45,11 @@ namespace MobileGL::MG_Util::PipeStats { // the frontend, which is why they are grouped this way rather than by call site. enum class ByteClass : Uint32 { // Buffer object contents flushed to the driver: glBufferData / glBufferSubData / - // map-write ranges / the persistent upload ring. + // map-write ranges / the persistent upload ring (Espryt), and every host->device + // copy of a buffer object's contents (Magma). StageBuffer = 0, - // Texel bytes handed to glTexSubImage & friends, whichever upload shape was chosen. + // Texel bytes handed to glTexSubImage & friends / packed into the Vulkan upload + // staging slice, whichever upload shape was chosen. StageTexture, // The default-uniform-block ("global UBO") image, uploaded at most once per program // per frame. @@ -53,10 +58,16 @@ namespace MobileGL::MG_Util::PipeStats { // ring. Espryt binds the frontend buffer straight to the driver and contributes // nothing here - which is exactly the asymmetry D-B8 is about. StageUboNamed, - // Client-memory vertex arrays uploaded into a scratch VBO on the draw path. + // Client-memory vertex arrays uploaded into a scratch VBO / transient arena slice on + // the draw path. StageVertexClient, // Client-memory / rewritten index data staged on the draw path. StageIndexClient, + // Draw-parameter bytes a backend synthesises and stages for the draw itself: the + // indirect-command array and the compute path's per-draw info array. These are the + // bytes that become MGPipe command-record payload once the boundary is explicit, + // which is why they are not folded into the index class. + StageIndirectCmd, // Bytes pushed because a persistently mapped range was published to the backend. PersistentMapPush, // PLACEHOLDER (plan section 6.3): the residual value block does not exist yet. The @@ -126,7 +137,7 @@ namespace MobileGL::MG_Util::PipeStats { inline Bool Enabled() { return g_pipeStatsEnabled; } - // Latches g_pipeStatsEnabled from MG_Config::Features.PipeStats and resets every + // Latches g_pipeStatsEnabled from MG_Config::Features.PipeStats and clears every // counter. Called from MobileGL::Initialize() right after the config load. void Init(); @@ -158,13 +169,20 @@ namespace MobileGL::MG_Util::PipeStats { const char* NameOf(CallClass callClass); const char* NameOf(Gate gate); - // The compact fixed-format one-liner MGLOG_I prints. Same text in the log and in the - // test, so the format is pinned by a test rather than by the log reader's memory. - String FormatSummaryLine(); + // The compact fixed-format one-liner MGLOG_I prints, covering the CURRENT window (see + // AdvanceSummaryWindow). PURE: calling it twice returns the same text and changes no + // counter, so a probe, a test or a second reporting channel can format the window + // without stealing it from the log. + String FormatWindowLine(); + // Closes the current window: the run totals as of now become the base the next + // FormatWindowLine() subtracts. Emitting the line and advancing the window are separate + // on purpose - the pair used to be one function whose name promised a formatter. + void AdvanceSummaryWindow(); // The teardown dump. Run totals only: a per-frame JSON stream is a different tool. String FormatJson(); - // Test hooks. Not used by any shipping path. + // Test hooks, used by no shipping path. Init() clears the counters through an internal + // ResetCounters() rather than by calling ResetForTesting(). void SetEnabledForTesting(Bool enabled); void ResetForTesting(); From 50815a232ebb969996e5434c5955d7c5c9e7b8b7 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 13:44:50 -0400 Subject: [PATCH 011/529] [Fix] (Backend, Getter): retire the two frontend queries that were never asked and strip the unreachable frontend arms from the third MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GLFunctionsTable had three entries that are frontend queries wearing a backend interface (plan B v2 §2.1(a)): GetIntegeri_v, GetInteger64i_v and GetProgramiv. Two of them have NO caller at all - `grep -o 'gBackendFunctionsTable\.GL\.[A-Za-z_0-9]*'` outside MG_Backend/ lists 69 distinct entries and neither GetInteger64i_v nor GetProgramiv is among them - so both table slots, both backends' implementations and both registrations are deleted here. This is plan B §11 P0's first "strictly no-op free win". - Nothing was moved into MG_Impl, because MG_Impl already answers all of it. glGetInteger64i_v is served by GL_Getter.cpp:1240-1314, which handles the indexed buffer queries itself and derives every other pname from its own GetIntegeri_v ("Handing the leftovers straight to the backend instead made glGetInteger64i_v disagree with glGetIntegeri_v on the very same pname"). glGetProgramiv is served by GL_Program.cpp, whose GL_COMPUTE_WORK_GROUP_SIZE arm (:928-946) reads ProgramObject::GetComputeLocalSize - a link artifact of the program the APPLICATION wrote, which is the only program in the application's namespace. §4.7.1 class B. - GetIntegeri_v stays, but only for what a backend genuinely owns. Its pure frontend arms were unreachable: GL_Getter::GetIntegeri_v answers GL_SHADER_STORAGE_BUFFER_{BINDING,START,SIZE} through TryDecodeIndexedBufferQuery (:991-1029) and the six GL_IMAGE_BINDING_* pnames at :1115-1153, and returns before touching the table. That left 9 dead cases in DirectGLES.cpp and 9 in DirectVulkan.cpp - the plan's "15" undercounts the two files separately. What still arrives is GL_MAX_COMPUTE_WORK_GROUP_COUNT / _SIZE (GL_Getter.cpp:1161-1177, and MG_Util/ShaderTranspiler/CompileEnv.cpp :134-138 asks the table directly), so DirectVulkan keeps exactly those two and DirectGLES becomes a plain driver passthrough. - The dead arms were also WRONG, which is why deleting rather than reconciling them is the strict no-op: they clamped a bound range's size to the buffer's current storage, while the frontend reports the size glBindBufferRange was asked for verbatim (GL 4.6 core tables 23.4/23.5 - the clamp answered 0 for KHR-GL43.shader_storage_buffer_object.basic-binding's shape). Had a later refactor made the table the answer, the regression would have been silent. - ProgramResourceCache::computeWorkGroupSize and the spirv-reflect entry-point loop that filled it go with DirectVulkan's GetProgramiv; nothing else read it. - Three cases added to AdvertisedLimitsScenario pin what the frontend answers, on both lanes: the indexed SSBO binding/start/size on the 32- and 64-bit widths INCLUDING a shrink of the store underneath the binding (the arm that actually separates verbatim from clamped), the six image-unit pnames on both widths, and the compute local size plus the INVALID_OPERATION a program with no compute stage must give. - Both new gates were shown to go red for their reason: making GL_COMPUTE_WORK_GROUP_SIZE answer a defaulted (1,1,1) fails ComputeLocalSizeComesFromTheLinkedProgram on both lanes, and re-introducing the deleted store clamp in GL_Getter fails IndexedBufferBindingsAreReportedVerbatimOnBothWidths on both lanes. - Tested: cmake --build build-linux -j 24 (clean); ctest -L unit -j 12 -> 1382/1382 passed; ctest -R AdvertisedLimits -> 18/18 passed (6 pre-existing + 3 new, x DirectGLES and DirectVulkan). --- MobileGL/MG_Backend/BackendObject.h | 18 +- .../DirectGLES/BackendObject_DirectGLES.cpp | 2 - MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 142 ++----------- MobileGL/MG_Backend/DirectGLES/DirectGLES.h | 2 - .../BackendObject_DirectVulkan.cpp | 2 - .../MG_Backend/DirectVulkan/DirectVulkan.cpp | 130 ++---------- .../MG_Backend/DirectVulkan/DirectVulkan.h | 2 - .../Scenarios/AdvertisedLimitsScenario.cpp | 186 ++++++++++++++++++ 8 files changed, 228 insertions(+), 256 deletions(-) diff --git a/MobileGL/MG_Backend/BackendObject.h b/MobileGL/MG_Backend/BackendObject.h index d528d820f..6b2c02318 100644 --- a/MobileGL/MG_Backend/BackendObject.h +++ b/MobileGL/MG_Backend/BackendObject.h @@ -192,9 +192,23 @@ namespace MobileGL { void (*MemoryBarrierByRegion)(GLbitfield barriers); void (*BindImageTexture)(GLuint unit, GLuint texture, GLint level, GLboolean layered, GLint layer, GLenum access, GLenum format); + // The ONLY indexed query that is genuinely a backend one, and only for the pnames + // MG_Impl/GLImpl/Getter/GL_Getter.cpp does not already own. Every indexed pname that + // names FRONTEND state - the indexed buffer bindings, the per-unit texture/sampler + // bindings, the image-unit bindings, the viewport rectangles, the indexed capabilities + // - is answered in GL_Getter::GetIntegeri_v and never reaches this entry; the + // 64-bit and float/double widths are derived there from the same answer, which is why + // no GetInteger64i_v/GetFloati_v/GetDoublei_v table entry exists. In practice this + // leaves GL_MAX_COMPUTE_WORK_GROUP_COUNT / _SIZE (also asked directly by + // MG_Util/ShaderTranspiler/CompileEnv.cpp) plus whatever pname the frontend has no + // case for at all. void (*GetIntegeri_v)(GLenum target, GLuint index, GLint* data); - void (*GetInteger64i_v)(GLenum target, GLuint index, GLint64* data); - void (*GetProgramiv)(GLuint program, GLenum pname, GLint* params); + // There is deliberately NO GetProgramiv entry: glGetProgramiv describes the program + // the APPLICATION wrote - link status, the transform-feedback mode, the compute local + // size - all of which are frontend link artifacts on ProgramObject, and + // MG_Impl/GLImpl/Program/GL_Program.cpp answers every one of them from there. Asking a + // backend would mean asking about a DIFFERENT program (a SPIRV-Cross-generated ESSL + // one, or a SPIR-V module), in a namespace the application never sees. // The GL program interface (glGetProgramInterfaceiv / glGetProgramResource*) is NOT // a backend query: it describes the program the application wrote, in the // application's namespace, which neither backend program is in. It is answered diff --git a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp index c9905508c..c8d7c5a15 100644 --- a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp @@ -1255,8 +1255,6 @@ namespace MobileGL::MG_Backend::DirectGLES { funcsTable.GL.MemoryBarrierByRegion = MemoryBarrierByRegion; funcsTable.GL.BindImageTexture = BindImageTexture; funcsTable.GL.GetIntegeri_v = GetIntegeri_v; - funcsTable.GL.GetInteger64i_v = GetInteger64i_v; - funcsTable.GL.GetProgramiv = GetProgramiv; funcsTable.GL.ShaderStorageBlockBinding = ShaderStorageBlockBinding; funcsTable.GL.Clear = Clear; funcsTable.GL.ClearBufferfi = ClearBufferfi; diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 6c26f1f6e..85a10e00a 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -7317,138 +7317,22 @@ namespace MobileGL::MG_Backend::DirectGLES { TextureImpl::SyncImageTextureBinding(unit); } + // Only the pnames MG_Impl/GLImpl/Getter/GL_Getter.cpp has no case for reach here. Every + // indexed pname naming FRONTEND state - the indexed buffer bindings, the per-unit + // texture/sampler bindings, the image-unit bindings, the viewport rectangles, the indexed + // capabilities - is answered there and returns before the table is consulted, so the arms + // this function used to carry for GL_SHADER_STORAGE_BUFFER_* and GL_IMAGE_BINDING_* were + // unreachable duplicates of the frontend's, and they did not even agree with it (the + // frontend reports the range glBindBufferRange was ASKED for, verbatim and unclamped; these + // clamped it to the buffer's current storage). In practice what arrives is + // GL_MAX_COMPUTE_WORK_GROUP_COUNT / _SIZE, which the driver owns. void GetIntegeri_v(GLenum target, GLuint index, GLint* data) { if (!data) return; - - switch (target) { - case GL_SHADER_STORAGE_BUFFER_BINDING: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - auto& obj = point.GetBoundObject(); - *data = obj ? static_cast(obj->GetExternalIndex()) : 0; - return; - } - case GL_SHADER_STORAGE_BUFFER_START: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - *data = static_cast(point.GetRange().start); - return; - } - case GL_SHADER_STORAGE_BUFFER_SIZE: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - auto& obj = point.GetBoundObject(); - if (!obj) { - *data = 0; - return; - } - const auto& range = point.GetRange(); - const auto start = std::min(range.start, obj->GetSize()); - const auto end = std::min(range.end, obj->GetSize()); - *data = static_cast(end - start); - return; - } - case GL_IMAGE_BINDING_NAME: { - if (index >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { - *data = 0; - return; - } - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index)); - *data = imageBinding.Texture ? static_cast(imageBinding.Texture->GetExternalIndex()) : 0; - return; - } - case GL_IMAGE_BINDING_LEVEL: { - if (index >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { - *data = 0; - return; - } - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index)); - *data = imageBinding.Level; - return; - } - case GL_IMAGE_BINDING_LAYERED: { - if (index >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { - *data = 0; - return; - } - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index)); - *data = imageBinding.Layered; - return; - } - case GL_IMAGE_BINDING_LAYER: { - if (index >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { - *data = 0; - return; - } - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index)); - *data = imageBinding.Layer; - return; - } - case GL_IMAGE_BINDING_ACCESS: { - if (index >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { - *data = 0; - return; - } - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index)); - *data = static_cast(imageBinding.Access); - return; - } - case GL_IMAGE_BINDING_FORMAT: { - if (index >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { - *data = 0; - return; - } - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index)); - *data = static_cast(imageBinding.Format); - return; - } - default: - if (g_GLESFuncs.glGetIntegeri_v) { - g_GLESFuncs.glGetIntegeri_v(target, index, data); - } else { - *data = 0; - } - return; - } - } - - void GetInteger64i_v(GLenum target, GLuint index, GLint64* data) { - if (!data) return; - - switch (target) { - case GL_SHADER_STORAGE_BUFFER_START: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - *data = static_cast(point.GetRange().start); - return; - } - case GL_SHADER_STORAGE_BUFFER_SIZE: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - auto& obj = point.GetBoundObject(); - if (!obj) { - *data = 0; - return; - } - const auto& range = point.GetRange(); - const auto start = std::min(range.start, obj->GetSize()); - const auto end = std::min(range.end, obj->GetSize()); - *data = static_cast(end - start); - return; - } - default: - if (g_GLESFuncs.glGetInteger64i_v) { - g_GLESFuncs.glGetInteger64i_v(target, index, data); - } else { - *data = 0; - } - return; - } - } - - void GetProgramiv(GLuint program, GLenum pname, GLint* params) { - if (!params) return; - GLuint backendProgramId = GetBackendProgramId(program); - if (!backendProgramId) { - params[0] = 0; - return; + if (g_GLESFuncs.glGetIntegeri_v) { + g_GLESFuncs.glGetIntegeri_v(target, index, data); + } else { + *data = 0; } - g_GLESFuncs.glGetProgramiv(backendProgramId, pname, params); } // NOTE the shape here, and do not "simplify" it back to GetBackendProgramId(): this entry diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.h b/MobileGL/MG_Backend/DirectGLES/DirectGLES.h index 949479655..63a48bc4d 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.h +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.h @@ -92,8 +92,6 @@ namespace MobileGL::MG_Backend::DirectGLES { void BindImageTexture(GLuint unit, GLuint texture, GLint level, GLboolean layered, GLint layer, GLenum access, GLenum format); void GetIntegeri_v(GLenum target, GLuint index, GLint* data); - void GetInteger64i_v(GLenum target, GLuint index, GLint64* data); - void GetProgramiv(GLuint program, GLenum pname, GLint* params); void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding); Bool InitWindowSurface(NativeWindowType window); Bool InitPbufferSurface(EGLint width, EGLint height); diff --git a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp index e8ce9d1cd..e113d81af 100644 --- a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp @@ -740,8 +740,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { funcsTable.GL.MemoryBarrierByRegion = MemoryBarrierByRegion; funcsTable.GL.BindImageTexture = BindImageTexture; funcsTable.GL.GetIntegeri_v = GetIntegeri_v; - funcsTable.GL.GetInteger64i_v = GetInteger64i_v; - funcsTable.GL.GetProgramiv = GetProgramiv; funcsTable.GL.ShaderStorageBlockBinding = ShaderStorageBlockBinding; funcsTable.GL.FenceSync = FenceSync; funcsTable.GL.ClientWaitSync = ClientWaitSync; diff --git a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp index 16cc7263c..33789e613 100644 --- a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp @@ -78,7 +78,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint32 blockBindingVersion = 0; Vector storageBlocks; Vector bufferVariables; - GLint computeWorkGroupSize[3] = {1, 1, 1}; }; struct DrawElementsIndirectCommand { @@ -209,16 +208,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { } for (auto& module : modules) { - for (Uint32 entryIndex = 0; entryIndex < module.entry_point_count; ++entryIndex) { - const auto& entryPoint = module.entry_points[entryIndex]; - if ((entryPoint.shader_stage & SPV_REFLECT_SHADER_STAGE_COMPUTE_BIT) == 0) { - continue; - } - cache.computeWorkGroupSize[0] = static_cast(std::max(entryPoint.local_size.x, 1)); - cache.computeWorkGroupSize[1] = static_cast(std::max(entryPoint.local_size.y, 1)); - cache.computeWorkGroupSize[2] = static_cast(std::max(entryPoint.local_size.z, 1)); - } - uint32_t bindingCount = 0; SpvReflectResult result = spvReflectEnumerateDescriptorBindings(&module, &bindingCount, nullptr); if (result != SPV_REFLECT_RESULT_SUCCESS || bindingCount == 0) { @@ -683,130 +672,37 @@ namespace MobileGL::MG_Backend::DirectVulkan { (void)format; } + // The two compute limits are the only indexed pnames a backend genuinely owns: they come + // from the physical device, and MG_Impl/GLImpl/Getter/GL_Getter.cpp asks for them here so it + // can raise the answer to the GL required minimum. Every other indexed pname names FRONTEND + // state (the indexed buffer bindings, the per-unit texture/sampler bindings, the image-unit + // bindings, the viewport rectangles, the indexed capabilities) and is answered there before + // the table is consulted, so the arms this function used to carry for + // GL_SHADER_STORAGE_BUFFER_* and GL_IMAGE_BINDING_* were unreachable duplicates - and not + // even faithful ones: the frontend reports the range glBindBufferRange was ASKED for, + // verbatim, while these clamped it to the buffer's current storage. void GetIntegeri_v(GLenum target, GLuint index, GLint* data) { if (!data) return; MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::GetIntegeri_v called with null VulkanRenderer"); + if (index >= 3) { + *data = 0; + return; + } switch (target) { case GL_MAX_COMPUTE_WORK_GROUP_COUNT: - if (index >= 3) { - *data = 0; - return; - } *data = static_cast( pVulkanRenderer->GetPhysicalDevice().properties.limits.maxComputeWorkGroupCount[index]); return; case GL_MAX_COMPUTE_WORK_GROUP_SIZE: - if (index >= 3) { - *data = 0; - return; - } *data = static_cast( pVulkanRenderer->GetPhysicalDevice().properties.limits.maxComputeWorkGroupSize[index]); return; - case GL_SHADER_STORAGE_BUFFER_BINDING: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - auto& obj = point.GetBoundObject(); - *data = obj ? static_cast(obj->GetExternalIndex()) : 0; - return; - } - case GL_SHADER_STORAGE_BUFFER_START: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - *data = static_cast(point.GetRange().start); - return; - } - case GL_SHADER_STORAGE_BUFFER_SIZE: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - auto& obj = point.GetBoundObject(); - if (!obj) { - *data = 0; - return; - } - const auto& range = point.GetRange(); - const auto start = std::min(range.start, obj->GetSize()); - const auto end = std::min(range.end, obj->GetSize()); - *data = static_cast(end - start); - return; - } - case GL_IMAGE_BINDING_NAME: - case GL_IMAGE_BINDING_LEVEL: - case GL_IMAGE_BINDING_LAYERED: - case GL_IMAGE_BINDING_LAYER: - case GL_IMAGE_BINDING_ACCESS: - case GL_IMAGE_BINDING_FORMAT: { - if (index >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { - *data = 0; - return; - } - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index)); - if (target == GL_IMAGE_BINDING_NAME) { - *data = imageBinding.Texture ? static_cast(imageBinding.Texture->GetExternalIndex()) : 0; - } else if (target == GL_IMAGE_BINDING_LEVEL) { - *data = imageBinding.Level; - } else if (target == GL_IMAGE_BINDING_LAYERED) { - *data = imageBinding.Layered; - } else if (target == GL_IMAGE_BINDING_LAYER) { - *data = imageBinding.Layer; - } else if (target == GL_IMAGE_BINDING_ACCESS) { - *data = static_cast(imageBinding.Access); - } else { - *data = static_cast(imageBinding.Format); - } - return; - } - default: - *data = 0; - return; - } - } - - void GetInteger64i_v(GLenum target, GLuint index, GLint64* data) { - if (!data) return; - switch (target) { - case GL_SHADER_STORAGE_BUFFER_START: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - *data = static_cast(point.GetRange().start); - return; - } - case GL_SHADER_STORAGE_BUFFER_SIZE: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - auto& obj = point.GetBoundObject(); - if (!obj) { - *data = 0; - return; - } - const auto& range = point.GetRange(); - const auto start = std::min(range.start, obj->GetSize()); - const auto end = std::min(range.end, obj->GetSize()); - *data = static_cast(end - start); - return; - } default: *data = 0; return; } } - void GetProgramiv(GLuint program, GLenum pname, GLint* params) { - if (!params) return; - auto* programObject = TryGetDirectVulkanProgram(program); - if (!programObject) { - params[0] = 0; - return; - } - switch (pname) { - case GL_COMPUTE_WORK_GROUP_SIZE: { - auto& cache = GetProgramResourceCache(*programObject); - params[0] = cache.computeWorkGroupSize[0]; - params[1] = cache.computeWorkGroupSize[1]; - params[2] = cache.computeWorkGroupSize[2]; - return; - } - default: - params[0] = 0; - return; - } - } - void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding) { auto* programObject = TryGetDirectVulkanProgram(program); if (!programObject || storageBlockName == nullptr) return; diff --git a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.h b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.h index 74241e810..cdf361412 100644 --- a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.h +++ b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.h @@ -95,8 +95,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { void BindImageTexture(GLuint unit, GLuint texture, GLint level, GLboolean layered, GLint layer, GLenum access, GLenum format); void GetIntegeri_v(GLenum target, GLuint index, GLint* data); - void GetInteger64i_v(GLenum target, GLuint index, GLint64* data); - void GetProgramiv(GLuint program, GLenum pname, GLint* params); void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding); void ReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels); void GetTexImage(GLenum target, GLint level, GLenum format, GLenum type, GLvoid* pixels); diff --git a/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp index 5ee416491..0e9296189 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp @@ -378,5 +378,191 @@ namespace MGITest { EXPECT_GE(viewportDims[1], maxRenderbufferSize); } + + // THE INDEXED AND PER-PROGRAM QUERIES THAT NAME FRONTEND STATE, pinned on both lanes. + // + // Both backends used to carry their own arms for GL_SHADER_STORAGE_BUFFER_* and + // GL_IMAGE_BINDING_* inside GLFunctionsTable::GetIntegeri_v, and their own + // GetInteger64i_v / GetProgramiv table entries. None of it was reachable: GL_Getter and + // GL_Program answer every one of these pnames from the frontend's own state and return + // before the table is consulted. The duplicates did not even agree - the backend arms + // clamped a bound range to the buffer's current storage, which GL 4.6 core tables + // 23.4/23.5 do not permit - so the code was one refactor away from becoming the answer. + // These cases pin what the frontend actually reports, so a future move of any of it back + // behind the interface has to keep saying the same thing. + TEST_F(AdvertisedLimitsScenario, IndexedBufferBindingsAreReportedVerbatimOnBothWidths) { + GLuint buffer = 0; + glGenBuffers(1, &buffer); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer); + glBufferData(GL_SHADER_STORAGE_BUFFER, 1024, nullptr, GL_DYNAMIC_DRAW); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + + // A range that is NOT the whole buffer, so a clamp to the store would be visible. + glBindBufferRange(GL_SHADER_STORAGE_BUFFER, 1, buffer, 256, 512); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + + GLint binding32 = -1; + GLint start32 = -1; + GLint size32 = -1; + glGetIntegeri_v(GL_SHADER_STORAGE_BUFFER_BINDING, 1, &binding32); + glGetIntegeri_v(GL_SHADER_STORAGE_BUFFER_START, 1, &start32); + glGetIntegeri_v(GL_SHADER_STORAGE_BUFFER_SIZE, 1, &size32); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_EQ(binding32, static_cast(buffer)); + EXPECT_EQ(start32, 256); + EXPECT_EQ(size32, 512); + + // The 64-bit width has to agree pname for pname. It has no backend entry of its own + // and derives everything from the 32-bit answer above plus its own buffer arm. + GLint64 binding64 = -1; + GLint64 start64 = -1; + GLint64 size64 = -1; + glGetInteger64i_v(GL_SHADER_STORAGE_BUFFER_BINDING, 1, &binding64); + glGetInteger64i_v(GL_SHADER_STORAGE_BUFFER_START, 1, &start64); + glGetInteger64i_v(GL_SHADER_STORAGE_BUFFER_SIZE, 1, &size64); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_EQ(binding64, static_cast(buffer)); + EXPECT_EQ(start64, static_cast(256)); + EXPECT_EQ(size64, static_cast(512)); + + // An unbound index answers zero rather than erroring or leaking the driver's answer. + GLint unbound = -1; + glGetIntegeri_v(GL_SHADER_STORAGE_BUFFER_BINDING, 0, &unbound); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_EQ(unbound, 0); + + // THE ARM THAT SEPARATES VERBATIM FROM CLAMPED. GL 4.6 core tables 23.4/23.5 report + // the size glBindBufferRange was ASKED for; it does not follow the buffer, so + // shrinking the store underneath the binding must not move it. A clamp to the + // current storage - which is exactly what both backends' deleted arms did - answers + // 128 here, and answers 0 for the bind-then-allocate shape + // KHR-GL43.shader_storage_buffer_object.basic-binding uses. + glBufferData(GL_SHADER_STORAGE_BUFFER, 128, nullptr, GL_DYNAMIC_DRAW); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + GLint startAfterShrink = -1; + GLint sizeAfterShrink = -1; + GLint64 sizeAfterShrink64 = -1; + glGetIntegeri_v(GL_SHADER_STORAGE_BUFFER_START, 1, &startAfterShrink); + glGetIntegeri_v(GL_SHADER_STORAGE_BUFFER_SIZE, 1, &sizeAfterShrink); + glGetInteger64i_v(GL_SHADER_STORAGE_BUFFER_SIZE, 1, &sizeAfterShrink64); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_EQ(startAfterShrink, 256) + << "the bound range's start followed the buffer through a re-specification"; + EXPECT_EQ(sizeAfterShrink, 512) + << "the bound range's size was clamped to the buffer's current 128-byte storage; the range is " + "state of the BINDING POINT and is reported verbatim"; + EXPECT_EQ(sizeAfterShrink64, static_cast(512)) + << "the 64-bit width disagreed with the 32-bit one about the same pname"; + + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, 0); + glDeleteBuffers(1, &buffer); + (void)FirstGLError(); + } + + TEST_F(AdvertisedLimitsScenario, ImageUnitBindingsAreReportedFromTheFrontendState) { + GLint maxImageUnits = 0; + glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits); + (void)FirstGLError(); + if (maxImageUnits < 2) GTEST_SKIP() << "no image units to bind on this lane"; + + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexStorage2D(GL_TEXTURE_2D, 2, GL_RGBA8, 8, 8); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + + glBindImageTexture(1, texture, 1, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + + struct Expectation { + GLenum pname; + const char* name; + GLint expected; + }; + const Expectation expectations[] = { + {GL_IMAGE_BINDING_NAME, "GL_IMAGE_BINDING_NAME", static_cast(texture)}, + {GL_IMAGE_BINDING_LEVEL, "GL_IMAGE_BINDING_LEVEL", 1}, + {GL_IMAGE_BINDING_LAYERED, "GL_IMAGE_BINDING_LAYERED", GL_FALSE}, + {GL_IMAGE_BINDING_LAYER, "GL_IMAGE_BINDING_LAYER", 0}, + {GL_IMAGE_BINDING_ACCESS, "GL_IMAGE_BINDING_ACCESS", GL_READ_ONLY}, + {GL_IMAGE_BINDING_FORMAT, "GL_IMAGE_BINDING_FORMAT", GL_RGBA8}, + }; + for (const Expectation& expectation : expectations) { + GLint value = -424242; + glGetIntegeri_v(expectation.pname, 1, &value); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << expectation.name; + EXPECT_EQ(value, expectation.expected) << expectation.name; + + // Same pname through the wide width - it must not fall through to a driver that + // knows nothing about MobileGL's image-unit state. + GLint64 wide = -424242; + glGetInteger64i_v(expectation.pname, 1, &wide); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << expectation.name << " (64-bit)"; + EXPECT_EQ(wide, static_cast(expectation.expected)) << expectation.name << " (64-bit)"; + } + + glBindImageTexture(1, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); + glDeleteTextures(1, &texture); + (void)FirstGLError(); + } + + // glGetProgramiv(GL_COMPUTE_WORK_GROUP_SIZE) is a LINK ARTIFACT of the program the + // application wrote. DirectVulkan used to answer it from its own spirv-reflect cache and + // DirectGLES by forwarding to the driver's ESSL program - neither of which the + // application ever named - while GL_Program.cpp has always answered it from + // ProgramObject::GetComputeLocalSize. This pins the declared local size on both lanes. + TEST_F(AdvertisedLimitsScenario, ComputeLocalSizeComesFromTheLinkedProgram) { + static const char* kSource = R"(#version 430 core +layout(local_size_x = 4, local_size_y = 3, local_size_z = 2) in; +layout(std430, binding = 0) buffer Output { uint g_data[]; }; +void main() { g_data[gl_LocalInvocationIndex] = 1u; } +)"; + const GLuint shader = glCreateShader(GL_COMPUTE_SHADER); + glShaderSource(shader, 1, &kSource, nullptr); + glCompileShader(shader); + GLint compiled = 0; + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (compiled == GL_FALSE) { + char log[2048] = {}; + glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log); + glDeleteShader(shader); + (void)FirstGLError(); + GTEST_SKIP() << "no compute shader support on this lane: " << log; + } + const GLuint program = glCreateProgram(); + glAttachShader(program, shader); + glLinkProgram(program); + glDeleteShader(shader); + GLint linked = 0; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (linked == GL_FALSE) { + char log[2048] = {}; + glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log); + glDeleteProgram(program); + (void)FirstGLError(); + GTEST_SKIP() << "the compute program did not link on this lane: " << log; + } + + GLint localSize[3] = {-1, -1, -1}; + glGetProgramiv(program, GL_COMPUTE_WORK_GROUP_SIZE, localSize); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_EQ(localSize[0], 4); + EXPECT_EQ(localSize[1], 3); + EXPECT_EQ(localSize[2], 2); + + // A program with no compute stage must answer INVALID_OPERATION, not a stale or + // defaulted (1, 1, 1) - the frontend's rule, and the one a backend that answers from + // its own reflection cache cannot express. + const GLuint empty = glCreateProgram(); + GLint ignored[3] = {0, 0, 0}; + glGetProgramiv(empty, GL_COMPUTE_WORK_GROUP_SIZE, ignored); + EXPECT_EQ(FirstGLError(), GLenum(GL_INVALID_OPERATION)) + << "GL 4.6 core 7.13: the query is only defined for a linked program with a compute shader"; + + glDeleteProgram(empty); + glDeleteProgram(program); + (void)FirstGLError(); + } + } // namespace } // namespace MGITest From 9c7339b214e31bebd19297620e500dddede15661 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 13:46:49 -0400 Subject: [PATCH 012/529] [Feat] (State): give RenderbufferObject the never-reused lifetime id every other cache-keyable state object already has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RenderbufferObject was the last state object a backend twin registry keys on that could only be identified by its heap address or its GL name - both of which recycle. BufferObject, VertexArrayObject and ProgramObject all carry a process-wide, never-reused id for exactly this; the renderbuffer's absence is named in plan B §10.4-5 as one of the two latent problems P0 closes. - Mirrors BufferObject.h:202-208 / BufferObject.cpp:19-24 verbatim in shape: a private static AllocateLifetimeId() over a namespace-scope std::atomic starting at 1 (so a zero-initialised memo slot can never carry a live object's id), a const member initialised from it at construction, and an inline const getter. The doc comment is the buffer one restated for the renderbuffer's own recycling sources. - Deliberately NO GetVersion(): plan B §11 P0 says the id only. A mutation counter would be a second, independent invalidation surface to keep correct, and nothing needs one yet - the renderbuffer's mutable content already reaches the backends through AllocateStorage / SetInternalFormat / SetSamples. - No caller yet, by design: the id exists so §4.7.3's D1 rekey (and the DirectGLES renderbuffer twin registry at Managers.h:1858) has something to key on. It is a pure addition - no existing field, signature or answer changes. - ObjectLifetimeIdTest gains the two cases the other two object types already have, so the renderbuffer is covered by the same allocator-reuse probe: an object rebuilt at a freed address must not answer to the dead one's id, and two live ones must differ. - Tested: cmake --build build-linux -j 24 (clean); ctest -R ObjectLifetimeId -> 6/6 passed (4 pre-existing + 2 new). --- .../RenderbufferState/RenderbufferObject.cpp | 12 ++++++++++++ .../RenderbufferState/RenderbufferObject.h | 11 +++++++++++ .../MG_Test/State/ObjectLifetimeIdTest.cpp | 18 ++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.cpp b/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.cpp index 90159adfc..91bfca505 100644 --- a/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.cpp +++ b/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.cpp @@ -9,9 +9,21 @@ #include "RenderbufferObject.h" #include +#include + namespace MobileGL { namespace MG_State { namespace GLState { + namespace { + // Starts at 1 so a zero-initialized cache slot can never carry a live + // renderbuffer's id. + std::atomic g_nextRenderbufferLifetimeId{1}; + } + + Uint64 RenderbufferObject::AllocateLifetimeId() { + return g_nextRenderbufferLifetimeId.fetch_add(1, std::memory_order_relaxed); + } + RenderbufferObject::RenderbufferObject(Uint externalIndex) : m_externalIndex(externalIndex) {} Uint RenderbufferObject::GetExternalIndex() const { diff --git a/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.h b/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.h index 11153772b..1e1f304bd 100644 --- a/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.h +++ b/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.h @@ -42,9 +42,20 @@ namespace MobileGL { Int GetDepthSize() const; Int GetStencilSize() const; Int GetSamples() const; + // Globally-unique, never-reused id for THIS object's lifetime - same contract + // and same motivation as BufferObject::GetLifetimeId(), + // ProgramObject::GetLifetimeId() and VertexArrayObject::GetLifetimeId(). A + // backend that folds a renderbuffer's IDENTITY into a cache key must use this, + // never the GL name (LIFO-recycled by glGenRenderbuffers) and never the heap + // address (recycled by the allocator): both let a deleted-and-recreated + // renderbuffer answer to a dead one's cache entry. + Uint64 GetLifetimeId() const { return m_lifetimeId; } private: + static Uint64 AllocateLifetimeId(); + Uint m_externalIndex = 0; + const Uint64 m_lifetimeId = AllocateLifetimeId(); TextureInternalFormat m_internalFormat = TextureInternalFormat::RGBA; Int m_width = 0; Int m_height = 0; diff --git a/MobileGL/MG_Test/State/ObjectLifetimeIdTest.cpp b/MobileGL/MG_Test/State/ObjectLifetimeIdTest.cpp index 3290be287..0f4a3f027 100644 --- a/MobileGL/MG_Test/State/ObjectLifetimeIdTest.cpp +++ b/MobileGL/MG_Test/State/ObjectLifetimeIdTest.cpp @@ -32,6 +32,7 @@ #include "Includes.h" #include +#include #include using namespace MobileGL; @@ -138,3 +139,20 @@ TEST(ObjectLifetimeIdTest, LiveVertexArrayObjectsHaveDistinctLifetimeIds) { TEST(ObjectLifetimeIdTest, LiveBufferObjectsHaveDistinctLifetimeIds) { ExpectDistinctIdsWhileBothAlive("BufferObject"); } + +// The renderbuffer had no lifetime id at all until plan B §11 P0 gave it one: it is +// the one FBO attachment source whose identity a backend twin registry can only have +// keyed on the heap address or the GL name, both of which recycle. +TEST(ObjectLifetimeIdTest, RenderbufferObjectAtARecycledAddressCarriesAFreshLifetimeId) { + using MG_State::GLState::RenderbufferObject; + const int reuseCount = ProbeLifetimeIdAcrossAddressReuse("RenderbufferObject"); + if (reuseCount == 0) { + GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 " + "construct/destroy rounds, so the recycled-address case was never exercised"; + } + RecordProperty("address_reuses_observed", reuseCount); +} + +TEST(ObjectLifetimeIdTest, LiveRenderbufferObjectsHaveDistinctLifetimeIds) { + ExpectDistinctIdsWhileBothAlive("RenderbufferObject"); +} From bd2b4158e040ab0afc387dba819e0b321d62b9df Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 14:04:07 -0400 Subject: [PATCH 013/529] [Fix] (DirectVulkan, State): key the transform-feedback counter slots on the object's identity, not on its recycled GL name - VulkanRenderer::CurrentXfbCounterSlot keyed m_xfbCounterSlotByObject on GetBoundTransformFeedbackName(). glGenTransformFeedbacks hands a deleted name straight back (IndexGenerator is LIFO) and nothing ever removed a map entry, so a transform feedback object created on a recycled name was served the DEAD object's counter group - and with it that group's m_xfbCountersValid and m_xfbLastSeenGeneration entries, which are the resume/fresh decision for vkCmdBeginTransformFeedbackEXT. This is D21 in plan B v2 4.7.3, the one entry in that table whose today-key "guards nothing", and 10.4-5 asks for it to land on dev on its own - hence this separate commit, kept in files no other commit on this branch touches so the cherry-pick applies unaided. - Frontend: TransformFeedbackObjectState gains a never-reused `lifetimeId` through a default member initialiser, so every route into existence (operator[] materialisation, `= {}` in GenTransformFeedbackNames and CreateTransformFeedbackObject) mints a fresh one and a recycled name cannot carry the dead object's id back. The allocator is the same shape as BufferObject::AllocateLifetimeId (atomic, starts at 1 so a zeroed backend slot is never a live object). - The bound object's id is mirrored in m_boundTransformFeedbackLifetimeId, refreshed by RestoreBoundTransformFeedbackState - which every bind, and the revert that deleting the bound object performs, goes through - and seeded for the default object by the GLContext constructor. GetBoundTransformFeedback LifetimeId is therefore a const load. Reading it through operator[] instead would have been an INSERT on the per-draw path, and UnorderedMap is ska::flat_hash_map, whose rehash invalidates every reference into the container, not just its iterators. - Backend: the UnorderedMap is replaced by a fixed 16-entry owner table, which fixes the second half of the same defect - the map was keyed on a value that recycles yet was never pruned, so it grew for the life of the context. With lifetime ids as keys a map would have grown without bound instead, so the bounded table is required, not cosmetic. - Slot exhaustion: past sixteen owners a group has to be taken over, and the victim is chosen among owners with NO OPEN SPAN, which GLContext::HasOpenTransformFeedbackSpan answers; an identity no live object carries any more answers false, and that is what lets a dead owner's group come back. Least-recently-used ALONE would have been exactly the wrong rule: GL only permits another object to capture while this one is PAUSED, so the paused span these groups exist to protect is by construction the least recently used entry, and an LRU takeover would reset the one resume offset that still matters. LRU is now only the tie-break among reclaimable groups. Sixteen genuinely open spans at once is reported (MGLOG_E_ONCE) rather than resolved silently, because whatever is taken then restarts at offset 0. - Not done, and why: the natural place to hand a group back is glEndTransformFeedback, but registering DirectVulkan's EndTransformFeedback table entry would flip the test FixupGsStripCaptureOrder makes of that same pointer (GL_Drawing.cpp:1255) to decide whether the backend already captured in GL's vertex order, silently disabling the geometry-stage strip fixup for DirectVulkan. Giving that discriminator a name of its own is a separate change; until then the no-open-span rule is what keeps the table honest. - CurrentXfbCounterSlot asserts the identity is never 0. Zero is the free-slot sentinel, so an identity of 0 would match every free slot as "mine" without ever claiming one - this bug reintroduced, with no symptom at the call site. - TransformFeedbackLifetimeIdTest, in its own translation unit, pins the frontend halves: an object created on a recycled name must not report the dead object's id, the default object has an identity before anything binds it, and a PAUSED span still reads as open while another object is bound and capturing - which is the whole correctness argument for the eviction rule. The name reuse is not simulated: the test asks the real generator and skips (loudly) if it never recycled. Still untested: the >16-owners path itself, which needs a backend scenario with seventeen capturing objects and there is none. - Negative controls, each applied then reverted: making a non-bound object's span read as closed reddens APausedSpanStaysOpenWhileAnotherObjectCaptures; making a vanished identity read as open reddens the same case on its delete assertion; dropping the constructor's seeding reddens AnObjectAtARecycledNameCarriesAFreshLifetimeId. - Tested: cmake --build build-linux -j 24 (clean, 166 targets); ctest -L unit -j 12 -> 1386/1386 passed; ctest -L integration-gpu -> 866/866 passed serially, and 866/866 on one of two -j 8 runs. The other -j 8 run failed DirectGLES.PointSizeDemotionScenario.TheDemotionIsActuallyArmedWhenTheEnviron mentPinsItOn, a member of the pre-existing parallel-ctest flake family: it passes in isolation here, and the unmodified parent tree (~/w7/p0-noop-wins-base) reproduces the same family under -j 8. --- .../DirectVulkan/Renderer/VulkanRenderer.cpp | 71 +++++++-- .../DirectVulkan/Renderer/VulkanRenderer.h | 14 +- MobileGL/MG_State/GLState/Core.cpp | 32 ++++ MobileGL/MG_State/GLState/Core.h | 34 ++++- MobileGL/MG_Test/State/CMakeLists.txt | 25 +++ .../State/TransformFeedbackLifetimeIdTest.cpp | 143 ++++++++++++++++++ 6 files changed, 306 insertions(+), 13 deletions(-) create mode 100644 MobileGL/MG_Test/State/TransformFeedbackLifetimeIdTest.cpp diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index e365603d1..289d2c98a 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -3267,8 +3267,9 @@ void main() { } m_vertexInputStateFactory.reset(); m_xfbCounterBuffer.Destroy(); - m_xfbCounterSlotByObject.clear(); - m_xfbNextCounterSlot = 0; + m_xfbCounterSlotOwner.fill(0); + m_xfbCounterSlotLastUse.fill(0); + m_xfbCounterSlotUseSerial = 0; m_xfbCountersValid.fill(false); m_xfbLastSeenGeneration.fill(0); if (m_occlusionQueryPool != VK_NULL_HANDLE) { @@ -11199,16 +11200,66 @@ void main() { } } + // Keyed on the frontend's never-reused lifetime id, NOT on the GL name. The name is + // recycled the moment glDeleteTransformFeedbacks gives it back, so a name-keyed slot + // handed a brand-new object the counter group - and the m_xfbCountersValid / + // m_xfbLastSeenGeneration entries - of the object that died under that name. + // + // Slots are never handed back (there is no backend entry telling this renderer that a span + // closed - registering the EndTransformFeedback one would flip the "captures through its own + // driver" test FixupGsStripCaptureOrder makes of it), so once all sixteen are owned a new + // object has to take one over. The victim is chosen among owners with NO OPEN SPAN: an object + // whose span is closed, or which no longer exists at all, can never resume, so its counter + // bytes are dead. Least-recently-used ALONE would be exactly the wrong rule - GL only permits + // another object to capture while this one is PAUSED, so the paused span whose counters the + // slots exist to protect is by construction the least recently used entry. Taking a group over + // resets its counter state, because those bytes describe the previous owner's span. Uint32 VulkanRenderer::CurrentXfbCounterSlot() { - const Uint name = MG_State::pGLContext->GetBoundTransformFeedbackName(); - const auto it = m_xfbCounterSlotByObject.find(name); - if (it != m_xfbCounterSlotByObject.end()) { - return it->second; + constexpr Uint32 kNoSlot = static_cast(kXfbCounterObjectSlots); + const Uint64 identity = MG_State::pGLContext->GetBoundTransformFeedbackLifetimeId(); + MOBILEGL_ASSERT(identity != 0, + "transform feedback object reported the free-slot sentinel (0) as its identity - " + "every slot would then read as 'mine' without ever being claimed"); + Uint32 freeSlot = kNoSlot; + for (Uint32 slot = 0; slot < kNoSlot; ++slot) { + if (m_xfbCounterSlotOwner[slot] == identity) { + m_xfbCounterSlotLastUse[slot] = ++m_xfbCounterSlotUseSerial; + return slot; + } + if (m_xfbCounterSlotOwner[slot] == 0 && freeSlot == kNoSlot) { + freeSlot = slot; + } + } + Uint32 slot = freeSlot; + if (slot == kNoSlot) { + for (Uint32 candidate = 0; candidate < kNoSlot; ++candidate) { + if (MG_State::pGLContext->HasOpenTransformFeedbackSpan(m_xfbCounterSlotOwner[candidate])) { + continue; + } + if (slot == kNoSlot || m_xfbCounterSlotLastUse[candidate] < m_xfbCounterSlotLastUse[slot]) { + slot = candidate; + } + } + } + if (slot == kNoSlot) { + // Sixteen capture spans open at once. Whatever is taken loses its resume offset and + // restarts at byte 0 of its capture buffers, which is a wrong picture rather than a + // slow one - hence a report rather than a silent choice. + MGLOG_E_ONCE("CurrentXfbCounterSlot: all %zu counter groups belong to transform feedback objects " + "with an open capture span; the least recently used one is taken over and that span " + "will restart at offset 0 instead of appending", + kXfbCounterObjectSlots); + slot = 0; + for (Uint32 candidate = 1; candidate < kNoSlot; ++candidate) { + if (m_xfbCounterSlotLastUse[candidate] < m_xfbCounterSlotLastUse[slot]) { + slot = candidate; + } + } } - // Past the tracked set every object shares slot group 0. Only concurrently-paused - // spans need distinct groups, and applications do not keep sixteen of those open. - const Uint32 slot = m_xfbNextCounterSlot < kXfbCounterObjectSlots ? m_xfbNextCounterSlot++ : 0; - m_xfbCounterSlotByObject[name] = slot; + m_xfbCounterSlotOwner[slot] = identity; + m_xfbCounterSlotLastUse[slot] = ++m_xfbCounterSlotUseSerial; + m_xfbCountersValid[slot] = false; + m_xfbLastSeenGeneration[slot] = 0; return slot; } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index c3d30855b..f8498a3af 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -675,8 +675,18 @@ namespace MobileGL::MG_Backend::DirectVulkan { // per object: one group of four slots each, handed out on first use. static constexpr SizeT kXfbCounterObjectSlots = 16; VkBufferObject m_xfbCounterBuffer; - UnorderedMap m_xfbCounterSlotByObject; - Uint32 m_xfbNextCounterSlot = 0; + // Which transform feedback object owns each slot group, by the frontend's never-reused + // lifetime id (0 = the slot is free). This used to be an UnorderedMap keyed on the GL + // NAME, which is recycled by glGenTransformFeedbacks: a deleted-and-recreated object + // inherited the dead one's slot, and since nothing ever removed an entry the map also + // grew for the life of the context. A fixed table cannot do either: a group is taken over + // only from an owner with no OPEN span (see CurrentXfbCounterSlot), so an object whose + // counters can still be resumed never loses them, and a dead object's group comes back. + Array m_xfbCounterSlotOwner{}; + // Tie-break among reclaimable groups only; never on its own, because the paused span the + // groups exist for is by construction the least recently used one. + Array m_xfbCounterSlotLastUse{}; + Uint64 m_xfbCounterSlotUseSerial = 0; // Set for a slot once a captured draw has been recorded into its span; selects // counter-buffer resume on the next captured draw of the same span. Array m_xfbCountersValid{}; diff --git a/MobileGL/MG_State/GLState/Core.cpp b/MobileGL/MG_State/GLState/Core.cpp index fd4207da9..952e1da8e 100644 --- a/MobileGL/MG_State/GLState/Core.cpp +++ b/MobileGL/MG_State/GLState/Core.cpp @@ -14,6 +14,8 @@ #include #include +#include + namespace MobileGL::MG_State { void Init() { MGLOG_D("Initializing MobileGL State..."); @@ -1259,6 +1261,33 @@ namespace MobileGL::MG_State { return m_renderbufferState.ValidateRenderbufferObject(index); } + Uint64 GLContext::AllocateTransformFeedbackLifetimeId() { + // Starts at 1 so a zero-initialised backend slot can never carry a live object's id. + static std::atomic nextId{1}; + return nextId.fetch_add(1, std::memory_order_relaxed); + } + + GLContext::GLContext() { + // The default transform feedback object (name 0) exists from the start of the context + // (GL 4.6 core 13.2.1), but nothing binds it, so nothing else would materialise it. + // Materialising it here is what lets GetBoundTransformFeedbackLifetimeId() be a plain + // const read instead of an operator[] insert on the draw path. + m_boundTransformFeedbackLifetimeId = m_transformFeedbackObjects[0].lifetimeId; + } + + Bool GLContext::HasOpenTransformFeedbackSpan(Uint64 lifetimeId) const { + if (lifetimeId == 0) return false; + for (const auto& [name, object] : m_transformFeedbackObjects) { + if (object.lifetimeId != lifetimeId) continue; + // The bound object's span state is live in the context; its saved copy is only + // written when a bind swaps it out. + return name == m_boundTransformFeedback ? m_transformFeedbackActive : object.active; + } + // No object carries this identity any more: it was deleted, and a deleted object can + // never resume. + return false; + } + void GLContext::SaveBoundTransformFeedbackState() { auto& object = m_transformFeedbackObjects[m_boundTransformFeedback]; for (Uint i = 0; i < MAX_TRANSFORM_FEEDBACK_BUFFERS; ++i) { @@ -1295,6 +1324,9 @@ namespace MobileGL::MG_State { m_transformFeedbackGeneration = object.generation; m_transformFeedbackCapturedVertices = object.capturedVertices; m_transformFeedbackInputPrimitives = object.inputPrimitives; + // Every route that changes which object is bound - BindTransformFeedbackObject and the + // revert a delete of the bound object performs - comes through here. + m_boundTransformFeedbackLifetimeId = object.lifetimeId; } void GLContext::GenTransformFeedbackNames(Uint number, Vector& ids) { diff --git a/MobileGL/MG_State/GLState/Core.h b/MobileGL/MG_State/GLState/Core.h index 73a8f7995..3b7e4802a 100644 --- a/MobileGL/MG_State/GLState/Core.h +++ b/MobileGL/MG_State/GLState/Core.h @@ -60,7 +60,7 @@ namespace MobileGL { class GLContext { public: - GLContext() = default; + GLContext(); // Error void RecordError(ErrorCode code, UniquePtr info); @@ -427,6 +427,27 @@ namespace MobileGL { void BindTransformFeedbackObject(Uint index); void MarkTransformFeedbackObjectForDeletion(Uint index); Uint GetBoundTransformFeedbackName() const { return m_boundTransformFeedback; } + // The bound object's never-reused identity, for a backend that keys a per-object + // resource on it. The NAME is not an identity: glGenTransformFeedbacks recycles a + // deleted one (LIFO), so a memo keyed on the name hands a brand-new object the dead + // one's slot. Cached rather than looked up on demand: the backend asks twice per + // captured draw, and an operator[] on m_transformFeedbackObjects would be an + // INSERT on the draw path - ska::flat_hash_map invalidates every reference into + // itself when it rehashes. The cache is refreshed by + // RestoreBoundTransformFeedbackState, which every bind (and the revert a delete + // performs) goes through, and seeded for the default object by the constructor. + // Never returns 0 - the counter starts at 1 so a zero-initialised memo slot cannot + // be mistaken for a live object. + Uint64 GetBoundTransformFeedbackLifetimeId() const { return m_boundTransformFeedbackLifetimeId; } + // Whether the object carrying this identity still has an OPEN capture span - one + // that glBeginTransformFeedback started and glEndTransformFeedback has not closed, + // paused or not. A backend that hands out a bounded set of per-object slots must + // never take one of these over: a paused span's counters are precisely what its + // resume reads, and GL only lets other objects capture WHILE it is paused, so the + // paused object is also the one that looks idle. An identity no live object + // carries any more (its object was deleted) answers false, which is what makes + // such a slot reclaimable. + Bool HasOpenTransformFeedbackSpan(Uint64 lifetimeId) const; // Vertices the object captured in its last completed span; the vertex count // glDrawTransformFeedback replays. Uint64 GetTransformFeedbackRecordedVertices(Uint index) const; @@ -514,6 +535,9 @@ namespace MobileGL { GLuint m_conditionalRenderQuery = 0; GLenum m_conditionalRenderMode = GL_NONE; + // Process-wide, never-reused. See GetBoundTransformFeedbackLifetimeId(); same + // contract as BufferObject::AllocateLifetimeId(). + static Uint64 AllocateTransformFeedbackLifetimeId(); // Everything a transform feedback object owns while it is NOT the bound one. struct TransformFeedbackObjectState { struct SavedBufferBinding { @@ -532,6 +556,10 @@ namespace MobileGL { Uint64 recordedVertices = 0; Bool hasCompletedSpan = false; Bool everBound = false; + // Assigned by the default member initialiser, so every way an object comes into + // being - operator[] materialisation, `= {}` in Gen/Create - gets a fresh one, + // and a recycled NAME never brings the dead object's id back with it. + Uint64 lifetimeId = AllocateTransformFeedbackLifetimeId(); }; void SaveBoundTransformFeedbackState(); void RestoreBoundTransformFeedbackState(); @@ -540,6 +568,10 @@ namespace MobileGL { UnorderedMap m_transformFeedbackObjects; IndexGenerator m_transformFeedbackNames; Uint m_boundTransformFeedback = 0; + // Mirror of m_transformFeedbackObjects[m_boundTransformFeedback].lifetimeId, so + // the per-draw read is a load rather than a hash lookup that could insert. + // Seeded by the constructor and rewritten by RestoreBoundTransformFeedbackState. + Uint64 m_boundTransformFeedbackLifetimeId = 0; // Map membership is object EXISTENCE, which is not the same as the answer // glIsProgramPipeline gives: any command that needs somewhere to put state // materializes a reserved name, so the object can exist well before it is diff --git a/MobileGL/MG_Test/State/CMakeLists.txt b/MobileGL/MG_Test/State/CMakeLists.txt index c04845c5d..52d7cb9c7 100644 --- a/MobileGL/MG_Test/State/CMakeLists.txt +++ b/MobileGL/MG_Test/State/CMakeLists.txt @@ -45,6 +45,31 @@ endif() include(GoogleTest) gtest_discover_tests(ObjectLifetimeIdTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +add_executable( + TransformFeedbackLifetimeIdTest + TransformFeedbackLifetimeIdTest.cpp +) + +target_include_directories(TransformFeedbackLifetimeIdTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/3rdparty/xxHash + ${MGL_ROOT}/3rdparty/Vulkan-Headers/include + ${MGL_ROOT}/3rdparty/SPIRV-Reflect +) + +target_link_libraries( + TransformFeedbackLifetimeIdTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + +if (MSVC) + target_compile_options(TransformFeedbackLifetimeIdTest PRIVATE /Zc:preprocessor) +endif() + +gtest_discover_tests(TransformFeedbackLifetimeIdTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) + add_executable( RenderStateTest RenderStateTest.cpp diff --git a/MobileGL/MG_Test/State/TransformFeedbackLifetimeIdTest.cpp b/MobileGL/MG_Test/State/TransformFeedbackLifetimeIdTest.cpp new file mode 100644 index 000000000..1b0274fa4 --- /dev/null +++ b/MobileGL/MG_Test/State/TransformFeedbackLifetimeIdTest.cpp @@ -0,0 +1,143 @@ +// MobileGL - MobileGL/MG_Test/State/TransformFeedbackLifetimeIdTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// D21 (plan B v2 §4.7.3): DirectVulkan hands every transform feedback object one of sixteen +// counter-buffer groups, and the group carries that span's resume offset. The map was keyed on +// the GL NAME, which glGenTransformFeedbacks recycles the moment the object is deleted, so an +// object created on a recycled name was served the DEAD object's group together with its +// m_xfbCountersValid / m_xfbLastSeenGeneration entries. +// +// The transform feedback object is a plain struct inside a map rather than a heap object, so the +// reuse to defend against is the NAME's, not an address's - which is why these cases live here +// and not in ObjectLifetimeIdTest.cpp with the heap-allocated object types. Keeping them in +// their own translation unit also keeps the D21 commit textually independent of the rest of the +// branch, which plan B §10.4-5 asks for so it can be cherry-picked to dev on its own. +// +// The second case pins the OTHER half of the backend contract: a bounded slot table has to be +// able to tell an object whose span is still open (and may yet resume) from one whose span is +// closed or whose object is gone. + +#include + +#include "Includes.h" +#include "Init.h" + +#include + +using namespace MobileGL; + +namespace { + + MG_State::GLState::GLContext& FreshContext() { + MobileGL::Initialize(); + MG_State::pGLContext = MakeUnique(); + return *MG_State::pGLContext; + } + +} // namespace + +TEST(TransformFeedbackLifetimeIdTest, AnObjectAtARecycledNameCarriesAFreshLifetimeId) { + auto& context = FreshContext(); + + // Before anything is bound. The default object exists from the start of the context, and the + // identity has to exist with it: a backend reading 0 here would match every FREE slot in its + // table without ever claiming one, which is the same bug this id was added to remove. + EXPECT_NE(context.GetBoundTransformFeedbackLifetimeId(), 0u) + << "the default transform feedback object has no identity until something binds it"; + + Vector names; + context.GenTransformFeedbackNames(1, names); + ASSERT_EQ(names.size(), 1u); + const Uint name = names[0]; + ASSERT_NE(name, 0u); + + context.BindTransformFeedbackObject(name); + const Uint64 firstId = context.GetBoundTransformFeedbackLifetimeId(); + EXPECT_NE(firstId, 0u) << "a live transform feedback object answered to id 0, which is the value a " + "zero-initialised backend slot already carries"; + + // The default object is a different object and must not share the id. + context.BindTransformFeedbackObject(0); + EXPECT_NE(context.GetBoundTransformFeedbackLifetimeId(), firstId) + << "the default transform feedback object shares an identity with a generated one"; + + // Deleting while bound reverts to the default object (GL 4.6 core 13.2.1), which is the + // shape the backend sees; delete from there anyway so the test does not depend on it. + context.BindTransformFeedbackObject(name); + context.MarkTransformFeedbackObjectForDeletion(name); + + Vector reborn; + context.GenTransformFeedbackNames(1, reborn); + ASSERT_EQ(reborn.size(), 1u); + if (reborn[0] != name) { + GTEST_SKIP() << "inconclusive, not proven: the name generator did not hand the deleted name back, so " + "the recycled-name case was never exercised"; + } + + context.BindTransformFeedbackObject(reborn[0]); + EXPECT_NE(context.GetBoundTransformFeedbackLifetimeId(), firstId) + << "a transform feedback object created on a recycled name reports the DEAD object's lifetime id - " + "DirectVulkan would hand it the dead span's counter slot, and with it that span's resume state"; +} + +// The predicate DirectVulkan's slot table asks before it takes a group over. The case that +// matters is the middle one: object A is PAUSED and another object is bound and capturing, so A +// looks completely idle to a least-recently-used rule while being exactly the object whose +// counters must survive. +TEST(TransformFeedbackLifetimeIdTest, APausedSpanStaysOpenWhileAnotherObjectCaptures) { + auto& context = FreshContext(); + + Vector names; + context.GenTransformFeedbackNames(2, names); + ASSERT_EQ(names.size(), 2u); + const Uint nameA = names[0]; + const Uint nameB = names[1]; + + context.BindTransformFeedbackObject(nameA); + const Uint64 idA = context.GetBoundTransformFeedbackLifetimeId(); + EXPECT_FALSE(context.HasOpenTransformFeedbackSpan(idA)) << "an object that never began a span reads as open"; + + context.BeginTransformFeedback(GL_POINTS, nullptr); + EXPECT_TRUE(context.HasOpenTransformFeedbackSpan(idA)); + + // Pausing is what makes interleaving legal (ARB_transform_feedback2); the span is still open. + context.SetTransformFeedbackPaused(true); + EXPECT_TRUE(context.HasOpenTransformFeedbackSpan(idA)); + + // Now the shape the slot table sees: B is bound and capturing, A is paused and untouched. + context.BindTransformFeedbackObject(nameB); + const Uint64 idB = context.GetBoundTransformFeedbackLifetimeId(); + EXPECT_NE(idB, idA); + context.BeginTransformFeedback(GL_POINTS, nullptr); + EXPECT_TRUE(context.HasOpenTransformFeedbackSpan(idA)) + << "a paused span stopped reading as open the moment another object was bound - a backend " + "reclaiming slots by 'is this owner still going' would take A's counters away"; + EXPECT_TRUE(context.HasOpenTransformFeedbackSpan(idB)); + + context.EndTransformFeedback(); + EXPECT_FALSE(context.HasOpenTransformFeedbackSpan(idB)) << "a closed span still reads as open"; + EXPECT_TRUE(context.HasOpenTransformFeedbackSpan(idA)); + + // A closes its own span; its slot becomes reclaimable. + context.BindTransformFeedbackObject(nameA); + context.EndTransformFeedback(); + EXPECT_FALSE(context.HasOpenTransformFeedbackSpan(idA)); + + // A deleted object can never resume, so its identity must not hold a slot either. + context.BindTransformFeedbackObject(nameB); + context.BeginTransformFeedback(GL_POINTS, nullptr); + EXPECT_TRUE(context.HasOpenTransformFeedbackSpan(idB)); + context.MarkTransformFeedbackObjectForDeletion(nameB); + EXPECT_FALSE(context.HasOpenTransformFeedbackSpan(idB)) + << "the identity of a deleted transform feedback object still claims an open span, so its counter " + "group would be pinned for the life of the context"; + + // Identities the context never issued, and the free-slot sentinel, are not open spans. + EXPECT_FALSE(context.HasOpenTransformFeedbackSpan(0)); + EXPECT_FALSE(context.HasOpenTransformFeedbackSpan(~0ull)); +} From a1e22c26ababac87c2f8eba39e309c500b68b0bb Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 14:05:12 -0400 Subject: [PATCH 014/529] [Build] (MG_Remote, Protocol): pin the flatbuffers submodule, add the control-plane schema and commit its generated header - 3rdparty/flatbuffers submodule pinned to the latest release tag v25.12.19 (7e163021). The runtime is header-only, so only 3rdparty/flatbuffers/include is ever used and no library is linked; CMake never calls add_subdirectory on it and flatc is not in the build graph (plan B section 8.1, inheriting the earlier plan's section 7.1). - MobileGL/MG_Remote/Protocol/protocol.fbs carries the CONTROL PLANE only: SegmentRef, Hello, Welcome, CapsSnapshot, DefaultFramebufferInfo, SurfaceOp, SurfaceReply, ResyncRequest, ResyncDone, AuxRequest, Fatal, LogLine, union CtrlMsg and the CtrlEnvelope root with a file_identifier. Hot-path records are FlatBuffers structs generated from MG_Pipe/PipeCalls.def in a later package and are deliberately absent here, so record numbering never churns. - Two deviations from the earlier plan's section 7.1 sketch, both deliberate: (a) ProgramReflection is not a union member - plan B ships program artifacts inside the create_shader_state CSO blob (section 8.2), and union tags are wire values that may only ever be appended, so reserving a tag for a message that may never exist is worse than appending one later; (b) maxComputeWorkGroupCount/Size are vectors, not [int:3] - fixed-size arrays are legal only in FlatBuffers structs, never in tables. - scripts/gen_protocol.py resolves flatc as MOBILEGL_FLATC_EXECUTABLE, otherwise builds the pinned flatc ONCE into /../flatc-build (override with MOBILEGL_FLATC_BUILD_DIR), outside the project build graph. A flatc found on PATH is deliberately refused and a version mismatch against the pinned runtime is a hard error: the generated header static_asserts FLATBUFFERS_VERSION, so a stray flatc either fails to compile or churns the committed file on every machine. The earlier branch did the opposite - Protocol/CMakeLists.txt:22-38 add_subdirectory'd the FlatBuffers tree with FLATBUFFERS_BUILD_FLATC=ON whenever MOBILEGL_FLATC_EXECUTABLE was unset, which is exactly the NDK trap it claimed to avoid (cross-compile an arm64 flatc, then run it on the host). - protocol_generated.h is committed with the project source header prepended by the generator, so regeneration is byte-identical: verified by running gen_protocol.py twice and by perturbing the file and regenerating it back. - Reuse from Feat/CS-Delta-IPC: MobileGL/Protocol/mg_protocol_base.h, kept as the shared C vocabulary (result codes, byte spans, shm region, id typedefs) and keeping the structSize-first versioning discipline that section 14.2 calls the answer to risk B-R10. Dropped from it: MobileGLObjectKind / MobileGLObjectScope / MobileGLObjectHandle - plan B never puts GL object identity on the wire (the frontend allocates {slot, generation} handles in MG_Pipe, section 4.2.1), so a second identity vocabulary would be a drift surface with no reader. Added MOBILEGL_ERR_BUFFER_TOO_SMALL as an append-only code for the receive contract. --- .gitmodules | 3 + 3rdparty/flatbuffers | 1 + .../Protocol/generated/protocol_generated.h | 1778 +++++++++++++++++ .../MG_Remote/Protocol/mg_protocol_base.h | 120 ++ MobileGL/MG_Remote/Protocol/protocol.fbs | 235 +++ scripts/gen_protocol.py | 175 ++ 6 files changed, 2312 insertions(+) create mode 160000 3rdparty/flatbuffers create mode 100644 MobileGL/MG_Remote/Protocol/generated/protocol_generated.h create mode 100644 MobileGL/MG_Remote/Protocol/mg_protocol_base.h create mode 100644 MobileGL/MG_Remote/Protocol/protocol.fbs create mode 100644 scripts/gen_protocol.py diff --git a/.gitmodules b/.gitmodules index 3ae968ea5..20ea83612 100644 --- a/.gitmodules +++ b/.gitmodules @@ -34,3 +34,6 @@ [submodule "include/ska"] path = include/ska url = https://github.com/MobileGL-Dev/flat_hash_map.git +[submodule "3rdparty/flatbuffers"] + path = 3rdparty/flatbuffers + url = https://github.com/google/flatbuffers.git diff --git a/3rdparty/flatbuffers b/3rdparty/flatbuffers new file mode 160000 index 000000000..7e163021e --- /dev/null +++ b/3rdparty/flatbuffers @@ -0,0 +1 @@ +Subproject commit 7e163021e59cca4f8e1e35a7c828b5c6b7915953 diff --git a/MobileGL/MG_Remote/Protocol/generated/protocol_generated.h b/MobileGL/MG_Remote/Protocol/generated/protocol_generated.h new file mode 100644 index 000000000..eb91a6a58 --- /dev/null +++ b/MobileGL/MG_Remote/Protocol/generated/protocol_generated.h @@ -0,0 +1,1778 @@ +// MobileGL - MobileGL/MG_Remote/Protocol/generated/protocol_generated.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// GENERATED FILE - DO NOT EDIT. +// Regenerate with `python3 scripts/gen_protocol.py` after changing +// MobileGL/MG_Remote/Protocol/protocol.fbs. CI's flatc-check step regenerates +// this file and fails on `git diff --exit-code`. + +// automatically generated by the FlatBuffers compiler, do not modify + + +#ifndef FLATBUFFERS_GENERATED_PROTOCOL_MOBILEGL_WIRE_H_ +#define FLATBUFFERS_GENERATED_PROTOCOL_MOBILEGL_WIRE_H_ + +#include "flatbuffers/flatbuffers.h" + +// Ensure the included flatbuffers.h is the same version as when this file was +// generated, otherwise it may not be compatible. +static_assert(FLATBUFFERS_VERSION_MAJOR == 25 && + FLATBUFFERS_VERSION_MINOR == 12 && + FLATBUFFERS_VERSION_REVISION == 19, + "Non-compatible flatbuffers version included"); + +namespace MobileGL { +namespace Wire { + +struct SegmentRef; +struct SegmentRefBuilder; + +struct Hello; +struct HelloBuilder; + +struct Welcome; +struct WelcomeBuilder; + +struct CapsSnapshot; +struct CapsSnapshotBuilder; + +struct DefaultFramebufferInfo; +struct DefaultFramebufferInfoBuilder; + +struct SurfaceOp; +struct SurfaceOpBuilder; + +struct SurfaceReply; +struct SurfaceReplyBuilder; + +struct ResyncRequest; +struct ResyncRequestBuilder; + +struct ResyncDone; +struct ResyncDoneBuilder; + +struct AuxRequest; +struct AuxRequestBuilder; + +struct Fatal; +struct FatalBuilder; + +struct LogLine; +struct LogLineBuilder; + +struct CtrlEnvelope; +struct CtrlEnvelopeBuilder; + +enum class SegmentKind : uint8_t { + None = 0, + Cmd = 1, + Stage = 2, + Reply = 3, + Event = 4, + Shadow = 5, + Adopt = 6, + MIN = None, + MAX = Adopt +}; + +inline const SegmentKind (&EnumValuesSegmentKind())[7] { + static const SegmentKind values[] = { + SegmentKind::None, + SegmentKind::Cmd, + SegmentKind::Stage, + SegmentKind::Reply, + SegmentKind::Event, + SegmentKind::Shadow, + SegmentKind::Adopt + }; + return values; +} + +inline const char * const *EnumNamesSegmentKind() { + static const char * const names[8] = { + "None", + "Cmd", + "Stage", + "Reply", + "Event", + "Shadow", + "Adopt", + nullptr + }; + return names; +} + +inline const char *EnumNameSegmentKind(SegmentKind e) { + if (::flatbuffers::IsOutRange(e, SegmentKind::None, SegmentKind::Adopt)) return ""; + const size_t index = static_cast(e); + return EnumNamesSegmentKind()[index]; +} + +enum class SurfaceOpKind : uint8_t { + None = 0, + InitializeDisplay = 1, + CreateWindowSurface = 2, + CreatePbufferSurface = 3, + ResizeWindowSurface = 4, + ReleaseSurface = 5, + MakeCurrent = 6, + ReleaseCurrent = 7, + MIN = None, + MAX = ReleaseCurrent +}; + +inline const SurfaceOpKind (&EnumValuesSurfaceOpKind())[8] { + static const SurfaceOpKind values[] = { + SurfaceOpKind::None, + SurfaceOpKind::InitializeDisplay, + SurfaceOpKind::CreateWindowSurface, + SurfaceOpKind::CreatePbufferSurface, + SurfaceOpKind::ResizeWindowSurface, + SurfaceOpKind::ReleaseSurface, + SurfaceOpKind::MakeCurrent, + SurfaceOpKind::ReleaseCurrent + }; + return values; +} + +inline const char * const *EnumNamesSurfaceOpKind() { + static const char * const names[9] = { + "None", + "InitializeDisplay", + "CreateWindowSurface", + "CreatePbufferSurface", + "ResizeWindowSurface", + "ReleaseSurface", + "MakeCurrent", + "ReleaseCurrent", + nullptr + }; + return names; +} + +inline const char *EnumNameSurfaceOpKind(SurfaceOpKind e) { + if (::flatbuffers::IsOutRange(e, SurfaceOpKind::None, SurfaceOpKind::ReleaseCurrent)) return ""; + const size_t index = static_cast(e); + return EnumNamesSurfaceOpKind()[index]; +} + +enum class WindowKind : uint8_t { + None = 0, + AndroidNativeWindow = 1, + X11 = 2, + Win32Hwnd = 3, + Surfaceless = 4, + Pbuffer = 5, + MIN = None, + MAX = Pbuffer +}; + +inline const WindowKind (&EnumValuesWindowKind())[6] { + static const WindowKind values[] = { + WindowKind::None, + WindowKind::AndroidNativeWindow, + WindowKind::X11, + WindowKind::Win32Hwnd, + WindowKind::Surfaceless, + WindowKind::Pbuffer + }; + return values; +} + +inline const char * const *EnumNamesWindowKind() { + static const char * const names[7] = { + "None", + "AndroidNativeWindow", + "X11", + "Win32Hwnd", + "Surfaceless", + "Pbuffer", + nullptr + }; + return names; +} + +inline const char *EnumNameWindowKind(WindowKind e) { + if (::flatbuffers::IsOutRange(e, WindowKind::None, WindowKind::Pbuffer)) return ""; + const size_t index = static_cast(e); + return EnumNamesWindowKind()[index]; +} + +enum class AuxRequestKind : uint8_t { + None = 0, + FenceClientWait = 1, + QueryResult = 2, + ScalarGet = 3, + MIN = None, + MAX = ScalarGet +}; + +inline const AuxRequestKind (&EnumValuesAuxRequestKind())[4] { + static const AuxRequestKind values[] = { + AuxRequestKind::None, + AuxRequestKind::FenceClientWait, + AuxRequestKind::QueryResult, + AuxRequestKind::ScalarGet + }; + return values; +} + +inline const char * const *EnumNamesAuxRequestKind() { + static const char * const names[5] = { + "None", + "FenceClientWait", + "QueryResult", + "ScalarGet", + nullptr + }; + return names; +} + +inline const char *EnumNameAuxRequestKind(AuxRequestKind e) { + if (::flatbuffers::IsOutRange(e, AuxRequestKind::None, AuxRequestKind::ScalarGet)) return ""; + const size_t index = static_cast(e); + return EnumNamesAuxRequestKind()[index]; +} + +enum class FatalCode : uint32_t { + None = 0, + ProtocolCorruption = 1, + RingOverrun = 2, + SegmentMismatch = 3, + DeviceLost = 4, + ServerCrashed = 5, + AbiMismatch = 6, + MIN = None, + MAX = AbiMismatch +}; + +inline const FatalCode (&EnumValuesFatalCode())[7] { + static const FatalCode values[] = { + FatalCode::None, + FatalCode::ProtocolCorruption, + FatalCode::RingOverrun, + FatalCode::SegmentMismatch, + FatalCode::DeviceLost, + FatalCode::ServerCrashed, + FatalCode::AbiMismatch + }; + return values; +} + +inline const char * const *EnumNamesFatalCode() { + static const char * const names[8] = { + "None", + "ProtocolCorruption", + "RingOverrun", + "SegmentMismatch", + "DeviceLost", + "ServerCrashed", + "AbiMismatch", + nullptr + }; + return names; +} + +inline const char *EnumNameFatalCode(FatalCode e) { + if (::flatbuffers::IsOutRange(e, FatalCode::None, FatalCode::AbiMismatch)) return ""; + const size_t index = static_cast(e); + return EnumNamesFatalCode()[index]; +} + +enum class LogLevel : uint8_t { + Debug = 0, + Info = 1, + Warn = 2, + Error = 3, + Fatal = 4, + MIN = Debug, + MAX = Fatal +}; + +inline const LogLevel (&EnumValuesLogLevel())[5] { + static const LogLevel values[] = { + LogLevel::Debug, + LogLevel::Info, + LogLevel::Warn, + LogLevel::Error, + LogLevel::Fatal + }; + return values; +} + +inline const char * const *EnumNamesLogLevel() { + static const char * const names[6] = { + "Debug", + "Info", + "Warn", + "Error", + "Fatal", + nullptr + }; + return names; +} + +inline const char *EnumNameLogLevel(LogLevel e) { + if (::flatbuffers::IsOutRange(e, LogLevel::Debug, LogLevel::Fatal)) return ""; + const size_t index = static_cast(e); + return EnumNamesLogLevel()[index]; +} + +enum class CtrlMsg : uint8_t { + NONE = 0, + Hello = 1, + Welcome = 2, + CapsSnapshot = 3, + SurfaceOp = 4, + SurfaceReply = 5, + ResyncRequest = 6, + ResyncDone = 7, + AuxRequest = 8, + Fatal = 9, + LogLine = 10, + MIN = NONE, + MAX = LogLine +}; + +inline const CtrlMsg (&EnumValuesCtrlMsg())[11] { + static const CtrlMsg values[] = { + CtrlMsg::NONE, + CtrlMsg::Hello, + CtrlMsg::Welcome, + CtrlMsg::CapsSnapshot, + CtrlMsg::SurfaceOp, + CtrlMsg::SurfaceReply, + CtrlMsg::ResyncRequest, + CtrlMsg::ResyncDone, + CtrlMsg::AuxRequest, + CtrlMsg::Fatal, + CtrlMsg::LogLine + }; + return values; +} + +inline const char * const *EnumNamesCtrlMsg() { + static const char * const names[12] = { + "NONE", + "Hello", + "Welcome", + "CapsSnapshot", + "SurfaceOp", + "SurfaceReply", + "ResyncRequest", + "ResyncDone", + "AuxRequest", + "Fatal", + "LogLine", + nullptr + }; + return names; +} + +inline const char *EnumNameCtrlMsg(CtrlMsg e) { + if (::flatbuffers::IsOutRange(e, CtrlMsg::NONE, CtrlMsg::LogLine)) return ""; + const size_t index = static_cast(e); + return EnumNamesCtrlMsg()[index]; +} + +template struct CtrlMsgTraits { + static const CtrlMsg enum_value = CtrlMsg::NONE; +}; + +template<> struct CtrlMsgTraits { + static const CtrlMsg enum_value = CtrlMsg::Hello; +}; + +template<> struct CtrlMsgTraits { + static const CtrlMsg enum_value = CtrlMsg::Welcome; +}; + +template<> struct CtrlMsgTraits { + static const CtrlMsg enum_value = CtrlMsg::CapsSnapshot; +}; + +template<> struct CtrlMsgTraits { + static const CtrlMsg enum_value = CtrlMsg::SurfaceOp; +}; + +template<> struct CtrlMsgTraits { + static const CtrlMsg enum_value = CtrlMsg::SurfaceReply; +}; + +template<> struct CtrlMsgTraits { + static const CtrlMsg enum_value = CtrlMsg::ResyncRequest; +}; + +template<> struct CtrlMsgTraits { + static const CtrlMsg enum_value = CtrlMsg::ResyncDone; +}; + +template<> struct CtrlMsgTraits { + static const CtrlMsg enum_value = CtrlMsg::AuxRequest; +}; + +template<> struct CtrlMsgTraits { + static const CtrlMsg enum_value = CtrlMsg::Fatal; +}; + +template<> struct CtrlMsgTraits { + static const CtrlMsg enum_value = CtrlMsg::LogLine; +}; + +template +bool VerifyCtrlMsg(::flatbuffers::VerifierTemplate &verifier, const void *obj, CtrlMsg type); +template +bool VerifyCtrlMsgVector(::flatbuffers::VerifierTemplate &verifier, const ::flatbuffers::Vector<::flatbuffers::Offset> *values, const ::flatbuffers::Vector *types); + +struct SegmentRef FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef SegmentRefBuilder Builder; + struct Traits; + enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { + VT_ID = 4, + VT_KIND = 6, + VT_SIZEBYTES = 8, + VT_NAME = 10 + }; + uint32_t id() const { + return GetField(VT_ID, 0); + } + MobileGL::Wire::SegmentKind kind() const { + return static_cast(GetField(VT_KIND, 0)); + } + uint64_t sizeBytes() const { + return GetField(VT_SIZEBYTES, 0); + } + const ::flatbuffers::String *name() const { + return GetPointer(VT_NAME); + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + VerifyField(verifier, VT_ID, 4) && + VerifyField(verifier, VT_KIND, 1) && + VerifyField(verifier, VT_SIZEBYTES, 8) && + VerifyOffset(verifier, VT_NAME) && + verifier.VerifyString(name()) && + verifier.EndTable(); + } +}; + +struct SegmentRefBuilder { + typedef SegmentRef Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + void add_id(uint32_t id) { + fbb_.AddElement(SegmentRef::VT_ID, id, 0); + } + void add_kind(MobileGL::Wire::SegmentKind kind) { + fbb_.AddElement(SegmentRef::VT_KIND, static_cast(kind), 0); + } + void add_sizeBytes(uint64_t sizeBytes) { + fbb_.AddElement(SegmentRef::VT_SIZEBYTES, sizeBytes, 0); + } + void add_name(::flatbuffers::Offset<::flatbuffers::String> name) { + fbb_.AddOffset(SegmentRef::VT_NAME, name); + } + explicit SegmentRefBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateSegmentRef( + ::flatbuffers::FlatBufferBuilder &_fbb, + uint32_t id = 0, + MobileGL::Wire::SegmentKind kind = MobileGL::Wire::SegmentKind::None, + uint64_t sizeBytes = 0, + ::flatbuffers::Offset<::flatbuffers::String> name = 0) { + SegmentRefBuilder builder_(_fbb); + builder_.add_sizeBytes(sizeBytes); + builder_.add_name(name); + builder_.add_id(id); + builder_.add_kind(kind); + return builder_.Finish(); +} + +struct SegmentRef::Traits { + using type = SegmentRef; + static auto constexpr Create = CreateSegmentRef; +}; + +inline ::flatbuffers::Offset CreateSegmentRefDirect( + ::flatbuffers::FlatBufferBuilder &_fbb, + uint32_t id = 0, + MobileGL::Wire::SegmentKind kind = MobileGL::Wire::SegmentKind::None, + uint64_t sizeBytes = 0, + const char *name = nullptr) { + auto name__ = name ? _fbb.CreateString(name) : 0; + return MobileGL::Wire::CreateSegmentRef( + _fbb, + id, + kind, + sizeBytes, + name__); +} + +struct Hello FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef HelloBuilder Builder; + struct Traits; + enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { + VT_ABIMAJOR = 4, + VT_ABIMINOR = 6, + VT_BUILDFINGERPRINT = 8, + VT_BACKENDTYPE = 10, + VT_PID = 12, + VT_CONFIGBLOB = 14 + }; + uint32_t abiMajor() const { + return GetField(VT_ABIMAJOR, 0); + } + uint32_t abiMinor() const { + return GetField(VT_ABIMINOR, 0); + } + const ::flatbuffers::String *buildFingerprint() const { + return GetPointer(VT_BUILDFINGERPRINT); + } + uint32_t backendType() const { + return GetField(VT_BACKENDTYPE, 0); + } + uint32_t pid() const { + return GetField(VT_PID, 0); + } + const ::flatbuffers::Vector *configBlob() const { + return GetPointer *>(VT_CONFIGBLOB); + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + VerifyField(verifier, VT_ABIMAJOR, 4) && + VerifyField(verifier, VT_ABIMINOR, 4) && + VerifyOffset(verifier, VT_BUILDFINGERPRINT) && + verifier.VerifyString(buildFingerprint()) && + VerifyField(verifier, VT_BACKENDTYPE, 4) && + VerifyField(verifier, VT_PID, 4) && + VerifyOffset(verifier, VT_CONFIGBLOB) && + verifier.VerifyVector(configBlob()) && + verifier.EndTable(); + } +}; + +struct HelloBuilder { + typedef Hello Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + void add_abiMajor(uint32_t abiMajor) { + fbb_.AddElement(Hello::VT_ABIMAJOR, abiMajor, 0); + } + void add_abiMinor(uint32_t abiMinor) { + fbb_.AddElement(Hello::VT_ABIMINOR, abiMinor, 0); + } + void add_buildFingerprint(::flatbuffers::Offset<::flatbuffers::String> buildFingerprint) { + fbb_.AddOffset(Hello::VT_BUILDFINGERPRINT, buildFingerprint); + } + void add_backendType(uint32_t backendType) { + fbb_.AddElement(Hello::VT_BACKENDTYPE, backendType, 0); + } + void add_pid(uint32_t pid) { + fbb_.AddElement(Hello::VT_PID, pid, 0); + } + void add_configBlob(::flatbuffers::Offset<::flatbuffers::Vector> configBlob) { + fbb_.AddOffset(Hello::VT_CONFIGBLOB, configBlob); + } + explicit HelloBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateHello( + ::flatbuffers::FlatBufferBuilder &_fbb, + uint32_t abiMajor = 0, + uint32_t abiMinor = 0, + ::flatbuffers::Offset<::flatbuffers::String> buildFingerprint = 0, + uint32_t backendType = 0, + uint32_t pid = 0, + ::flatbuffers::Offset<::flatbuffers::Vector> configBlob = 0) { + HelloBuilder builder_(_fbb); + builder_.add_configBlob(configBlob); + builder_.add_pid(pid); + builder_.add_backendType(backendType); + builder_.add_buildFingerprint(buildFingerprint); + builder_.add_abiMinor(abiMinor); + builder_.add_abiMajor(abiMajor); + return builder_.Finish(); +} + +struct Hello::Traits { + using type = Hello; + static auto constexpr Create = CreateHello; +}; + +inline ::flatbuffers::Offset CreateHelloDirect( + ::flatbuffers::FlatBufferBuilder &_fbb, + uint32_t abiMajor = 0, + uint32_t abiMinor = 0, + const char *buildFingerprint = nullptr, + uint32_t backendType = 0, + uint32_t pid = 0, + const std::vector *configBlob = nullptr) { + auto buildFingerprint__ = buildFingerprint ? _fbb.CreateString(buildFingerprint) : 0; + auto configBlob__ = configBlob ? _fbb.CreateVector(*configBlob) : 0; + return MobileGL::Wire::CreateHello( + _fbb, + abiMajor, + abiMinor, + buildFingerprint__, + backendType, + pid, + configBlob__); +} + +struct Welcome FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef WelcomeBuilder Builder; + struct Traits; + enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { + VT_ABIMAJOR = 4, + VT_ABIMINOR = 6, + VT_SERVERPID = 8, + VT_CMDRING = 10, + VT_STAGERING = 12, + VT_REPLYPOOL = 14, + VT_EVENTRING = 16 + }; + uint32_t abiMajor() const { + return GetField(VT_ABIMAJOR, 0); + } + uint32_t abiMinor() const { + return GetField(VT_ABIMINOR, 0); + } + uint32_t serverPid() const { + return GetField(VT_SERVERPID, 0); + } + const MobileGL::Wire::SegmentRef *cmdRing() const { + return GetPointer(VT_CMDRING); + } + const MobileGL::Wire::SegmentRef *stageRing() const { + return GetPointer(VT_STAGERING); + } + const MobileGL::Wire::SegmentRef *replyPool() const { + return GetPointer(VT_REPLYPOOL); + } + const MobileGL::Wire::SegmentRef *eventRing() const { + return GetPointer(VT_EVENTRING); + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + VerifyField(verifier, VT_ABIMAJOR, 4) && + VerifyField(verifier, VT_ABIMINOR, 4) && + VerifyField(verifier, VT_SERVERPID, 4) && + VerifyOffset(verifier, VT_CMDRING) && + verifier.VerifyTable(cmdRing()) && + VerifyOffset(verifier, VT_STAGERING) && + verifier.VerifyTable(stageRing()) && + VerifyOffset(verifier, VT_REPLYPOOL) && + verifier.VerifyTable(replyPool()) && + VerifyOffset(verifier, VT_EVENTRING) && + verifier.VerifyTable(eventRing()) && + verifier.EndTable(); + } +}; + +struct WelcomeBuilder { + typedef Welcome Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + void add_abiMajor(uint32_t abiMajor) { + fbb_.AddElement(Welcome::VT_ABIMAJOR, abiMajor, 0); + } + void add_abiMinor(uint32_t abiMinor) { + fbb_.AddElement(Welcome::VT_ABIMINOR, abiMinor, 0); + } + void add_serverPid(uint32_t serverPid) { + fbb_.AddElement(Welcome::VT_SERVERPID, serverPid, 0); + } + void add_cmdRing(::flatbuffers::Offset cmdRing) { + fbb_.AddOffset(Welcome::VT_CMDRING, cmdRing); + } + void add_stageRing(::flatbuffers::Offset stageRing) { + fbb_.AddOffset(Welcome::VT_STAGERING, stageRing); + } + void add_replyPool(::flatbuffers::Offset replyPool) { + fbb_.AddOffset(Welcome::VT_REPLYPOOL, replyPool); + } + void add_eventRing(::flatbuffers::Offset eventRing) { + fbb_.AddOffset(Welcome::VT_EVENTRING, eventRing); + } + explicit WelcomeBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateWelcome( + ::flatbuffers::FlatBufferBuilder &_fbb, + uint32_t abiMajor = 0, + uint32_t abiMinor = 0, + uint32_t serverPid = 0, + ::flatbuffers::Offset cmdRing = 0, + ::flatbuffers::Offset stageRing = 0, + ::flatbuffers::Offset replyPool = 0, + ::flatbuffers::Offset eventRing = 0) { + WelcomeBuilder builder_(_fbb); + builder_.add_eventRing(eventRing); + builder_.add_replyPool(replyPool); + builder_.add_stageRing(stageRing); + builder_.add_cmdRing(cmdRing); + builder_.add_serverPid(serverPid); + builder_.add_abiMinor(abiMinor); + builder_.add_abiMajor(abiMajor); + return builder_.Finish(); +} + +struct Welcome::Traits { + using type = Welcome; + static auto constexpr Create = CreateWelcome; +}; + +struct CapsSnapshot FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef CapsSnapshotBuilder Builder; + struct Traits; + enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { + VT_DYNAMICPARAMETERS = 4, + VT_RENDERERINFO = 6, + VT_FORMATCAPS = 8, + VT_EXTENSIONS = 10, + VT_APIVERSION = 12, + VT_MAXCOMPUTEWORKGROUPCOUNT = 14, + VT_MAXCOMPUTEWORKGROUPSIZE = 16, + VT_TABLESLOTMASK = 18, + VT_PREFERSCPUXFBPRIMITIVEACCOUNTING = 20 + }; + const ::flatbuffers::Vector *dynamicParameters() const { + return GetPointer *>(VT_DYNAMICPARAMETERS); + } + const ::flatbuffers::Vector *rendererInfo() const { + return GetPointer *>(VT_RENDERERINFO); + } + const ::flatbuffers::Vector *formatCaps() const { + return GetPointer *>(VT_FORMATCAPS); + } + const ::flatbuffers::Vector<::flatbuffers::Offset<::flatbuffers::String>> *extensions() const { + return GetPointer> *>(VT_EXTENSIONS); + } + const ::flatbuffers::String *apiVersion() const { + return GetPointer(VT_APIVERSION); + } + const ::flatbuffers::Vector *maxComputeWorkGroupCount() const { + return GetPointer *>(VT_MAXCOMPUTEWORKGROUPCOUNT); + } + const ::flatbuffers::Vector *maxComputeWorkGroupSize() const { + return GetPointer *>(VT_MAXCOMPUTEWORKGROUPSIZE); + } + uint64_t tableSlotMask() const { + return GetField(VT_TABLESLOTMASK, 0); + } + bool prefersCpuXfbPrimitiveAccounting() const { + return GetField(VT_PREFERSCPUXFBPRIMITIVEACCOUNTING, 0) != 0; + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + VerifyOffset(verifier, VT_DYNAMICPARAMETERS) && + verifier.VerifyVector(dynamicParameters()) && + VerifyOffset(verifier, VT_RENDERERINFO) && + verifier.VerifyVector(rendererInfo()) && + VerifyOffset(verifier, VT_FORMATCAPS) && + verifier.VerifyVector(formatCaps()) && + VerifyOffset(verifier, VT_EXTENSIONS) && + verifier.VerifyVector(extensions()) && + verifier.VerifyVectorOfStrings(extensions()) && + VerifyOffset(verifier, VT_APIVERSION) && + verifier.VerifyString(apiVersion()) && + VerifyOffset(verifier, VT_MAXCOMPUTEWORKGROUPCOUNT) && + verifier.VerifyVector(maxComputeWorkGroupCount()) && + VerifyOffset(verifier, VT_MAXCOMPUTEWORKGROUPSIZE) && + verifier.VerifyVector(maxComputeWorkGroupSize()) && + VerifyField(verifier, VT_TABLESLOTMASK, 8) && + VerifyField(verifier, VT_PREFERSCPUXFBPRIMITIVEACCOUNTING, 1) && + verifier.EndTable(); + } +}; + +struct CapsSnapshotBuilder { + typedef CapsSnapshot Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + void add_dynamicParameters(::flatbuffers::Offset<::flatbuffers::Vector> dynamicParameters) { + fbb_.AddOffset(CapsSnapshot::VT_DYNAMICPARAMETERS, dynamicParameters); + } + void add_rendererInfo(::flatbuffers::Offset<::flatbuffers::Vector> rendererInfo) { + fbb_.AddOffset(CapsSnapshot::VT_RENDERERINFO, rendererInfo); + } + void add_formatCaps(::flatbuffers::Offset<::flatbuffers::Vector> formatCaps) { + fbb_.AddOffset(CapsSnapshot::VT_FORMATCAPS, formatCaps); + } + void add_extensions(::flatbuffers::Offset<::flatbuffers::Vector<::flatbuffers::Offset<::flatbuffers::String>>> extensions) { + fbb_.AddOffset(CapsSnapshot::VT_EXTENSIONS, extensions); + } + void add_apiVersion(::flatbuffers::Offset<::flatbuffers::String> apiVersion) { + fbb_.AddOffset(CapsSnapshot::VT_APIVERSION, apiVersion); + } + void add_maxComputeWorkGroupCount(::flatbuffers::Offset<::flatbuffers::Vector> maxComputeWorkGroupCount) { + fbb_.AddOffset(CapsSnapshot::VT_MAXCOMPUTEWORKGROUPCOUNT, maxComputeWorkGroupCount); + } + void add_maxComputeWorkGroupSize(::flatbuffers::Offset<::flatbuffers::Vector> maxComputeWorkGroupSize) { + fbb_.AddOffset(CapsSnapshot::VT_MAXCOMPUTEWORKGROUPSIZE, maxComputeWorkGroupSize); + } + void add_tableSlotMask(uint64_t tableSlotMask) { + fbb_.AddElement(CapsSnapshot::VT_TABLESLOTMASK, tableSlotMask, 0); + } + void add_prefersCpuXfbPrimitiveAccounting(bool prefersCpuXfbPrimitiveAccounting) { + fbb_.AddElement(CapsSnapshot::VT_PREFERSCPUXFBPRIMITIVEACCOUNTING, static_cast(prefersCpuXfbPrimitiveAccounting), 0); + } + explicit CapsSnapshotBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateCapsSnapshot( + ::flatbuffers::FlatBufferBuilder &_fbb, + ::flatbuffers::Offset<::flatbuffers::Vector> dynamicParameters = 0, + ::flatbuffers::Offset<::flatbuffers::Vector> rendererInfo = 0, + ::flatbuffers::Offset<::flatbuffers::Vector> formatCaps = 0, + ::flatbuffers::Offset<::flatbuffers::Vector<::flatbuffers::Offset<::flatbuffers::String>>> extensions = 0, + ::flatbuffers::Offset<::flatbuffers::String> apiVersion = 0, + ::flatbuffers::Offset<::flatbuffers::Vector> maxComputeWorkGroupCount = 0, + ::flatbuffers::Offset<::flatbuffers::Vector> maxComputeWorkGroupSize = 0, + uint64_t tableSlotMask = 0, + bool prefersCpuXfbPrimitiveAccounting = false) { + CapsSnapshotBuilder builder_(_fbb); + builder_.add_tableSlotMask(tableSlotMask); + builder_.add_maxComputeWorkGroupSize(maxComputeWorkGroupSize); + builder_.add_maxComputeWorkGroupCount(maxComputeWorkGroupCount); + builder_.add_apiVersion(apiVersion); + builder_.add_extensions(extensions); + builder_.add_formatCaps(formatCaps); + builder_.add_rendererInfo(rendererInfo); + builder_.add_dynamicParameters(dynamicParameters); + builder_.add_prefersCpuXfbPrimitiveAccounting(prefersCpuXfbPrimitiveAccounting); + return builder_.Finish(); +} + +struct CapsSnapshot::Traits { + using type = CapsSnapshot; + static auto constexpr Create = CreateCapsSnapshot; +}; + +inline ::flatbuffers::Offset CreateCapsSnapshotDirect( + ::flatbuffers::FlatBufferBuilder &_fbb, + const std::vector *dynamicParameters = nullptr, + const std::vector *rendererInfo = nullptr, + const std::vector *formatCaps = nullptr, + const std::vector<::flatbuffers::Offset<::flatbuffers::String>> *extensions = nullptr, + const char *apiVersion = nullptr, + const std::vector *maxComputeWorkGroupCount = nullptr, + const std::vector *maxComputeWorkGroupSize = nullptr, + uint64_t tableSlotMask = 0, + bool prefersCpuXfbPrimitiveAccounting = false) { + auto dynamicParameters__ = dynamicParameters ? _fbb.CreateVector(*dynamicParameters) : 0; + auto rendererInfo__ = rendererInfo ? _fbb.CreateVector(*rendererInfo) : 0; + auto formatCaps__ = formatCaps ? _fbb.CreateVector(*formatCaps) : 0; + auto extensions__ = extensions ? _fbb.CreateVector<::flatbuffers::Offset<::flatbuffers::String>>(*extensions) : 0; + auto apiVersion__ = apiVersion ? _fbb.CreateString(apiVersion) : 0; + auto maxComputeWorkGroupCount__ = maxComputeWorkGroupCount ? _fbb.CreateVector(*maxComputeWorkGroupCount) : 0; + auto maxComputeWorkGroupSize__ = maxComputeWorkGroupSize ? _fbb.CreateVector(*maxComputeWorkGroupSize) : 0; + return MobileGL::Wire::CreateCapsSnapshot( + _fbb, + dynamicParameters__, + rendererInfo__, + formatCaps__, + extensions__, + apiVersion__, + maxComputeWorkGroupCount__, + maxComputeWorkGroupSize__, + tableSlotMask, + prefersCpuXfbPrimitiveAccounting); +} + +struct DefaultFramebufferInfo FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef DefaultFramebufferInfoBuilder Builder; + struct Traits; + enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { + VT_WIDTH = 4, + VT_HEIGHT = 6, + VT_COLORFORMAT = 8, + VT_DEPTHFORMAT = 10, + VT_STENCILFORMAT = 12 + }; + int32_t width() const { + return GetField(VT_WIDTH, 0); + } + int32_t height() const { + return GetField(VT_HEIGHT, 0); + } + uint32_t colorFormat() const { + return GetField(VT_COLORFORMAT, 0); + } + uint32_t depthFormat() const { + return GetField(VT_DEPTHFORMAT, 0); + } + uint32_t stencilFormat() const { + return GetField(VT_STENCILFORMAT, 0); + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + VerifyField(verifier, VT_WIDTH, 4) && + VerifyField(verifier, VT_HEIGHT, 4) && + VerifyField(verifier, VT_COLORFORMAT, 4) && + VerifyField(verifier, VT_DEPTHFORMAT, 4) && + VerifyField(verifier, VT_STENCILFORMAT, 4) && + verifier.EndTable(); + } +}; + +struct DefaultFramebufferInfoBuilder { + typedef DefaultFramebufferInfo Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + void add_width(int32_t width) { + fbb_.AddElement(DefaultFramebufferInfo::VT_WIDTH, width, 0); + } + void add_height(int32_t height) { + fbb_.AddElement(DefaultFramebufferInfo::VT_HEIGHT, height, 0); + } + void add_colorFormat(uint32_t colorFormat) { + fbb_.AddElement(DefaultFramebufferInfo::VT_COLORFORMAT, colorFormat, 0); + } + void add_depthFormat(uint32_t depthFormat) { + fbb_.AddElement(DefaultFramebufferInfo::VT_DEPTHFORMAT, depthFormat, 0); + } + void add_stencilFormat(uint32_t stencilFormat) { + fbb_.AddElement(DefaultFramebufferInfo::VT_STENCILFORMAT, stencilFormat, 0); + } + explicit DefaultFramebufferInfoBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateDefaultFramebufferInfo( + ::flatbuffers::FlatBufferBuilder &_fbb, + int32_t width = 0, + int32_t height = 0, + uint32_t colorFormat = 0, + uint32_t depthFormat = 0, + uint32_t stencilFormat = 0) { + DefaultFramebufferInfoBuilder builder_(_fbb); + builder_.add_stencilFormat(stencilFormat); + builder_.add_depthFormat(depthFormat); + builder_.add_colorFormat(colorFormat); + builder_.add_height(height); + builder_.add_width(width); + return builder_.Finish(); +} + +struct DefaultFramebufferInfo::Traits { + using type = DefaultFramebufferInfo; + static auto constexpr Create = CreateDefaultFramebufferInfo; +}; + +struct SurfaceOp FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef SurfaceOpBuilder Builder; + struct Traits; + enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { + VT_SEQ = 4, + VT_KIND = 6, + VT_DISPLAY = 8, + VT_SURFACE = 10, + VT_WINDOWKIND = 12, + VT_NATIVETOKEN = 14, + VT_WIDTH = 16, + VT_HEIGHT = 18, + VT_SWAPINTERVAL = 20 + }; + uint64_t seq() const { + return GetField(VT_SEQ, 0); + } + MobileGL::Wire::SurfaceOpKind kind() const { + return static_cast(GetField(VT_KIND, 0)); + } + uint64_t display() const { + return GetField(VT_DISPLAY, 0); + } + uint64_t surface() const { + return GetField(VT_SURFACE, 0); + } + MobileGL::Wire::WindowKind windowKind() const { + return static_cast(GetField(VT_WINDOWKIND, 0)); + } + uint64_t nativeToken() const { + return GetField(VT_NATIVETOKEN, 0); + } + int32_t width() const { + return GetField(VT_WIDTH, 0); + } + int32_t height() const { + return GetField(VT_HEIGHT, 0); + } + int32_t swapInterval() const { + return GetField(VT_SWAPINTERVAL, 0); + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + VerifyField(verifier, VT_SEQ, 8) && + VerifyField(verifier, VT_KIND, 1) && + VerifyField(verifier, VT_DISPLAY, 8) && + VerifyField(verifier, VT_SURFACE, 8) && + VerifyField(verifier, VT_WINDOWKIND, 1) && + VerifyField(verifier, VT_NATIVETOKEN, 8) && + VerifyField(verifier, VT_WIDTH, 4) && + VerifyField(verifier, VT_HEIGHT, 4) && + VerifyField(verifier, VT_SWAPINTERVAL, 4) && + verifier.EndTable(); + } +}; + +struct SurfaceOpBuilder { + typedef SurfaceOp Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + void add_seq(uint64_t seq) { + fbb_.AddElement(SurfaceOp::VT_SEQ, seq, 0); + } + void add_kind(MobileGL::Wire::SurfaceOpKind kind) { + fbb_.AddElement(SurfaceOp::VT_KIND, static_cast(kind), 0); + } + void add_display(uint64_t display) { + fbb_.AddElement(SurfaceOp::VT_DISPLAY, display, 0); + } + void add_surface(uint64_t surface) { + fbb_.AddElement(SurfaceOp::VT_SURFACE, surface, 0); + } + void add_windowKind(MobileGL::Wire::WindowKind windowKind) { + fbb_.AddElement(SurfaceOp::VT_WINDOWKIND, static_cast(windowKind), 0); + } + void add_nativeToken(uint64_t nativeToken) { + fbb_.AddElement(SurfaceOp::VT_NATIVETOKEN, nativeToken, 0); + } + void add_width(int32_t width) { + fbb_.AddElement(SurfaceOp::VT_WIDTH, width, 0); + } + void add_height(int32_t height) { + fbb_.AddElement(SurfaceOp::VT_HEIGHT, height, 0); + } + void add_swapInterval(int32_t swapInterval) { + fbb_.AddElement(SurfaceOp::VT_SWAPINTERVAL, swapInterval, 0); + } + explicit SurfaceOpBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateSurfaceOp( + ::flatbuffers::FlatBufferBuilder &_fbb, + uint64_t seq = 0, + MobileGL::Wire::SurfaceOpKind kind = MobileGL::Wire::SurfaceOpKind::None, + uint64_t display = 0, + uint64_t surface = 0, + MobileGL::Wire::WindowKind windowKind = MobileGL::Wire::WindowKind::None, + uint64_t nativeToken = 0, + int32_t width = 0, + int32_t height = 0, + int32_t swapInterval = 0) { + SurfaceOpBuilder builder_(_fbb); + builder_.add_nativeToken(nativeToken); + builder_.add_surface(surface); + builder_.add_display(display); + builder_.add_seq(seq); + builder_.add_swapInterval(swapInterval); + builder_.add_height(height); + builder_.add_width(width); + builder_.add_windowKind(windowKind); + builder_.add_kind(kind); + return builder_.Finish(); +} + +struct SurfaceOp::Traits { + using type = SurfaceOp; + static auto constexpr Create = CreateSurfaceOp; +}; + +struct SurfaceReply FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef SurfaceReplyBuilder Builder; + struct Traits; + enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { + VT_SEQ = 4, + VT_OK = 6, + VT_EGLMAJOR = 8, + VT_EGLMINOR = 10, + VT_DEFAULTFB = 12 + }; + uint64_t seq() const { + return GetField(VT_SEQ, 0); + } + bool ok() const { + return GetField(VT_OK, 0) != 0; + } + int32_t eglMajor() const { + return GetField(VT_EGLMAJOR, 0); + } + int32_t eglMinor() const { + return GetField(VT_EGLMINOR, 0); + } + const MobileGL::Wire::DefaultFramebufferInfo *defaultFb() const { + return GetPointer(VT_DEFAULTFB); + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + VerifyField(verifier, VT_SEQ, 8) && + VerifyField(verifier, VT_OK, 1) && + VerifyField(verifier, VT_EGLMAJOR, 4) && + VerifyField(verifier, VT_EGLMINOR, 4) && + VerifyOffset(verifier, VT_DEFAULTFB) && + verifier.VerifyTable(defaultFb()) && + verifier.EndTable(); + } +}; + +struct SurfaceReplyBuilder { + typedef SurfaceReply Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + void add_seq(uint64_t seq) { + fbb_.AddElement(SurfaceReply::VT_SEQ, seq, 0); + } + void add_ok(bool ok) { + fbb_.AddElement(SurfaceReply::VT_OK, static_cast(ok), 0); + } + void add_eglMajor(int32_t eglMajor) { + fbb_.AddElement(SurfaceReply::VT_EGLMAJOR, eglMajor, 0); + } + void add_eglMinor(int32_t eglMinor) { + fbb_.AddElement(SurfaceReply::VT_EGLMINOR, eglMinor, 0); + } + void add_defaultFb(::flatbuffers::Offset defaultFb) { + fbb_.AddOffset(SurfaceReply::VT_DEFAULTFB, defaultFb); + } + explicit SurfaceReplyBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateSurfaceReply( + ::flatbuffers::FlatBufferBuilder &_fbb, + uint64_t seq = 0, + bool ok = false, + int32_t eglMajor = 0, + int32_t eglMinor = 0, + ::flatbuffers::Offset defaultFb = 0) { + SurfaceReplyBuilder builder_(_fbb); + builder_.add_seq(seq); + builder_.add_defaultFb(defaultFb); + builder_.add_eglMinor(eglMinor); + builder_.add_eglMajor(eglMajor); + builder_.add_ok(ok); + return builder_.Finish(); +} + +struct SurfaceReply::Traits { + using type = SurfaceReply; + static auto constexpr Create = CreateSurfaceReply; +}; + +struct ResyncRequest FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef ResyncRequestBuilder Builder; + struct Traits; + enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { + VT_SERVEREPOCH = 4 + }; + uint32_t serverEpoch() const { + return GetField(VT_SERVEREPOCH, 0); + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + VerifyField(verifier, VT_SERVEREPOCH, 4) && + verifier.EndTable(); + } +}; + +struct ResyncRequestBuilder { + typedef ResyncRequest Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + void add_serverEpoch(uint32_t serverEpoch) { + fbb_.AddElement(ResyncRequest::VT_SERVEREPOCH, serverEpoch, 0); + } + explicit ResyncRequestBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateResyncRequest( + ::flatbuffers::FlatBufferBuilder &_fbb, + uint32_t serverEpoch = 0) { + ResyncRequestBuilder builder_(_fbb); + builder_.add_serverEpoch(serverEpoch); + return builder_.Finish(); +} + +struct ResyncRequest::Traits { + using type = ResyncRequest; + static auto constexpr Create = CreateResyncRequest; +}; + +struct ResyncDone FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef ResyncDoneBuilder Builder; + struct Traits; + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + verifier.EndTable(); + } +}; + +struct ResyncDoneBuilder { + typedef ResyncDone Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + explicit ResyncDoneBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateResyncDone( + ::flatbuffers::FlatBufferBuilder &_fbb) { + ResyncDoneBuilder builder_(_fbb); + return builder_.Finish(); +} + +struct ResyncDone::Traits { + using type = ResyncDone; + static auto constexpr Create = CreateResyncDone; +}; + +struct AuxRequest FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef AuxRequestBuilder Builder; + struct Traits; + enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { + VT_SEQ = 4, + VT_KIND = 6, + VT_PAYLOAD = 8 + }; + uint64_t seq() const { + return GetField(VT_SEQ, 0); + } + MobileGL::Wire::AuxRequestKind kind() const { + return static_cast(GetField(VT_KIND, 0)); + } + const ::flatbuffers::Vector *payload() const { + return GetPointer *>(VT_PAYLOAD); + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + VerifyField(verifier, VT_SEQ, 8) && + VerifyField(verifier, VT_KIND, 1) && + VerifyOffset(verifier, VT_PAYLOAD) && + verifier.VerifyVector(payload()) && + verifier.EndTable(); + } +}; + +struct AuxRequestBuilder { + typedef AuxRequest Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + void add_seq(uint64_t seq) { + fbb_.AddElement(AuxRequest::VT_SEQ, seq, 0); + } + void add_kind(MobileGL::Wire::AuxRequestKind kind) { + fbb_.AddElement(AuxRequest::VT_KIND, static_cast(kind), 0); + } + void add_payload(::flatbuffers::Offset<::flatbuffers::Vector> payload) { + fbb_.AddOffset(AuxRequest::VT_PAYLOAD, payload); + } + explicit AuxRequestBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateAuxRequest( + ::flatbuffers::FlatBufferBuilder &_fbb, + uint64_t seq = 0, + MobileGL::Wire::AuxRequestKind kind = MobileGL::Wire::AuxRequestKind::None, + ::flatbuffers::Offset<::flatbuffers::Vector> payload = 0) { + AuxRequestBuilder builder_(_fbb); + builder_.add_seq(seq); + builder_.add_payload(payload); + builder_.add_kind(kind); + return builder_.Finish(); +} + +struct AuxRequest::Traits { + using type = AuxRequest; + static auto constexpr Create = CreateAuxRequest; +}; + +inline ::flatbuffers::Offset CreateAuxRequestDirect( + ::flatbuffers::FlatBufferBuilder &_fbb, + uint64_t seq = 0, + MobileGL::Wire::AuxRequestKind kind = MobileGL::Wire::AuxRequestKind::None, + const std::vector *payload = nullptr) { + auto payload__ = payload ? _fbb.CreateVector(*payload) : 0; + return MobileGL::Wire::CreateAuxRequest( + _fbb, + seq, + kind, + payload__); +} + +struct Fatal FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef FatalBuilder Builder; + struct Traits; + enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { + VT_CODE = 4, + VT_MESSAGE = 6 + }; + MobileGL::Wire::FatalCode code() const { + return static_cast(GetField(VT_CODE, 0)); + } + const ::flatbuffers::String *message() const { + return GetPointer(VT_MESSAGE); + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + VerifyField(verifier, VT_CODE, 4) && + VerifyOffset(verifier, VT_MESSAGE) && + verifier.VerifyString(message()) && + verifier.EndTable(); + } +}; + +struct FatalBuilder { + typedef Fatal Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + void add_code(MobileGL::Wire::FatalCode code) { + fbb_.AddElement(Fatal::VT_CODE, static_cast(code), 0); + } + void add_message(::flatbuffers::Offset<::flatbuffers::String> message) { + fbb_.AddOffset(Fatal::VT_MESSAGE, message); + } + explicit FatalBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateFatal( + ::flatbuffers::FlatBufferBuilder &_fbb, + MobileGL::Wire::FatalCode code = MobileGL::Wire::FatalCode::None, + ::flatbuffers::Offset<::flatbuffers::String> message = 0) { + FatalBuilder builder_(_fbb); + builder_.add_message(message); + builder_.add_code(code); + return builder_.Finish(); +} + +struct Fatal::Traits { + using type = Fatal; + static auto constexpr Create = CreateFatal; +}; + +inline ::flatbuffers::Offset CreateFatalDirect( + ::flatbuffers::FlatBufferBuilder &_fbb, + MobileGL::Wire::FatalCode code = MobileGL::Wire::FatalCode::None, + const char *message = nullptr) { + auto message__ = message ? _fbb.CreateString(message) : 0; + return MobileGL::Wire::CreateFatal( + _fbb, + code, + message__); +} + +struct LogLine FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef LogLineBuilder Builder; + struct Traits; + enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { + VT_LEVEL = 4, + VT_TEXT = 6 + }; + MobileGL::Wire::LogLevel level() const { + return static_cast(GetField(VT_LEVEL, 0)); + } + const ::flatbuffers::String *text() const { + return GetPointer(VT_TEXT); + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + VerifyField(verifier, VT_LEVEL, 1) && + VerifyOffset(verifier, VT_TEXT) && + verifier.VerifyString(text()) && + verifier.EndTable(); + } +}; + +struct LogLineBuilder { + typedef LogLine Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + void add_level(MobileGL::Wire::LogLevel level) { + fbb_.AddElement(LogLine::VT_LEVEL, static_cast(level), 0); + } + void add_text(::flatbuffers::Offset<::flatbuffers::String> text) { + fbb_.AddOffset(LogLine::VT_TEXT, text); + } + explicit LogLineBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateLogLine( + ::flatbuffers::FlatBufferBuilder &_fbb, + MobileGL::Wire::LogLevel level = MobileGL::Wire::LogLevel::Debug, + ::flatbuffers::Offset<::flatbuffers::String> text = 0) { + LogLineBuilder builder_(_fbb); + builder_.add_text(text); + builder_.add_level(level); + return builder_.Finish(); +} + +struct LogLine::Traits { + using type = LogLine; + static auto constexpr Create = CreateLogLine; +}; + +inline ::flatbuffers::Offset CreateLogLineDirect( + ::flatbuffers::FlatBufferBuilder &_fbb, + MobileGL::Wire::LogLevel level = MobileGL::Wire::LogLevel::Debug, + const char *text = nullptr) { + auto text__ = text ? _fbb.CreateString(text) : 0; + return MobileGL::Wire::CreateLogLine( + _fbb, + level, + text__); +} + +struct CtrlEnvelope FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef CtrlEnvelopeBuilder Builder; + struct Traits; + enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { + VT_MSG_TYPE = 4, + VT_MSG = 6 + }; + MobileGL::Wire::CtrlMsg msg_type() const { + return static_cast(GetField(VT_MSG_TYPE, 0)); + } + const void *msg() const { + return GetPointer(VT_MSG); + } + template const T *msg_as() const; + const MobileGL::Wire::Hello *msg_as_Hello() const { + return msg_type() == MobileGL::Wire::CtrlMsg::Hello ? static_cast(msg()) : nullptr; + } + const MobileGL::Wire::Welcome *msg_as_Welcome() const { + return msg_type() == MobileGL::Wire::CtrlMsg::Welcome ? static_cast(msg()) : nullptr; + } + const MobileGL::Wire::CapsSnapshot *msg_as_CapsSnapshot() const { + return msg_type() == MobileGL::Wire::CtrlMsg::CapsSnapshot ? static_cast(msg()) : nullptr; + } + const MobileGL::Wire::SurfaceOp *msg_as_SurfaceOp() const { + return msg_type() == MobileGL::Wire::CtrlMsg::SurfaceOp ? static_cast(msg()) : nullptr; + } + const MobileGL::Wire::SurfaceReply *msg_as_SurfaceReply() const { + return msg_type() == MobileGL::Wire::CtrlMsg::SurfaceReply ? static_cast(msg()) : nullptr; + } + const MobileGL::Wire::ResyncRequest *msg_as_ResyncRequest() const { + return msg_type() == MobileGL::Wire::CtrlMsg::ResyncRequest ? static_cast(msg()) : nullptr; + } + const MobileGL::Wire::ResyncDone *msg_as_ResyncDone() const { + return msg_type() == MobileGL::Wire::CtrlMsg::ResyncDone ? static_cast(msg()) : nullptr; + } + const MobileGL::Wire::AuxRequest *msg_as_AuxRequest() const { + return msg_type() == MobileGL::Wire::CtrlMsg::AuxRequest ? static_cast(msg()) : nullptr; + } + const MobileGL::Wire::Fatal *msg_as_Fatal() const { + return msg_type() == MobileGL::Wire::CtrlMsg::Fatal ? static_cast(msg()) : nullptr; + } + const MobileGL::Wire::LogLine *msg_as_LogLine() const { + return msg_type() == MobileGL::Wire::CtrlMsg::LogLine ? static_cast(msg()) : nullptr; + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + VerifyField(verifier, VT_MSG_TYPE, 1) && + VerifyOffset(verifier, VT_MSG) && + VerifyCtrlMsg(verifier, msg(), msg_type()) && + verifier.EndTable(); + } +}; + +template<> inline const MobileGL::Wire::Hello *CtrlEnvelope::msg_as() const { + return msg_as_Hello(); +} + +template<> inline const MobileGL::Wire::Welcome *CtrlEnvelope::msg_as() const { + return msg_as_Welcome(); +} + +template<> inline const MobileGL::Wire::CapsSnapshot *CtrlEnvelope::msg_as() const { + return msg_as_CapsSnapshot(); +} + +template<> inline const MobileGL::Wire::SurfaceOp *CtrlEnvelope::msg_as() const { + return msg_as_SurfaceOp(); +} + +template<> inline const MobileGL::Wire::SurfaceReply *CtrlEnvelope::msg_as() const { + return msg_as_SurfaceReply(); +} + +template<> inline const MobileGL::Wire::ResyncRequest *CtrlEnvelope::msg_as() const { + return msg_as_ResyncRequest(); +} + +template<> inline const MobileGL::Wire::ResyncDone *CtrlEnvelope::msg_as() const { + return msg_as_ResyncDone(); +} + +template<> inline const MobileGL::Wire::AuxRequest *CtrlEnvelope::msg_as() const { + return msg_as_AuxRequest(); +} + +template<> inline const MobileGL::Wire::Fatal *CtrlEnvelope::msg_as() const { + return msg_as_Fatal(); +} + +template<> inline const MobileGL::Wire::LogLine *CtrlEnvelope::msg_as() const { + return msg_as_LogLine(); +} + +struct CtrlEnvelopeBuilder { + typedef CtrlEnvelope Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + void add_msg_type(MobileGL::Wire::CtrlMsg msg_type) { + fbb_.AddElement(CtrlEnvelope::VT_MSG_TYPE, static_cast(msg_type), 0); + } + void add_msg(::flatbuffers::Offset msg) { + fbb_.AddOffset(CtrlEnvelope::VT_MSG, msg); + } + explicit CtrlEnvelopeBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateCtrlEnvelope( + ::flatbuffers::FlatBufferBuilder &_fbb, + MobileGL::Wire::CtrlMsg msg_type = MobileGL::Wire::CtrlMsg::NONE, + ::flatbuffers::Offset msg = 0) { + CtrlEnvelopeBuilder builder_(_fbb); + builder_.add_msg(msg); + builder_.add_msg_type(msg_type); + return builder_.Finish(); +} + +struct CtrlEnvelope::Traits { + using type = CtrlEnvelope; + static auto constexpr Create = CreateCtrlEnvelope; +}; + +template +inline bool VerifyCtrlMsg(::flatbuffers::VerifierTemplate &verifier, const void *obj, CtrlMsg type) { + switch (type) { + case CtrlMsg::NONE: { + return true; + } + case CtrlMsg::Hello: { + auto ptr = reinterpret_cast(obj); + return verifier.VerifyTable(ptr); + } + case CtrlMsg::Welcome: { + auto ptr = reinterpret_cast(obj); + return verifier.VerifyTable(ptr); + } + case CtrlMsg::CapsSnapshot: { + auto ptr = reinterpret_cast(obj); + return verifier.VerifyTable(ptr); + } + case CtrlMsg::SurfaceOp: { + auto ptr = reinterpret_cast(obj); + return verifier.VerifyTable(ptr); + } + case CtrlMsg::SurfaceReply: { + auto ptr = reinterpret_cast(obj); + return verifier.VerifyTable(ptr); + } + case CtrlMsg::ResyncRequest: { + auto ptr = reinterpret_cast(obj); + return verifier.VerifyTable(ptr); + } + case CtrlMsg::ResyncDone: { + auto ptr = reinterpret_cast(obj); + return verifier.VerifyTable(ptr); + } + case CtrlMsg::AuxRequest: { + auto ptr = reinterpret_cast(obj); + return verifier.VerifyTable(ptr); + } + case CtrlMsg::Fatal: { + auto ptr = reinterpret_cast(obj); + return verifier.VerifyTable(ptr); + } + case CtrlMsg::LogLine: { + auto ptr = reinterpret_cast(obj); + return verifier.VerifyTable(ptr); + } + default: return true; + } +} + +template +inline bool VerifyCtrlMsgVector(::flatbuffers::VerifierTemplate &verifier, const ::flatbuffers::Vector<::flatbuffers::Offset> *values, const ::flatbuffers::Vector *types) { + if (!values || !types) return !values && !types; + if (values->size() != types->size()) return false; + for (::flatbuffers::uoffset_t i = 0; i < values->size(); ++i) { + if (!VerifyCtrlMsg( + verifier, values->Get(i), types->GetEnum(i))) { + return false; + } + } + return true; +} + +inline const MobileGL::Wire::CtrlEnvelope *GetCtrlEnvelope(const void *buf) { + return ::flatbuffers::GetRoot(buf); +} + +inline const MobileGL::Wire::CtrlEnvelope *GetSizePrefixedCtrlEnvelope(const void *buf) { + return ::flatbuffers::GetSizePrefixedRoot(buf); +} + +inline const char *CtrlEnvelopeIdentifier() { + return "MGLC"; +} + +inline bool CtrlEnvelopeBufferHasIdentifier(const void *buf) { + return ::flatbuffers::BufferHasIdentifier( + buf, CtrlEnvelopeIdentifier()); +} + +inline bool SizePrefixedCtrlEnvelopeBufferHasIdentifier(const void *buf) { + return ::flatbuffers::BufferHasIdentifier( + buf, CtrlEnvelopeIdentifier(), true); +} + +template +inline bool VerifyCtrlEnvelopeBuffer( + ::flatbuffers::VerifierTemplate &verifier) { + return verifier.template VerifyBuffer(CtrlEnvelopeIdentifier()); +} + +template +inline bool VerifySizePrefixedCtrlEnvelopeBuffer( + ::flatbuffers::VerifierTemplate &verifier) { + return verifier.template VerifySizePrefixedBuffer(CtrlEnvelopeIdentifier()); +} + +inline void FinishCtrlEnvelopeBuffer( + ::flatbuffers::FlatBufferBuilder &fbb, + ::flatbuffers::Offset root) { + fbb.Finish(root, CtrlEnvelopeIdentifier()); +} + +inline void FinishSizePrefixedCtrlEnvelopeBuffer( + ::flatbuffers::FlatBufferBuilder &fbb, + ::flatbuffers::Offset root) { + fbb.FinishSizePrefixed(root, CtrlEnvelopeIdentifier()); +} + +} // namespace Wire +} // namespace MobileGL + +#endif // FLATBUFFERS_GENERATED_PROTOCOL_MOBILEGL_WIRE_H_ diff --git a/MobileGL/MG_Remote/Protocol/mg_protocol_base.h b/MobileGL/MG_Remote/Protocol/mg_protocol_base.h new file mode 100644 index 000000000..b3555c318 --- /dev/null +++ b/MobileGL/MG_Remote/Protocol/mg_protocol_base.h @@ -0,0 +1,120 @@ +// MobileGL - MobileGL/MG_Remote/Protocol/mg_protocol_base.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// Shared vocabulary of the MG_Remote wire contracts (transport, framing, ring, +// shm). Inherited from the earlier `Feat/CS-Delta-IPC` branch +// (MobileGL/Protocol/mg_protocol_base.h) and cut down to what plan B's +// transport actually needs: result codes, byte spans, a shm region reference +// and the id typedefs. +// +// Deliberately NOT inherited: MobileGLObjectKind / MobileGLObjectScope / +// MobileGLObjectHandle. Plan B does not put GL object identity on the wire at +// all - the frontend allocates {slot, generation} handles in MG_Pipe +// (PLAN-B.md section 4.2.1) and those are the only identity the backend ever +// sees, so a second object-identity vocabulary here would be a drift surface +// with no reader. +// +// This header must stay: +// - pure C (compilable from C and C++, no MG C++ types, no exceptions/RTTI), +// - dependency-free (only //), +// - append-only within an ABI major (see versioning rules below). +// +// Versioning rules (contract-wide): +// - Every versioned struct starts with uint32_t structSize. +// - Appending fields at the tail is a MINOR bump; receivers must ignore +// bytes beyond the structSize they know. +// - Changing/removing/reordering existing fields is a MAJOR bump. +// - A major mismatch is a hard, structured failure, never an exception. +// (Plan B keeps the structSize-first discipline as the answer to risk B-R10, +// PLAN-B.md section 14.2.) + +#ifndef MOBILEGL_REMOTE_PROTOCOL_BASE_H +#define MOBILEGL_REMOTE_PROTOCOL_BASE_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// --------------------------------------------------------------------------- +// ABI versions +// --------------------------------------------------------------------------- + +#define MOBILEGL_PROTOCOL_ABI_MAJOR 1 +#define MOBILEGL_PROTOCOL_ABI_MINOR 0 + +#define MOBILEGL_ABI_VERSION(major, minor) (((uint32_t)(major) << 16) | (uint32_t)(minor)) +#define MOBILEGL_ABI_MAJOR_OF(version) ((uint32_t)(version) >> 16) +#define MOBILEGL_ABI_MINOR_OF(version) ((uint32_t)(version) & 0xFFFFu) + +// --------------------------------------------------------------------------- +// Ids +// --------------------------------------------------------------------------- + +typedef uint64_t MobileGLSessionId; // one client GL context flow +typedef uint64_t MobileGLRequestSeq; // matches a request to its reply +typedef uint32_t MobileGLSegmentId; // shm segment id within a connection + +// --------------------------------------------------------------------------- +// Spans / regions +// --------------------------------------------------------------------------- + +// Borrowed, read-only byte span. The pointee is owned by the producing side +// and is only valid for the duration documented at the consuming call site. +typedef struct MobileGLByteSpan { + const void* data; + uint64_t size; +} MobileGLByteSpan; + +typedef struct MobileGLMutableByteSpan { + void* data; + uint64_t size; +} MobileGLMutableByteSpan; + +// A byte range inside an already-established shm segment. Segments are +// announced out of band (the SegmentRef table on the control channel, with the +// fd itself passed by SCM_RIGHTS) and stay stable for their declared lifetime; +// offsets are segment-relative. +typedef struct MobileGLShmRegion { + MobileGLSegmentId segmentId; + uint32_t reserved; + uint64_t offset; + uint64_t size; +} MobileGLShmRegion; + +// --------------------------------------------------------------------------- +// Result codes (structured errors across every contract boundary) +// --------------------------------------------------------------------------- + +typedef enum MobileGLResult { + MOBILEGL_OK = 0, + MOBILEGL_ERR_NOT_INITIALIZED = 1, + MOBILEGL_ERR_INVALID_ARGUMENT = 2, + MOBILEGL_ERR_UNSUPPORTED = 3, + MOBILEGL_ERR_OUT_OF_MEMORY = 4, + MOBILEGL_ERR_PROTOCOL_MISMATCH = 5, // ABI/wire major mismatch, bad framing + MOBILEGL_ERR_TRANSPORT_CLOSED = 6, // peer gone / EOF + MOBILEGL_ERR_TIMEOUT = 7, // nothing arrived within the deadline + MOBILEGL_ERR_SHM_EXHAUSTED = 8, + MOBILEGL_ERR_SESSION_UNKNOWN = 9, + MOBILEGL_ERR_HANDLE_UNKNOWN = 10, + // The caller's buffer is smaller than the pending message. The message is + // NOT consumed and the required size is reported back; see + // ITransport::ReceiveFrame. + MOBILEGL_ERR_BUFFER_TOO_SMALL = 11, + MOBILEGL_ERR_FORCE_U32 = 0x7FFFFFFF +} MobileGLResult; + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // MOBILEGL_REMOTE_PROTOCOL_BASE_H diff --git a/MobileGL/MG_Remote/Protocol/protocol.fbs b/MobileGL/MG_Remote/Protocol/protocol.fbs new file mode 100644 index 000000000..968f235bc --- /dev/null +++ b/MobileGL/MG_Remote/Protocol/protocol.fbs @@ -0,0 +1,235 @@ +// MobileGL - MobileGL/MG_Remote/Protocol/protocol.fbs +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// MobileGL disaggregated wire protocol - CONTROL PLANE ONLY. +// +// Plan B (docs plan "MGPipe") section 8.1 inherits the transport design of the +// earlier plan verbatim, and its section 7.1 splits the schema in two: +// +// - rare / variable-length / must-evolve messages -> FlatBuffers *tables*, +// carried as complete framed messages over the control channel. That is +// everything in this file. +// - the hot path -> FlatBuffers *structs* (fixed layout, no vtable, no +// offset indirection) written straight into the SEG_CMD ring. Those +// records are generated from MG_Pipe/PipeCalls.def and are deliberately +// NOT in this schema yet: the call catalogue is a separate P0 deliverable +// and record numbering must never churn. +// +// Regeneration: scripts/gen_protocol.py (flatc is NOT part of the default +// build graph). generated/protocol_generated.h is committed and CI's +// flatc-check regenerates it and runs `git diff --exit-code`. + +namespace MobileGL.Wire; + +// --------------------------------------------------------------------------- +// Segments +// --------------------------------------------------------------------------- + +// Segment layout is inherited unchanged (earlier plan section 6.1): +// SEG_CMD 8MiB / SEG_STAGE 32MiB+ / SEG_REPLY 8MiB / SEG_EVENT 256KiB / +// SEG_SHADOW[n] / SEG_ADOPT[n]. +enum SegmentKind : ubyte { + None = 0, + Cmd = 1, // client-owned command ring (RingControl + records) + Stage = 2, // client-owned bulk staging + Reply = 3, // server-owned reply pool + Event = 4, // server-owned event ring + Shadow = 5, // client-owned per-object shadow (P4.5+) + Adopt = 6, // server-owned adopted store, client RW (>= 16MiB) +} + +// The fd itself never travels in a message: POSIX passes it with SCM_RIGHTS on +// the aux socket (ITransport::ShareFd), Windows resolves `name`. +table SegmentRef { + id: uint; + kind: SegmentKind; + sizeBytes: ulong; + name: string; +} + +// --------------------------------------------------------------------------- +// Handshake +// --------------------------------------------------------------------------- + +table Hello { + abiMajor: uint; + abiMinor: uint; + buildFingerprint: string; + backendType: uint; + pid: uint; + configBlob: [ubyte]; +} + +table Welcome { + abiMajor: uint; + abiMinor: uint; + serverPid: uint; + cmdRing: SegmentRef; + stageRing: SegmentRef; + replyPool: SegmentRef; + eventRing: SegmentRef; +} + +// --------------------------------------------------------------------------- +// Capabilities +// --------------------------------------------------------------------------- + +// Replaces the 40 `pActiveBackendObject->` reads plus the 89 caps read sites +// (plan B appendix A, `get_caps`). The three blobs are byte-for-byte images of +// the corresponding POD structs; they are versioned by structSize-first +// discipline, not by this schema. +table CapsSnapshot { + dynamicParameters: [ubyte]; + rendererInfo: [ubyte]; + formatCaps: [ubyte]; + extensions: [string]; + apiVersion: string; + maxComputeWorkGroupCount: [int]; // 3 entries + maxComputeWorkGroupSize: [int]; // 3 entries + tableSlotMask: ulong; // which GLFunctionsTable slots the peer registered + prefersCpuXfbPrimitiveAccounting: bool; +} + +table DefaultFramebufferInfo { + width: int; + height: int; + colorFormat: uint; + depthFormat: uint; + stencilFormat: uint; +} + +// --------------------------------------------------------------------------- +// Surface / EGL lifecycle +// --------------------------------------------------------------------------- + +enum SurfaceOpKind : ubyte { + None = 0, + InitializeDisplay = 1, + CreateWindowSurface = 2, + CreatePbufferSurface = 3, + ResizeWindowSurface = 4, + ReleaseSurface = 5, + MakeCurrent = 6, + ReleaseCurrent = 7, +} + +enum WindowKind : ubyte { + None = 0, + AndroidNativeWindow = 1, + X11 = 2, + Win32Hwnd = 3, + Surfaceless = 4, + Pbuffer = 5, +} + +table SurfaceOp { + seq: ulong; + kind: SurfaceOpKind; + display: ulong; + surface: ulong; + windowKind: WindowKind; + nativeToken: ulong; // X11 XID / HWND; Android transfers the window out of band + width: int; + height: int; + swapInterval: int; +} + +table SurfaceReply { + seq: ulong; + ok: bool; + eglMajor: int; + eglMinor: int; + defaultFb: DefaultFramebufferInfo; +} + +// --------------------------------------------------------------------------- +// Resync / aux / diagnostics +// --------------------------------------------------------------------------- + +// Sent by the client after it observes a serverEpoch bump (context lost or +// server restart): every cached ring offset and every server-side object is +// gone and the whole pushed state has to be replayed. +table ResyncRequest { + serverEpoch: uint; +} + +table ResyncDone {} + +enum AuxRequestKind : ubyte { + None = 0, + FenceClientWait = 1, + QueryResult = 2, + ScalarGet = 3, +} + +// Requests issued from a thread that is not the ring producer (foreign-thread +// sync / query polling), so they cannot take the SPSC ring. +table AuxRequest { + seq: ulong; + kind: AuxRequestKind; + payload: [ubyte]; +} + +enum FatalCode : uint { + None = 0, + ProtocolCorruption = 1, // record bounds / self-describing length violated + RingOverrun = 2, + SegmentMismatch = 3, + DeviceLost = 4, + ServerCrashed = 5, + AbiMismatch = 6, +} + +table Fatal { + code: FatalCode; + message: string; +} + +// Severity-graded per plan B section 8.2: <= Warn is lossy, >= Error is +// lossless and rate limited. +enum LogLevel : ubyte { + Debug = 0, + Info = 1, + Warn = 2, + Error = 3, + Fatal = 4, +} + +table LogLine { + level: LogLevel; + text: string; +} + +// --------------------------------------------------------------------------- +// Envelope +// --------------------------------------------------------------------------- + +// Union tags are wire values: only ever APPEND to this list. +// ProgramReflection from the earlier plan's section 7.1 is intentionally +// absent - plan B ships program artifacts inside the create_shader_state CSO +// blob, so if a control-plane reflection message is ever needed it appends +// here rather than reserving a tag today. +union CtrlMsg { + Hello, + Welcome, + CapsSnapshot, + SurfaceOp, + SurfaceReply, + ResyncRequest, + ResyncDone, + AuxRequest, + Fatal, + LogLine, +} + +table CtrlEnvelope { + msg: CtrlMsg; +} + +root_type CtrlEnvelope; +file_identifier "MGLC"; diff --git a/scripts/gen_protocol.py b/scripts/gen_protocol.py new file mode 100644 index 000000000..6420b43a5 --- /dev/null +++ b/scripts/gen_protocol.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Regenerate MobileGL/MG_Remote/Protocol/generated/protocol_generated.h from protocol.fbs. + +flatc is a developer/CI tool ONLY: it is never part of the default build graph +(the earlier branch's Protocol/CMakeLists.txt:22-38 did add_subdirectory the +FlatBuffers tree and turned FLATBUFFERS_BUILD_FLATC ON when +MOBILEGL_FLATC_EXECUTABLE was unset, which is exactly the NDK trap it claimed +to avoid: cross-compiling an arm64 flatc and then trying to run it on the +host). The FlatBuffers runtime is header-only, so a build only needs +3rdparty/flatbuffers/include on the include path. + +flatc resolution order: + 1. --flatc / MOBILEGL_FLATC_EXECUTABLE + 2. a flatc built once from the pinned 3rdparty/flatbuffers submodule into a + directory OUTSIDE the repository (default: /../flatc-build, + override with MOBILEGL_FLATC_BUILD_DIR) + +A flatc found on PATH is deliberately NOT used: the generated header carries a +FLATBUFFERS_VERSION static_assert against the runtime headers, so a stray flatc +of another version produces a header that either fails to compile or churns the +committed file on every machine. +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCHEMA = REPO_ROOT / "MobileGL" / "MG_Remote" / "Protocol" / "protocol.fbs" +OUT_DIR = REPO_ROOT / "MobileGL" / "MG_Remote" / "Protocol" / "generated" +OUT_FILE = OUT_DIR / "protocol_generated.h" +SUBMODULE = REPO_ROOT / "3rdparty" / "flatbuffers" + +LICENSE_HEADER = """\ +// MobileGL - MobileGL/MG_Remote/Protocol/generated/protocol_generated.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// GENERATED FILE - DO NOT EDIT. +// Regenerate with `python3 scripts/gen_protocol.py` after changing +// MobileGL/MG_Remote/Protocol/protocol.fbs. CI's flatc-check step regenerates +// this file and fails on `git diff --exit-code`. +""" + + +def submodule_version() -> str | None: + base = SUBMODULE / "include" / "flatbuffers" / "base.h" + if not base.is_file(): + return None + text = base.read_text(encoding="utf-8", errors="replace") + parts = [] + for macro in ("FLATBUFFERS_VERSION_MAJOR", "FLATBUFFERS_VERSION_MINOR", + "FLATBUFFERS_VERSION_REVISION"): + match = re.search(r"#\s*define\s+" + macro + r"\s+(\d+)", text) + if not match: + return None + parts.append(match.group(1)) + return ".".join(parts) + + +def flatc_version(flatc: Path) -> str | None: + try: + out = subprocess.run([str(flatc), "--version"], check=True, + capture_output=True, text=True).stdout + except (OSError, subprocess.CalledProcessError): + return None + match = re.search(r"(\d+\.\d+\.\d+)", out) + return match.group(1) if match else None + + +def build_flatc(build_dir: Path, jobs: int) -> Path: + if not (SUBMODULE / "CMakeLists.txt").is_file(): + sys.exit(f"error: {SUBMODULE} is empty - run " + f"`git submodule update --init 3rdparty/flatbuffers`") + exe_name = "flatc.exe" if os.name == "nt" else "flatc" + for candidate in (build_dir / exe_name, build_dir / "Release" / exe_name): + if candidate.is_file(): + return candidate + + build_dir.mkdir(parents=True, exist_ok=True) + cmake = shutil.which("cmake") + if cmake is None: + sys.exit("error: cmake not found; needed to build flatc from the submodule") + configure = [ + cmake, "-S", str(SUBMODULE), "-B", str(build_dir), + "-DCMAKE_BUILD_TYPE=Release", + "-DFLATBUFFERS_BUILD_FLATC=ON", + "-DFLATBUFFERS_BUILD_FLATLIB=OFF", + "-DFLATBUFFERS_BUILD_FLATHASH=OFF", + "-DFLATBUFFERS_BUILD_TESTS=OFF", + "-DFLATBUFFERS_INSTALL=OFF", + ] + if shutil.which("ninja"): + configure += ["-G", "Ninja"] + print("[gen_protocol] configuring flatc:", " ".join(configure), flush=True) + subprocess.run(configure, check=True) + print("[gen_protocol] building flatc", flush=True) + subprocess.run([cmake, "--build", str(build_dir), "--target", "flatc", + "--config", "Release", "--parallel", str(jobs)], check=True) + + for candidate in (build_dir / exe_name, build_dir / "Release" / exe_name): + if candidate.is_file(): + return candidate + sys.exit(f"error: flatc was not produced under {build_dir}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--flatc", default=os.environ.get("MOBILEGL_FLATC_EXECUTABLE", ""), + help="path to a flatc binary (default: $MOBILEGL_FLATC_EXECUTABLE)") + parser.add_argument("--build-dir", + default=os.environ.get("MOBILEGL_FLATC_BUILD_DIR", ""), + help="where to build flatc from the submodule " + "(default: /../flatc-build, kept out of the repo)") + parser.add_argument("--jobs", type=int, default=os.cpu_count() or 4) + parser.add_argument("--check", action="store_true", + help="fail if the committed header is not what flatc produces") + parser.add_argument("--allow-version-mismatch", action="store_true", + help="proceed when flatc's version differs from the submodule's") + args = parser.parse_args() + + if not SCHEMA.is_file(): + sys.exit(f"error: schema not found: {SCHEMA}") + + if args.flatc: + flatc = Path(args.flatc) + if not flatc.is_file(): + sys.exit(f"error: --flatc/{'MOBILEGL_FLATC_EXECUTABLE'} points at a " + f"missing file: {flatc}") + else: + build_dir = Path(args.build_dir) if args.build_dir else REPO_ROOT.parent / "flatc-build" + flatc = build_flatc(build_dir.resolve(), args.jobs) + + have, want = flatc_version(flatc), submodule_version() + print(f"[gen_protocol] flatc={flatc} version={have} submodule={want}", flush=True) + if have and want and have != want and not args.allow_version_mismatch: + sys.exit(f"error: flatc {have} does not match the pinned FlatBuffers runtime " + f"{want}; the generated header's version static_assert would fail. " + f"Unset MOBILEGL_FLATC_EXECUTABLE to build the pinned flatc, or pass " + f"--allow-version-mismatch.") + + OUT_DIR.mkdir(parents=True, exist_ok=True) + previous = OUT_FILE.read_bytes() if OUT_FILE.is_file() else None + + cmd = [str(flatc), "--cpp", "--cpp-std", "c++17", "-o", str(OUT_DIR), str(SCHEMA)] + print("[gen_protocol]", " ".join(cmd), flush=True) + subprocess.run(cmd, check=True, cwd=str(REPO_ROOT)) + + if not OUT_FILE.is_file(): + sys.exit(f"error: flatc did not produce {OUT_FILE}") + + body = OUT_FILE.read_text(encoding="utf-8") + OUT_FILE.write_text(LICENSE_HEADER + "\n" + body, encoding="utf-8", newline="\n") + + if args.check and previous is not None and previous != OUT_FILE.read_bytes(): + sys.exit("error: committed protocol_generated.h is stale; rerun " + "scripts/gen_protocol.py and commit the result") + + print(f"[gen_protocol] wrote {OUT_FILE.relative_to(REPO_ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From bfa087d0f7873ea3caf96b337c22bb8aacdca021 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 14:05:47 -0400 Subject: [PATCH 015/529] [Feat] (MG_Remote, Transport): land the transport skeleton behind MOBILEGL_BUILD_DISAGGREGATED - framing, the SPSC ring, doorbells, shm segments and SCM_RIGHTS - New CMake option MOBILEGL_BUILD_DISAGGREGATED (default OFF, plan B appendix B). ON appends the MG_Remote sources to SOURCE_FILES, puts 3rdparty/flatbuffers/include on the include path and defines MOBILEGL_BUILD_DISAGGREGATED=1. OFF compiles nothing from MG_Remote and adds no include path and no library, which is one of the two byte-level equalities plan B section 10.3 keeps: measured `nm --defined-only build-linux/libMobileGL.so | grep -ic MG_Remote` = 0 with the option OFF and 93 with it ON, with an identical ldd set in both configurations. - The option forces itself OFF with message(WARNING) when 3rdparty/flatbuffers/include/flatbuffers/flatbuffers.h is missing, so a checkout without the submodule still configures and builds rather than failing a hundred lines later on a missing header. - ITransport.h: SendFrame / ReceiveFrame / PeekFrameSize / ShareFd / ReceiveFd / Shutdown. ReceiveFrame's contract is the fix for a defect of the earlier branch: a destination buffer smaller than the pending message returns MOBILEGL_ERR_BUFFER_TOO_SMALL with the required size and KEEPS the message queued. The earlier transport failed the call and popped the message anyway, which wedges the stream permanently the first time a reader guesses a size wrong. - Framing.h: [u32 'MGLF'][u32 len][payload], 64 MiB cap, validated on read. A bad magic or an oversized length latches the reader dead, is logged at ERROR and makes every later call return MOBILEGL_ERR_PROTOCOL_MISMATCH. This is the second inherited defect: Feat/CS-Delta-IPC's Framing.h:41-45 Feed() always returned OK and its header peek merely returned false, so a desynchronized stream became a silent permanent hang; its LocalSocketTransport.cpp:232-236 then allocated on the peer-supplied length with no cap. The magic is also byte-ordered so it reads "MGLF" on the wire, where the earlier constant spelled "FLGM". - Ring.h/.cpp: RingControl exactly as the inherited design (plan B section 8.1 -> earlier section 6.2): two independent cursor triples (cmd and stage, each {head, appliedTail, retiredTail}), appliedSeq / submittedSeq / retiredSeq / completedFrameSerial / presentAckSerial, serverEpoch, ringGeneration, consumerParked, producerParked, eventRingFull, eventDropped. One 4096-byte page with each contended group on its own cache line, pinned by static_assert on size and alignment and by an offset test. - The SPSC pair uses monotonic byte cursors and a power-of-two mask, so a torn read can never look like a valid earlier position. A record never straddles the wrap: the producer emits a kPad filler to the boundary, which is always a multiple of 8 and therefore always has room for a header. The producer reclaims against retiredTail rather than appliedTail, so the day the server borrows a ring slot into the GPU timeline (kRecBorrowSlot) it does not silently degrade to early recycling. HardDrainRing bumps ringGeneration only when the ring is quiesced, and leaves the cursors monotonic so cached offsets are recognisably stale. - The consumer bounds-checks every record header before dispatch (8-aligned, at least a header, no larger than what the producer published, contiguous inside the mapping) and reports corruption to the caller instead of dispatching into undefined behaviour. That is the runtime half of the earlier section 6.3 discipline: SEG_CMD is written by another process, so a compile-time static_assert on record sizes proves nothing about what is in the mapping. - Doorbell.h/.cpp: spin then park, both directions (earlier section 6.2a). CondVarDoorbell for inproc, SocketDoorbell for spawn (one byte, codes 0x01 ring-advanced and 0x02 watermark-advanced) - no futex, eventfd or named event anywhere. The lost-wakeup window is closed by ordering: the waiter stores its park flag seq_cst and then re-tests the condition, NotifyIfParked loads the same flag seq_cst after the watermark is published, so one of the two always sees the other. kDefaultSpinUs is 50, the MOBILEGL_IPC_SPIN_US default. - ShmSegment.{h,cpp} + ShmSegmentPosix.cpp: memfd_create by raw syscall on desktop Linux (the glibc wrapper is too recent to rely on), ASharedMemory_create on Android (API 26; libc's memfd_create wrapper is API 30, above MobileGL's floor), shm_open + immediate shm_unlink as the fallback. Adopt() refuses a descriptor whose fstat size is smaller than the size the peer announced, so a short segment cannot turn every later offset into an out-of-bounds map. ShmSegmentWin32.cpp (CreateFileMappingW in Local\) is compile-guarded and untested - this project's Windows machine is not a correctness gate. - The Android path is compile-verified, not just written: an arm64-v8a NDK build with the option ON links, ShmSegmentPosix.cpp.o carries an undefined ASharedMemory_create, and ShmSegmentWin32.cpp.o is empty there. That build is also what caught the missing in ShmSegment.h and Framing.h, where std::size_t / std::ptrdiff_t only resolved through a transitive include on the host sysroot. - FdPassing.{h,cpp}: SCM_RIGHTS in the FIRST transport commit, as plan B section 8.1 demands, over a dedicated AF_UNIX SOCK_DGRAM socketpair rather than the control byte stream (message boundaries survive on every POSIX - SOCK_SEQPACKET does not exist on macOS - and ancillary data can never be split from its payload). The third inherited defect this replaces: Feat/CS-Delta-IPC deferred fd passing and hardcoded `out->fd = -1` in LocalSocketTransport.cpp:296, so on the only platform that matters its data plane could not move one byte between processes. MSG_CTRUNC, an unexpected descriptor count and a malformed sideband header all close every descriptor received before failing, and a too-small sideband buffer is refused before the recvmsg so a datagram is never half-consumed. - InProcessTransport.{h,cpp}: two in-memory queues plus the condvar doorbell pair. It keeps the frame size cap so nothing that passes in inproc becomes illegal after the switch to spawn, hands descriptors over with dup() under the same ownership rule as SCM_RIGHTS, and lets a peer's queued messages be drained after Shutdown - usually the last one says why it is going away. - Not done here on purpose: the MOBILEGL_IPC_* environment variables are parsed in ConfigLoader.cpp, which belongs to another P0 work package running in parallel; this commit only exposes the constants (kDefaultSpinUs, kMaxFramePayloadSize, FdPassing::kMaxSidebandBytes) so that plumbing has something to set. --- CMakeLists.txt | 53 ++++ MobileGL/MG_Remote/Transport/Doorbell.cpp | 168 ++++++++++ MobileGL/MG_Remote/Transport/Doorbell.h | 189 +++++++++++ MobileGL/MG_Remote/Transport/FdPassing.cpp | 296 ++++++++++++++++++ MobileGL/MG_Remote/Transport/FdPassing.h | 67 ++++ MobileGL/MG_Remote/Transport/Framing.h | 206 ++++++++++++ MobileGL/MG_Remote/Transport/ITransport.h | 124 ++++++++ .../Transport/InProcessTransport.cpp | 275 ++++++++++++++++ .../MG_Remote/Transport/InProcessTransport.h | 71 +++++ MobileGL/MG_Remote/Transport/Ring.cpp | 286 +++++++++++++++++ MobileGL/MG_Remote/Transport/Ring.h | 225 +++++++++++++ MobileGL/MG_Remote/Transport/ShmSegment.cpp | 49 +++ MobileGL/MG_Remote/Transport/ShmSegment.h | 87 +++++ .../MG_Remote/Transport/ShmSegmentPosix.cpp | 191 +++++++++++ .../MG_Remote/Transport/ShmSegmentWin32.cpp | 162 ++++++++++ 15 files changed, 2449 insertions(+) create mode 100644 MobileGL/MG_Remote/Transport/Doorbell.cpp create mode 100644 MobileGL/MG_Remote/Transport/Doorbell.h create mode 100644 MobileGL/MG_Remote/Transport/FdPassing.cpp create mode 100644 MobileGL/MG_Remote/Transport/FdPassing.h create mode 100644 MobileGL/MG_Remote/Transport/Framing.h create mode 100644 MobileGL/MG_Remote/Transport/ITransport.h create mode 100644 MobileGL/MG_Remote/Transport/InProcessTransport.cpp create mode 100644 MobileGL/MG_Remote/Transport/InProcessTransport.h create mode 100644 MobileGL/MG_Remote/Transport/Ring.cpp create mode 100644 MobileGL/MG_Remote/Transport/Ring.h create mode 100644 MobileGL/MG_Remote/Transport/ShmSegment.cpp create mode 100644 MobileGL/MG_Remote/Transport/ShmSegment.h create mode 100644 MobileGL/MG_Remote/Transport/ShmSegmentPosix.cpp create mode 100644 MobileGL/MG_Remote/Transport/ShmSegmentWin32.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 0caa8a7cf..f0f5f610e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,13 @@ option(MOBILEGL_ENABLE_TRACY "Enable tracy for profiling" option(MOBILEGL_BUILD_TRACE_REPLAY "Build desktop apitrace replay runner" OFF) option(MOBILEGL_TRACE_ANGLE_VARIANTS "Enable signed trace-APK ANGLE variant loading" OFF) option(MOBILEGL_IOS "Build MobileGL for iOS instead of macOS when APPLE is set" OFF) +# The disaggregated (two-process) shape. OFF is the shipping default and OFF +# must stay byte-comparable to a tree without MG_Remote at all: nothing under +# MobileGL/MG_Remote/ is compiled, no include path is added, and no library is +# linked, so `nm --defined-only libMobileGL.so | grep -i MG_Remote` is empty. +# That emptiness is one of the two byte-level equalities the plan's validation +# gates keep (section 10.3). +option(MOBILEGL_BUILD_DISAGGREGATED "Build the MG_Remote transport layer (two-process shape)" OFF) set(MOBILEGL_LOG_ACTIVE_LEVEL "MOBILEGL_LOG_LEVEL_INFO" CACHE STRING "MobileGL active log level macro") set(MOBILEGL_VULKAN_LIBRARY "" CACHE FILEPATH "Vulkan loader/MoltenVK library to link for iOS builds") @@ -419,6 +426,41 @@ set(SOURCE_FILES MobileGL/MG_State/GLState/RenderbufferState/RenderbufferState.cpp ) +# --------------------------------------------------------------------------- +# MG_Remote (disaggregated transport). Everything below is gated: with the +# option OFF not one file here is compiled and no include path is added. +# --------------------------------------------------------------------------- + +# FlatBuffers is a submodule and its runtime is header-only. Guard both ways: +# a checkout without the submodule must configure and build, just without the +# disaggregated shape, rather than fail with a missing-header error a hundred +# lines later. Note this only checks for the RUNTIME headers - flatc is never +# built here (see scripts/gen_protocol.py). +if (MOBILEGL_BUILD_DISAGGREGATED AND + NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/3rdparty/flatbuffers/include/flatbuffers/flatbuffers.h") + message(WARNING + "MOBILEGL_BUILD_DISAGGREGATED=ON but 3rdparty/flatbuffers/include is missing. " + "Run `git submodule update --init 3rdparty/flatbuffers`. Forcing the option OFF.") + set(MOBILEGL_BUILD_DISAGGREGATED OFF CACHE BOOL + "Build the MG_Remote transport layer (two-process shape)" FORCE) +endif() + +if (MOBILEGL_BUILD_DISAGGREGATED) + message(STATUS "MobileGL: disaggregated transport ON, appending MG_Remote sources") + list(APPEND SOURCE_FILES + MobileGL/MG_Remote/Transport/Ring.cpp + MobileGL/MG_Remote/Transport/Doorbell.cpp + MobileGL/MG_Remote/Transport/ShmSegment.cpp + # Both platform halves are listed unconditionally and each is empty on + # the other OS, so neither can rot behind an `if (WIN32)` nobody + # configures. + MobileGL/MG_Remote/Transport/ShmSegmentPosix.cpp + MobileGL/MG_Remote/Transport/ShmSegmentWin32.cpp + MobileGL/MG_Remote/Transport/FdPassing.cpp + MobileGL/MG_Remote/Transport/InProcessTransport.cpp + ) +endif() + if (APPLE AND NOT MOBILEGL_IOS) list(APPEND SOURCE_FILES MobileGL/MG_Impl/CGLImpl/CGLImpl.cpp @@ -469,6 +511,10 @@ set(MOBILEGL_COMPILE_DEF -DASIO_NO_DEPRECATED ) +if (MOBILEGL_BUILD_DISAGGREGATED) + list(APPEND MOBILEGL_COMPILE_DEF -DMOBILEGL_BUILD_DISAGGREGATED=1) +endif() + message(STATUS "MOBILEGL_COMPILE_DEF=${MOBILEGL_COMPILE_DEF}") set(MOBILEGL_INCLUDE_DIR @@ -488,6 +534,13 @@ set(MOBILEGL_INCLUDE_DIR ${CMAKE_SOURCE_DIR}/3rdparty/asio/include ) +if (MOBILEGL_BUILD_DISAGGREGATED) + # Header-only runtime: an include path, no add_subdirectory, no link + # target, and above all no flatc in the build graph. protocol_generated.h + # is committed and regenerated by scripts/gen_protocol.py. + list(APPEND MOBILEGL_INCLUDE_DIR ${CMAKE_SOURCE_DIR}/3rdparty/flatbuffers/include) +endif() + add_library(${CMAKE_PROJECT_NAME} SHARED ${SOURCE_FILES} ) diff --git a/MobileGL/MG_Remote/Transport/Doorbell.cpp b/MobileGL/MG_Remote/Transport/Doorbell.cpp new file mode 100644 index 000000000..1d02da81d --- /dev/null +++ b/MobileGL/MG_Remote/Transport/Doorbell.cpp @@ -0,0 +1,168 @@ +// MobileGL - MobileGL/MG_Remote/Transport/Doorbell.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#include "Doorbell.h" + +#include + +#include +#include + +#if !defined(_WIN32) +#include +#include +#include +#include +#endif + +namespace MobileGL::MG_Remote::Transport { + + // ----------------------------------------------------------------------- + // CondVarDoorbell + // ----------------------------------------------------------------------- + + struct CondVarDoorbell::Impl { + std::mutex mutex; + std::condition_variable cv; + // Counted, not a flag: a wakeup that arrives while nobody is parked + // must still be observed by the next Park. + std::uint32_t signals = 0; + }; + + CondVarDoorbell::CondVarDoorbell() : m_impl(new Impl()) {} + + CondVarDoorbell::~CondVarDoorbell() { delete m_impl; } + + void CondVarDoorbell::Notify() { + { + std::lock_guard lock(m_impl->mutex); + ++m_impl->signals; + } + m_impl->cv.notify_one(); + } + + bool CondVarDoorbell::Park(std::uint32_t timeoutMs) { + std::unique_lock lock(m_impl->mutex); + if (m_impl->signals != 0) { + --m_impl->signals; + return true; + } + if (timeoutMs == 0) { + return false; + } + if (timeoutMs == kWaitForever) { + m_impl->cv.wait(lock, [this] { return m_impl->signals != 0; }); + } else if (!m_impl->cv.wait_for(lock, std::chrono::milliseconds(timeoutMs), + [this] { return m_impl->signals != 0; })) { + return false; + } + --m_impl->signals; + return true; + } + + void CondVarDoorbell::Reset() { + std::lock_guard lock(m_impl->mutex); + m_impl->signals = 0; + } + +#if !defined(_WIN32) + + // ----------------------------------------------------------------------- + // SocketDoorbell + // ----------------------------------------------------------------------- + + SocketDoorbell::SocketDoorbell(int fd, std::uint8_t code, bool ownsFd) + : m_fd(fd), m_code(code), m_ownsFd(ownsFd) {} + + SocketDoorbell::~SocketDoorbell() { + if (m_ownsFd && m_fd >= 0) { + ::close(m_fd); + } + } + + void SocketDoorbell::Notify() { + if (m_fd < 0) { + return; + } + const std::uint8_t byte = m_code; + for (;;) { + const ssize_t written = ::send(m_fd, &byte, 1, MSG_DONTWAIT | MSG_NOSIGNAL); + if (written == 1) { + return; + } + if (written < 0 && errno == EINTR) { + continue; + } + if (written < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + // The socket buffer already holds unread wakeups: the peer has + // one pending, which is all a doorbell promises. + return; + } + if (written < 0 && errno == EPIPE) { + return; // peer gone; the waiter learns it from its own read + } + MGLOG_D("MG_Remote doorbell: send failed (errno=%d)", errno); + return; + } + } + + bool SocketDoorbell::Park(std::uint32_t timeoutMs) { + if (m_fd < 0) { + return false; + } + const auto start = std::chrono::steady_clock::now(); + for (;;) { + int pollTimeout = -1; + if (timeoutMs != kWaitForever) { + const auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + const long long remaining = static_cast(timeoutMs) - elapsed; + pollTimeout = remaining <= 0 ? 0 : static_cast(remaining); + } + struct pollfd pfd{}; + pfd.fd = m_fd; + pfd.events = POLLIN; + const int ready = ::poll(&pfd, 1, pollTimeout); + if (ready < 0) { + if (errno == EINTR) { + continue; // a signal is not a wakeup; keep the deadline + } + MGLOG_D("MG_Remote doorbell: poll failed (errno=%d)", errno); + return false; + } + if (ready == 0) { + return false; // timed out + } + Reset(); + return true; + } + } + + void SocketDoorbell::Reset() { + if (m_fd < 0) { + return; + } + // Level-triggered to edge-triggered: swallow every queued byte so one + // stale wakeup cannot make later Parks return without an event. + std::uint8_t scratch[64]; + for (;;) { + const ssize_t got = ::recv(m_fd, scratch, sizeof(scratch), MSG_DONTWAIT); + if (got > 0) { + continue; + } + if (got < 0 && errno == EINTR) { + continue; + } + return; + } + } + +#endif // !_WIN32 + +} // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/Doorbell.h b/MobileGL/MG_Remote/Transport/Doorbell.h new file mode 100644 index 000000000..78fc48c49 --- /dev/null +++ b/MobileGL/MG_Remote/Transport/Doorbell.h @@ -0,0 +1,189 @@ +// MobileGL - MobileGL/MG_Remote/Transport/Doorbell.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The bidirectional doorbell: spin briefly, then park. +// +// Both directions exist, and that is the point (inherited design, earlier plan +// section 6.2a): +// - client -> server: the consumer spins, sets consumerParked, then blocks; +// the producer rings only when consumerParked is set. +// - server -> client: the client spins MOBILEGL_IPC_SPIN_US (default 50us), +// sets producerParked, then blocks; the server rings after advancing any +// watermark, only when producerParked is set. +// Without the second direction every client wait - present credit, a blocking +// kNeedsAck request, a full ring - degenerates into a cross-process spin on +// one shared cache line: up to a whole frame of a big core at full clock on a +// phone, fighting the GPU and the game's JVM for it. MobileGL has no affinity +// control anywhere in the tree, so it cannot even be pushed to a little core. +// +// Two implementations, no platform-specific wakeup primitive (no futex, no +// eventfd, no named event): +// - CondVarDoorbell for `inproc` (one process, two threads), +// - SocketDoorbell for `spawn` (one byte on a socket; POSIX only). +// +// The lost-wakeup window is closed by ordering, not by luck: the waiter stores +// its park flag and THEN re-tests the condition, while the notifier publishes +// the watermark and THEN tests the park flag. Both use seq_cst on those two +// accesses, so at least one of the two sees the other. + +#pragma once + +#include +#include +#include + +#if defined(__x86_64__) || defined(__i386__) +#include +#endif + +namespace MobileGL::MG_Remote::Transport { + + // MOBILEGL_IPC_SPIN_US default. + inline constexpr std::uint32_t kDefaultSpinUs = 50; + + // Park with no deadline. + inline constexpr std::uint32_t kWaitForever = 0xFFFFFFFFu; + + // Wire codes, so a shared socket can carry both directions distinguishably. + inline constexpr std::uint8_t kDoorbellRingAdvanced = 0x01; // client -> server + inline constexpr std::uint8_t kDoorbellWatermarkAdvanced = 0x02; // server -> client + + inline void CpuRelax() { +#if defined(__x86_64__) || defined(__i386__) + _mm_pause(); +#elif defined(__aarch64__) || defined(__arm__) + __asm__ __volatile__("yield" ::: "memory"); +#else + std::atomic_signal_fence(std::memory_order_seq_cst); +#endif + } + + class Doorbell { + public: + virtual ~Doorbell() = default; + + Doorbell(const Doorbell&) = delete; + Doorbell& operator=(const Doorbell&) = delete; + + // Wakes a parked peer. Cheap and idempotent: a wakeup that arrives when + // nobody is parked is remembered, so the next Park returns immediately + // rather than sleeping through an event that already happened. + virtual void Notify() = 0; + + // Blocks until notified or the deadline passes. Returns true when a + // wakeup was consumed. timeoutMs == 0 polls; kWaitForever never times + // out. + virtual bool Park(std::uint32_t timeoutMs) = 0; + + // Drops pending wakeups. Used when a waiter gives up, so a stale byte + // does not make the next Park return spuriously forever. + virtual void Reset() = 0; + + // Spin `spinUs`, then park until `ready()` or the deadline. + // `parked` is the RingControl flag the peer tests before ringing. + template + bool Wait(std::atomic& parked, Ready&& ready, std::uint32_t spinUs, + std::uint32_t timeoutMs) { + if (ready()) { + return true; + } + const auto start = std::chrono::steady_clock::now(); + const auto deadline = timeoutMs == kWaitForever + ? std::chrono::steady_clock::time_point::max() + : start + std::chrono::milliseconds(timeoutMs); + + const auto spinEnd = start + std::chrono::microseconds(spinUs); + while (std::chrono::steady_clock::now() < spinEnd) { + if (ready()) { + return true; + } + CpuRelax(); + } + + for (;;) { + // Announce, THEN re-test: the notifier publishes and then reads + // this flag, so one of the two orderings always sees the other. + parked.store(1, std::memory_order_seq_cst); + if (ready()) { + parked.store(0, std::memory_order_seq_cst); + return true; + } + const auto now = std::chrono::steady_clock::now(); + if (now >= deadline) { + parked.store(0, std::memory_order_seq_cst); + return ready(); + } + std::uint32_t chunkMs = kWaitForever; + if (timeoutMs != kWaitForever) { + const auto remaining = + std::chrono::duration_cast(deadline - now).count(); + chunkMs = remaining <= 0 ? 0 : static_cast(remaining); + } + Park(chunkMs); + parked.store(0, std::memory_order_seq_cst); + if (ready()) { + return true; + } + if (timeoutMs != kWaitForever && std::chrono::steady_clock::now() >= deadline) { + return false; + } + } + } + + protected: + Doorbell() = default; + }; + + // Rings `bell` only when the peer said it is parked. The seq_cst load pairs + // with the waiter's seq_cst store of the same flag. + inline void NotifyIfParked(Doorbell& bell, std::atomic& parked) { + if (parked.load(std::memory_order_seq_cst) != 0) { + bell.Notify(); + } + } + + // `inproc`: one process, two threads. + class CondVarDoorbell final : public Doorbell { + public: + CondVarDoorbell(); + ~CondVarDoorbell() override; + + void Notify() override; + bool Park(std::uint32_t timeoutMs) override; + void Reset() override; + + private: + struct Impl; + Impl* m_impl; + }; + +#if !defined(_WIN32) + // `spawn`: one byte on a socket (one direction of a socketpair, or the aux + // socket). POSIX only; the Windows path will use an overlapped named pipe + // and is not part of this skeleton. + class SocketDoorbell final : public Doorbell { + public: + // `fd` must be a socket or pipe end. When `ownsFd` the descriptor is + // closed with this object. `code` is the byte written by Notify. + SocketDoorbell(int fd, std::uint8_t code, bool ownsFd); + ~SocketDoorbell() override; + + void Notify() override; + bool Park(std::uint32_t timeoutMs) override; + void Reset() override; + + int Fd() const { return m_fd; } + + private: + int m_fd; + std::uint8_t m_code; + bool m_ownsFd; + }; +#endif + +} // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/FdPassing.cpp b/MobileGL/MG_Remote/Transport/FdPassing.cpp new file mode 100644 index 000000000..229911776 --- /dev/null +++ b/MobileGL/MG_Remote/Transport/FdPassing.cpp @@ -0,0 +1,296 @@ +// MobileGL - MobileGL/MG_Remote/Transport/FdPassing.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#include "FdPassing.h" + +#include + +#include +#include + +#if !defined(_WIN32) +#include +#include +#include +#include +#include +#endif + +namespace MobileGL::MG_Remote::Transport::FdPassing { + +#if defined(_WIN32) + + bool Supported() { return false; } + + MobileGLResult CreateSocketPair(int[2]) { return MOBILEGL_ERR_UNSUPPORTED; } + + MobileGLResult SendFd(int, int, MobileGLByteSpan) { return MOBILEGL_ERR_UNSUPPORTED; } + + MobileGLResult ReceiveFd(int, int*, MobileGLMutableByteSpan, std::uint64_t*, std::uint32_t) { + return MOBILEGL_ERR_UNSUPPORTED; + } + +#else + + namespace { + // Every datagram starts with this, so the sideband length is explicit + // and a stray datagram is recognisable. + struct SidebandHeader { + std::uint32_t magic; + std::uint32_t sidebandSize; + }; + constexpr std::uint32_t kSidebandMagic = 0x4446474Du; // 'MGFD' on the wire + + int WaitReadable(int socket, std::uint32_t timeoutMs) { + const auto start = std::chrono::steady_clock::now(); + for (;;) { + int pollTimeout = -1; + if (timeoutMs != 0xFFFFFFFFu) { + const auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + const long long remaining = static_cast(timeoutMs) - elapsed; + pollTimeout = remaining <= 0 ? 0 : static_cast(remaining); + } + struct pollfd pfd{}; + pfd.fd = socket; + pfd.events = POLLIN; + const int ready = ::poll(&pfd, 1, pollTimeout); + if (ready < 0 && errno == EINTR) { + continue; + } + return ready; + } + } + } // namespace + + bool Supported() { return true; } + + MobileGLResult CreateSocketPair(int outFds[2]) { + if (outFds == nullptr) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + int fds[2] = {-1, -1}; + int type = SOCK_DGRAM; +#if defined(SOCK_CLOEXEC) + type |= SOCK_CLOEXEC; +#endif + if (::socketpair(AF_UNIX, type, 0, fds) != 0) { + MGLOG_E("MG_Remote fd passing: socketpair failed (errno=%d)", errno); + return MOBILEGL_ERR_TRANSPORT_CLOSED; + } + outFds[0] = fds[0]; + outFds[1] = fds[1]; + return MOBILEGL_OK; + } + + MobileGLResult SendFd(int socket, int fd, MobileGLByteSpan sideband) { + if (socket < 0 || fd < 0) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + if (sideband.size > kMaxSidebandBytes || (sideband.size != 0 && sideband.data == nullptr)) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + + std::uint8_t payload[sizeof(SidebandHeader) + kMaxSidebandBytes]; + SidebandHeader header{}; + header.magic = kSidebandMagic; + header.sidebandSize = static_cast(sideband.size); + std::memcpy(payload, &header, sizeof(header)); + if (sideband.size != 0) { + std::memcpy(payload + sizeof(header), sideband.data, + static_cast(sideband.size)); + } + const std::size_t payloadSize = sizeof(header) + static_cast(sideband.size); + + struct iovec iov{}; + iov.iov_base = payload; + iov.iov_len = payloadSize; + + // CMSG_SPACE, not sizeof: the control buffer has to hold the aligned + // cmsghdr as well as the descriptor. + union { + struct cmsghdr align; + char bytes[CMSG_SPACE(sizeof(int))]; + } control{}; + std::memset(&control, 0, sizeof(control)); + + struct msghdr msg{}; + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + msg.msg_control = control.bytes; + msg.msg_controllen = sizeof(control.bytes); + + struct cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); + cmsg->cmsg_level = SOL_SOCKET; + cmsg->cmsg_type = SCM_RIGHTS; + cmsg->cmsg_len = CMSG_LEN(sizeof(int)); + std::memcpy(CMSG_DATA(cmsg), &fd, sizeof(fd)); + + for (;;) { + const ssize_t sent = ::sendmsg(socket, &msg, MSG_NOSIGNAL); + if (sent >= 0) { + if (static_cast(sent) != payloadSize) { + // A datagram socket sends all or nothing. + MGLOG_E("MG_Remote fd passing: short datagram (%zd of %zu bytes)", sent, + payloadSize); + return MOBILEGL_ERR_TRANSPORT_CLOSED; + } + return MOBILEGL_OK; + } + if (errno == EINTR) { + continue; + } + if (errno == EPIPE || errno == ECONNRESET) { + return MOBILEGL_ERR_TRANSPORT_CLOSED; + } + MGLOG_E("MG_Remote fd passing: sendmsg failed (errno=%d)", errno); + return MOBILEGL_ERR_TRANSPORT_CLOSED; + } + } + + MobileGLResult ReceiveFd(int socket, int* outFd, MobileGLMutableByteSpan sideband, + std::uint64_t* outSidebandSize, std::uint32_t timeoutMs) { + if (socket < 0 || outFd == nullptr) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + *outFd = -1; + if (outSidebandSize != nullptr) { + *outSidebandSize = 0; + } + // Checked before the recvmsg: a datagram cannot be partially consumed, + // so a too-small destination must never cost us the descriptor. + if (sideband.size < kMaxSidebandBytes) { + if (outSidebandSize != nullptr) { + *outSidebandSize = kMaxSidebandBytes; + } + return MOBILEGL_ERR_BUFFER_TOO_SMALL; + } + if (sideband.data == nullptr) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + + const int ready = WaitReadable(socket, timeoutMs); + if (ready < 0) { + MGLOG_E("MG_Remote fd passing: poll failed (errno=%d)", errno); + return MOBILEGL_ERR_TRANSPORT_CLOSED; + } + if (ready == 0) { + return MOBILEGL_ERR_TIMEOUT; + } + + std::uint8_t payload[sizeof(SidebandHeader) + kMaxSidebandBytes]; + struct iovec iov{}; + iov.iov_base = payload; + iov.iov_len = sizeof(payload); + + union { + struct cmsghdr align; + char bytes[CMSG_SPACE(sizeof(int) * 4)]; + } control{}; + std::memset(&control, 0, sizeof(control)); + + struct msghdr msg{}; + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + msg.msg_control = control.bytes; + msg.msg_controllen = sizeof(control.bytes); + + ssize_t got = 0; + for (;;) { + int flags = 0; +#if defined(MSG_CMSG_CLOEXEC) + flags |= MSG_CMSG_CLOEXEC; +#endif + got = ::recvmsg(socket, &msg, flags); + if (got >= 0) { + break; + } + if (errno == EINTR) { + continue; + } + if (errno == ECONNRESET) { + return MOBILEGL_ERR_TRANSPORT_CLOSED; + } + MGLOG_E("MG_Remote fd passing: recvmsg failed (errno=%d)", errno); + return MOBILEGL_ERR_TRANSPORT_CLOSED; + } + if (got == 0) { + return MOBILEGL_ERR_TRANSPORT_CLOSED; + } + + // Collect every descriptor first, so an unexpected extra one is closed + // rather than leaked, whatever else is wrong with the message. + int received[4]; + int receivedCount = 0; + for (struct cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); cmsg != nullptr; + cmsg = CMSG_NXTHDR(&msg, cmsg)) { + if (cmsg->cmsg_level != SOL_SOCKET || cmsg->cmsg_type != SCM_RIGHTS) { + continue; + } + const std::size_t bytes = cmsg->cmsg_len - CMSG_LEN(0); + const int count = static_cast(bytes / sizeof(int)); + for (int i = 0; i < count && receivedCount < 4; ++i) { + int fd = -1; + std::memcpy(&fd, CMSG_DATA(cmsg) + i * sizeof(int), sizeof(fd)); + received[receivedCount++] = fd; + } + } + const auto closeAll = [&](int keepIndex) { + for (int i = 0; i < receivedCount; ++i) { + if (i != keepIndex && received[i] >= 0) { + ::close(received[i]); + } + } + }; + + if ((msg.msg_flags & MSG_CTRUNC) != 0) { + // The kernel dropped ancillary data: whatever arrived is not a + // complete offer, and silently continuing would hand the caller a + // half-transferred segment. + MGLOG_E("MG_Remote fd passing: ancillary data truncated; the descriptor did not " + "arrive intact"); + closeAll(-1); + return MOBILEGL_ERR_PROTOCOL_MISMATCH; + } + if (receivedCount != 1) { + MGLOG_E("MG_Remote fd passing: expected exactly one descriptor, got %d", receivedCount); + closeAll(-1); + return MOBILEGL_ERR_PROTOCOL_MISMATCH; + } + if (static_cast(got) < sizeof(SidebandHeader)) { + MGLOG_E("MG_Remote fd passing: %zd byte datagram is shorter than the header", got); + closeAll(-1); + return MOBILEGL_ERR_PROTOCOL_MISMATCH; + } + + SidebandHeader header{}; + std::memcpy(&header, payload, sizeof(header)); + if (header.magic != kSidebandMagic || + header.sidebandSize > kMaxSidebandBytes || + sizeof(SidebandHeader) + header.sidebandSize != static_cast(got)) { + MGLOG_E("MG_Remote fd passing: bad sideband header (magic=0x%08X size=%u datagram=%zd)", + header.magic, header.sidebandSize, got); + closeAll(-1); + return MOBILEGL_ERR_PROTOCOL_MISMATCH; + } + + if (header.sidebandSize != 0) { + std::memcpy(sideband.data, payload + sizeof(SidebandHeader), header.sidebandSize); + } + if (outSidebandSize != nullptr) { + *outSidebandSize = header.sidebandSize; + } + *outFd = received[0]; + closeAll(0); + return MOBILEGL_OK; + } + +#endif // _WIN32 + +} // namespace MobileGL::MG_Remote::Transport::FdPassing diff --git a/MobileGL/MG_Remote/Transport/FdPassing.h b/MobileGL/MG_Remote/Transport/FdPassing.h new file mode 100644 index 000000000..d3ccb5c47 --- /dev/null +++ b/MobileGL/MG_Remote/Transport/FdPassing.h @@ -0,0 +1,67 @@ +// MobileGL - MobileGL/MG_Remote/Transport/FdPassing.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// SCM_RIGHTS descriptor passing over an AF_UNIX socket pair. POSIX only. +// +// This is the FIRST transport commit, deliberately (inherited design, plan +// section 8.1, "SCM_RIGHTS must be implemented in the first transport +// commit"). The earlier branch pushed it to a later phase and hardcoded +// `out->fd = -1` in its offer poll, so on the only platform that matters its +// data plane could never move a byte: every segment announcement resolved to +// "no descriptor". A transport whose shm cannot cross the process boundary is +// not a transport. +// +// Channel shape: a dedicated AF_UNIX SOCK_DGRAM socketpair, NOT the control +// byte stream. Two reasons: +// - SOCK_DGRAM preserves message boundaries on every POSIX (SOCK_SEQPACKET +// does not exist on macOS), so one sendmsg is exactly one recvmsg and the +// ancillary data can never be split away from its payload; +// - ancillary data attached to a byte stream binds to whichever ordinary +// byte happens to be at the front of the reader's buffer, which is +// unmanageable once frames are being reassembled. + +#pragma once + +#include "../Protocol/mg_protocol_base.h" + +#include + +namespace MobileGL::MG_Remote::Transport::FdPassing { + + // Upper bound for the bytes that travel with a descriptor (a SegmentRef + // sized announcement, not payload). + inline constexpr std::uint64_t kMaxSidebandBytes = 256; + + // False on platforms without SCM_RIGHTS (Windows). + bool Supported(); + + // Creates the aux socket pair. Both descriptors are CLOEXEC and owned by + // the caller. outFds[0] is conventionally the client end, [1] the server's + // (the one that is inherited or passed to the spawned process). + MobileGLResult CreateSocketPair(int outFds[2]); + + // Sends `fd` with `sideband` attached. The caller keeps ownership of `fd` + // (the peer gets its own descriptor for the same open file description). + // sideband.size must be <= kMaxSidebandBytes. + MobileGLResult SendFd(int socket, int fd, MobileGLByteSpan sideband); + + // Receives one descriptor and its sideband bytes. + // + // `sideband` must be at least kMaxSidebandBytes: a datagram cannot be + // partially consumed, so the capacity is checked BEFORE anything is read. + // A short buffer returns MOBILEGL_ERR_BUFFER_TOO_SMALL with + // *outSidebandSize = kMaxSidebandBytes and consumes nothing, so no + // descriptor is ever dropped on the floor. + // + // On success *outFd owns a descriptor this process must close. + // MOBILEGL_ERR_TIMEOUT when nothing arrived (timeoutMs 0 = poll), + // MOBILEGL_ERR_TRANSPORT_CLOSED on peer close. + MobileGLResult ReceiveFd(int socket, int* outFd, MobileGLMutableByteSpan sideband, + std::uint64_t* outSidebandSize, std::uint32_t timeoutMs); + +} // namespace MobileGL::MG_Remote::Transport::FdPassing diff --git a/MobileGL/MG_Remote/Transport/Framing.h b/MobileGL/MG_Remote/Transport/Framing.h new file mode 100644 index 000000000..9123f20c9 --- /dev/null +++ b/MobileGL/MG_Remote/Transport/Framing.h @@ -0,0 +1,206 @@ +// MobileGL - MobileGL/MG_Remote/Transport/Framing.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// Control-channel wire framing: [u32 magic 'MGLF'][u32 payloadLength][payload]. +// Length excludes the 8-byte header and is capped at 64 MiB. +// +// Two defects of the earlier branch's codec are fixed here, and both are the +// reason this file is not a copy of it: +// +// 1. Its Feed() unconditionally returned OK and its header peek merely +// returned false on a bad magic or an oversized length. A corrupt or +// desynchronized stream therefore turned into a silent, permanent hang - +// the reader kept waiting for a message that could never be parsed, with +// no error anywhere. Here a violation latches a failed state, is logged at +// ERROR, and every later call returns MOBILEGL_ERR_PROTOCOL_MISMATCH. +// +// 2. Its receive path failed the call and consumed the message when the +// caller's buffer was too small, wedging the stream. Here +// MOBILEGL_ERR_BUFFER_TOO_SMALL reports the required size and KEEPS the +// message queued. +// +// The reader is a plain byte-stream reassembler: it never assumes a read() +// returned a whole frame. + +#pragma once + +#include "../Protocol/mg_protocol_base.h" + +#include + +#include +#include +#include +#include + +namespace MobileGL::MG_Remote::Transport { + + // 'MGLF', little-endian on the wire (both ends are the same machine). + inline constexpr std::uint32_t kFrameMagic = 0x464C474Du; + inline constexpr std::uint64_t kFrameHeaderSize = 8; + inline constexpr std::uint64_t kMaxFramePayloadSize = 64ull * 1024 * 1024; + + // Compaction threshold: consumed bytes are dropped from the front once + // enough of them accumulate, so a long-lived reader neither memmoves per + // message nor grows without bound. + inline constexpr std::uint64_t kFrameReaderCompactThreshold = 64ull * 1024; + + // Appends one framed message to `out`. + inline MobileGLResult AppendFrame(std::vector& out, const void* payload, + std::uint64_t size) { + if (size > kMaxFramePayloadSize) { + MGLOG_E("MG_Remote framing: refusing to send a %llu byte payload (cap %llu); bulk " + "bytes belong in shm", + static_cast(size), + static_cast(kMaxFramePayloadSize)); + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + if (size != 0 && payload == nullptr) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + std::uint8_t header[kFrameHeaderSize]; + const std::uint32_t magic = kFrameMagic; + const std::uint32_t length = static_cast(size); + std::memcpy(header + 0, &magic, sizeof(magic)); + std::memcpy(header + 4, &length, sizeof(length)); + out.insert(out.end(), header, header + kFrameHeaderSize); + const auto* bytes = static_cast(payload); + out.insert(out.end(), bytes, bytes + size); + return MOBILEGL_OK; + } + + // Incremental frame extractor over a raw byte stream. + class FrameReader { + public: + // Feeds raw stream bytes. Validates the frame header the moment enough + // bytes for one exist - a bad magic or an oversized length is reported + // here, not swallowed. + MobileGLResult Feed(const void* data, std::uint64_t size) { + if (m_failed) { + return MOBILEGL_ERR_PROTOCOL_MISMATCH; + } + if (size != 0) { + if (data == nullptr) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + const auto* bytes = static_cast(data); + m_buffer.insert(m_buffer.end(), bytes, bytes + size); + } + return ParseHeader(); + } + + bool Failed() const { return m_failed; } + + bool HasMessage() const { + return !m_failed && m_haveHeader && Available() >= kFrameHeaderSize + m_pendingSize; + } + + // Size of the next complete message, or 0 when none is complete yet. + std::uint64_t PendingMessageSize() const { return HasMessage() ? m_pendingSize : 0; } + + std::uint64_t BufferedBytes() const { return Available(); } + + // Copies the next complete message out. + // MOBILEGL_OK - copied, *outSize set, message consumed + // MOBILEGL_ERR_BUFFER_TOO_SMALL - *outSize = required size, message KEPT + // MOBILEGL_ERR_TIMEOUT - no complete message buffered + // MOBILEGL_ERR_PROTOCOL_MISMATCH- the stream is latched failed + MobileGLResult TakeMessage(MobileGLMutableByteSpan buffer, std::uint64_t* outSize) { + if (m_failed) { + return MOBILEGL_ERR_PROTOCOL_MISMATCH; + } + if (!HasMessage()) { + return MOBILEGL_ERR_TIMEOUT; + } + if (outSize != nullptr) { + *outSize = m_pendingSize; + } + if (buffer.size < m_pendingSize) { + // The message stays queued; the caller retries with a big + // enough buffer. + return MOBILEGL_ERR_BUFFER_TOO_SMALL; + } + if (m_pendingSize != 0) { + if (buffer.data == nullptr) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + std::memcpy(buffer.data, m_buffer.data() + m_readPos + kFrameHeaderSize, + static_cast(m_pendingSize)); + } + Consume(); + return MOBILEGL_OK; + } + + // Convenience overload that sizes the destination itself. + MobileGLResult TakeMessage(std::vector& out) { + if (m_failed) { + return MOBILEGL_ERR_PROTOCOL_MISMATCH; + } + if (!HasMessage()) { + return MOBILEGL_ERR_TIMEOUT; + } + const auto* first = m_buffer.data() + m_readPos + kFrameHeaderSize; + out.assign(first, first + m_pendingSize); + Consume(); + return MOBILEGL_OK; + } + + private: + std::uint64_t Available() const { return m_buffer.size() - m_readPos; } + + MobileGLResult ParseHeader() { + if (m_haveHeader || Available() < kFrameHeaderSize) { + return MOBILEGL_OK; + } + std::uint32_t magic = 0; + std::uint32_t length = 0; + std::memcpy(&magic, m_buffer.data() + m_readPos, sizeof(magic)); + std::memcpy(&length, m_buffer.data() + m_readPos + 4, sizeof(length)); + if (magic != kFrameMagic) { + m_failed = true; + MGLOG_E("MG_Remote framing: bad frame magic 0x%08X (expected 0x%08X); the control " + "stream is desynchronized and this transport is now dead", + magic, kFrameMagic); + return MOBILEGL_ERR_PROTOCOL_MISMATCH; + } + if (length > kMaxFramePayloadSize) { + m_failed = true; + MGLOG_E("MG_Remote framing: frame length %u exceeds the %llu byte cap; refusing to " + "allocate on a peer-supplied length", + length, static_cast(kMaxFramePayloadSize)); + return MOBILEGL_ERR_PROTOCOL_MISMATCH; + } + m_pendingSize = length; + m_haveHeader = true; + return MOBILEGL_OK; + } + + void Consume() { + m_readPos += kFrameHeaderSize + m_pendingSize; + m_pendingSize = 0; + m_haveHeader = false; + if (m_readPos == m_buffer.size()) { + m_buffer.clear(); + m_readPos = 0; + } else if (m_readPos >= kFrameReaderCompactThreshold) { + m_buffer.erase(m_buffer.begin(), + m_buffer.begin() + static_cast(m_readPos)); + m_readPos = 0; + } + // Header of the next message may already be buffered. + (void)ParseHeader(); + } + + std::vector m_buffer; + std::uint64_t m_readPos = 0; + std::uint64_t m_pendingSize = 0; + bool m_haveHeader = false; + bool m_failed = false; + }; + +} // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/ITransport.h b/MobileGL/MG_Remote/Transport/ITransport.h new file mode 100644 index 000000000..1a68bd242 --- /dev/null +++ b/MobileGL/MG_Remote/Transport/ITransport.h @@ -0,0 +1,124 @@ +// MobileGL - MobileGL/MG_Remote/Transport/ITransport.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The control-plane transport interface. +// +// It is deliberately dumb: complete messages in, complete messages out, plus +// the one thing shared memory cannot do without help - handing a file +// descriptor to the peer. No session routing, no seq accounting, no +// serialization; those live above, in the protocol layer. +// +// Everything on the hot path bypasses this interface entirely: records go into +// the SEG_CMD ring (Ring.h) and the peer is woken through a Doorbell +// (Doorbell.h). ITransport carries the handshake, surface ops, resync, aux +// requests and fatals - the rare, variable-length, must-evolve traffic that +// plan section 7.1 assigns to FlatBuffers tables. +// +// This header stays dependency-light on purpose (mg_protocol_base.h plus the +// standard library): it is included by both roles and by the eventual +// server-side binary, and nothing about a byte pipe needs the GL frontend's +// umbrella header. +// +// Threading: one instance is not internally synchronized for send; callers +// serialize sends. ReceiveFrame/ReceiveFd may be called from one dedicated +// reader thread concurrently with sends from another. + +#pragma once + +#include "../Protocol/mg_protocol_base.h" + +#include + +namespace MobileGL::MG_Remote::Transport { + + // Which end of the connection this instance is. + enum class TransportRole : std::uint32_t { + Server = 1, // accepts the client connection + Client = 2, // connects to the server endpoint + InProcess = 3, // same-process hand-off (CI / inproc delivery mode) + }; + + class ITransport { + public: + virtual ~ITransport() = default; + + ITransport(const ITransport&) = delete; + ITransport& operator=(const ITransport&) = delete; + + // ---- control plane ------------------------------------------------- + + // Sends one complete message. `bytes` is borrowed: the implementation + // either copies it or completes the underlying write before returning. + // A payload larger than Framing::kMaxFramePayloadSize is rejected with + // MOBILEGL_ERR_INVALID_ARGUMENT - bulk bytes belong in shm, never here. + virtual MobileGLResult SendFrame(MobileGLByteSpan bytes) = 0; + + // Receives the next complete message. + // + // MOBILEGL_OK - copied into `buffer`, *outSize is + // the message size, message consumed. + // MOBILEGL_ERR_BUFFER_TOO_SMALL - `buffer` is too small. *outSize is + // the size required and THE MESSAGE + // STAYS QUEUED: call again with a + // buffer of at least that size and it + // is still there. + // MOBILEGL_ERR_TIMEOUT - nothing arrived within timeoutMs + // (0 = non-blocking poll). + // MOBILEGL_ERR_TRANSPORT_CLOSED - peer gone, nothing left buffered. + // MOBILEGL_ERR_PROTOCOL_MISMATCH- framing violated; the transport is + // latched failed and never recovers. + // + // The buffer-too-small half of that contract is the whole point of + // having one: the earlier branch's transport failed the call AND + // dropped the message, which wedges the stream permanently the first + // time a message is bigger than the reader's guess. + virtual MobileGLResult ReceiveFrame(MobileGLMutableByteSpan buffer, std::uint64_t* outSize, + std::uint32_t timeoutMs) = 0; + + // Size of the next pending message, or 0 when none is buffered. Lets a + // caller size its buffer without a failed receive first. + virtual std::uint64_t PeekFrameSize() = 0; + + // ---- descriptor passing -------------------------------------------- + + // Hands `fd` to the peer. POSIX: SCM_RIGHTS over the aux socket (see + // FdPassing.h). Windows: not applicable, returns + // MOBILEGL_ERR_UNSUPPORTED - the section name travels inside SegmentRef + // instead. The caller keeps ownership of `fd` and closes it itself. + // + // This is a first-class member of the interface, not a later phase: the + // earlier branch deferred it and hardcoded `out->fd = -1` in its offer + // poll, so its data plane could not move a single byte on the only + // platform that matters. + virtual MobileGLResult ShareFd(int fd, MobileGLByteSpan sideband) = 0; + + // Receives one fd previously shared by the peer. On success *outFd owns + // a descriptor this process must close. `sideband` receives the bytes + // that travelled with it (may be empty) and must be at least + // FdPassing::kMaxSidebandBytes: an fd offer is one datagram and cannot + // be half-consumed, so the capacity is checked BEFORE anything is read + // and a short buffer returns MOBILEGL_ERR_BUFFER_TOO_SMALL with the + // required size, having consumed nothing and dropped no descriptor. + virtual MobileGLResult ReceiveFd(int* outFd, MobileGLMutableByteSpan sideband, + std::uint64_t* outSidebandSize, std::uint32_t timeoutMs) = 0; + + // ---- lifecycle ------------------------------------------------------ + + // Idempotent. Unblocks every waiter with MOBILEGL_ERR_TRANSPORT_CLOSED + // and releases the endpoint. Messages already queued for this endpoint + // stay readable until drained, so a peer that shuts down after sending + // does not lose its last message. + virtual void Shutdown() = 0; + + virtual TransportRole Role() const = 0; + + protected: + ITransport() = default; + }; + +} // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/InProcessTransport.cpp b/MobileGL/MG_Remote/Transport/InProcessTransport.cpp new file mode 100644 index 000000000..289ec3d8b --- /dev/null +++ b/MobileGL/MG_Remote/Transport/InProcessTransport.cpp @@ -0,0 +1,275 @@ +// MobileGL - MobileGL/MG_Remote/Transport/InProcessTransport.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#include "InProcessTransport.h" + +#include "FdPassing.h" +#include "Framing.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#endif + +namespace MobileGL::MG_Remote::Transport { + + namespace { + struct FdOffer { + int fd = -1; + std::vector sideband; + }; + } // namespace + + // One direction of the channel: everything queued FOR one endpoint. + class InProcessChannel { + public: + struct Direction { + std::mutex mutex; + std::condition_variable cv; + std::deque> messages; + std::deque fdOffers; + bool closed = false; + }; + + ~InProcessChannel() { + for (Direction& dir : m_directions) { + for (FdOffer& offer : dir.fdOffers) { +#if !defined(_WIN32) + if (offer.fd >= 0) { + ::close(offer.fd); + } +#endif + } + dir.fdOffers.clear(); + } + } + + Direction& Inbox(int endpoint) { return m_directions[endpoint]; } + Direction& Outbox(int endpoint) { return m_directions[1 - endpoint]; } + CondVarDoorbell& Bell(int endpoint) { return m_bells[endpoint]; } + + void Close() { + for (Direction& dir : m_directions) { + { + std::lock_guard lock(dir.mutex); + dir.closed = true; + } + dir.cv.notify_all(); + } + // Anything parked on a ring doorbell has to come back too, or a + // shutdown mid-frame hangs the peer forever. + for (CondVarDoorbell& bell : m_bells) { + bell.Notify(); + } + } + + private: + Direction m_directions[2]; + CondVarDoorbell m_bells[2]; + }; + + InProcessTransport::InProcessTransport(std::shared_ptr channel, int endpoint) + : m_channel(std::move(channel)), m_endpoint(endpoint) {} + + InProcessTransport::~InProcessTransport() = default; + + void InProcessTransport::CreatePair(std::unique_ptr& outClient, + std::unique_ptr& outServer) { + auto channel = std::make_shared(); + outClient.reset(new InProcessTransport(channel, 0)); + outServer.reset(new InProcessTransport(channel, 1)); + } + + MobileGLResult InProcessTransport::SendFrame(MobileGLByteSpan bytes) { + if (bytes.size != 0 && bytes.data == nullptr) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + // Same cap as the byte-stream transports, so nothing legal here becomes + // illegal the day the delivery mode changes to `spawn`. + if (bytes.size > kMaxFramePayloadSize) { + MGLOG_E("MG_Remote inproc: refusing a %llu byte message (cap %llu)", + static_cast(bytes.size), + static_cast(kMaxFramePayloadSize)); + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + + InProcessChannel::Direction& dir = m_channel->Outbox(m_endpoint); + { + std::lock_guard lock(dir.mutex); + if (dir.closed) { + return MOBILEGL_ERR_TRANSPORT_CLOSED; + } + const auto* first = static_cast(bytes.data); + dir.messages.emplace_back(first, first + bytes.size); + } + dir.cv.notify_one(); + return MOBILEGL_OK; + } + + MobileGLResult InProcessTransport::ReceiveFrame(MobileGLMutableByteSpan buffer, + std::uint64_t* outSize, + std::uint32_t timeoutMs) { + if (outSize != nullptr) { + *outSize = 0; + } + InProcessChannel::Direction& dir = m_channel->Inbox(m_endpoint); + std::unique_lock lock(dir.mutex); + if (dir.messages.empty() && !dir.closed && timeoutMs != 0) { + const auto ready = [&dir] { return !dir.messages.empty() || dir.closed; }; + if (timeoutMs == kWaitForever) { + dir.cv.wait(lock, ready); + } else { + dir.cv.wait_for(lock, std::chrono::milliseconds(timeoutMs), ready); + } + } + if (dir.messages.empty()) { + // Queued messages outlive the peer's Shutdown; only an empty inbox + // is a closed one. + return dir.closed ? MOBILEGL_ERR_TRANSPORT_CLOSED : MOBILEGL_ERR_TIMEOUT; + } + + const std::vector& front = dir.messages.front(); + const std::uint64_t size = front.size(); + if (outSize != nullptr) { + *outSize = size; + } + if (buffer.size < size) { + // Contract: the message STAYS QUEUED. The earlier branch's + // transport failed the call and popped the message anyway, which + // wedges the stream permanently the first time a reader guesses the + // size wrong. + return MOBILEGL_ERR_BUFFER_TOO_SMALL; + } + if (size != 0) { + if (buffer.data == nullptr) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + std::memcpy(buffer.data, front.data(), static_cast(size)); + } + dir.messages.pop_front(); + return MOBILEGL_OK; + } + + std::uint64_t InProcessTransport::PeekFrameSize() { + InProcessChannel::Direction& dir = m_channel->Inbox(m_endpoint); + std::lock_guard lock(dir.mutex); + return dir.messages.empty() ? 0 : dir.messages.front().size(); + } + + MobileGLResult InProcessTransport::ShareFd(int fd, MobileGLByteSpan sideband) { +#if defined(_WIN32) + (void)fd; + (void)sideband; + return MOBILEGL_ERR_UNSUPPORTED; +#else + if (fd < 0) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + if (sideband.size > FdPassing::kMaxSidebandBytes || + (sideband.size != 0 && sideband.data == nullptr)) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + // Same ownership rule as SCM_RIGHTS: the peer gets its own descriptor + // for the same open file description and the caller keeps its own. + const int duplicate = ::dup(fd); + if (duplicate < 0) { + MGLOG_E("MG_Remote inproc: dup failed (errno=%d)", errno); + return MOBILEGL_ERR_TRANSPORT_CLOSED; + } + + FdOffer offer; + offer.fd = duplicate; + if (sideband.size != 0) { + const auto* first = static_cast(sideband.data); + offer.sideband.assign(first, first + sideband.size); + } + + InProcessChannel::Direction& dir = m_channel->Outbox(m_endpoint); + { + std::lock_guard lock(dir.mutex); + if (dir.closed) { + ::close(duplicate); + return MOBILEGL_ERR_TRANSPORT_CLOSED; + } + dir.fdOffers.push_back(std::move(offer)); + } + dir.cv.notify_one(); + return MOBILEGL_OK; +#endif + } + + MobileGLResult InProcessTransport::ReceiveFd(int* outFd, MobileGLMutableByteSpan sideband, + std::uint64_t* outSidebandSize, + std::uint32_t timeoutMs) { + if (outFd == nullptr) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + *outFd = -1; + if (outSidebandSize != nullptr) { + *outSidebandSize = 0; + } +#if defined(_WIN32) + (void)sideband; + (void)timeoutMs; + return MOBILEGL_ERR_UNSUPPORTED; +#else + // Symmetric with FdPassing::ReceiveFd so callers behave identically in + // both delivery modes. + if (sideband.size < FdPassing::kMaxSidebandBytes) { + if (outSidebandSize != nullptr) { + *outSidebandSize = FdPassing::kMaxSidebandBytes; + } + return MOBILEGL_ERR_BUFFER_TOO_SMALL; + } + if (sideband.data == nullptr) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + + InProcessChannel::Direction& dir = m_channel->Inbox(m_endpoint); + std::unique_lock lock(dir.mutex); + if (dir.fdOffers.empty() && !dir.closed && timeoutMs != 0) { + const auto ready = [&dir] { return !dir.fdOffers.empty() || dir.closed; }; + if (timeoutMs == kWaitForever) { + dir.cv.wait(lock, ready); + } else { + dir.cv.wait_for(lock, std::chrono::milliseconds(timeoutMs), ready); + } + } + if (dir.fdOffers.empty()) { + return dir.closed ? MOBILEGL_ERR_TRANSPORT_CLOSED : MOBILEGL_ERR_TIMEOUT; + } + + FdOffer offer = std::move(dir.fdOffers.front()); + dir.fdOffers.pop_front(); + if (!offer.sideband.empty()) { + std::memcpy(sideband.data, offer.sideband.data(), offer.sideband.size()); + } + if (outSidebandSize != nullptr) { + *outSidebandSize = offer.sideband.size(); + } + *outFd = offer.fd; + return MOBILEGL_OK; +#endif + } + + void InProcessTransport::Shutdown() { m_channel->Close(); } + + Doorbell& InProcessTransport::PeerDoorbell() { return m_channel->Bell(1 - m_endpoint); } + + Doorbell& InProcessTransport::SelfDoorbell() { return m_channel->Bell(m_endpoint); } + +} // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/InProcessTransport.h b/MobileGL/MG_Remote/Transport/InProcessTransport.h new file mode 100644 index 000000000..d3b1fe340 --- /dev/null +++ b/MobileGL/MG_Remote/Transport/InProcessTransport.h @@ -0,0 +1,71 @@ +// MobileGL - MobileGL/MG_Remote/Transport/InProcessTransport.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The `inproc` transport: two in-memory message queues and a pair of condvar +// doorbells, one connected endpoint at each end. +// +// It is not a test double. `inproc` is a delivery mode of its own (CMake +// option MOBILEGL_BUILD_DISAGGREGATED_INPROC): the server side is the +// monolith's own render thread, which is the single largest CPU lever this +// project has, and it is also the CI form of the split build. What it does NOT +// exercise is serialization of the byte stream, so the framing codec is +// covered separately by FramingTest. +// +// Messages are queued whole, so no framing bytes are involved; the size cap is +// still enforced so that a payload which would be illegal on a socket is +// illegal here too and does not pass CI only to fail after the switch to +// `spawn`. +// +// Descriptor passing is a plain dup(): both ends are the same process, so +// there is nothing to transfer, but the API stays identical so callers can be +// written once. + +#pragma once + +#include "Doorbell.h" +#include "ITransport.h" + +#include + +namespace MobileGL::MG_Remote::Transport { + + class InProcessChannel; + + class InProcessTransport final : public ITransport { + public: + ~InProcessTransport() override; + + // Creates one connected pair. Endpoint 0 is the client, endpoint 1 the + // server; both share one channel and either may be destroyed first. + static void CreatePair(std::unique_ptr& outClient, + std::unique_ptr& outServer); + + MobileGLResult SendFrame(MobileGLByteSpan bytes) override; + MobileGLResult ReceiveFrame(MobileGLMutableByteSpan buffer, std::uint64_t* outSize, + std::uint32_t timeoutMs) override; + std::uint64_t PeekFrameSize() override; + MobileGLResult ShareFd(int fd, MobileGLByteSpan sideband) override; + MobileGLResult ReceiveFd(int* outFd, MobileGLMutableByteSpan sideband, + std::uint64_t* outSidebandSize, std::uint32_t timeoutMs) override; + void Shutdown() override; + TransportRole Role() const override { return TransportRole::InProcess; } + + // The wake channel for the SEG_CMD/SEG_STAGE rings living beside this + // transport: ring the peer's bell after publishing a watermark (only + // when its park flag is set - see NotifyIfParked), park on your own. + Doorbell& PeerDoorbell(); + Doorbell& SelfDoorbell(); + + private: + InProcessTransport(std::shared_ptr channel, int endpoint); + + std::shared_ptr m_channel; + int m_endpoint = 0; + }; + +} // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/Ring.cpp b/MobileGL/MG_Remote/Transport/Ring.cpp new file mode 100644 index 000000000..ee0926454 --- /dev/null +++ b/MobileGL/MG_Remote/Transport/Ring.cpp @@ -0,0 +1,286 @@ +// MobileGL - MobileGL/MG_Remote/Transport/Ring.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#include "Ring.h" + +#include + +#include + +namespace MobileGL::MG_Remote::Transport { + + namespace { + constexpr std::uint64_t Align8(std::uint64_t value) { + return (value + (kRingRecordAlignment - 1)) & ~(kRingRecordAlignment - 1); + } + + bool IsPowerOfTwo(std::uint64_t value) { return value != 0 && (value & (value - 1)) == 0; } + + std::atomic& Head(RingControl& c, RingCursorSet which) { + return which == RingCursorSet::Cmd ? c.cmdHead : c.stageHead; + } + const std::atomic& Head(const RingControl& c, RingCursorSet which) { + return which == RingCursorSet::Cmd ? c.cmdHead : c.stageHead; + } + std::atomic& AppliedTail(RingControl& c, RingCursorSet which) { + return which == RingCursorSet::Cmd ? c.cmdAppliedTail : c.stageAppliedTail; + } + const std::atomic& AppliedTail(const RingControl& c, RingCursorSet which) { + return which == RingCursorSet::Cmd ? c.cmdAppliedTail : c.stageAppliedTail; + } + std::atomic& RetiredTail(RingControl& c, RingCursorSet which) { + return which == RingCursorSet::Cmd ? c.cmdRetiredTail : c.stageRetiredTail; + } + const std::atomic& RetiredTail(const RingControl& c, RingCursorSet which) { + return which == RingCursorSet::Cmd ? c.cmdRetiredTail : c.stageRetiredTail; + } + } // namespace + + void InitRingControl(RingControl& control) { + std::memset(static_cast(&control), 0, sizeof(RingControl)); + // 0 means "uninitialized" for both generations, so a peer that reads a + // zero page can tell it from a legal generation. + control.serverEpoch.store(1, std::memory_order_relaxed); + control.ringGeneration.store(1, std::memory_order_relaxed); + } + + bool RingCursorsValid(const RingControl& control, RingCursorSet cursors, + std::uint64_t capacityBytes) { + const std::uint64_t head = Head(control, cursors).load(std::memory_order_acquire); + const std::uint64_t applied = AppliedTail(control, cursors).load(std::memory_order_acquire); + const std::uint64_t retired = RetiredTail(control, cursors).load(std::memory_order_acquire); + if (applied > head || retired > applied) { + return false; + } + return head - retired <= capacityBytes; + } + + MobileGLResult HardDrainRing(RingControl& control, RingCursorSet cursors) { + const std::uint64_t head = Head(control, cursors).load(std::memory_order_acquire); + const std::uint64_t applied = AppliedTail(control, cursors).load(std::memory_order_acquire); + const std::uint64_t retired = RetiredTail(control, cursors).load(std::memory_order_acquire); + if (head != applied || applied != retired) { + MGLOG_E("MG_Remote ring: hard drain refused, ring is not quiesced " + "(head=%llu applied=%llu retired=%llu)", + static_cast(head), + static_cast(applied), + static_cast(retired)); + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + // Cursors stay monotonic across the drain - only the generation moves, + // so any offset either side cached is now recognisably stale. + control.ringGeneration.fetch_add(1, std::memory_order_acq_rel); + return MOBILEGL_OK; + } + + // ----------------------------------------------------------------------- + // Producer + // ----------------------------------------------------------------------- + + RingProducer::RingProducer(RingControl* control, void* base, std::uint64_t capacityBytes, + RingCursorSet cursors) + : m_control(control), m_base(static_cast(base)), m_capacity(capacityBytes), + m_mask(capacityBytes - 1), m_cursors(cursors) { + if (control == nullptr || base == nullptr || !IsPowerOfTwo(capacityBytes) || + capacityBytes < sizeof(RingRecordHeader)) { + MGLOG_E("MG_Remote ring: producer rejected, capacity %llu must be a power of two of at " + "least %zu bytes over a non-null mapping", + static_cast(capacityBytes), sizeof(RingRecordHeader)); + m_control = nullptr; + m_base = nullptr; + m_capacity = 0; + m_mask = 0; + return; + } + m_localHead = Head(*control, cursors).load(std::memory_order_acquire); + } + + std::uint64_t RingProducer::TailForReclaim() const { + // The conservative watermark: a slot borrowed into the GPU timeline is + // only free after retiredTail passes it. A consumer that never borrows + // publishes retired together with applied, so this costs nothing there. + return RetiredTail(*m_control, m_cursors).load(std::memory_order_acquire); + } + + std::uint64_t RingProducer::FreeBytes() const { + if (m_control == nullptr) { + return 0; + } + const std::uint64_t inFlight = m_localHead - TailForReclaim(); + return inFlight >= m_capacity ? 0 : m_capacity - inFlight; + } + + void* RingProducer::Reserve(std::uint16_t kind, std::uint16_t flags, + std::uint64_t payloadBytes) { + if (m_control == nullptr) { + return nullptr; + } + const std::uint64_t total = Align8(sizeof(RingRecordHeader) + payloadBytes); + if (total > m_capacity) { + // A single record larger than the whole ring is a caller bug: the + // record catalogue has to chunk oversized payloads (large subdata + // becomes several records) rather than emit one giant record. + MGLOG_E("MG_Remote ring: record kind %u of %llu bytes does not fit a %llu byte ring; " + "the emitter must chunk it", + static_cast(kind), static_cast(total), + static_cast(m_capacity)); + return nullptr; + } + + const std::uint64_t offset = m_localHead & m_mask; + const std::uint64_t spaceToEnd = m_capacity - offset; + // Every record is a multiple of 8, so the distance to the wrap boundary + // is too, and a pad header always fits. + const bool needsPad = spaceToEnd < total; + const std::uint64_t needed = needsPad ? spaceToEnd + total : total; + if (FreeBytes() < needed) { + MGLOG_D("MG_Remote ring: full, %llu bytes free, %llu needed", + static_cast(FreeBytes()), + static_cast(needed)); + return nullptr; + } + + if (needsPad) { + RingRecordHeader pad{}; + pad.kind = kRingPadRecordKind; + pad.flags = kRecPad; + pad.size = static_cast(spaceToEnd); + std::memcpy(SlotAt(m_localHead), &pad, sizeof(pad)); + m_localHead += spaceToEnd; + } + + RingRecordHeader header{}; + header.kind = kind; + header.flags = static_cast(flags & ~static_cast(kRecPad)); + header.size = static_cast(total); + std::uint8_t* slot = SlotAt(m_localHead); + std::memcpy(slot, &header, sizeof(header)); + m_localHead += total; + return slot + sizeof(RingRecordHeader); + } + + void RingProducer::Publish() { + if (m_control == nullptr) { + return; + } + // Release: everything written into the slots happens-before the peer's + // acquire load of the head. + Head(*m_control, m_cursors).store(m_localHead, std::memory_order_release); + } + + // ----------------------------------------------------------------------- + // Consumer + // ----------------------------------------------------------------------- + + RingConsumer::RingConsumer(RingControl* control, void* base, std::uint64_t capacityBytes, + RingCursorSet cursors) + : m_control(control), m_base(static_cast(base)), + m_capacity(capacityBytes), m_mask(capacityBytes - 1), m_cursors(cursors) { + if (control == nullptr || base == nullptr || !IsPowerOfTwo(capacityBytes) || + capacityBytes < sizeof(RingRecordHeader)) { + MGLOG_E("MG_Remote ring: consumer rejected, capacity %llu must be a power of two of at " + "least %zu bytes over a non-null mapping", + static_cast(capacityBytes), sizeof(RingRecordHeader)); + m_control = nullptr; + m_base = nullptr; + m_capacity = 0; + m_mask = 0; + return; + } + m_localTail = AppliedTail(*control, cursors).load(std::memory_order_acquire); + } + + bool RingConsumer::Pop(RingRecordView& out, bool* outCorrupt) { + if (outCorrupt != nullptr) { + *outCorrupt = false; + } + if (m_control == nullptr) { + return false; + } + const std::uint64_t head = Head(*m_control, m_cursors).load(std::memory_order_acquire); + while (m_localTail != head) { + const std::uint64_t available = head - m_localTail; + if (available < sizeof(RingRecordHeader) || available > m_capacity) { + MGLOG_E("MG_Remote ring: %llu bytes between tail and head is impossible for a %llu " + "byte ring", + static_cast(available), + static_cast(m_capacity)); + if (outCorrupt != nullptr) { + *outCorrupt = true; + } + return false; + } + const std::uint64_t offset = m_localTail & m_mask; + RingRecordHeader header{}; + std::memcpy(&header, m_base + offset, sizeof(header)); + + // SEG_CMD is written by the peer process: compile-time asserts on + // record sizes cannot see runtime corruption, so every dispatch is + // preceded by these bounds checks and a violation is fatal, never a + // retry (plan section 6.3, runtime bounds discipline). + const std::uint64_t size = header.size; + if (size < sizeof(RingRecordHeader) || (size % kRingRecordAlignment) != 0 || + size > available || offset + size > m_capacity) { + MGLOG_E("MG_Remote ring: corrupt record header at cursor %llu " + "(kind=%u flags=0x%04X size=%u available=%llu)", + static_cast(m_localTail), + static_cast(header.kind), static_cast(header.flags), + header.size, static_cast(available)); + if (outCorrupt != nullptr) { + *outCorrupt = true; + } + return false; + } + + if ((header.flags & kRecPad) != 0) { + m_localTail += size; + continue; + } + + out.kind = header.kind; + out.flags = header.flags; + out.payload = m_base + offset + sizeof(RingRecordHeader); + // Includes the alignment tail; the record catalogue knows the real + // payload length. + out.payloadSize = size - sizeof(RingRecordHeader); + out.cursor = m_localTail; + m_localTail += size; + return true; + } + return false; + } + + void RingConsumer::PublishApplied() { + if (m_control == nullptr) { + return; + } + AppliedTail(*m_control, m_cursors).store(m_localTail, std::memory_order_release); + } + + void RingConsumer::PublishRetired() { + if (m_control == nullptr) { + return; + } + // retiredTail must never overtake appliedTail, so publish both. + AppliedTail(*m_control, m_cursors).store(m_localTail, std::memory_order_release); + RetiredTail(*m_control, m_cursors).store(m_localTail, std::memory_order_release); + } + + void RingConsumer::PublishRetiredUpTo(std::uint64_t cursor) { + if (m_control == nullptr) { + return; + } + const std::uint64_t applied = AppliedTail(*m_control, m_cursors).load(std::memory_order_acquire); + const std::uint64_t clamped = cursor > applied ? applied : cursor; + const std::uint64_t current = RetiredTail(*m_control, m_cursors).load(std::memory_order_relaxed); + if (clamped > current) { + RetiredTail(*m_control, m_cursors).store(clamped, std::memory_order_release); + } + } + +} // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/Ring.h b/MobileGL/MG_Remote/Transport/Ring.h new file mode 100644 index 000000000..7fc042059 --- /dev/null +++ b/MobileGL/MG_Remote/Transport/Ring.h @@ -0,0 +1,225 @@ +// MobileGL - MobileGL/MG_Remote/Transport/Ring.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// SEG_CMD / SEG_STAGE ring control and the SPSC producer/consumer over it. +// +// RingControl is the shared page at the head of SEG_CMD, laid out exactly as +// the inherited transport design (plan section 8.1, referring the earlier +// plan's section 6.2) specifies: +// +// - TWO independent cursor triples, one for SEG_CMD and one for SEG_STAGE. +// The stage ring needs its own because "SEG_STAGE has less than a quarter +// left" is a publish trigger and that occupancy cannot be derived from the +// command ring's cursors, and because a stage slot retires on a different +// event than a command record does. +// - THREE separate sequence watermarks. Conflating them is the classic bug: +// appliedSeq releases *AppliedTail, submittedSeq releases staging, +// retiredSeq / completedFrameSerial release *RetiredTail and adopted +// stores. +// - TWO tails per ring, not one. Once the server borrows a ring slot into +// the GPU timeline instead of copying it out again, that slot can only be +// recycled after completedFrameSerial; a single tail would silently +// degrade to conservative reclaim the day borrowing lands. +// - Both park flags, because the doorbell is bidirectional: without the +// server->client direction every client wait degenerates into a +// cross-process spin on one shared cache line (a whole 16.6ms frame of a +// big core, on a phone, competing with the GPU and the game's JVM). +// +// Cursors are monotonically increasing byte counts; the ring is indexed with a +// power-of-two mask. They are never reset, so a torn read can never look like +// a valid earlier position. ringGeneration is bumped after a hard drain to +// invalidate every cached offset. +// +// Record framing inside the ring is the 8-byte header below, which is the +// layout the plan's RecHeader already fixes ({u16 kind, u16 flags, u32 size}, +// size including the header and a multiple of 8). The record CATALOGUE +// (Records.def / PipeCalls.def) is a separate deliverable; the ring itself +// only needs kind/flags/size, so it can carry the real records the day they +// land without changing shape. + +#pragma once + +#include "../Protocol/mg_protocol_base.h" + +#include +#include +#include + +namespace MobileGL::MG_Remote::Transport { + + // The shared control page. One 4 KiB page so it can be mapped alone, with + // each contended group on its own cache line. + struct alignas(4096) RingControl { + // ---- SEG_CMD cursors ------------------------------------------------ + alignas(64) std::atomic cmdHead; // producer: bytes written + alignas(64) std::atomic cmdAppliedTail; // consumer: bytes decoded/copied out + std::atomic cmdRetiredTail; // consumer: borrowed slots released + + // ---- SEG_STAGE cursors ---------------------------------------------- + alignas(64) std::atomic stageHead; + alignas(64) std::atomic stageAppliedTail; + std::atomic stageRetiredTail; + + // ---- sequence / frame watermarks ------------------------------------- + alignas(64) std::atomic appliedSeq; // records applied + std::atomic submittedSeq; // handed to the driver + std::atomic retiredSeq; // GPU finished + std::atomic completedFrameSerial; + std::atomic presentAckSerial; + + // ---- doorbell / generation ------------------------------------------- + alignas(64) std::atomic serverEpoch; // ++ on context loss / server restart + std::atomic ringGeneration; // ++ after a hard drain + std::atomic consumerParked; // server asleep, producer must ring + std::atomic producerParked; // client asleep, server must ring + std::atomic eventRingFull; // SEG_EVENT full, server stopped applying + std::atomic eventDropped; // dropped lossy events + }; + + static_assert(sizeof(RingControl) == 4096, "RingControl must be exactly one page"); + static_assert(alignof(RingControl) == 4096, "RingControl must be page aligned"); + static_assert(std::atomic::is_always_lock_free, + "the ring cursors are shared across processes: they must be lock-free"); + static_assert(std::atomic::is_always_lock_free, + "the doorbell flags are shared across processes: they must be lock-free"); + + // Per-record header. Prefix-identical to the plan's RecHeader so the + // generated record catalogue drops straight in. + struct RingRecordHeader { + std::uint16_t kind; + std::uint16_t flags; + std::uint32_t size; // header + payload + alignment padding, multiple of 8 + }; + static_assert(sizeof(RingRecordHeader) == 8, "RecHeader is 8 bytes on the wire"); + + enum RingRecordFlags : std::uint16_t { + kRecNone = 0, + kRecNeedsAck = 1u << 0, + kRecHasBlob = 1u << 1, + kRecPad = 1u << 2, // filler to the wrap boundary, no payload meaning + kRecBorrowSlot = 1u << 3, // slot is borrowed into the GPU timeline; retires late + kRecVarTail = 1u << 4, + }; + + // Reserved kind for the wrap filler. The catalogue starts at 1. + inline constexpr std::uint16_t kRingPadRecordKind = 0; + + inline constexpr std::uint64_t kRingRecordAlignment = 8; + + // Which cursor triple a producer/consumer pair drives. + enum class RingCursorSet : std::uint32_t { + Cmd = 0, + Stage = 1, + }; + + // Zeroes every cursor and starts serverEpoch / ringGeneration at 1, so that + // a zero read is always "uninitialized", never a legal generation. + void InitRingControl(RingControl& control); + + // head >= appliedTail >= retiredTail, and the ring never holds more than + // its capacity. False means the shared page is corrupt (or a peer is + // misbehaving), which is a Fatal{ProtocolCorruption}, never a retry. + bool RingCursorsValid(const RingControl& control, RingCursorSet cursors, + std::uint64_t capacityBytes); + + // Bumps ringGeneration, invalidating every offset either side has cached. + // Both sides must be quiesced and the ring fully drained + // (head == appliedTail == retiredTail); otherwise this returns + // MOBILEGL_ERR_INVALID_ARGUMENT and changes nothing. + MobileGLResult HardDrainRing(RingControl& control, RingCursorSet cursors); + + // A record as seen by the consumer. + struct RingRecordView { + std::uint16_t kind = 0; + std::uint16_t flags = 0; + const void* payload = nullptr; + std::uint64_t payloadSize = 0; + std::uint64_t cursor = 0; // producer cursor at the START of this record + }; + + // Single producer. Not thread-safe: one writer thread, by construction. + class RingProducer { + public: + RingProducer() = default; + // `base` is the ring's byte area (NOT the control page) and + // `capacityBytes` must be a power of two. + RingProducer(RingControl* control, void* base, std::uint64_t capacityBytes, + RingCursorSet cursors); + + bool Valid() const { return m_control != nullptr; } + + // Bytes still writable before the consumer has to catch up. + std::uint64_t FreeBytes() const; + + // Reserves room for one record and returns a pointer to its payload, + // or nullptr when the ring is full (or the record cannot fit at all). + // The payload is uninitialized; alignment padding at its tail is NOT + // zeroed. Emits a pad record automatically when the record would + // straddle the wrap boundary, so every record is contiguous. + void* Reserve(std::uint16_t kind, std::uint16_t flags, std::uint64_t payloadBytes); + + // Makes every reserved record visible to the consumer (release store on + // the head cursor). Cheap: publishing per record is fine, batching 8-16 + // only amortizes the doorbell store. + void Publish(); + + // Producer-local cursor including records not yet published. + std::uint64_t LocalHead() const { return m_localHead; } + std::uint64_t Capacity() const { return m_capacity; } + + private: + std::uint64_t TailForReclaim() const; + std::uint8_t* SlotAt(std::uint64_t cursor) const { + return m_base + static_cast(cursor & m_mask); + } + + RingControl* m_control = nullptr; + std::uint8_t* m_base = nullptr; + std::uint64_t m_capacity = 0; + std::uint64_t m_mask = 0; + std::uint64_t m_localHead = 0; + RingCursorSet m_cursors = RingCursorSet::Cmd; + }; + + // Single consumer. Not thread-safe: one reader thread, by construction. + class RingConsumer { + public: + RingConsumer() = default; + RingConsumer(RingControl* control, void* base, std::uint64_t capacityBytes, + RingCursorSet cursors); + + bool Valid() const { return m_control != nullptr; } + + // Pops the next record, skipping wrap fillers. Returns false when the + // ring is empty at this moment. A record whose header is impossible + // (size not 8-aligned, smaller than a header, or larger than what the + // producer has published) is refused: *outCorrupt is set, which the + // caller must escalate to Fatal{ProtocolCorruption} rather than retry. + bool Pop(RingRecordView& out, bool* outCorrupt = nullptr); + + // Publishes the applied cursor, releasing those bytes to the producer. + void PublishApplied(); + // Publishes the retired cursor. Records without kRecBorrowSlot retire + // as soon as they are applied; borrowed slots retire on + // completedFrameSerial, which is why this is a separate call. + void PublishRetired(); + void PublishRetiredUpTo(std::uint64_t cursor); + + std::uint64_t LocalTail() const { return m_localTail; } + std::uint64_t Capacity() const { return m_capacity; } + + private: + RingControl* m_control = nullptr; + const std::uint8_t* m_base = nullptr; + std::uint64_t m_capacity = 0; + std::uint64_t m_mask = 0; + std::uint64_t m_localTail = 0; + RingCursorSet m_cursors = RingCursorSet::Cmd; + }; + +} // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/ShmSegment.cpp b/MobileGL/MG_Remote/Transport/ShmSegment.cpp new file mode 100644 index 000000000..298062ddd --- /dev/null +++ b/MobileGL/MG_Remote/Transport/ShmSegment.cpp @@ -0,0 +1,49 @@ +// MobileGL - MobileGL/MG_Remote/Transport/ShmSegment.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// Platform-independent half of ShmSegment. The create/map/close bodies live in +// ShmSegmentPosix.cpp and ShmSegmentWin32.cpp. + +#include "ShmSegment.h" + +#include +#include + +namespace MobileGL::MG_Remote::Transport { + + ShmSegment::~ShmSegment() { Close(); } + + ShmSegment::ShmSegment(ShmSegment&& other) noexcept { Steal(std::move(other)); } + + ShmSegment& ShmSegment::operator=(ShmSegment&& other) noexcept { + if (this != &other) { + Close(); + Steal(std::move(other)); + } + return *this; + } + + void ShmSegment::Steal(ShmSegment&& other) noexcept { + std::memcpy(m_name, other.m_name, sizeof(m_name)); + m_mapping = other.m_mapping; + m_nativeHandle = other.m_nativeHandle; + m_size = other.m_size; + m_fd = other.m_fd; + m_readOnly = other.m_readOnly; + + std::memset(other.m_name, 0, sizeof(other.m_name)); + other.m_mapping = nullptr; + other.m_nativeHandle = nullptr; + other.m_size = 0; + other.m_fd = -1; + other.m_readOnly = false; + } + + bool ShmSegment::Valid() const { return m_size != 0 && (m_fd >= 0 || m_nativeHandle != nullptr); } + +} // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/ShmSegment.h b/MobileGL/MG_Remote/Transport/ShmSegment.h new file mode 100644 index 000000000..c40b2cb9a --- /dev/null +++ b/MobileGL/MG_Remote/Transport/ShmSegment.h @@ -0,0 +1,87 @@ +// MobileGL - MobileGL/MG_Remote/Transport/ShmSegment.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// One shared-memory segment: SEG_CMD, SEG_STAGE, SEG_REPLY, SEG_EVENT, a +// per-object SEG_SHADOW or a SEG_ADOPT store (inherited segment layout, plan +// section 8.1). +// +// Creation matrix (earlier plan section 6.1): +// - Android: ASharedMemory_create (API 26; libc's memfd_create wrapper +// only appears at API 30, which is above our floor) +// - desktop Linux: syscall(SYS_memfd_create, ...) directly, for the same +// reason - the glibc wrapper is recent and this file has to +// build against old sysroots +// - other POSIX: shm_open + immediate shm_unlink, the fd keeps it alive +// - Windows: CreateFileMappingW in the Local\ namespace +// +// Transfer is NOT done here. On POSIX the fd travels by SCM_RIGHTS +// (FdPassing.h / ITransport::ShareFd) and the name is only a debugging label; +// on Windows the section name travels inside the SegmentRef table. +// +// The Windows implementation is compile-guarded and untested at the time it +// was written: no Windows machine is a correctness gate for this project. + +#pragma once + +#include "../Protocol/mg_protocol_base.h" + +#include +#include + +namespace MobileGL::MG_Remote::Transport { + + inline constexpr std::size_t kShmNameMax = 128; + + class ShmSegment { + public: + ShmSegment() = default; + ~ShmSegment(); + + ShmSegment(const ShmSegment&) = delete; + ShmSegment& operator=(const ShmSegment&) = delete; + ShmSegment(ShmSegment&& other) noexcept; + ShmSegment& operator=(ShmSegment&& other) noexcept; + + // Creates a segment of `size` bytes owned by this process. `nameHint` + // is a short debug label (Windows: part of the section name peers + // resolve). The segment is NOT mapped yet. + static MobileGLResult Create(const char* nameHint, std::uint64_t size, ShmSegment& out); + + // POSIX only: adopts a descriptor received over SCM_RIGHTS. Takes + // ownership of `fd` on success; on failure the caller still owns it. + static MobileGLResult Adopt(int fd, std::uint64_t size, ShmSegment& out); + + // Windows only: opens a section the peer published by name. + static MobileGLResult OpenNamed(const char* name, std::uint64_t size, ShmSegment& out); + + // Maps the whole segment. Read-only mappings are what the peer gets for + // a segment it does not own (SEG_CMD/SEG_STAGE on the server side). + MobileGLResult Map(bool readOnly); + void Unmap(); + void Close(); // unmaps and releases the descriptor/handle + + bool Valid() const; + void* Data() const { return m_mapping; } + std::uint64_t Size() const { return m_size; } + bool MappedReadOnly() const { return m_readOnly; } + const char* Name() const { return m_name; } + // POSIX: the descriptor to hand to ShareFd. -1 on Windows. + int Fd() const { return m_fd; } + + private: + void Steal(ShmSegment&& other) noexcept; + + char m_name[kShmNameMax] = {}; + void* m_mapping = nullptr; + void* m_nativeHandle = nullptr; // Windows HANDLE; unused on POSIX + std::uint64_t m_size = 0; + int m_fd = -1; + bool m_readOnly = false; + }; + +} // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/ShmSegmentPosix.cpp b/MobileGL/MG_Remote/Transport/ShmSegmentPosix.cpp new file mode 100644 index 000000000..6c334d1de --- /dev/null +++ b/MobileGL/MG_Remote/Transport/ShmSegmentPosix.cpp @@ -0,0 +1,191 @@ +// MobileGL - MobileGL/MG_Remote/Transport/ShmSegmentPosix.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#include "ShmSegment.h" + +#if !defined(_WIN32) + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__ANDROID__) +#include +#elif defined(__linux__) +#include +#ifndef MFD_CLOEXEC +#define MFD_CLOEXEC 0x0001U +#endif +#endif + +namespace MobileGL::MG_Remote::Transport { + + namespace { + void CopyName(char (&dst)[kShmNameMax], const char* src) { + if (src == nullptr) { + dst[0] = '\0'; + return; + } + std::snprintf(dst, kShmNameMax, "%s", src); + } + +#if !defined(__ANDROID__) + // Unique per process; only used by the shm_open fallback, whose name + // must not collide with a concurrent creator's. + std::atomic g_shmCounter{0}; +#endif + } // namespace + + MobileGLResult ShmSegment::Create(const char* nameHint, std::uint64_t size, ShmSegment& out) { + if (size == 0) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + out.Close(); + + char label[kShmNameMax]; + std::snprintf(label, sizeof(label), "mgl-%s", nameHint != nullptr ? nameHint : "seg"); + + int fd = -1; +#if defined(__ANDROID__) + // API 26. libc's memfd_create wrapper is API 30, above MobileGL's floor. + fd = ASharedMemory_create(label, static_cast(size)); + if (fd < 0) { + MGLOG_W("MG_Remote shm: ASharedMemory_create(%s, %llu) failed (errno=%d)", label, + static_cast(size), errno); + } +#elif defined(__linux__) + // Raw syscall, not the glibc wrapper: the wrapper is too recent to rely + // on across the sysroots this builds against. + fd = static_cast(::syscall(SYS_memfd_create, label, MFD_CLOEXEC)); + if (fd >= 0 && ::ftruncate(fd, static_cast(size)) != 0) { + MGLOG_E("MG_Remote shm: ftruncate(%llu) failed (errno=%d)", + static_cast(size), errno); + ::close(fd); + fd = -1; + } +#endif + +#if !defined(__ANDROID__) + if (fd < 0) { + // Fallback: shm_open + immediate unlink. The name disappears at + // once; the descriptor is what keeps the object alive and what + // travels by SCM_RIGHTS. + char shmName[kShmNameMax]; + std::snprintf(shmName, sizeof(shmName), "/mgl-%d-%u-%s", static_cast(::getpid()), + g_shmCounter.fetch_add(1, std::memory_order_relaxed), + nameHint != nullptr ? nameHint : "seg"); + fd = ::shm_open(shmName, O_RDWR | O_CREAT | O_EXCL, 0600); + if (fd < 0) { + MGLOG_E("MG_Remote shm: shm_open(%s) failed (errno=%d)", shmName, errno); + return MOBILEGL_ERR_SHM_EXHAUSTED; + } + ::shm_unlink(shmName); + if (::ftruncate(fd, static_cast(size)) != 0) { + MGLOG_E("MG_Remote shm: ftruncate(%llu) failed (errno=%d)", + static_cast(size), errno); + ::close(fd); + return MOBILEGL_ERR_SHM_EXHAUSTED; + } + CopyName(out.m_name, shmName); + } else { + CopyName(out.m_name, label); + } +#else + if (fd < 0) { + return MOBILEGL_ERR_SHM_EXHAUSTED; + } + CopyName(out.m_name, label); +#endif + + out.m_fd = fd; + out.m_size = size; + out.m_nativeHandle = nullptr; + out.m_mapping = nullptr; + out.m_readOnly = false; + return MOBILEGL_OK; + } + + MobileGLResult ShmSegment::Adopt(int fd, std::uint64_t size, ShmSegment& out) { + if (fd < 0 || size == 0) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + // The peer's declared size is not trusted: a segment smaller than what + // the announcement claims would turn every later offset into an + // out-of-bounds map. + struct stat st{}; + if (::fstat(fd, &st) == 0 && st.st_size > 0 && + static_cast(st.st_size) < size) { + MGLOG_E("MG_Remote shm: peer announced %llu bytes but the descriptor is %lld", + static_cast(size), static_cast(st.st_size)); + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + out.Close(); + out.m_fd = fd; // ownership transferred + out.m_size = size; + out.m_nativeHandle = nullptr; + out.m_mapping = nullptr; + out.m_readOnly = false; + CopyName(out.m_name, "adopted"); + return MOBILEGL_OK; + } + + MobileGLResult ShmSegment::OpenNamed(const char*, std::uint64_t, ShmSegment&) { + // POSIX shares descriptors, not names. + return MOBILEGL_ERR_UNSUPPORTED; + } + + MobileGLResult ShmSegment::Map(bool readOnly) { + if (m_fd < 0 || m_size == 0) { + return MOBILEGL_ERR_NOT_INITIALIZED; + } + if (m_mapping != nullptr) { + if (m_readOnly == readOnly) { + return MOBILEGL_OK; + } + Unmap(); + } + const int prot = readOnly ? PROT_READ : (PROT_READ | PROT_WRITE); + void* addr = ::mmap(nullptr, static_cast(m_size), prot, MAP_SHARED, m_fd, 0); + if (addr == MAP_FAILED) { + MGLOG_E("MG_Remote shm: mmap of %llu bytes failed (errno=%d)", + static_cast(m_size), errno); + return MOBILEGL_ERR_OUT_OF_MEMORY; + } + m_mapping = addr; + m_readOnly = readOnly; + return MOBILEGL_OK; + } + + void ShmSegment::Unmap() { + if (m_mapping != nullptr) { + ::munmap(m_mapping, static_cast(m_size)); + m_mapping = nullptr; + } + } + + void ShmSegment::Close() { + Unmap(); + if (m_fd >= 0) { + ::close(m_fd); + m_fd = -1; + } + m_size = 0; + m_readOnly = false; + m_name[0] = '\0'; + } + +} // namespace MobileGL::MG_Remote::Transport + +#endif // !_WIN32 diff --git a/MobileGL/MG_Remote/Transport/ShmSegmentWin32.cpp b/MobileGL/MG_Remote/Transport/ShmSegmentWin32.cpp new file mode 100644 index 000000000..8fc07ea10 --- /dev/null +++ b/MobileGL/MG_Remote/Transport/ShmSegmentWin32.cpp @@ -0,0 +1,162 @@ +// MobileGL - MobileGL/MG_Remote/Transport/ShmSegmentWin32.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// Windows half of ShmSegment: a named file-mapping section in the Local\ +// namespace, which the peer opens by the name carried in SegmentRef. +// +// UNTESTED. This project's Windows machine is not a correctness gate (its +// Vulkan lacks vkCreateHeadlessSurfaceEXT and accounts for most of its +// baseline integration failures), and the whole disaggregated build is gated +// behind MOBILEGL_BUILD_DISAGGREGATED, which is OFF by default. It is written +// now so the abstraction is shaped by two real platforms rather than one. + +#include "ShmSegment.h" + +#if defined(_WIN32) + +#include + +#include +#include +#include + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include + +namespace MobileGL::MG_Remote::Transport { + + namespace { + std::atomic g_sectionCounter{0}; + + bool ToWide(const char* utf8, wchar_t* out, int outChars) { + if (utf8 == nullptr || out == nullptr || outChars <= 0) { + return false; + } + const int written = ::MultiByteToWideChar(CP_UTF8, 0, utf8, -1, out, outChars); + return written > 0; + } + } // namespace + + MobileGLResult ShmSegment::Create(const char* nameHint, std::uint64_t size, ShmSegment& out) { + if (size == 0) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + out.Close(); + + char name[kShmNameMax]; + std::snprintf(name, sizeof(name), "Local\\mgl-%lu-%u-%s", + static_cast(::GetCurrentProcessId()), + g_sectionCounter.fetch_add(1, std::memory_order_relaxed), + nameHint != nullptr ? nameHint : "seg"); + + wchar_t wide[kShmNameMax]; + if (!ToWide(name, wide, static_cast(kShmNameMax))) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + + HANDLE section = ::CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, + static_cast(size >> 32), + static_cast(size & 0xFFFFFFFFull), wide); + if (section == nullptr) { + MGLOG_E("MG_Remote shm: CreateFileMappingW(%s, %llu) failed (GetLastError=%lu)", name, + static_cast(size), + static_cast(::GetLastError())); + return MOBILEGL_ERR_SHM_EXHAUSTED; + } + if (::GetLastError() == ERROR_ALREADY_EXISTS) { + ::CloseHandle(section); + MGLOG_E("MG_Remote shm: section name %s already exists", name); + return MOBILEGL_ERR_SHM_EXHAUSTED; + } + + std::snprintf(out.m_name, kShmNameMax, "%s", name); + out.m_nativeHandle = section; + out.m_size = size; + out.m_fd = -1; + out.m_mapping = nullptr; + out.m_readOnly = false; + return MOBILEGL_OK; + } + + MobileGLResult ShmSegment::Adopt(int, std::uint64_t, ShmSegment&) { + // No SCM_RIGHTS here: Windows peers resolve the section by name. + return MOBILEGL_ERR_UNSUPPORTED; + } + + MobileGLResult ShmSegment::OpenNamed(const char* name, std::uint64_t size, ShmSegment& out) { + if (name == nullptr || name[0] == '\0' || size == 0) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + out.Close(); + + wchar_t wide[kShmNameMax]; + if (!ToWide(name, wide, static_cast(kShmNameMax))) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + HANDLE section = ::OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE, wide); + if (section == nullptr) { + MGLOG_E("MG_Remote shm: OpenFileMappingW(%s) failed (GetLastError=%lu)", name, + static_cast(::GetLastError())); + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + std::snprintf(out.m_name, kShmNameMax, "%s", name); + out.m_nativeHandle = section; + out.m_size = size; + out.m_fd = -1; + out.m_mapping = nullptr; + out.m_readOnly = false; + return MOBILEGL_OK; + } + + MobileGLResult ShmSegment::Map(bool readOnly) { + if (m_nativeHandle == nullptr || m_size == 0) { + return MOBILEGL_ERR_NOT_INITIALIZED; + } + if (m_mapping != nullptr) { + if (m_readOnly == readOnly) { + return MOBILEGL_OK; + } + Unmap(); + } + void* view = ::MapViewOfFile(static_cast(m_nativeHandle), + readOnly ? FILE_MAP_READ : FILE_MAP_ALL_ACCESS, 0, 0, + static_cast(m_size)); + if (view == nullptr) { + MGLOG_E("MG_Remote shm: MapViewOfFile of %llu bytes failed (GetLastError=%lu)", + static_cast(m_size), + static_cast(::GetLastError())); + return MOBILEGL_ERR_OUT_OF_MEMORY; + } + m_mapping = view; + m_readOnly = readOnly; + return MOBILEGL_OK; + } + + void ShmSegment::Unmap() { + if (m_mapping != nullptr) { + ::UnmapViewOfFile(m_mapping); + m_mapping = nullptr; + } + } + + void ShmSegment::Close() { + Unmap(); + if (m_nativeHandle != nullptr) { + ::CloseHandle(static_cast(m_nativeHandle)); + m_nativeHandle = nullptr; + } + m_size = 0; + m_readOnly = false; + m_name[0] = '\0'; + } + +} // namespace MobileGL::MG_Remote::Transport + +#endif // _WIN32 From 10315e71f31fb168e5192ff385d4451e06a9e904 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 14:06:12 -0400 Subject: [PATCH 016/529] [Test] (MG_Remote, CI): cover the wire layer with five suites and gate the generated header with flatc-check - MobileGL/MG_Test/Wire, 42 gtest cases in five binaries, ctest label `unit`, registered only when MOBILEGL_BUILD_DISAGGREGATED is ON so the default configuration is untouched. - FdPassingTest is the one the earlier branch could not have had: it forks a child that creates a shared segment, fills 64 KiB with a pattern and hands the descriptor over SCM_RIGHTS; the parent adopts it, maps it read-only and compares every byte. Everything in Feat/CS-Delta-IPC ran in one process, which is why `out->fd = -1` survived unnoticed. It also pins that a too-small sideband buffer is refused before the datagram is consumed (so no descriptor is dropped), that an empty sideband still carries its fd, and that a receive with no offer times out. The `spawn` SocketDoorbell is covered in the same file because it rides the same kind of socket: a parked waiter woken through NotifyIfParked, a clean timeout, and a wakeup that arrives before anyone parks being remembered and then consumed exactly once. - RingTest: control-page size/alignment/cache-line layout, a non-power-of-two capacity refused, payload alignment, 200 records driven through a 256-byte ring so the wrap filler path runs repeatedly and no record ever straddles the boundary, backpressure (full ring refuses, applied alone frees nothing, retired frees), a record larger than the ring refused, the generation bump refused while records are in flight and accepted once quiesced, a corrupt header refused instead of dispatched, and a 20000-record two-thread producer/consumer run checking order, content and the final cursor equality. - FramingTest: byte-at-a-time reassembly of two frames, the magic reading "MGLF" on the wire, empty payloads, a bad magic and an oversized length each latching the reader dead (the old code hung silently instead), the send-side cap, and the buffer-too-small contract keeping the message so the retry still finds it. - InProcessTransportTest: both directions, ordering, buffer-too-small, poll and timeout, shutdown draining queued messages before it closes, a blocked receiver woken by a send and by a shutdown, the frame cap, descriptor hand-off with its sideband, and three condvar doorbell cases - a parked waiter woken through NotifyIfParked, an already-true condition that must never park, and a clean timeout. - ProtocolSmokeTest: a Hello built and read back through the committed generated header, a Welcome carrying the four segment announcements, the same buffer travelling across the transport unchanged, a truncated buffer failing verification rather than being read, and the CtrlMsg / SegmentKind / LogLevel / FatalCode tag values frozen - they are wire numbers, so if that test has to be edited the schema change was a wire break. - CI: a `flatc-check` job in test.yml that checks out only the flatbuffers submodule, builds the pinned flatc through scripts/gen_protocol.py into the runner temp directory, regenerates and runs `git diff --exit-code` on protocol_generated.h. It needs no MobileGL build, so it does not depend on build-linux (plan B section 8.1 / earlier section 7.1: the committed header plus a CI regeneration check, and no flatc in the default build graph). - Verified: `ctest -L unit` is green in both configurations - 1382 cases with the option OFF and 1424 with it ON (the same 1382 plus these 42); the nm gate is 0 matches OFF and 93 ON; regeneration of protocol_generated.h is byte-identical, and the flatc-check gate goes red on a hand-edited header and green again after a clean regeneration. --- .github/workflows/test.yml | 27 ++ MobileGL/MG_Test/CMakeLists.txt | 5 + MobileGL/MG_Test/Wire/CMakeLists.txt | 36 ++ MobileGL/MG_Test/Wire/FdPassingTest.cpp | 250 ++++++++++++++ MobileGL/MG_Test/Wire/FramingTest.cpp | 197 +++++++++++ .../MG_Test/Wire/InProcessTransportTest.cpp | 276 +++++++++++++++ MobileGL/MG_Test/Wire/ProtocolSmokeTest.cpp | 149 ++++++++ MobileGL/MG_Test/Wire/RingTest.cpp | 325 ++++++++++++++++++ 8 files changed, 1265 insertions(+) create mode 100644 MobileGL/MG_Test/Wire/CMakeLists.txt create mode 100644 MobileGL/MG_Test/Wire/FdPassingTest.cpp create mode 100644 MobileGL/MG_Test/Wire/FramingTest.cpp create mode 100644 MobileGL/MG_Test/Wire/InProcessTransportTest.cpp create mode 100644 MobileGL/MG_Test/Wire/ProtocolSmokeTest.cpp create mode 100644 MobileGL/MG_Test/Wire/RingTest.cpp diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fa7b37771..e29f42ae6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -295,6 +295,33 @@ jobs: path: /tmp/core.* if-no-files-found: ignore + # MobileGL/MG_Remote/Protocol/generated/protocol_generated.h is COMMITTED, and + # flatc is deliberately absent from the default build graph (a codegen step in + # the graph is how the earlier branch ended up cross-compiling an arm64 flatc + # and trying to run it on the host). This job is what keeps the committed + # header honest: build the pinned flatc, regenerate, and fail on any diff. + # It needs no MobileGL build, so it does not depend on build-linux. + flatc-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Get CMake + uses: lukka/get-cmake@v4.3.3 + + - name: Check out the FlatBuffers submodule only + # Just this one: the schema check has nothing to do with glslang, + # SPIRV-Cross or the trace fixtures. + run: git submodule update --init 3rdparty/flatbuffers + + - name: Regenerate protocol_generated.h + run: python3 scripts/gen_protocol.py --build-dir "${{ runner.temp }}/flatc-build" + + - name: Fail if the committed header is stale + run: git diff --exit-code -- MobileGL/MG_Remote/Protocol/generated/protocol_generated.h + benchmark: runs-on: ubuntu-latest needs: build-linux diff --git a/MobileGL/MG_Test/CMakeLists.txt b/MobileGL/MG_Test/CMakeLists.txt index 14615b54e..5811bb226 100644 --- a/MobileGL/MG_Test/CMakeLists.txt +++ b/MobileGL/MG_Test/CMakeLists.txt @@ -88,3 +88,8 @@ add_subdirectory(Backend/DirectGLES) if (ENABLE_INTEGRATION_TESTS) add_subdirectory(Backend/DirectVulkan) endif() +# The wire layer only exists in the disaggregated configuration, so its suite +# is only registered there. Nothing under MG_Remote is compiled otherwise. +if (MOBILEGL_BUILD_DISAGGREGATED) + add_subdirectory(Wire) +endif() diff --git a/MobileGL/MG_Test/Wire/CMakeLists.txt b/MobileGL/MG_Test/Wire/CMakeLists.txt new file mode 100644 index 000000000..0af34f0bc --- /dev/null +++ b/MobileGL/MG_Test/Wire/CMakeLists.txt @@ -0,0 +1,36 @@ +cmake_minimum_required(VERSION 3.14) + +# The MG_Remote wire layer: framing, the SPSC ring, the in-process transport, +# SCM_RIGHTS descriptor passing and the generated control-plane schema. Only +# reachable with MOBILEGL_BUILD_DISAGGREGATED=ON (see MG_Test/CMakeLists.txt). + +set(MOBILEGL_WIRE_TESTS + FramingTest + RingTest + InProcessTransportTest + ProtocolSmokeTest +) + +if (NOT WIN32) + # SCM_RIGHTS and fork(): POSIX only. + list(APPEND MOBILEGL_WIRE_TESTS FdPassingTest) +endif() + +include(GoogleTest) + +foreach (test IN LISTS MOBILEGL_WIRE_TESTS) + add_executable(${test} ${test}.cpp) + + target_include_directories(${test} PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/3rdparty/flatbuffers/include + ) + + target_link_libraries(${test} PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} + ) + + gtest_discover_tests(${test} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +endforeach () diff --git a/MobileGL/MG_Test/Wire/FdPassingTest.cpp b/MobileGL/MG_Test/Wire/FdPassingTest.cpp new file mode 100644 index 000000000..21f58f5fd --- /dev/null +++ b/MobileGL/MG_Test/Wire/FdPassingTest.cpp @@ -0,0 +1,250 @@ +// MobileGL - MobileGL/MG_Test/Wire/FdPassingTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// SCM_RIGHTS across a real process boundary: a forked child creates a shared +// segment, fills it, and hands the descriptor over the aux socket; the parent +// adopts it, maps it read-only and compares every byte. +// +// This is the test the earlier branch never had. Its transport hardcoded +// `out->fd = -1` in the offer poll, so its data plane could not move a byte +// between processes - and nothing in its suite noticed, because everything ran +// in one process. + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MobileGL::MG_Remote::Transport; + +namespace { + + constexpr std::uint64_t kSegmentSize = 64 * 1024; + + std::uint8_t ByteAt(std::uint64_t index) { + return static_cast((index * 31u + 7u) & 0xFFu); + } + + // Child-side exit codes, so a failure says where it happened. + enum ChildStatus : int { + kChildOk = 0, + kChildCreateFailed = 2, + kChildMapFailed = 3, + kChildSendFailed = 4, + }; + +} // namespace + +TEST(FdPassingTest, IsSupportedOnThisPlatform) { EXPECT_TRUE(FdPassing::Supported()); } + +TEST(FdPassingTest, ChildSharesASegmentThatTheParentMapsAndVerifies) { + int sockets[2] = {-1, -1}; + ASSERT_EQ(FdPassing::CreateSocketPair(sockets), MOBILEGL_OK); + + const std::string sideband = "SegmentRef{id=7,kind=Stage}"; + + const pid_t pid = ::fork(); + ASSERT_GE(pid, 0); + if (pid == 0) { + // Child. No gtest assertions here: a failed expectation in a forked + // child would report into a copy of the parent's test state. + ::close(sockets[0]); + int status = kChildOk; + ShmSegment segment; + if (ShmSegment::Create("fdpass", kSegmentSize, segment) != MOBILEGL_OK) { + status = kChildCreateFailed; + } else if (segment.Map(false) != MOBILEGL_OK) { + status = kChildMapFailed; + } else { + auto* bytes = static_cast(segment.Data()); + for (std::uint64_t i = 0; i < kSegmentSize; ++i) { + bytes[i] = ByteAt(i); + } + const MobileGLByteSpan span{sideband.data(), sideband.size()}; + if (FdPassing::SendFd(sockets[1], segment.Fd(), span) != MOBILEGL_OK) { + status = kChildSendFailed; + } + } + ::close(sockets[1]); + ::_exit(status); + } + + // Parent. + ::close(sockets[1]); + + // A destination smaller than kMaxSidebandBytes is refused BEFORE the + // datagram is consumed, so the descriptor is not lost by a caller that + // guessed the size wrong. + std::vector small(8); + int fd = -1; + std::uint64_t required = 0; + MobileGLMutableByteSpan smallSpan{small.data(), small.size()}; + EXPECT_EQ(FdPassing::ReceiveFd(sockets[0], &fd, smallSpan, &required, 5000), + MOBILEGL_ERR_BUFFER_TOO_SMALL); + EXPECT_EQ(required, FdPassing::kMaxSidebandBytes); + EXPECT_EQ(fd, -1); + + std::vector sidebandBuffer(FdPassing::kMaxSidebandBytes); + std::uint64_t sidebandSize = 0; + MobileGLMutableByteSpan sidebandSpan{sidebandBuffer.data(), sidebandBuffer.size()}; + ASSERT_EQ(FdPassing::ReceiveFd(sockets[0], &fd, sidebandSpan, &sidebandSize, 5000), + MOBILEGL_OK); + ASSERT_GE(fd, 0); + EXPECT_EQ(std::string(reinterpret_cast(sidebandBuffer.data()), + static_cast(sidebandSize)), + sideband); + + ShmSegment adopted; + ASSERT_EQ(ShmSegment::Adopt(fd, kSegmentSize, adopted), MOBILEGL_OK); + EXPECT_TRUE(adopted.Valid()); + ASSERT_EQ(adopted.Map(true), MOBILEGL_OK); + EXPECT_TRUE(adopted.MappedReadOnly()); + + const auto* bytes = static_cast(adopted.Data()); + ASSERT_NE(bytes, nullptr); + std::uint64_t mismatches = 0; + for (std::uint64_t i = 0; i < kSegmentSize; ++i) { + if (bytes[i] != ByteAt(i)) { + ++mismatches; + } + } + EXPECT_EQ(mismatches, 0u); + + int childStatus = 0; + ASSERT_EQ(::waitpid(pid, &childStatus, 0), pid); + ASSERT_TRUE(WIFEXITED(childStatus)); + EXPECT_EQ(WEXITSTATUS(childStatus), kChildOk); + + ::close(sockets[0]); +} + +TEST(FdPassingTest, ReceiveTimesOutWithNoOffer) { + int sockets[2] = {-1, -1}; + ASSERT_EQ(FdPassing::CreateSocketPair(sockets), MOBILEGL_OK); + + std::vector sidebandBuffer(FdPassing::kMaxSidebandBytes); + int fd = -1; + std::uint64_t sidebandSize = 0; + MobileGLMutableByteSpan span{sidebandBuffer.data(), sidebandBuffer.size()}; + EXPECT_EQ(FdPassing::ReceiveFd(sockets[0], &fd, span, &sidebandSize, 20), MOBILEGL_ERR_TIMEOUT); + EXPECT_EQ(fd, -1); + + ::close(sockets[0]); + ::close(sockets[1]); +} + +TEST(FdPassingTest, RejectsBadArguments) { + int sockets[2] = {-1, -1}; + ASSERT_EQ(FdPassing::CreateSocketPair(sockets), MOBILEGL_OK); + + const std::vector tooBig(FdPassing::kMaxSidebandBytes + 1, 0); + const MobileGLByteSpan oversized{tooBig.data(), tooBig.size()}; + EXPECT_EQ(FdPassing::SendFd(sockets[1], sockets[0], oversized), MOBILEGL_ERR_INVALID_ARGUMENT); + EXPECT_EQ(FdPassing::SendFd(sockets[1], -1, MobileGLByteSpan{nullptr, 0}), + MOBILEGL_ERR_INVALID_ARGUMENT); + + ::close(sockets[0]); + ::close(sockets[1]); +} + +TEST(FdPassingTest, SegmentWithoutASidebandStillCarriesItsDescriptor) { + int sockets[2] = {-1, -1}; + ASSERT_EQ(FdPassing::CreateSocketPair(sockets), MOBILEGL_OK); + + ShmSegment segment; + ASSERT_EQ(ShmSegment::Create("nosideband", 4096, segment), MOBILEGL_OK); + ASSERT_EQ(segment.Map(false), MOBILEGL_OK); + static_cast(segment.Data())[0] = 0xA5; + + ASSERT_EQ(FdPassing::SendFd(sockets[1], segment.Fd(), MobileGLByteSpan{nullptr, 0}), + MOBILEGL_OK); + + std::vector sidebandBuffer(FdPassing::kMaxSidebandBytes); + int fd = -1; + std::uint64_t sidebandSize = 123; + MobileGLMutableByteSpan span{sidebandBuffer.data(), sidebandBuffer.size()}; + ASSERT_EQ(FdPassing::ReceiveFd(sockets[0], &fd, span, &sidebandSize, 5000), MOBILEGL_OK); + EXPECT_EQ(sidebandSize, 0u); + ASSERT_GE(fd, 0); + + ShmSegment adopted; + ASSERT_EQ(ShmSegment::Adopt(fd, 4096, adopted), MOBILEGL_OK); + ASSERT_EQ(adopted.Map(true), MOBILEGL_OK); + EXPECT_EQ(static_cast(adopted.Data())[0], 0xA5); + + ::close(sockets[0]); + ::close(sockets[1]); +} + +// The `spawn` doorbell rides the same kind of socket as the fd channel, so it +// is covered here rather than beside the in-process one. +TEST(FdPassingTest, SocketDoorbellWakesAParkedWaiter) { + int sockets[2] = {-1, -1}; + ASSERT_EQ(FdPassing::CreateSocketPair(sockets), MOBILEGL_OK); + + // One end each: the waiter reads its own end, the notifier writes the + // other, exactly as the two processes will. + SocketDoorbell waiterBell(sockets[0], kDoorbellWatermarkAdvanced, /*ownsFd=*/false); + SocketDoorbell notifierBell(sockets[1], kDoorbellWatermarkAdvanced, /*ownsFd=*/false); + + std::atomic parked{0}; + std::atomic ready{false}; + std::atomic woke{false}; + + std::thread waiter([&] { + woke.store(waiterBell.Wait( + parked, [&] { return ready.load(std::memory_order_acquire); }, kDefaultSpinUs, 5000)); + }); + + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + ready.store(true, std::memory_order_release); + NotifyIfParked(notifierBell, parked); + + waiter.join(); + EXPECT_TRUE(woke.load()); + EXPECT_EQ(parked.load(), 0u); + + ::close(sockets[0]); + ::close(sockets[1]); +} + +TEST(FdPassingTest, SocketDoorbellTimesOutAndRemembersAnEarlyWakeup) { + int sockets[2] = {-1, -1}; + ASSERT_EQ(FdPassing::CreateSocketPair(sockets), MOBILEGL_OK); + + SocketDoorbell waiterBell(sockets[0], kDoorbellRingAdvanced, /*ownsFd=*/false); + SocketDoorbell notifierBell(sockets[1], kDoorbellRingAdvanced, /*ownsFd=*/false); + + // Nothing rings: the park has to end on its deadline, not hang. + const auto start = std::chrono::steady_clock::now(); + EXPECT_FALSE(waiterBell.Park(30)); + EXPECT_GE(std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(), + 20); + + // A wakeup that arrives before anyone parks is not lost - it is sitting in + // the socket buffer, so the next Park returns at once. + notifierBell.Notify(); + EXPECT_TRUE(waiterBell.Park(1000)); + // ...and it was consumed, so the one after that times out again. + EXPECT_FALSE(waiterBell.Park(10)); + + ::close(sockets[0]); + ::close(sockets[1]); +} diff --git a/MobileGL/MG_Test/Wire/FramingTest.cpp b/MobileGL/MG_Test/Wire/FramingTest.cpp new file mode 100644 index 000000000..6632825b6 --- /dev/null +++ b/MobileGL/MG_Test/Wire/FramingTest.cpp @@ -0,0 +1,197 @@ +// MobileGL - MobileGL/MG_Test/Wire/FramingTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The control-channel frame codec, and specifically the two contracts the +// earlier branch's codec got wrong: a bad header must be REPORTED (it used to +// turn into a silent permanent hang) and a too-small destination buffer must +// KEEP the message (it used to fail the call and drop it, wedging the stream). + +#include + +#include + +#include +#include +#include + +using namespace MobileGL::MG_Remote::Transport; + +namespace { + + std::vector Pattern(std::size_t size, std::uint8_t seed) { + std::vector out(size); + for (std::size_t i = 0; i < size; ++i) { + out[i] = static_cast(seed + i * 7u); + } + return out; + } + +} // namespace + +TEST(FramingTest, RoundTripsTwoMessagesFedOneByteAtATime) { + const std::vector first = Pattern(37, 0x11); + const std::vector second = Pattern(120, 0x83); + + std::vector stream; + ASSERT_EQ(AppendFrame(stream, first.data(), first.size()), MOBILEGL_OK); + ASSERT_EQ(AppendFrame(stream, second.data(), second.size()), MOBILEGL_OK); + EXPECT_EQ(stream.size(), 2 * kFrameHeaderSize + first.size() + second.size()); + + // A stream transport hands over arbitrary fragments; one byte at a time is + // the worst case and must work. + FrameReader reader; + std::vector> received; + for (std::uint8_t byte : stream) { + ASSERT_EQ(reader.Feed(&byte, 1), MOBILEGL_OK); + while (reader.HasMessage()) { + std::vector message; + ASSERT_EQ(reader.TakeMessage(message), MOBILEGL_OK); + received.push_back(std::move(message)); + } + } + + ASSERT_EQ(received.size(), 2u); + EXPECT_EQ(received[0], first); + EXPECT_EQ(received[1], second); + EXPECT_FALSE(reader.Failed()); + EXPECT_EQ(reader.BufferedBytes(), 0u); +} + +TEST(FramingTest, MagicIsOnTheWireAsMGLF) { + std::vector stream; + const std::uint8_t payload = 0xAB; + ASSERT_EQ(AppendFrame(stream, &payload, 1), MOBILEGL_OK); + ASSERT_GE(stream.size(), 4u); + EXPECT_EQ(stream[0], 'M'); + EXPECT_EQ(stream[1], 'G'); + EXPECT_EQ(stream[2], 'L'); + EXPECT_EQ(stream[3], 'F'); +} + +TEST(FramingTest, EmptyPayloadRoundTrips) { + std::vector stream; + ASSERT_EQ(AppendFrame(stream, nullptr, 0), MOBILEGL_OK); + + FrameReader reader; + ASSERT_EQ(reader.Feed(stream.data(), stream.size()), MOBILEGL_OK); + ASSERT_TRUE(reader.HasMessage()); + EXPECT_EQ(reader.PendingMessageSize(), 0u); + + std::vector message{0xFF}; + ASSERT_EQ(reader.TakeMessage(message), MOBILEGL_OK); + EXPECT_TRUE(message.empty()); +} + +TEST(FramingTest, BadMagicIsReportedAndLatchesTheReaderDead) { + std::uint8_t header[8] = {}; + const std::uint32_t wrongMagic = 0xDEADBEEF; + const std::uint32_t length = 4; + std::memcpy(header + 0, &wrongMagic, sizeof(wrongMagic)); + std::memcpy(header + 4, &length, sizeof(length)); + + FrameReader reader; + // The failure surfaces at Feed time, not as a message that never arrives. + EXPECT_EQ(reader.Feed(header, sizeof(header)), MOBILEGL_ERR_PROTOCOL_MISMATCH); + EXPECT_TRUE(reader.Failed()); + EXPECT_FALSE(reader.HasMessage()); + + // And it stays dead: a desynchronized stream is never re-synchronized by + // feeding it more bytes. + const std::uint8_t more[4] = {1, 2, 3, 4}; + EXPECT_EQ(reader.Feed(more, sizeof(more)), MOBILEGL_ERR_PROTOCOL_MISMATCH); + std::vector message; + EXPECT_EQ(reader.TakeMessage(message), MOBILEGL_ERR_PROTOCOL_MISMATCH); +} + +TEST(FramingTest, OversizedLengthIsRejectedBeforeAnyAllocation) { + std::uint8_t header[8] = {}; + const std::uint32_t magic = kFrameMagic; + const std::uint32_t length = static_cast(kMaxFramePayloadSize) + 1; + std::memcpy(header + 0, &magic, sizeof(magic)); + std::memcpy(header + 4, &length, sizeof(length)); + + FrameReader reader; + EXPECT_EQ(reader.Feed(header, sizeof(header)), MOBILEGL_ERR_PROTOCOL_MISMATCH); + EXPECT_TRUE(reader.Failed()); +} + +TEST(FramingTest, SendRefusesAPayloadOverTheCap) { + std::vector stream; + const std::uint8_t dummy = 0; + // The size check happens before the payload is touched, so no 64MiB + // allocation is needed to cover it. + EXPECT_EQ(AppendFrame(stream, &dummy, kMaxFramePayloadSize + 1), MOBILEGL_ERR_INVALID_ARGUMENT); + EXPECT_TRUE(stream.empty()); +} + +TEST(FramingTest, BufferTooSmallReportsTheSizeAndKeepsTheMessage) { + const std::vector payload = Pattern(200, 0x5A); + std::vector stream; + ASSERT_EQ(AppendFrame(stream, payload.data(), payload.size()), MOBILEGL_OK); + + FrameReader reader; + ASSERT_EQ(reader.Feed(stream.data(), stream.size()), MOBILEGL_OK); + ASSERT_TRUE(reader.HasMessage()); + + std::vector small(8); + std::uint64_t required = 0; + MobileGLMutableByteSpan smallSpan{small.data(), small.size()}; + EXPECT_EQ(reader.TakeMessage(smallSpan, &required), MOBILEGL_ERR_BUFFER_TOO_SMALL); + EXPECT_EQ(required, payload.size()); + + // Still there. This is the whole point: the old transport dropped it here + // and the stream never recovered. + ASSERT_TRUE(reader.HasMessage()); + + std::vector big(required); + std::uint64_t got = 0; + MobileGLMutableByteSpan bigSpan{big.data(), big.size()}; + ASSERT_EQ(reader.TakeMessage(bigSpan, &got), MOBILEGL_OK); + EXPECT_EQ(got, payload.size()); + EXPECT_EQ(big, payload); + EXPECT_FALSE(reader.HasMessage()); +} + +TEST(FramingTest, TakeWithNoCompleteMessageDoesNotBlockOrCorrupt) { + const std::vector payload = Pattern(64, 0x22); + std::vector stream; + ASSERT_EQ(AppendFrame(stream, payload.data(), payload.size()), MOBILEGL_OK); + + FrameReader reader; + // Header plus half the payload. + ASSERT_EQ(reader.Feed(stream.data(), kFrameHeaderSize + 32), MOBILEGL_OK); + EXPECT_FALSE(reader.HasMessage()); + EXPECT_EQ(reader.PendingMessageSize(), 0u); + + std::vector message; + EXPECT_EQ(reader.TakeMessage(message), MOBILEGL_ERR_TIMEOUT); + + ASSERT_EQ(reader.Feed(stream.data() + kFrameHeaderSize + 32, + stream.size() - kFrameHeaderSize - 32), + MOBILEGL_OK); + ASSERT_TRUE(reader.HasMessage()); + ASSERT_EQ(reader.TakeMessage(message), MOBILEGL_OK); + EXPECT_EQ(message, payload); +} + +TEST(FramingTest, ManyMessagesCompactTheBufferInsteadOfGrowing) { + // Drives the reader past its compaction threshold so the "consumed bytes + // are reclaimed" path is actually taken. + const std::vector payload = Pattern(1024, 0x07); + FrameReader reader; + for (int i = 0; i < 300; ++i) { + std::vector stream; + ASSERT_EQ(AppendFrame(stream, payload.data(), payload.size()), MOBILEGL_OK); + ASSERT_EQ(reader.Feed(stream.data(), stream.size()), MOBILEGL_OK); + ASSERT_TRUE(reader.HasMessage()); + std::vector message; + ASSERT_EQ(reader.TakeMessage(message), MOBILEGL_OK); + ASSERT_EQ(message, payload); + } + EXPECT_EQ(reader.BufferedBytes(), 0u); +} diff --git a/MobileGL/MG_Test/Wire/InProcessTransportTest.cpp b/MobileGL/MG_Test/Wire/InProcessTransportTest.cpp new file mode 100644 index 000000000..eed66577f --- /dev/null +++ b/MobileGL/MG_Test/Wire/InProcessTransportTest.cpp @@ -0,0 +1,276 @@ +// MobileGL - MobileGL/MG_Test/Wire/InProcessTransportTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The `inproc` transport: message queues in both directions, the +// buffer-too-small contract, shutdown semantics, descriptor hand-off, and the +// condvar doorbells the rings park on. + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#endif + +using namespace MobileGL::MG_Remote::Transport; + +namespace { + + MobileGLByteSpan Span(const std::string& text) { + return MobileGLByteSpan{text.data(), text.size()}; + } + + std::string Receive(ITransport& transport, std::uint32_t timeoutMs = 1000) { + std::vector buffer(4096); + std::uint64_t size = 0; + MobileGLMutableByteSpan span{buffer.data(), buffer.size()}; + const MobileGLResult result = transport.ReceiveFrame(span, &size, timeoutMs); + if (result != MOBILEGL_OK) { + return std::string("(result)) + ">"; + } + return std::string(reinterpret_cast(buffer.data()), + static_cast(size)); + } + +} // namespace + +TEST(InProcessTransportTest, CarriesFramesInBothDirections) { + std::unique_ptr client; + std::unique_ptr server; + InProcessTransport::CreatePair(client, server); + ASSERT_TRUE(client && server); + EXPECT_EQ(client->Role(), TransportRole::InProcess); + + const std::string hello = "Hello{abiMajor=1}"; + const std::string welcome = "Welcome{serverPid=42}"; + ASSERT_EQ(client->SendFrame(Span(hello)), MOBILEGL_OK); + EXPECT_EQ(server->PeekFrameSize(), hello.size()); + // A message goes to the PEER's inbox, never back to the sender. + EXPECT_EQ(client->PeekFrameSize(), 0u); + EXPECT_EQ(Receive(*server), hello); + + ASSERT_EQ(server->SendFrame(Span(welcome)), MOBILEGL_OK); + EXPECT_EQ(Receive(*client), welcome); +} + +TEST(InProcessTransportTest, PreservesOrder) { + std::unique_ptr client; + std::unique_ptr server; + InProcessTransport::CreatePair(client, server); + + for (int i = 0; i < 64; ++i) { + const std::string message = "msg-" + std::to_string(i); + ASSERT_EQ(client->SendFrame(Span(message)), MOBILEGL_OK); + } + for (int i = 0; i < 64; ++i) { + EXPECT_EQ(Receive(*server), "msg-" + std::to_string(i)); + } +} + +TEST(InProcessTransportTest, BufferTooSmallKeepsTheMessage) { + std::unique_ptr client; + std::unique_ptr server; + InProcessTransport::CreatePair(client, server); + + const std::string message(300, 'x'); + ASSERT_EQ(client->SendFrame(Span(message)), MOBILEGL_OK); + + std::vector small(16); + std::uint64_t required = 0; + MobileGLMutableByteSpan smallSpan{small.data(), small.size()}; + EXPECT_EQ(server->ReceiveFrame(smallSpan, &required, 0), MOBILEGL_ERR_BUFFER_TOO_SMALL); + EXPECT_EQ(required, message.size()); + // Still queued - the caller just retries with the size it was told. + EXPECT_EQ(server->PeekFrameSize(), message.size()); + EXPECT_EQ(Receive(*server), message); +} + +TEST(InProcessTransportTest, PollAndTimeoutDoNotBlockForever) { + std::unique_ptr client; + std::unique_ptr server; + InProcessTransport::CreatePair(client, server); + + std::vector buffer(64); + std::uint64_t size = 0; + MobileGLMutableByteSpan span{buffer.data(), buffer.size()}; + EXPECT_EQ(server->ReceiveFrame(span, &size, 0), MOBILEGL_ERR_TIMEOUT); + + const auto start = std::chrono::steady_clock::now(); + EXPECT_EQ(server->ReceiveFrame(span, &size, 30), MOBILEGL_ERR_TIMEOUT); + EXPECT_GE(std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(), + 20); +} + +TEST(InProcessTransportTest, ShutdownDrainsBeforeItCloses) { + std::unique_ptr client; + std::unique_ptr server; + InProcessTransport::CreatePair(client, server); + + const std::string last = "Fatal{code=DeviceLost}"; + ASSERT_EQ(client->SendFrame(Span(last)), MOBILEGL_OK); + client->Shutdown(); + + // A peer that shuts down right after sending must not lose its last + // message - that is usually the one that says why it is going away. + EXPECT_EQ(Receive(*server), last); + + std::vector buffer(64); + std::uint64_t size = 0; + MobileGLMutableByteSpan span{buffer.data(), buffer.size()}; + EXPECT_EQ(server->ReceiveFrame(span, &size, 100), MOBILEGL_ERR_TRANSPORT_CLOSED); + EXPECT_EQ(server->SendFrame(Span(last)), MOBILEGL_ERR_TRANSPORT_CLOSED); +} + +TEST(InProcessTransportTest, BlockedReceiverWakesOnSendAndOnShutdown) { + std::unique_ptr client; + std::unique_ptr server; + InProcessTransport::CreatePair(client, server); + + std::atomic got{false}; + std::thread reader([&] { + got.store(Receive(*server, kWaitForever) == "wake"); + }); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + ASSERT_EQ(client->SendFrame(Span(std::string("wake"))), MOBILEGL_OK); + reader.join(); + EXPECT_TRUE(got.load()); + + std::thread closer([&] { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + client->Shutdown(); + }); + std::vector buffer(64); + std::uint64_t size = 0; + MobileGLMutableByteSpan span{buffer.data(), buffer.size()}; + EXPECT_EQ(server->ReceiveFrame(span, &size, kWaitForever), MOBILEGL_ERR_TRANSPORT_CLOSED); + closer.join(); +} + +TEST(InProcessTransportTest, RefusesAPayloadOverTheFrameCap) { + std::unique_ptr client; + std::unique_ptr server; + InProcessTransport::CreatePair(client, server); + + // Not allocated: the cap is checked before the bytes are touched. Keeping + // the same limit as the socket transports means nothing passes CI here and + // then fails after the switch to `spawn`. + const std::uint8_t dummy = 0; + MobileGLByteSpan huge{&dummy, 64ull * 1024 * 1024 + 1}; + EXPECT_EQ(client->SendFrame(huge), MOBILEGL_ERR_INVALID_ARGUMENT); +} + +#if !defined(_WIN32) +TEST(InProcessTransportTest, HandsOverADescriptorAndItsSideband) { + std::unique_ptr client; + std::unique_ptr server; + InProcessTransport::CreatePair(client, server); + + int pipeFds[2] = {-1, -1}; + ASSERT_EQ(::pipe(pipeFds), 0); + + const std::string sideband = "SegmentRef{id=1,kind=Cmd}"; + ASSERT_EQ(client->ShareFd(pipeFds[0], Span(sideband)), MOBILEGL_OK); + + // Symmetric with the SCM_RIGHTS path: a short sideband buffer is refused + // before anything is consumed, so the descriptor is never dropped. + std::vector small(8); + int fd = -1; + std::uint64_t required = 0; + MobileGLMutableByteSpan smallSpan{small.data(), small.size()}; + EXPECT_EQ(server->ReceiveFd(&fd, smallSpan, &required, 0), MOBILEGL_ERR_BUFFER_TOO_SMALL); + EXPECT_EQ(required, FdPassing::kMaxSidebandBytes); + EXPECT_EQ(fd, -1); + + std::vector big(FdPassing::kMaxSidebandBytes); + std::uint64_t sidebandSize = 0; + MobileGLMutableByteSpan bigSpan{big.data(), big.size()}; + ASSERT_EQ(server->ReceiveFd(&fd, bigSpan, &sidebandSize, 100), MOBILEGL_OK); + ASSERT_GE(fd, 0); + EXPECT_EQ(std::string(reinterpret_cast(big.data()), + static_cast(sidebandSize)), + sideband); + + // Same open file description, independent descriptor. + const char payload[] = "bytes"; + ASSERT_EQ(::write(pipeFds[1], payload, sizeof(payload)), static_cast(sizeof(payload))); + char readBack[sizeof(payload)] = {}; + ASSERT_EQ(::read(fd, readBack, sizeof(readBack)), static_cast(sizeof(payload))); + EXPECT_STREQ(readBack, payload); + + ::close(fd); + ::close(pipeFds[0]); + ::close(pipeFds[1]); +} +#endif + +TEST(InProcessTransportTest, DoorbellWakesAParkedWaiter) { + std::unique_ptr client; + std::unique_ptr server; + InProcessTransport::CreatePair(client, server); + + // producerParked / consumerParked live in RingControl; here a standalone + // flag stands in for one. + std::atomic parked{0}; + std::atomic ready{false}; + std::atomic woke{false}; + + std::thread waiter([&] { + woke.store(client->SelfDoorbell().Wait( + parked, [&] { return ready.load(std::memory_order_acquire); }, kDefaultSpinUs, 5000)); + }); + + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + ready.store(true, std::memory_order_release); + // The peer only rings when the waiter says it parked, which is what makes + // the common (spin-only) case free. + NotifyIfParked(server->PeerDoorbell(), parked); + + waiter.join(); + EXPECT_TRUE(woke.load()); + EXPECT_EQ(parked.load(), 0u); +} + +TEST(InProcessTransportTest, DoorbellReturnsImmediatelyWhenAlreadyReady) { + std::unique_ptr client; + std::unique_ptr server; + InProcessTransport::CreatePair(client, server); + + std::atomic parked{0}; + // No notification is sent at all: a condition that is already true must + // never park, or the lost-wakeup window would be reachable. + EXPECT_TRUE(client->SelfDoorbell().Wait( + parked, [] { return true; }, kDefaultSpinUs, 0)); + EXPECT_EQ(parked.load(), 0u); +} + +TEST(InProcessTransportTest, DoorbellTimesOutWhenNothingHappens) { + std::unique_ptr client; + std::unique_ptr server; + InProcessTransport::CreatePair(client, server); + + std::atomic parked{0}; + const auto start = std::chrono::steady_clock::now(); + EXPECT_FALSE(client->SelfDoorbell().Wait( + parked, [] { return false; }, kDefaultSpinUs, 30)); + EXPECT_GE(std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(), + 20); + EXPECT_EQ(parked.load(), 0u); +} diff --git a/MobileGL/MG_Test/Wire/ProtocolSmokeTest.cpp b/MobileGL/MG_Test/Wire/ProtocolSmokeTest.cpp new file mode 100644 index 000000000..61d4f8651 --- /dev/null +++ b/MobileGL/MG_Test/Wire/ProtocolSmokeTest.cpp @@ -0,0 +1,149 @@ +// MobileGL - MobileGL/MG_Test/Wire/ProtocolSmokeTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The committed control-plane schema: encode/decode a handshake through the +// generated header, and pin the union tag values, which are wire numbers that +// may only ever be appended to. + +#include +#include +#include + +#include + +#include +#include +#include +#include + +using namespace MobileGL::Wire; +namespace Transport = MobileGL::MG_Remote::Transport; + +namespace { + + std::vector BuildHello() { + ::flatbuffers::FlatBufferBuilder builder(1024); + const std::vector config{1, 2, 3, 4}; + auto hello = CreateHelloDirect(builder, MOBILEGL_PROTOCOL_ABI_MAJOR, + MOBILEGL_PROTOCOL_ABI_MINOR, "mobilegl-test-build", + /*backendType=*/2, /*pid=*/4242, &config); + auto envelope = CreateCtrlEnvelope(builder, CtrlMsg::Hello, hello.Union()); + FinishCtrlEnvelopeBuffer(builder, envelope); + const std::uint8_t* begin = builder.GetBufferPointer(); + return std::vector(begin, begin + builder.GetSize()); + } + +} // namespace + +TEST(ProtocolSmokeTest, HelloRoundTrips) { + const std::vector buffer = BuildHello(); + + // Every message from the peer is verified before a single field is read: + // the control plane is parsed from another process's memory. + ::flatbuffers::Verifier verifier(buffer.data(), buffer.size()); + ASSERT_TRUE(VerifyCtrlEnvelopeBuffer(verifier)); + ASSERT_TRUE(CtrlEnvelopeBufferHasIdentifier(buffer.data())); + + const CtrlEnvelope* envelope = GetCtrlEnvelope(buffer.data()); + ASSERT_NE(envelope, nullptr); + ASSERT_EQ(envelope->msg_type(), CtrlMsg::Hello); + + const Hello* hello = envelope->msg_as_Hello(); + ASSERT_NE(hello, nullptr); + EXPECT_EQ(hello->abiMajor(), static_cast(MOBILEGL_PROTOCOL_ABI_MAJOR)); + EXPECT_EQ(hello->abiMinor(), static_cast(MOBILEGL_PROTOCOL_ABI_MINOR)); + ASSERT_NE(hello->buildFingerprint(), nullptr); + EXPECT_EQ(hello->buildFingerprint()->str(), "mobilegl-test-build"); + EXPECT_EQ(hello->backendType(), 2u); + EXPECT_EQ(hello->pid(), 4242u); + ASSERT_NE(hello->configBlob(), nullptr); + ASSERT_EQ(hello->configBlob()->size(), 4u); + EXPECT_EQ(hello->configBlob()->Get(3), 4u); + + // A message of the wrong kind reads back as null rather than as garbage. + EXPECT_EQ(envelope->msg_as_Welcome(), nullptr); +} + +TEST(ProtocolSmokeTest, WelcomeCarriesTheFourSegmentAnnouncements) { + ::flatbuffers::FlatBufferBuilder builder(1024); + auto cmd = CreateSegmentRefDirect(builder, 1, SegmentKind::Cmd, 8ull * 1024 * 1024, "cmd"); + auto stage = CreateSegmentRefDirect(builder, 2, SegmentKind::Stage, 32ull * 1024 * 1024, "stage"); + auto reply = CreateSegmentRefDirect(builder, 3, SegmentKind::Reply, 8ull * 1024 * 1024, "reply"); + auto event = CreateSegmentRefDirect(builder, 4, SegmentKind::Event, 256ull * 1024, "event"); + auto welcome = CreateWelcome(builder, MOBILEGL_PROTOCOL_ABI_MAJOR, MOBILEGL_PROTOCOL_ABI_MINOR, + /*serverPid=*/99, cmd, stage, reply, event); + auto envelope = CreateCtrlEnvelope(builder, CtrlMsg::Welcome, welcome.Union()); + FinishCtrlEnvelopeBuffer(builder, envelope); + + ::flatbuffers::Verifier verifier(builder.GetBufferPointer(), builder.GetSize()); + ASSERT_TRUE(VerifyCtrlEnvelopeBuffer(verifier)); + + const Welcome* parsed = GetCtrlEnvelope(builder.GetBufferPointer())->msg_as_Welcome(); + ASSERT_NE(parsed, nullptr); + EXPECT_EQ(parsed->serverPid(), 99u); + ASSERT_NE(parsed->cmdRing(), nullptr); + EXPECT_EQ(parsed->cmdRing()->kind(), SegmentKind::Cmd); + EXPECT_EQ(parsed->cmdRing()->sizeBytes(), 8ull * 1024 * 1024); + ASSERT_NE(parsed->stageRing(), nullptr); + EXPECT_EQ(parsed->stageRing()->sizeBytes(), 32ull * 1024 * 1024); + ASSERT_NE(parsed->eventRing(), nullptr); + EXPECT_EQ(parsed->eventRing()->sizeBytes(), 256ull * 1024); +} + +TEST(ProtocolSmokeTest, UnionTagsAreFrozenWireValues) { + // Appending to CtrlMsg is a compatible change; reordering it is not. If + // this test has to be edited, the schema change was a wire break. + EXPECT_EQ(static_cast(CtrlMsg::NONE), 0); + EXPECT_EQ(static_cast(CtrlMsg::Hello), 1); + EXPECT_EQ(static_cast(CtrlMsg::Welcome), 2); + EXPECT_EQ(static_cast(CtrlMsg::CapsSnapshot), 3); + EXPECT_EQ(static_cast(CtrlMsg::SurfaceOp), 4); + EXPECT_EQ(static_cast(CtrlMsg::SurfaceReply), 5); + EXPECT_EQ(static_cast(CtrlMsg::ResyncRequest), 6); + EXPECT_EQ(static_cast(CtrlMsg::ResyncDone), 7); + EXPECT_EQ(static_cast(CtrlMsg::AuxRequest), 8); + EXPECT_EQ(static_cast(CtrlMsg::Fatal), 9); + EXPECT_EQ(static_cast(CtrlMsg::LogLine), 10); + + EXPECT_EQ(static_cast(SegmentKind::Cmd), 1); + EXPECT_EQ(static_cast(SegmentKind::Adopt), 6); + EXPECT_EQ(static_cast(LogLevel::Error), 3); + EXPECT_EQ(static_cast(FatalCode::ProtocolCorruption), 1); +} + +TEST(ProtocolSmokeTest, TruncatedMessageFailsVerificationInsteadOfReadingGarbage) { + std::vector buffer = BuildHello(); + ASSERT_GT(buffer.size(), 8u); + buffer.resize(buffer.size() / 2); + + ::flatbuffers::Verifier verifier(buffer.data(), buffer.size()); + EXPECT_FALSE(VerifyCtrlEnvelopeBuffer(verifier)); +} + +TEST(ProtocolSmokeTest, TravelsAcrossTheTransportUnchanged) { + std::unique_ptr client; + std::unique_ptr server; + Transport::InProcessTransport::CreatePair(client, server); + + const std::vector sent = BuildHello(); + ASSERT_EQ(client->SendFrame(MobileGLByteSpan{sent.data(), sent.size()}), MOBILEGL_OK); + + const std::uint64_t pending = server->PeekFrameSize(); + ASSERT_EQ(pending, sent.size()); + std::vector received(pending); + std::uint64_t size = 0; + MobileGLMutableByteSpan span{received.data(), received.size()}; + ASSERT_EQ(server->ReceiveFrame(span, &size, 1000), MOBILEGL_OK); + ASSERT_EQ(size, sent.size()); + + ::flatbuffers::Verifier verifier(received.data(), received.size()); + ASSERT_TRUE(VerifyCtrlEnvelopeBuffer(verifier)); + const Hello* hello = GetCtrlEnvelope(received.data())->msg_as_Hello(); + ASSERT_NE(hello, nullptr); + EXPECT_EQ(hello->pid(), 4242u); +} diff --git a/MobileGL/MG_Test/Wire/RingTest.cpp b/MobileGL/MG_Test/Wire/RingTest.cpp new file mode 100644 index 000000000..bf325a569 --- /dev/null +++ b/MobileGL/MG_Test/Wire/RingTest.cpp @@ -0,0 +1,325 @@ +// MobileGL - MobileGL/MG_Test/Wire/RingTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The SEG_CMD/SEG_STAGE SPSC ring: layout of the shared control page, cursor +// invariants, wrap-around, backpressure, the generation bump after a hard +// drain, and a real two-thread producer/consumer run. + +#include + +#include + +#include +#include +#include +#include + +using namespace MobileGL::MG_Remote::Transport; + +namespace { + + // A ring plus its control page, sized like a small SEG_CMD. + class RingFixture { + public: + explicit RingFixture(std::uint64_t capacity, RingCursorSet cursors = RingCursorSet::Cmd) + : m_bytes(static_cast(capacity)), m_capacity(capacity) { + InitRingControl(m_control); + m_producer = RingProducer(&m_control, m_bytes.data(), capacity, cursors); + m_consumer = RingConsumer(&m_control, m_bytes.data(), capacity, cursors); + m_cursors = cursors; + } + + RingControl& Control() { return m_control; } + RingProducer& Producer() { return m_producer; } + RingConsumer& Consumer() { return m_consumer; } + std::uint64_t Capacity() const { return m_capacity; } + bool Invariants() const { return RingCursorsValid(m_control, m_cursors, m_capacity); } + + // Writes one record whose payload is `size` bytes of a recognisable + // pattern seeded by `seed`. + bool WriteRecord(std::uint16_t kind, std::uint64_t size, std::uint8_t seed) { + void* payload = m_producer.Reserve(kind, kRecNone, size); + if (payload == nullptr) { + return false; + } + auto* bytes = static_cast(payload); + for (std::uint64_t i = 0; i < size; ++i) { + bytes[i] = static_cast(seed + i); + } + m_producer.Publish(); + return true; + } + + static bool CheckPattern(const RingRecordView& view, std::uint64_t size, std::uint8_t seed) { + const auto* bytes = static_cast(view.payload); + for (std::uint64_t i = 0; i < size; ++i) { + if (bytes[i] != static_cast(seed + i)) { + return false; + } + } + return true; + } + + private: + alignas(4096) RingControl m_control{}; + std::vector m_bytes; + RingProducer m_producer; + RingConsumer m_consumer; + std::uint64_t m_capacity; + RingCursorSet m_cursors = RingCursorSet::Cmd; + }; + +} // namespace + +TEST(RingTest, ControlPageLayoutIsTheSharedContract) { + // The page is mapped by two processes; its size and alignment are wire + // contract, not an implementation detail. + EXPECT_EQ(sizeof(RingControl), 4096u); + EXPECT_EQ(alignof(RingControl), 4096u); + EXPECT_EQ(sizeof(RingRecordHeader), 8u); + + alignas(4096) RingControl control{}; + InitRingControl(control); + // Zero is reserved for "uninitialized" on both generations. + EXPECT_EQ(control.serverEpoch.load(), 1u); + EXPECT_EQ(control.ringGeneration.load(), 1u); + EXPECT_EQ(control.cmdHead.load(), 0u); + EXPECT_EQ(control.stageHead.load(), 0u); + EXPECT_EQ(control.consumerParked.load(), 0u); + EXPECT_EQ(control.producerParked.load(), 0u); + EXPECT_EQ(control.eventRingFull.load(), 0u); + EXPECT_EQ(control.eventDropped.load(), 0u); + + // Each contended group on its own cache line. + const auto offset = [&control](const void* member) { + return reinterpret_cast(member) - + reinterpret_cast(&control); + }; + EXPECT_EQ(offset(&control.cmdHead) % 64, 0); + EXPECT_EQ(offset(&control.cmdAppliedTail) % 64, 0); + EXPECT_EQ(offset(&control.stageHead) % 64, 0); + EXPECT_EQ(offset(&control.stageAppliedTail) % 64, 0); + EXPECT_EQ(offset(&control.appliedSeq) % 64, 0); + EXPECT_EQ(offset(&control.serverEpoch) % 64, 0); + // cmdHead and cmdAppliedTail are written by different processes: they must + // not share a line. + EXPECT_NE(offset(&control.cmdHead) / 64, offset(&control.cmdAppliedTail) / 64); +} + +TEST(RingTest, RejectsANonPowerOfTwoCapacity) { + alignas(4096) RingControl control{}; + InitRingControl(control); + std::vector bytes(1000); + RingProducer producer(&control, bytes.data(), 1000, RingCursorSet::Cmd); + EXPECT_FALSE(producer.Valid()); + EXPECT_EQ(producer.Reserve(1, kRecNone, 8), nullptr); +} + +TEST(RingTest, RoundTripsRecordsInOrder) { + RingFixture ring(4096); + ASSERT_TRUE(ring.WriteRecord(1, 16, 0x10)); + ASSERT_TRUE(ring.WriteRecord(2, 24, 0x20)); + EXPECT_TRUE(ring.Invariants()); + + RingRecordView view{}; + bool corrupt = false; + ASSERT_TRUE(ring.Consumer().Pop(view, &corrupt)); + EXPECT_FALSE(corrupt); + EXPECT_EQ(view.kind, 1u); + EXPECT_EQ(view.payloadSize, 16u); + EXPECT_TRUE(RingFixture::CheckPattern(view, 16, 0x10)); + + ASSERT_TRUE(ring.Consumer().Pop(view, &corrupt)); + EXPECT_EQ(view.kind, 2u); + EXPECT_EQ(view.payloadSize, 24u); + EXPECT_TRUE(RingFixture::CheckPattern(view, 24, 0x20)); + + EXPECT_FALSE(ring.Consumer().Pop(view, &corrupt)); + ring.Consumer().PublishRetired(); + EXPECT_TRUE(ring.Invariants()); + EXPECT_EQ(ring.Control().cmdAppliedTail.load(), ring.Control().cmdHead.load()); + EXPECT_EQ(ring.Control().cmdRetiredTail.load(), ring.Control().cmdHead.load()); +} + +TEST(RingTest, PayloadIsPaddedToTheRecordAlignment) { + RingFixture ring(4096); + ASSERT_TRUE(ring.WriteRecord(7, 3, 0x77)); + RingRecordView view{}; + ASSERT_TRUE(ring.Consumer().Pop(view)); + // 8 (header) + 3 rounded up to 16 -> 8 bytes of payload space. + EXPECT_EQ(view.payloadSize, 8u); + EXPECT_TRUE(RingFixture::CheckPattern(view, 3, 0x77)); +} + +TEST(RingTest, WrapsWithoutSplittingARecord) { + // Small ring, records that do not divide it evenly, so the wrap boundary + // lands mid-record and the pad path is exercised many times. + RingFixture ring(256); + std::uint8_t seed = 0; + for (int i = 0; i < 200; ++i) { + const std::uint64_t size = 24 + (i % 5) * 8; + ASSERT_TRUE(ring.WriteRecord(static_cast(1 + (i % 3)), size, seed)) + << "record " << i; + RingRecordView view{}; + bool corrupt = false; + ASSERT_TRUE(ring.Consumer().Pop(view, &corrupt)) << "record " << i; + ASSERT_FALSE(corrupt); + EXPECT_EQ(view.kind, static_cast(1 + (i % 3))); + // Contiguity: the payload never straddles the end of the mapping. + EXPECT_TRUE(RingFixture::CheckPattern(view, size, seed)) << "record " << i; + ring.Consumer().PublishRetired(); + ASSERT_TRUE(ring.Invariants()); + seed = static_cast(seed + 13); + } + // Cursors are monotonic byte counts, so they are far past the capacity. + EXPECT_GT(ring.Control().cmdHead.load(), ring.Capacity()); +} + +TEST(RingTest, FullRingRefusesAndRecoversWhenTheConsumerRetires) { + RingFixture ring(256); + int written = 0; + while (ring.WriteRecord(1, 24, static_cast(written))) { + ++written; + ASSERT_LT(written, 100); + } + EXPECT_GT(written, 0); + // Backpressure, not corruption. + EXPECT_TRUE(ring.Invariants()); + EXPECT_LT(ring.Producer().FreeBytes(), 32u); + + RingRecordView view{}; + ASSERT_TRUE(ring.Consumer().Pop(view)); + // Applied alone does not free a slot that may still be borrowed by the GPU + // timeline: reclaim follows the retired cursor. + ring.Consumer().PublishApplied(); + EXPECT_EQ(ring.Producer().FreeBytes(), 0u); + ring.Consumer().PublishRetired(); + EXPECT_GT(ring.Producer().FreeBytes(), 0u); + EXPECT_TRUE(ring.WriteRecord(1, 24, 0xEE)); +} + +TEST(RingTest, RecordLargerThanTheRingIsRefused) { + RingFixture ring(256); + EXPECT_EQ(ring.Producer().Reserve(1, kRecNone, 4096), nullptr); + EXPECT_TRUE(ring.Invariants()); +} + +TEST(RingTest, HardDrainBumpsTheGenerationOnlyWhenQuiesced) { + RingFixture ring(256); + ASSERT_TRUE(ring.WriteRecord(1, 32, 0x01)); + const std::uint32_t before = ring.Control().ringGeneration.load(); + + // Records still in flight: the drain is refused and nothing changes. + EXPECT_EQ(HardDrainRing(ring.Control(), RingCursorSet::Cmd), MOBILEGL_ERR_INVALID_ARGUMENT); + EXPECT_EQ(ring.Control().ringGeneration.load(), before); + + RingRecordView view{}; + ASSERT_TRUE(ring.Consumer().Pop(view)); + ring.Consumer().PublishRetired(); + EXPECT_EQ(HardDrainRing(ring.Control(), RingCursorSet::Cmd), MOBILEGL_OK); + EXPECT_EQ(ring.Control().ringGeneration.load(), before + 1); + // Cursors stay monotonic across the drain - only the generation moves. + EXPECT_EQ(ring.Control().cmdHead.load(), ring.Control().cmdAppliedTail.load()); + EXPECT_GT(ring.Control().cmdHead.load(), 0u); +} + +TEST(RingTest, CorruptHeaderIsRefusedRatherThanDispatched) { + // SEG_CMD is written by the peer process, so a compile-time size assert on + // the record catalogue proves nothing about what is actually in the + // mapping. Hand-build a ring whose first header is impossible (a size that + // is not a multiple of 8) and check the consumer refuses it instead of + // dispatching into undefined behaviour. + alignas(4096) RingControl control{}; + InitRingControl(control); + std::vector bytes(256, 0); + RingRecordHeader bad{}; + bad.kind = 5; + bad.flags = kRecNone; + bad.size = 13; // not 8-aligned + std::memcpy(bytes.data(), &bad, sizeof(bad)); + control.cmdHead.store(64, std::memory_order_release); + + RingConsumer consumer(&control, bytes.data(), bytes.size(), RingCursorSet::Cmd); + RingRecordView view{}; + bool corrupt = false; + EXPECT_FALSE(consumer.Pop(view, &corrupt)); + EXPECT_TRUE(corrupt); + + // A record claiming more bytes than the producer has published is the same + // class of violation and is refused the same way. + bad.size = 128; + std::memcpy(bytes.data(), &bad, sizeof(bad)); + RingConsumer second(&control, bytes.data(), bytes.size(), RingCursorSet::Cmd); + corrupt = false; + EXPECT_FALSE(second.Pop(view, &corrupt)); + EXPECT_TRUE(corrupt); +} + +TEST(RingTest, SpscProducerConsumerThreadsAgreeOnEveryRecord) { + constexpr int kRecords = 20000; + RingFixture ring(4096); + + std::atomic failed{false}; + std::atomic consumed{0}; + + std::thread consumer([&] { + int next = 0; + while (next < kRecords) { + RingRecordView view{}; + bool corrupt = false; + if (!ring.Consumer().Pop(view, &corrupt)) { + if (corrupt) { + failed.store(true); + return; + } + std::this_thread::yield(); + continue; + } + const std::uint32_t expectedKind = static_cast(1 + (next % 7)); + if (view.kind != expectedKind || view.payloadSize < sizeof(std::uint32_t)) { + failed.store(true); + return; + } + std::uint32_t value = 0; + std::memcpy(&value, view.payload, sizeof(value)); + if (value != static_cast(next)) { + failed.store(true); + return; + } + ++next; + consumed.store(next, std::memory_order_relaxed); + // Retire as we go; a consumer that never retires would deadlock the + // producer, which is exactly the contract being pinned. + ring.Consumer().PublishRetired(); + } + }); + + for (int i = 0; i < kRecords; ++i) { + const std::uint64_t payloadSize = sizeof(std::uint32_t) + (i % 4) * 8; + void* payload = nullptr; + while ((payload = ring.Producer().Reserve(static_cast(1 + (i % 7)), + kRecNone, payloadSize)) == nullptr) { + if (failed.load()) { + break; + } + std::this_thread::yield(); + } + if (payload == nullptr) { + break; + } + const std::uint32_t value = static_cast(i); + std::memcpy(payload, &value, sizeof(value)); + ring.Producer().Publish(); + } + + consumer.join(); + EXPECT_FALSE(failed.load()); + EXPECT_EQ(consumed.load(), kRecords); + EXPECT_TRUE(ring.Invariants()); + EXPECT_EQ(ring.Control().cmdRetiredTail.load(), ring.Control().cmdHead.load()); +} From bdd4bed431949b16e01a33e2c3a81f0fe58cb0a6 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 14:42:44 -0400 Subject: [PATCH 017/529] [Fix] (MG_Remote, Transport): close the doorbell's lost-wakeup window with two seq_cst fences and stop a hung-up peer turning every park into a spin - The header claimed the park flag's own seq_cst store and load closed the lost-wakeup window. They cannot: that Dekker argument needs all FOUR accesses in the seq_cst total order, and the other two are not in it - the watermark publish is a release store (RingProducer::Publish) and the condition re-test is an acquire load. There was no atomic_thread_fence anywhere under MG_Remote. On x86 a release store and a seq_cst load are both plain MOVs, so the notifier can read parked==0 while its head store still sits in the store buffer, and the waiter then parks on a stale watermark forever; ARMv8 survived only because STLR->LDAR is RCsc, which is luck, not the design. Doorbell::Wait now fences after announcing and NotifyIfParked fences before reading the flag - the pairing the standard actually guarantees ([atomics.order]) - and the header says so, including the other half of the contract: publish the watermark BEFORE ringing, because a fence only orders what precedes it. This is the claim the whole P5/P6 wait discipline (present credit, kNeedsAck blocking requests, full-ring escalation) will be built on, and its failure mode is a silent cross-process hang. Inherited design, plan section 8.1 (PLAN.md section 6.2/6.2a: bidirectional doorbell, MOBILEGL_IPC_SPIN_US default 50us, condvar for inproc). - SocketDoorbell::Park treated any poll() return > 0 as a wakeup and never looked at revents. Measured on this machine: an AF_UNIX SOCK_STREAM socketpair whose peer has closed returns revents=POLLIN|POLLHUP with recv()==0 immediately and forever. Park therefore returned true, Wait stored parked=0, found its condition still false and re-parked - so a waiter with kWaitForever burned a big core at full clock with no bound. That is exactly the pathology the bidirectional doorbell exists to prevent (a whole 16.6ms frame of a big core on a phone, competing with the GPU and the game's JVM), reached from the other side. Park now branches on revents, a new Drain() latches death on EOF (and on ECONNRESET/EPIPE from Notify), and the new Doorbell::Dead() lets Wait give up instead of re-parking on a descriptor that can never deliver another wakeup. - Same commit, same defect class: `fd` is documented as one end of an AF_UNIX socket pair, not "a socket or pipe end". Notify uses send(MSG_DONTWAIT| MSG_NOSIGNAL) and Park uses poll()+recv(), which a pipe end refuses with ENOTSOCK, and a SOCK_DGRAM pair reports no readiness at all when the peer closes (measured), so the spawn transport wants SOCK_STREAM. - Evidence: build-linux-split rebuilt clean; the wire suite is green (47/47). Negative control - reinstating the revents-blind Park makes FdPassingTest.SocketDoorbellStopsParkingWhenThePeerHangsUp fail on both Park assertions, and restoring this code turns it green again. --- MobileGL/MG_Remote/Transport/Doorbell.cpp | 73 +++++++++++++++--- MobileGL/MG_Remote/Transport/Doorbell.h | 93 +++++++++++++++++++---- 2 files changed, 141 insertions(+), 25 deletions(-) diff --git a/MobileGL/MG_Remote/Transport/Doorbell.cpp b/MobileGL/MG_Remote/Transport/Doorbell.cpp index 1d02da81d..8c5301954 100644 --- a/MobileGL/MG_Remote/Transport/Doorbell.cpp +++ b/MobileGL/MG_Remote/Transport/Doorbell.cpp @@ -103,8 +103,11 @@ namespace MobileGL::MG_Remote::Transport { // one pending, which is all a doorbell promises. return; } - if (written < 0 && errno == EPIPE) { - return; // peer gone; the waiter learns it from its own read + if (written < 0 && (errno == EPIPE || errno == ECONNRESET)) { + // The peer is gone: it can never ring back either, so latch it + // here too rather than waiting for a Park to discover it. + m_dead = true; + return; } MGLOG_D("MG_Remote doorbell: send failed (errno=%d)", errno); return; @@ -112,7 +115,7 @@ namespace MobileGL::MG_Remote::Transport { } bool SocketDoorbell::Park(std::uint32_t timeoutMs) { - if (m_fd < 0) { + if (m_fd < 0 || m_dead) { return false; } const auto start = std::chrono::steady_clock::now(); @@ -139,28 +142,78 @@ namespace MobileGL::MG_Remote::Transport { if (ready == 0) { return false; // timed out } - Reset(); - return true; + // revents has to be inspected, not just `ready > 0`. Once the peer + // closes its end the descriptor is permanently poll-ready with + // nothing to read (measured on Linux: revents=POLLIN|POLLHUP, + // recv()==0), so treating any readiness as a wakeup turns every + // park on a dead peer into a 100% CPU spin - unbounded, because + // Doorbell::Wait re-parks until its deadline and kWaitForever has + // none. + if ((pfd.revents & (POLLERR | POLLNVAL)) != 0) { + MGLOG_D("MG_Remote doorbell: fd %d unusable (revents=0x%X)", m_fd, + static_cast(pfd.revents)); + m_dead = true; + return false; + } + if ((pfd.revents & POLLIN) != 0) { + if (Drain() != 0) { + return true; // a real wakeup byte + } + if (m_dead) { + return false; // EOF, not an event + } + // Ready but empty and still alive: someone else drained it. + // Report the wakeup and let the caller re-test its condition. + return true; + } + if ((pfd.revents & POLLHUP) != 0) { + m_dead = true; + return false; + } + // Readiness with no bit we requested or recognise: there is + // nothing to consume and no way to make progress, so refuse to + // poll this descriptor again. + MGLOG_D("MG_Remote doorbell: fd %d ready with revents=0x%X", m_fd, + static_cast(pfd.revents)); + m_dead = true; + return false; } } - void SocketDoorbell::Reset() { - if (m_fd < 0) { - return; - } + std::uint64_t SocketDoorbell::Drain() { // Level-triggered to edge-triggered: swallow every queued byte so one // stale wakeup cannot make later Parks return without an event. + std::uint64_t consumed = 0; std::uint8_t scratch[64]; for (;;) { const ssize_t got = ::recv(m_fd, scratch, sizeof(scratch), MSG_DONTWAIT); if (got > 0) { + consumed += static_cast(got); continue; } - if (got < 0 && errno == EINTR) { + if (got == 0) { + // Orderly shutdown on a stream socket: the peer is gone and + // will never ring again. + m_dead = true; + return consumed; + } + if (errno == EINTR) { continue; } + if (errno == EAGAIN || errno == EWOULDBLOCK) { + return consumed; // drained + } + MGLOG_D("MG_Remote doorbell: recv failed (errno=%d)", errno); + m_dead = true; + return consumed; + } + } + + void SocketDoorbell::Reset() { + if (m_fd < 0 || m_dead) { return; } + (void)Drain(); } #endif // !_WIN32 diff --git a/MobileGL/MG_Remote/Transport/Doorbell.h b/MobileGL/MG_Remote/Transport/Doorbell.h index 78fc48c49..ec37678cc 100644 --- a/MobileGL/MG_Remote/Transport/Doorbell.h +++ b/MobileGL/MG_Remote/Transport/Doorbell.h @@ -26,10 +26,30 @@ // - CondVarDoorbell for `inproc` (one process, two threads), // - SocketDoorbell for `spawn` (one byte on a socket; POSIX only). // -// The lost-wakeup window is closed by ordering, not by luck: the waiter stores -// its park flag and THEN re-tests the condition, while the notifier publishes -// the watermark and THEN tests the park flag. Both use seq_cst on those two -// accesses, so at least one of the two sees the other. +// The lost-wakeup window is closed by two seq_cst FENCES, not by the ordering +// of the park flag's own load and store: +// - the waiter sets the flag, executes std::atomic_thread_fence(seq_cst), +// and THEN re-tests the condition (Doorbell::Wait); +// - the notifier publishes its watermark, executes the same fence, and THEN +// reads the flag (NotifyIfParked). +// Both fences sit in the single seq_cst total order, so one precedes the +// other, and [atomics.order] then forces at least one side to observe the +// other's store. The flag's own accesses may be relaxed: they are not what +// closes the window. +// +// A seq_cst store paired with a seq_cst load would NOT be enough, which is +// why the fences are here and why neither may be removed. That Dekker +// argument needs all FOUR accesses in the total order, and the other two are +// not: the watermark publish is a release store (RingProducer::Publish) and +// the condition re-test is an acquire load. On x86 the gap is concrete rather +// than theoretical - a release store is a plain MOV that can still sit in the +// store buffer while the load of the park flag, also a plain MOV, reads 0, so +// the notifier skips the ring and the waiter parks on a stale watermark +// forever. (ARMv8 survives it only because STLR->LDAR is RCsc, i.e. by luck.) +// +// The other half of the contract is ordering between the caller and the +// fence: NotifyIfParked must be called AFTER the watermark is published. A +// fence only orders what precedes it. #pragma once @@ -84,6 +104,14 @@ namespace MobileGL::MG_Remote::Transport { // does not make the next Park return spuriously forever. virtual void Reset() = 0; + // True once the wakeup channel is permanently unusable, e.g. the peer + // closed its end of the socket. A dead doorbell can never deliver + // another wakeup AND its descriptor is permanently poll-ready, so Wait + // must stop re-parking on it: otherwise a waiter with no deadline + // burns a big core at full clock, which is the exact pathology the + // bidirectional doorbell exists to prevent. + virtual bool Dead() const { return false; } + // Spin `spinUs`, then park until `ready()` or the deadline. // `parked` is the RingControl flag the peer tests before ringing. template @@ -106,16 +134,17 @@ namespace MobileGL::MG_Remote::Transport { } for (;;) { - // Announce, THEN re-test: the notifier publishes and then reads - // this flag, so one of the two orderings always sees the other. - parked.store(1, std::memory_order_seq_cst); + // Announce, FENCE, then re-test. The fence is the mechanism - + // see the file header - so setting the flag itself is relaxed. + parked.store(1, std::memory_order_relaxed); + std::atomic_thread_fence(std::memory_order_seq_cst); if (ready()) { - parked.store(0, std::memory_order_seq_cst); + parked.store(0, std::memory_order_relaxed); return true; } const auto now = std::chrono::steady_clock::now(); if (now >= deadline) { - parked.store(0, std::memory_order_seq_cst); + parked.store(0, std::memory_order_relaxed); return ready(); } std::uint32_t chunkMs = kWaitForever; @@ -125,10 +154,22 @@ namespace MobileGL::MG_Remote::Transport { chunkMs = remaining <= 0 ? 0 : static_cast(remaining); } Park(chunkMs); - parked.store(0, std::memory_order_seq_cst); + // Clearing is relaxed on purpose: a notifier that reads a + // stale 1 only rings a bell nobody is waiting on, which the + // doorbell remembers and the next Park consumes. The dangerous + // direction - a notifier reading 0 while the waiter is really + // parked - is the one the fence above rules out. + parked.store(0, std::memory_order_relaxed); if (ready()) { return true; } + if (Dead()) { + // Nothing can ring this bell again and parking on it no + // longer blocks, so looping here would spin at full clock + // for as long as the caller is willing to wait - which, + // with kWaitForever, is forever. + return false; + } if (timeoutMs != kWaitForever && std::chrono::steady_clock::now() >= deadline) { return false; } @@ -139,10 +180,17 @@ namespace MobileGL::MG_Remote::Transport { Doorbell() = default; }; - // Rings `bell` only when the peer said it is parked. The seq_cst load pairs - // with the waiter's seq_cst store of the same flag. + // Rings `bell` only when the peer said it is parked. + // + // PRECONDITION: whatever the waiter's condition reads - the ring head, a + // sequence watermark, a queue push - is ALREADY published when this is + // called. The fence only orders what precedes it, so ringing before + // publishing reopens the window this closes. The fence pairs with the one + // in Doorbell::Wait; see the file header for why the flag's own memory + // order is not what makes this sound. inline void NotifyIfParked(Doorbell& bell, std::atomic& parked) { - if (parked.load(std::memory_order_seq_cst) != 0) { + std::atomic_thread_fence(std::memory_order_seq_cst); + if (parked.load(std::memory_order_relaxed) != 0) { bell.Notify(); } } @@ -168,21 +216,36 @@ namespace MobileGL::MG_Remote::Transport { // and is not part of this skeleton. class SocketDoorbell final : public Doorbell { public: - // `fd` must be a socket or pipe end. When `ownsFd` the descriptor is - // closed with this object. `code` is the byte written by Notify. + // `fd` must be one end of an AF_UNIX socket pair, not a pipe: Notify + // uses send() with MSG_DONTWAIT|MSG_NOSIGNAL and Park uses + // poll()+recv(), which a pipe end refuses with ENOTSOCK. Prefer + // SOCK_STREAM for the spawn transport - measured on Linux, a closed + // peer makes a stream end report POLLIN|POLLHUP with recv()==0, which + // is how death is detected, while a SOCK_DGRAM end reports no + // readiness at all and a waiter with no deadline would simply hang. + // When `ownsFd` the descriptor is closed with this object. `code` is + // the byte written by Notify. SocketDoorbell(int fd, std::uint8_t code, bool ownsFd); ~SocketDoorbell() override; void Notify() override; bool Park(std::uint32_t timeoutMs) override; void Reset() override; + bool Dead() const override { return m_dead; } int Fd() const { return m_fd; } private: + // Consumes every queued wakeup byte and returns how many. Latches + // m_dead on EOF: recv returning 0 on a stream socket is the peer's + // hangup, not a wakeup, and the descriptor stays poll-ready forever + // afterwards. + std::uint64_t Drain(); + int m_fd; std::uint8_t m_code; bool m_ownsFd; + bool m_dead = false; }; #endif From c1a7ffac94755146cf66438c53785ebcc8b2443c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 14:42:44 -0400 Subject: [PATCH 018/529] [Fix] (MG_Remote, Transport): give descriptor offers their own condition variable, cap the ring at what a 32-bit size field can describe, and keep the frontend umbrella out of Framing.h - InProcessChannel::Direction served two different predicates from one condition_variable signalled with notify_one, so a SendFrame wakeup could be delivered to a thread blocked in ReceiveFd, which re-tests its own predicate and goes back to sleep - leaving a queued message undelivered until some unrelated later event. ITransport narrows the contract to one dedicated reader thread, but that is a comment, not a mechanism, and the first caller that splits its reader should not have to discover this. Offers now have their own fdCv, and Close() notifies both. - RingProducer/RingConsumer accepted any power-of-two capacity while RingRecordHeader::size is 32-bit by wire contract (plan section 8.1 -> PLAN.md section 6.3: 8-byte RecHeader). At 4 GiB or more a record's size - or a wrap filler's, which is sized by the distance to the boundary - would be truncated on the way in, and the consumer would then bounds-check the truncated value against the real one. kMaxRingCapacity rejects that at construction, the same class of guard as the power-of-two and smaller-than-a-header checks beside it. Unreachable today (SEG_CMD 8 MiB, SEG_STAGE 32 MiB), which is the point of catching it now. - Framing.h included MG_Util/Debug/Log.h, which includes Includes.h, the GL frontend's umbrella header: 661 headers by `clang++ -H`. It is the one header under Transport/ that broke the rule ITransport.h states for this layer ("nothing about a byte pipe needs the GL frontend's umbrella header"), which matters when the server-side binary links this and when the include-graph purity gate of plan section 10.3 (gate A, asserted on -H output rather than on symbols) lands. Its three error paths now call WireLogError, declared in a new dependency-free WireLog.h whose .cpp owns the umbrella. Framing.h is down to 134 headers, none of them MG_State, Includes.h or Log.h. - Two documentation corrections. ITransport::Shutdown documented a one-sided "releases the endpoint" while InProcessTransport::Shutdown closes both directions - which is what closing a socket does, so the spawn transport will behave the same way; the interface now says whole-connection teardown, and keeps the promise that queued messages stay readable until drained. InProcessTransport.h cited a CMake option MOBILEGL_BUILD_DISAGGREGATED_INPROC that grep finds nowhere: plan appendix B reserves it for the role-isolation shim, this skeleton does not add it, and the delivery mode is a runtime choice (MOBILEGL_TRANSPORT), not a build one. - Evidence: both configurations reconfigured and rebuilt; nm --defined-only on the OFF build still reports 0 MG_Remote symbols and the ldd dependency set is byte-identical to the OFF link (plan section 10.3, the two surviving byte-level equalities). Negative controls: removing the capacity ceiling makes RingTest.RejectsACapacityTheRecordHeaderCannotDescribe fail on both roles; collapsing fdCv back into cv makes InProcessTransportTest.AFrameWakeupIsNotEatenByAWaiterOnDescriptors fail at 3950ms against its 2000ms bound. --- CMakeLists.txt | 3 ++ MobileGL/MG_Remote/Transport/Framing.h | 20 +++++----- MobileGL/MG_Remote/Transport/ITransport.h | 14 +++++-- .../Transport/InProcessTransport.cpp | 21 ++++++++-- .../MG_Remote/Transport/InProcessTransport.h | 18 ++++++--- MobileGL/MG_Remote/Transport/Ring.cpp | 20 ++++++---- MobileGL/MG_Remote/Transport/Ring.h | 12 +++++- MobileGL/MG_Remote/Transport/WireLog.cpp | 33 ++++++++++++++++ MobileGL/MG_Remote/Transport/WireLog.h | 38 +++++++++++++++++++ 9 files changed, 147 insertions(+), 32 deletions(-) create mode 100644 MobileGL/MG_Remote/Transport/WireLog.cpp create mode 100644 MobileGL/MG_Remote/Transport/WireLog.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f0f5f610e..6e2dd414b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -458,6 +458,9 @@ if (MOBILEGL_BUILD_DISAGGREGATED) MobileGL/MG_Remote/Transport/ShmSegmentWin32.cpp MobileGL/MG_Remote/Transport/FdPassing.cpp MobileGL/MG_Remote/Transport/InProcessTransport.cpp + # Keeps MG_Util/Debug/Log.h - and through it the GL frontend's + # umbrella header - out of the header-only wire code (WireLog.h). + MobileGL/MG_Remote/Transport/WireLog.cpp ) endif() diff --git a/MobileGL/MG_Remote/Transport/Framing.h b/MobileGL/MG_Remote/Transport/Framing.h index 9123f20c9..f05239c40 100644 --- a/MobileGL/MG_Remote/Transport/Framing.h +++ b/MobileGL/MG_Remote/Transport/Framing.h @@ -31,7 +31,9 @@ #include "../Protocol/mg_protocol_base.h" -#include +// NOT : that header pulls the GL frontend's umbrella into +// every translation unit that reassembles a frame. See WireLog.h. +#include "WireLog.h" #include #include @@ -54,8 +56,8 @@ namespace MobileGL::MG_Remote::Transport { inline MobileGLResult AppendFrame(std::vector& out, const void* payload, std::uint64_t size) { if (size > kMaxFramePayloadSize) { - MGLOG_E("MG_Remote framing: refusing to send a %llu byte payload (cap %llu); bulk " - "bytes belong in shm", + WireLogError("MG_Remote framing: refusing to send a %llu byte payload (cap %llu); " + "bulk bytes belong in shm", static_cast(size), static_cast(kMaxFramePayloadSize)); return MOBILEGL_ERR_INVALID_ARGUMENT; @@ -163,16 +165,16 @@ namespace MobileGL::MG_Remote::Transport { std::memcpy(&length, m_buffer.data() + m_readPos + 4, sizeof(length)); if (magic != kFrameMagic) { m_failed = true; - MGLOG_E("MG_Remote framing: bad frame magic 0x%08X (expected 0x%08X); the control " - "stream is desynchronized and this transport is now dead", - magic, kFrameMagic); + WireLogError("MG_Remote framing: bad frame magic 0x%08X (expected 0x%08X); the " + "control stream is desynchronized and this transport is now dead", + magic, kFrameMagic); return MOBILEGL_ERR_PROTOCOL_MISMATCH; } if (length > kMaxFramePayloadSize) { m_failed = true; - MGLOG_E("MG_Remote framing: frame length %u exceeds the %llu byte cap; refusing to " - "allocate on a peer-supplied length", - length, static_cast(kMaxFramePayloadSize)); + WireLogError("MG_Remote framing: frame length %u exceeds the %llu byte cap; " + "refusing to allocate on a peer-supplied length", + length, static_cast(kMaxFramePayloadSize)); return MOBILEGL_ERR_PROTOCOL_MISMATCH; } m_pendingSize = length; diff --git a/MobileGL/MG_Remote/Transport/ITransport.h b/MobileGL/MG_Remote/Transport/ITransport.h index 1a68bd242..723ab538e 100644 --- a/MobileGL/MG_Remote/Transport/ITransport.h +++ b/MobileGL/MG_Remote/Transport/ITransport.h @@ -109,10 +109,16 @@ namespace MobileGL::MG_Remote::Transport { // ---- lifecycle ------------------------------------------------------ - // Idempotent. Unblocks every waiter with MOBILEGL_ERR_TRANSPORT_CLOSED - // and releases the endpoint. Messages already queued for this endpoint - // stay readable until drained, so a peer that shuts down after sending - // does not lose its last message. + // Idempotent. Tears down the WHOLE connection, not just this end: + // both directions are half-closed, so after either endpoint calls it + // neither side can send any more (SendFrame returns + // MOBILEGL_ERR_TRANSPORT_CLOSED) and every waiter on either side is + // unblocked. That is what closing a socket does, and the spawn + // transport behaves the same way, so a one-sided contract here would + // be a promise only the in-process implementation could keep. + // + // Messages already queued stay readable until drained: a peer that + // shuts down right after sending does not lose its last message. virtual void Shutdown() = 0; virtual TransportRole Role() const = 0; diff --git a/MobileGL/MG_Remote/Transport/InProcessTransport.cpp b/MobileGL/MG_Remote/Transport/InProcessTransport.cpp index 289ec3d8b..da5989ab8 100644 --- a/MobileGL/MG_Remote/Transport/InProcessTransport.cpp +++ b/MobileGL/MG_Remote/Transport/InProcessTransport.cpp @@ -39,7 +39,16 @@ namespace MobileGL::MG_Remote::Transport { public: struct Direction { std::mutex mutex; - std::condition_variable cv; + // One variable per predicate. A single cv signalled with + // notify_one would let a SendFrame's wakeup land on a thread + // blocked in ReceiveFd, which re-tests its own predicate and goes + // straight back to sleep - leaving a queued message undelivered + // until some unrelated later event. ITransport narrows the + // contract to one dedicated reader thread, but a comment is not a + // reason to ship a primitive that breaks the moment someone + // splits the reader. + std::condition_variable cv; // messages + std::condition_variable fdCv; // fdOffers std::deque> messages; std::deque fdOffers; bool closed = false; @@ -69,6 +78,7 @@ namespace MobileGL::MG_Remote::Transport { dir.closed = true; } dir.cv.notify_all(); + dir.fdCv.notify_all(); } // Anything parked on a ring doorbell has to come back too, or a // shutdown mid-frame hangs the peer forever. @@ -207,7 +217,7 @@ namespace MobileGL::MG_Remote::Transport { } dir.fdOffers.push_back(std::move(offer)); } - dir.cv.notify_one(); + dir.fdCv.notify_one(); return MOBILEGL_OK; #endif } @@ -244,9 +254,9 @@ namespace MobileGL::MG_Remote::Transport { if (dir.fdOffers.empty() && !dir.closed && timeoutMs != 0) { const auto ready = [&dir] { return !dir.fdOffers.empty() || dir.closed; }; if (timeoutMs == kWaitForever) { - dir.cv.wait(lock, ready); + dir.fdCv.wait(lock, ready); } else { - dir.cv.wait_for(lock, std::chrono::milliseconds(timeoutMs), ready); + dir.fdCv.wait_for(lock, std::chrono::milliseconds(timeoutMs), ready); } } if (dir.fdOffers.empty()) { @@ -266,6 +276,9 @@ namespace MobileGL::MG_Remote::Transport { #endif } + // Whole-connection teardown, as ITransport::Shutdown documents: both + // directions are half-closed and both ring doorbells are rung, because a + // peer parked on a ring doorbell mid-frame would otherwise never come back. void InProcessTransport::Shutdown() { m_channel->Close(); } Doorbell& InProcessTransport::PeerDoorbell() { return m_channel->Bell(1 - m_endpoint); } diff --git a/MobileGL/MG_Remote/Transport/InProcessTransport.h b/MobileGL/MG_Remote/Transport/InProcessTransport.h index d3b1fe340..efa8c0057 100644 --- a/MobileGL/MG_Remote/Transport/InProcessTransport.h +++ b/MobileGL/MG_Remote/Transport/InProcessTransport.h @@ -9,12 +9,18 @@ // The `inproc` transport: two in-memory message queues and a pair of condvar // doorbells, one connected endpoint at each end. // -// It is not a test double. `inproc` is a delivery mode of its own (CMake -// option MOBILEGL_BUILD_DISAGGREGATED_INPROC): the server side is the -// monolith's own render thread, which is the single largest CPU lever this -// project has, and it is also the CI form of the split build. What it does NOT -// exercise is serialization of the byte stream, so the framing codec is -// covered separately by FramingTest. +// It is not a test double. `inproc` is a delivery mode of its own - the server +// side is the monolith's own render thread, which is the single largest CPU +// lever this project has, and it is also the CI form of the split build. What +// it does NOT exercise is serialization of the byte stream, so the framing +// codec is covered separately by FramingTest. +// +// It is built by MOBILEGL_BUILD_DISAGGREGATED, the one option this skeleton +// adds, and selected at RUNTIME (plan appendix B: MOBILEGL_TRANSPORT = +// monolith / inproc / spawn / ...). The plan also reserves a separate +// MOBILEGL_BUILD_DISAGGREGATED_INPROC option for the role-isolation shim that +// a single-process CI build will need; that option does not exist yet, and +// nothing here depends on it. // // Messages are queued whole, so no framing bytes are involved; the size cap is // still enforced so that a payload which would be illegal on a socket is diff --git a/MobileGL/MG_Remote/Transport/Ring.cpp b/MobileGL/MG_Remote/Transport/Ring.cpp index ee0926454..90fb95c66 100644 --- a/MobileGL/MG_Remote/Transport/Ring.cpp +++ b/MobileGL/MG_Remote/Transport/Ring.cpp @@ -87,10 +87,12 @@ namespace MobileGL::MG_Remote::Transport { : m_control(control), m_base(static_cast(base)), m_capacity(capacityBytes), m_mask(capacityBytes - 1), m_cursors(cursors) { if (control == nullptr || base == nullptr || !IsPowerOfTwo(capacityBytes) || - capacityBytes < sizeof(RingRecordHeader)) { - MGLOG_E("MG_Remote ring: producer rejected, capacity %llu must be a power of two of at " - "least %zu bytes over a non-null mapping", - static_cast(capacityBytes), sizeof(RingRecordHeader)); + capacityBytes < sizeof(RingRecordHeader) || capacityBytes > kMaxRingCapacity) { + MGLOG_E("MG_Remote ring: producer rejected, capacity %llu must be a power of two " + "between %zu and %llu bytes over a non-null mapping (the record header's size " + "field is 32-bit, so a bigger ring would truncate it)", + static_cast(capacityBytes), sizeof(RingRecordHeader), + static_cast(kMaxRingCapacity)); m_control = nullptr; m_base = nullptr; m_capacity = 0; @@ -182,10 +184,12 @@ namespace MobileGL::MG_Remote::Transport { : m_control(control), m_base(static_cast(base)), m_capacity(capacityBytes), m_mask(capacityBytes - 1), m_cursors(cursors) { if (control == nullptr || base == nullptr || !IsPowerOfTwo(capacityBytes) || - capacityBytes < sizeof(RingRecordHeader)) { - MGLOG_E("MG_Remote ring: consumer rejected, capacity %llu must be a power of two of at " - "least %zu bytes over a non-null mapping", - static_cast(capacityBytes), sizeof(RingRecordHeader)); + capacityBytes < sizeof(RingRecordHeader) || capacityBytes > kMaxRingCapacity) { + MGLOG_E("MG_Remote ring: consumer rejected, capacity %llu must be a power of two " + "between %zu and %llu bytes over a non-null mapping (the record header's size " + "field is 32-bit, so a bigger ring would truncate it)", + static_cast(capacityBytes), sizeof(RingRecordHeader), + static_cast(kMaxRingCapacity)); m_control = nullptr; m_base = nullptr; m_capacity = 0; diff --git a/MobileGL/MG_Remote/Transport/Ring.h b/MobileGL/MG_Remote/Transport/Ring.h index 7fc042059..115a99312 100644 --- a/MobileGL/MG_Remote/Transport/Ring.h +++ b/MobileGL/MG_Remote/Transport/Ring.h @@ -111,6 +111,15 @@ namespace MobileGL::MG_Remote::Transport { inline constexpr std::uint64_t kRingRecordAlignment = 8; + // Largest ring the 8-byte header can describe. Both a record's size and a + // wrap filler's size are bounded only by the capacity and are stored in + // RingRecordHeader::size, which is 32 bits by wire contract: a ring of + // 4 GiB or more would silently truncate them, and the consumer would then + // bounds-check the truncated value against the real one. SEG_CMD is 8 MiB + // and SEG_STAGE 32 MiB today, so this is unreachable - it is the same + // class of construction-time guard as the power-of-two check beside it. + inline constexpr std::uint64_t kMaxRingCapacity = 0xFFFFFFFFull; + // Which cursor triple a producer/consumer pair drives. enum class RingCursorSet : std::uint32_t { Cmd = 0, @@ -147,7 +156,8 @@ namespace MobileGL::MG_Remote::Transport { public: RingProducer() = default; // `base` is the ring's byte area (NOT the control page) and - // `capacityBytes` must be a power of two. + // `capacityBytes` must be a power of two of at least one record header + // and at most kMaxRingCapacity. Anything else leaves Valid() false. RingProducer(RingControl* control, void* base, std::uint64_t capacityBytes, RingCursorSet cursors); diff --git a/MobileGL/MG_Remote/Transport/WireLog.cpp b/MobileGL/MG_Remote/Transport/WireLog.cpp new file mode 100644 index 000000000..34d18a53a --- /dev/null +++ b/MobileGL/MG_Remote/Transport/WireLog.cpp @@ -0,0 +1,33 @@ +// MobileGL - MobileGL/MG_Remote/Transport/WireLog.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#include "WireLog.h" + +#include + +#include +#include + +namespace MobileGL::MG_Remote::Transport { + + void WireLogError(const char* format, ...) { + // One stack line, no allocation: this runs on paths that have just + // decided the connection is unusable. + char line[512]; + va_list args; + va_start(args, format); + const int written = std::vsnprintf(line, sizeof(line), format, args); + va_end(args); + if (written < 0) { + MGLOG_E("MG_Remote wire: unformattable diagnostic (format=%s)", format); + return; + } + MGLOG_E("%s", line); + } + +} // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/WireLog.h b/MobileGL/MG_Remote/Transport/WireLog.h new file mode 100644 index 000000000..1be056d35 --- /dev/null +++ b/MobileGL/MG_Remote/Transport/WireLog.h @@ -0,0 +1,38 @@ +// MobileGL - MobileGL/MG_Remote/Transport/WireLog.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// A one-function logging shim for the wire layer's header-only code. +// +// MG_Util/Debug/Log.h includes , the GL frontend's umbrella +// header - 661 headers, measured with `clang++ -H`. That is fine inside a +// .cpp, and Ring.cpp / Doorbell.cpp / the transports all do it. It is not fine +// in a header of this layer: ITransport.h states the rule ("nothing about a +// byte pipe needs the GL frontend's umbrella header") because these headers +// are included by both roles and by the eventual server-side binary, and +// because the disaggregated build's include-graph purity gate (plan section +// 10.3, gate A) asserts on `-H` output rather than on symbols. Framing.h was +// the one header under Transport/ that broke the rule; it now calls this +// instead, and the umbrella stays inside WireLog.cpp. +// +// ERROR only, deliberately. Everything routed here is a latched protocol +// violation, never per-frame noise; non-critical wire lines use MGLOG_D from a +// .cpp, where the INFO build compiles them out entirely. + +#pragma once + +namespace MobileGL::MG_Remote::Transport { + + // Formats one line and emits it at ERROR level (MGLOG_E). printf-style, + // with the format checked against the arguments at compile time. +#if defined(__GNUC__) || defined(__clang__) + __attribute__((format(printf, 1, 2))) +#endif + void + WireLogError(const char* format, ...); + +} // namespace MobileGL::MG_Remote::Transport From aa005720d07aebeb16779ec3c1148ac2a7a6f6f6 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 14:42:44 -0400 Subject: [PATCH 019/529] [Test] (MG_Remote, Wire): pin the hung-up doorbell, the wakeup that must not be eaten, the ring's capacity ceiling and the publish-then-ring handoff - FdPassingTest.SocketDoorbellStopsParkingWhenThePeerHangsUp builds a SOCK_STREAM socketpair - deliberately not FdPassing::CreateSocketPair's datagram pair, because only a stream end reports the hangup at all - closes the notifier, and asserts Park returns false, latches Dead(), stays latched, and that a Wait with kWaitForever gives up in under a second instead of spinning. - FdPassingTest.SocketDoorbellStillDeliversTheLastRingBeforeAHangup rings and then closes: detecting death must not swallow the wakeup already sitting in the socket buffer, since the peer's last publish is the one a waiter is most likely to be blocked on. - InProcessTransportTest.AFrameWakeupIsNotEatenByAWaiterOnDescriptors blocks two readers on one endpoint with two different predicates and requires the frame to arrive within 2s rather than "eventually, when a receive timed out". - RingTest.RejectsACapacityTheRecordHeaderCannotDescribe refuses 4 GiB from both roles without mapping anything (the constructor rejects before it touches the base pointer) and keeps 2 GiB accepted as the positive control. - RingTest.DoorbellHandoffWakesBothSidesOnEveryPublish runs 2000 records through the ring with real parking in both directions, in the publish-then- NotifyIfParked order the fences assume. It cannot prove the fence pairing - no test can, since x86 has to actually hold the release store in the store buffer across the flag read - but it exercises the exact call order, and a lost wakeup surfaces as a Wait that times out with work available (a red test) rather than as a hung CI job. - Result: 1429 unit tests pass with MOBILEGL_BUILD_DISAGGREGATED=ON (1424 before this commit; the wire subset is 47), 1382 pass with it OFF, and the same three pre-existing skips appear in both. Every new case was verified to fail with its fix reverted and to pass again with it restored. --- MobileGL/MG_Test/Wire/FdPassingTest.cpp | 55 +++++++++ .../MG_Test/Wire/InProcessTransportTest.cpp | 56 +++++++++ MobileGL/MG_Test/Wire/RingTest.cpp | 106 ++++++++++++++++++ 3 files changed, 217 insertions(+) diff --git a/MobileGL/MG_Test/Wire/FdPassingTest.cpp b/MobileGL/MG_Test/Wire/FdPassingTest.cpp index 21f58f5fd..5ca489830 100644 --- a/MobileGL/MG_Test/Wire/FdPassingTest.cpp +++ b/MobileGL/MG_Test/Wire/FdPassingTest.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -248,3 +249,57 @@ TEST(FdPassingTest, SocketDoorbellTimesOutAndRemembersAnEarlyWakeup) { ::close(sockets[0]); ::close(sockets[1]); } + +// A doorbell whose peer has hung up must report that, not keep saying "ready". +// Park used to treat any `poll` return > 0 as a wakeup without ever looking at +// revents, and a closed peer leaves a stream socket permanently poll-ready +// with nothing to read - so Doorbell::Wait re-parked in a tight loop at full +// clock, unbounded when the caller passed kWaitForever. That is the pathology +// the bidirectional doorbell exists to prevent, arrived at from the other +// side. +TEST(FdPassingTest, SocketDoorbellStopsParkingWhenThePeerHangsUp) { + // A SOCK_STREAM pair, not FdPassing::CreateSocketPair's datagram pair: + // measured on Linux, a closed peer makes a stream end report + // POLLIN|POLLHUP with recv()==0, while a datagram end reports no readiness + // at all. The stream shape is what the spawn transport will use, and it is + // the shape that used to spin. + int sockets[2] = {-1, -1}; + ASSERT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sockets), 0); + + SocketDoorbell waiterBell(sockets[0], kDoorbellRingAdvanced, /*ownsFd=*/true); + ASSERT_EQ(::close(sockets[1]), 0); + + const auto start = std::chrono::steady_clock::now(); + EXPECT_FALSE(waiterBell.Park(kWaitForever)); + EXPECT_TRUE(waiterBell.Dead()); + // Latched: no second syscall storm either. + EXPECT_FALSE(waiterBell.Park(kWaitForever)); + + // ...and a Wait with no deadline at all gives up instead of re-parking. + std::atomic parked{0}; + EXPECT_FALSE(waiterBell.Wait( + parked, [] { return false; }, /*spinUs=*/0, kWaitForever)); + EXPECT_EQ(parked.load(), 0u); + EXPECT_LT(std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(), + 1000); +} + +TEST(FdPassingTest, SocketDoorbellStillDeliversTheLastRingBeforeAHangup) { + int sockets[2] = {-1, -1}; + ASSERT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sockets), 0); + + SocketDoorbell waiterBell(sockets[0], kDoorbellRingAdvanced, /*ownsFd=*/true); + SocketDoorbell notifierBell(sockets[1], kDoorbellRingAdvanced, /*ownsFd=*/false); + + // Ring, then die. Detecting the hangup must not swallow the wakeup that + // was already queued - the peer's last publish is the one a waiter is + // most likely to be blocked on. + notifierBell.Notify(); + ASSERT_EQ(::close(sockets[1]), 0); + + EXPECT_TRUE(waiterBell.Park(1000)); + EXPECT_TRUE(waiterBell.Dead()); + EXPECT_FALSE(waiterBell.Park(kWaitForever)); +} diff --git a/MobileGL/MG_Test/Wire/InProcessTransportTest.cpp b/MobileGL/MG_Test/Wire/InProcessTransportTest.cpp index eed66577f..9808c3a6a 100644 --- a/MobileGL/MG_Test/Wire/InProcessTransportTest.cpp +++ b/MobileGL/MG_Test/Wire/InProcessTransportTest.cpp @@ -217,6 +217,62 @@ TEST(InProcessTransportTest, HandsOverADescriptorAndItsSideband) { ::close(pipeFds[0]); ::close(pipeFds[1]); } + +TEST(InProcessTransportTest, AFrameWakeupIsNotEatenByAWaiterOnDescriptors) { + std::unique_ptr client; + std::unique_ptr server; + InProcessTransport::CreatePair(client, server); + + // Two readers on the SAME endpoint, blocked on two different predicates. + // With one condition_variable per direction and notify_one, the SendFrame + // below could be delivered to the descriptor waiter, which re-tests its + // own predicate and goes back to sleep - and the message then sits + // undelivered until some unrelated later event. ITransport narrows the + // contract to one dedicated reader thread, but that is a comment, and the + // first caller that splits its reader should not have to discover this. + std::atomic fdWaiterStarted{false}; + std::thread fdWaiter([&] { + std::vector sideband(FdPassing::kMaxSidebandBytes); + MobileGLMutableByteSpan span{sideband.data(), sideband.size()}; + int fd = -1; + std::uint64_t size = 0; + fdWaiterStarted.store(true); + // Never offered a descriptor: this one ends on the Shutdown below. + EXPECT_EQ(client->ReceiveFd(&fd, span, &size, kWaitForever), + MOBILEGL_ERR_TRANSPORT_CLOSED); + EXPECT_EQ(fd, -1); + }); + while (!fdWaiterStarted.load()) { + std::this_thread::yield(); + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + std::atomic frameWaiterStarted{false}; + std::string got; + std::thread frameWaiter([&] { + frameWaiterStarted.store(true); + got = Receive(*client, 4000); + }); + while (!frameWaiterStarted.load()) { + std::this_thread::yield(); + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + const std::string message = "wake the right waiter"; + const auto start = std::chrono::steady_clock::now(); + ASSERT_EQ(server->SendFrame(Span(message)), MOBILEGL_OK); + frameWaiter.join(); + const auto elapsedMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + + EXPECT_EQ(got, message); + // Not "eventually, when the receive timed out and re-checked". + EXPECT_LT(elapsedMs, 2000); + + client->Shutdown(); + fdWaiter.join(); +} #endif TEST(InProcessTransportTest, DoorbellWakesAParkedWaiter) { diff --git a/MobileGL/MG_Test/Wire/RingTest.cpp b/MobileGL/MG_Test/Wire/RingTest.cpp index bf325a569..3656f72a4 100644 --- a/MobileGL/MG_Test/Wire/RingTest.cpp +++ b/MobileGL/MG_Test/Wire/RingTest.cpp @@ -10,6 +10,7 @@ // invariants, wrap-around, backpressure, the generation bump after a hard // drain, and a real two-thread producer/consumer run. +#include #include #include @@ -120,6 +121,27 @@ TEST(RingTest, RejectsANonPowerOfTwoCapacity) { EXPECT_EQ(producer.Reserve(1, kRecNone, 8), nullptr); } +TEST(RingTest, RejectsACapacityTheRecordHeaderCannotDescribe) { + alignas(4096) RingControl control{}; + InitRingControl(control); + // 4 GiB is a legal power of two, but RingRecordHeader::size is 32 bits and + // both a record's size and a wrap filler's size are bounded only by the + // capacity: they would be truncated on the way in and then bounds-checked + // in their truncated form on the way out. Nothing is mapped here - the + // constructor rejects before it ever touches the base pointer. + std::uint8_t dummy = 0; + constexpr std::uint64_t kFourGiB = 4ull * 1024 * 1024 * 1024; + EXPECT_GT(kFourGiB, kMaxRingCapacity); + RingProducer producer(&control, &dummy, kFourGiB, RingCursorSet::Cmd); + EXPECT_FALSE(producer.Valid()); + RingConsumer consumer(&control, &dummy, kFourGiB, RingCursorSet::Cmd); + EXPECT_FALSE(consumer.Valid()); + + // The largest ring the header CAN describe stays accepted. + RingProducer biggest(&control, &dummy, 1ull << 31, RingCursorSet::Cmd); + EXPECT_TRUE(biggest.Valid()); +} + TEST(RingTest, RoundTripsRecordsInOrder) { RingFixture ring(4096); ASSERT_TRUE(ring.WriteRecord(1, 16, 0x10)); @@ -323,3 +345,87 @@ TEST(RingTest, SpscProducerConsumerThreadsAgreeOnEveryRecord) { EXPECT_TRUE(ring.Invariants()); EXPECT_EQ(ring.Control().cmdRetiredTail.load(), ring.Control().cmdHead.load()); } + +// The publish/park protocol end to end, in both directions: publish the +// watermark, THEN NotifyIfParked; park with Doorbell::Wait. A lost wakeup on +// either side shows up as a Wait that times out with work available rather +// than as a hang, so the failure is a red test and not a stuck CI job. +// +// This cannot prove the seq_cst fence pairing (no test can - x86 needs the +// store buffer to hold the release store across the flag read, and it usually +// does not), but it does exercise the exact call order the fences assume, so a +// future edit that rings the bell BEFORE publishing has somewhere to fail. +TEST(RingTest, DoorbellHandoffWakesBothSidesOnEveryPublish) { + // 4 byte payloads: every record is exactly 16 bytes and 4096 is a multiple + // of that, so no wrap filler ever appears and "head != tail" is exactly + // "a record is waiting". + RingFixture ring(4096); + CondVarDoorbell consumerBell; + CondVarDoorbell producerBell; + std::atomic ok{true}; + constexpr int kRecords = 2000; + constexpr std::uint64_t kRecordBytes = 16; + + std::thread consumerThread([&] { + int seen = 0; + while (seen < kRecords) { + const bool woke = consumerBell.Wait( + ring.Control().consumerParked, + [&] { + return ring.Control().cmdHead.load(std::memory_order_acquire) != + ring.Consumer().LocalTail(); + }, + kDefaultSpinUs, 5000); + if (!woke) { + ok.store(false); // a wakeup was lost, or the producer stalled + return; + } + RingRecordView view{}; + bool corrupt = false; + while (ring.Consumer().Pop(view, &corrupt)) { + std::uint32_t value = 0; + std::memcpy(&value, view.payload, sizeof(value)); + if (value != static_cast(seen)) { + ok.store(false); + return; + } + ++seen; + } + if (corrupt) { + ok.store(false); + return; + } + ring.Consumer().PublishRetired(); + NotifyIfParked(producerBell, ring.Control().producerParked); + } + }); + + for (int i = 0; i < kRecords && ok.load(); ++i) { + void* payload = nullptr; + while ((payload = ring.Producer().Reserve(1, kRecNone, sizeof(std::uint32_t))) == nullptr) { + if (!ok.load()) { + break; + } + if (!producerBell.Wait( + ring.Control().producerParked, + [&] { return ring.Producer().FreeBytes() >= kRecordBytes; }, kDefaultSpinUs, + 5000)) { + ok.store(false); + break; + } + } + if (payload == nullptr) { + break; + } + const std::uint32_t value = static_cast(i); + std::memcpy(payload, &value, sizeof(value)); + // Publish first, ring second. The other order reopens the lost-wakeup + // window no matter how strong the flag's memory order is. + ring.Producer().Publish(); + NotifyIfParked(consumerBell, ring.Control().consumerParked); + } + + consumerThread.join(); + EXPECT_TRUE(ok.load()); + EXPECT_TRUE(ring.Invariants()); +} From 87ee17c68ca1b4bb7c93c9cb13729aa913435457 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 20:29:30 -0400 Subject: [PATCH 020/529] [Docs] (Disaggregated): fold the P0 measurements and corrections into the plan - GL_COMPUTE_WORK_GROUP_SIZE is answered by MG_Impl from ProgramObject::GetComputeLocalSize, not by a backend; only the compute limits are caps, and the two dead table entries (GetInteger64i_v, GetProgramiv) were retired in P0 - the glRenderbufferStorage OOM-probe idiom appears in 0 of 41 fixtures, so kNeedsAck is carried by glBufferStorage only - FramebufferSrgb/DepthClamp: six readers of a constant false and zero readers respectively, no fixture enables either; recorded as a decision to take before the render-state chunk table freezes - the call catalogue is 68 unique records (screen 10, ctx-query 6, CSO 13, kCtxState 17, kCtxObject 9, kCtxVerb 13); PipeCalls.def is the single source of truth and the wire opcode is a line's position - measured layouts (MGPDrawInfo head 56 B, RenderStateParameters 1168 B, ResidualValueBlock 1248 B, MGPipeContext 464 B ...), the 926/73 MG_Impl mutator surface, the first per-draw accessor numbers on lavapipe (Espryt 20.65, Magma 15.54), the EndTransformFeedback null-as-capability trap, and the host-side spike results --- docs/Disaggregated/PLAN.md | 107 +++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 27 deletions(-) diff --git a/docs/Disaggregated/PLAN.md b/docs/Disaggregated/PLAN.md index ccf7ac388..39b8c2881 100644 --- a/docs/Disaggregated/PLAN.md +++ b/docs/Disaggregated/PLAN.md @@ -170,7 +170,7 @@ v1 把 GO/NO-GO 放在"只迁了渲染状态"的时点,而渲染状态恰好 ### 2.1 今天的边界有七个面(数字按工作树复核) **(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`)。 +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` 完全不碰 GL)、`GetProgramiv`(`:197`)。**(P0 实测修正)"15 个 case"是错数**:`:7264-7386` 是 `GetIntegeri_v` 的 9 个分支加 `GetInteger64i_v` 的 2 个,共 11 个。**`GetInteger64i_v` 与 `GetProgramiv` 两个表项已在 P0 从 `GLFunctionsTable` 连同两个 backend 的实现一起删除**(提交 "retire the two frontend queries that were never asked"),本节的表项计数是删除前的基线数。 **这 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。 @@ -237,7 +237,11 @@ v1 的 §13.2 把今天的每 draw 状态获取写成 "Espryt 124 / Magma 169 | `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 的对照表必须按动态口径重写(已改)。**推论**: +**所以真实稳态大约是每 backend 每 draw 10-25 次 accessor 调用加几十次字比较,不是 124/169。** 推送模型的优势因此比 v1 声称的**窄得多**,而且它在 §13.2 的对照表必须按动态口径重写(已改)。 + +**(P0 实测修正)第一个实测数据点:预测成立。** P0 的动态 accessor 计数器在 lavapipe / llvmpipe 上跑 `GuiBatchScenario`(14 帧 / 26 draw),得到**每 draw 动态 accessor 调用数:Espryt 20.65、Magma 15.54**——两者都落在本节预测的 10-25 区间内,且都远低于 124/169 的静态调用点数。**告诫两条**:(a) llvmpipe 上 pipeline memo 是**冷的**(场景太短,未进入真正的稳态命中率),所以这两个数偏**高**而不是偏低,真机稳态只会更靠近区间下沿;(b) **两台设备的数字仍然欠着**(设备锁),第 43 天的 GO/NO-GO 绝对 ns 阈值必须等真机基线,不能拿这组桌面数字定。 + +**推论**: 1. P0 的计数器交付物**必须包含动态调用计数器**(每 draw 实际执行的 accessor 次数、每个 memo 门的命中/未命中),不只是字节计数器——否则 P2 仍然是在猜。 2. 第 43 天的 GO/NO-GO 阈值必须是一个**绝对数字**(tracker 每 draw 的 ns,两台设备实测),不能只写"落在 monolith-pull 的噪声内"——当真实基线是 20 次调用时,相对噪声阈值会平凡通过。 @@ -372,8 +376,8 @@ scripts/check_doc_citations.py # ★v2:docs/**.md 的 file:line 必须 /* ---- verb ---- */ \ X(DrawVbo, MGPDrawInfo, kCtxVerb, kHostSpan|kVarTail) \ X(ResourceSubData, MGPSubData, kCtxObject,kHasBlob|kVarTail) \ - X(RenderbufferStorage, MGPRbStorage, kCtxObject,kNeedsAck) \ - /* … 共约 74 项,完整目录见 §3.4 与附 A 的速查表 … */ + X(RenderbufferStorage, MGPRbStorage, kCtxObject,kNone) /*P0:非 ack*/\ + /* … 共 68 项(P0 实测,非"约 74"),完整目录见 §3.4 与附 A 的速查表 … */ ``` | 生成器 | 产物 | 替代/新增 | @@ -444,7 +448,13 @@ v1 只有一个 screen、一个 context、一条 flow。**但两张表从第一 ### 3.4 完整调用目录 -#### 3.4.1 `MGPipeScreen`(14 项) +**(P0 实测修正)落地的 `PipeCalls.def` 是 68 条**唯一调用,不是"约 74"。按 `.def` 的 Class 列分组:**screen 10、ctx-query 6、CSO 13、`set_*`(`kCtxState`)17、object(`kCtxObject`)9、verb(`kCtxVerb`)13**。旧数虚高有三个来源,本节各小标题下逐条标出:(1) `bind_sampler_states` 与 `set_sampler_views` 在 CSO 组与 `set_*` 组**各记了一次**;(2) query 族被并进 screen 一起统计,而 §3.3 已经把 query 命名空间**给了 context**;(3) transfer 标 12,正文与速查表实际只列出 11 条。 + +**为什么这个算术是承重的**:`PipeCalls.def` 是**唯一真相源**,而**线上 opcode 就是一行在文件里的位置**——所以这份目录必须是**唯一记录的集合**,同一个调用在两个组里各出现一次会让 opcode 编号与目录永久错位(且 G3 的 `static_assert` 抓不到,它只校验单条记录的尺寸)。 + +**`kCtxState` 为什么是 17**:16 个 `set_*` 加上迁移期临时的 `set_residual_value_state`。**`set_texture_params` 不在其中**——它按资源寻址,Class 是 `kCtxObject`。 + +#### 3.4.1 `MGPipeScreen`(14 项 → **P0 实测 10 项**) | 调用 | payload | 取代 | |---|---|---| @@ -457,15 +467,19 @@ v1 只有一个 screen、一个 context、一条 flow。**但两张表从第一 | `query_create/begin/end/available/result/destroy` | handle + kind | `BackendObject.h:230-256` | | EGL 生命周期 8 项 | `BackendObject.h:548-559` | 原样保留为虚函数(罕见) | +**(P0 实测修正)本表的 query 族 6 项不属于 screen。** §3.3 已把 query 的命名空间划给 context,落地的 `.def` 因此给它们 `kCtxQuery`,独立成组。screen 组是余下的 10 项:`get_caps`、`resource_create`/`_respecify`/`_destroy`、`map_persistent`/`unmap_persistent`、`fence_create`/`_status`/`_wait`/`_destroy`。EGL 生命周期 8 项留在虚函数上,本来就不在 `.def` 里。 + **`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 项) +#### 3.4.2 `MGPipeContext` — CSO(15 项 → **P0 实测 13 项**) `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) +**(P0 实测修正)13 而不是 15**:`create`/`delete` × 5 = 10,`bind` 只有 3(`render_state`、`vertex_elements`、`shader`)。sampler 与 sampler view 的绑定**就是**下一节的 `bind_sampler_states` 与 `set_sampler_views`(它们是带 start/count 的批量绑定,不是单条 CSO bind),在两组各记一次是"约 74"里最大的一处重复计数。 + +#### 3.4.3 `MGPipeContext` — `set_*`(17 项,v2 从 14 增至 17;**P0 实测 `kCtxState` 亦为 17**) | 调用 | 取代的拉取点 | |---|---| @@ -492,13 +506,17 @@ v1 只有一个 screen、一个 context、一条 flow。**但两张表从第一 **迁移期额外一项(显式临时)**:`set_residual_value_state(MGPBlobRef)`,见 §5.3。 -#### 3.4.4 `MGPipeContext` — transfer(12 项) +**(P0 实测修正)`kCtxState` 的 17 项这样凑出来**:本表 17 行里 `set_texture_params` 被划成 `kCtxObject`(它按资源寻址,见 §3.4.3 上一段"为什么纹理参数不能只挂在 sampler view 上"——它的载体是 `res`,不是 context),剩 16 个 `set_*`,再加迁移期临时的 `set_residual_value_state` = 17。**巧合的是它与本节旧标题同为 17,但成分不同**,改动这张表时别把两者当同一个数。 + +#### 3.4.4 `MGPipeContext` — transfer(12 项 → **P0 实测正文只有 11 条**) `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 项) +**(P0 实测修正)"transfer"在 `.def` 里不是一个 Class。** 标题的 12 是虚数——附 A 的速查表实际列出 11 条。落地的 `.def` 按**寻址方式**给它们分类:按资源寻址的(`resource_subdata`、`renderbuffer_storage`、`set_texture_params` 等)进 `kCtxObject`(该组共 9 项),按上下文寻址的动词(`blit`、`clear`、`read_pixels` 等)进 `kCtxVerb`(该组共 13 项)。**统计时按 Class 数,不要按本节的功能分组数**,否则又会重复计数。 + +#### 3.4.5 `MGPipeContext` — 命令(10 项;在 `.def` 里与 transfer 的动词合成 `kCtxVerb` 13 项) ```cpp void draw_vbo (const MGPDrawInfo*, Uint32 drawIdOffset, @@ -516,7 +534,8 @@ void present(Uint64 frameSerial); void set_swap_interval(Int interval); // #### 3.4.6 显式删除、不移植的项 -- `GetIntegeri_v` / `GetInteger64i_v` / `GetProgramiv`(`BackendObject.h:195-197`)。只有 `GL_COMPUTE_WORK_GROUP_SIZE`(`DirectVulkan.cpp:790-795`)是真后端答案,进 `MGPCaps`。 +- `GetIntegeri_v` / `GetInteger64i_v` / `GetProgramiv`(`BackendObject.h:195-197`)。**(P0 实测修正)`GL_COMPUTE_WORK_GROUP_SIZE` 不是后端答案,别把它放进 `MGPCaps`**:`MG_Impl/GLImpl/Program/GL_Program.cpp:928-946` 用 `ProgramObject::GetComputeLocalSize` 自己回答它,没有链接 compute stage 时抛 `INVALID_OPERATION`——它是一个**程序反射查询**,纯 client。真正属于后端、且确实带下标的只有 **`GL_MAX_COMPUTE_WORK_GROUP_COUNT` / `GL_MAX_COMPUTE_WORK_GROUP_SIZE`**(读点 `GL_Getter.cpp:1160` 与 `MG_Util/ShaderTranspiler/CompileEnv.cpp:134-138`),它们以 **compute 限制**的身份进 `MGPCaps`,与 `DynamicBackendParameters` 的其余标量同列。 +- **(P0 实测修正)`GetInteger64i_v` 与 `GetProgramiv` 的退役已在 P0 落地**(提交 "retire the two frontend queries that were never asked"):两个 `GLFunctionsTable` 表项与两个 backend 的实现均已删除。本文其余处(§8.6-3、§14 P0)把它写成待办的地方,读作**已完成**。 - `ShaderStorageBlockBinding`(`:207-208`)→ 折进 `MGPProgramDesc` 的反射归档。 - **总规则:server 不回答任何 client 能自己回答的问题;剩下的每个 server 查询都是 async-with-handle,绝不阻塞。** @@ -724,7 +743,24 @@ struct MGPDrawInfo { // = pipe_draw_info 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` 的定尺依据)。 +**v2 成本诚实化**:今天的 `DrawArrays(GLenum, GLint, GLsizei)` 是三个寄存器实参(`BackendObject.h:117`)。替换成一个 **56 B**(**P0 实测,不是 ~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` 的定尺依据)。 + +**(P0 实测修正)定尺用的实测布局**(P0 骨架编译产物的 `sizeof`,64 位 arm64/x86-64 一致;本表取代此前散落各处的估数): + +| 类型 | 实测字节 | 用途 | +|---|---|---| +| `MGPDrawInfo`(固定头) | **56**(此前写 ~48) | 每 draw;`MGHostSpan` 的 **32 B 只在 `kHasUserIndices` 时**进变长尾 | +| `MGHostSpan` | 32 | 见上;不进固定头 | +| `MGPBindRenderState` | **12** | 每次 CSO 绑定 | +| `RenderStateParameters` | **1168**(此前写 ~1.2KB) | server 侧每 context 一份 working 副本;**不整块过线** | +| `ResidualValueBlock` | **1248** | 迁移期 `set_residual_value_state` 的 payload 上界,`static_assert` 逐阶段下调至 0(§5.3、P13) | +| `DynamicBackendParameters` | **328** | `MGPCaps` 的主体 | +| `MGPCaps` | **384** | 握手后一次 | +| `MGPipeScreen` | **80** | 函数指针表(进程内,不过线) | +| `MGPipeContext` | **464** | 同上 | +| `PixelStoreParameters` | **28** | `set_pixel_pack_state` 的整块 payload | + +**两条直接后果**:(a) `SEG_CMD` 的定尺按 **56 B 头**算,不是 48——MC 帧 1000-4000 draw 时这是每帧 8-32 KiB 的差额;(b) `ResidualValueBlock` 的 1248 B 是**迁移期每 draw 最坏情况**的额外 payload(`RenderStateParameters` 1168 占了绝大部分),这解释了为什么它的退役绊线要按阶段下调而不是一次性删除。 **`MGHostSpan` 是整份接口里唯一一个"形状随传输而变"的东西**: @@ -912,6 +948,9 @@ private: **完整性由 `gen_pipe_dirty_surface.py` 保证**(推论 4):它枚举 `MG_Impl/GLImpl/**` 里每一个会改变某组的 mutator,映射到必须 bump 的聚合世代,CI 重生成 + `git diff --exit-code`,**未映射的 mutator 直接失败**。这是 B-R6 的第四层。 +**(P0 实测修正)这个面到底有多大——已用 `gen_pipe_dirty_surface.py` 量过。** `MG_Impl/GLImpl` 下共 **926 次 `pGLContext` mutator 调用**,但它们只落在 **73 个不同的 mutator** 上。其中 **92 次(7 个不同 mutator,绝大多数是 `RecordError`)位于同时会走到 backend 的函数里**——只有这批需要"在同一个 GL 入口内既改状态又已经发过消息"的顺序推敲;**其余 834 次由紧随其后的 verb 发布**,不需要各自的即时推送。 +**结论:P1/P2 的 dirty-surface 映射是一个 73 条目的问题,不是 926 条目的问题**,映射表的规模因此可控(每条目一行"mutator → 必须 bump 的聚合世代"),而 CI 门的成本也是按 73 条计。**调用点数仍要监控**(新增调用点若落在未映射的 mutator 上必须失败),但它不是工作量口径。 + **三个回绕的 `Uint16` 在 tracker 边界加宽。** `m_lastPushed[]` 是 tracker 自己的字段,加宽到 `Uint32`/`Uint64` **不需要改 `MG_State` 一行**;同时 handle 与它同行过线。**回绕在 tracker 本地是无害的**(一次回绕造成一次多余的重推,永不漏推),何况集合 hash 抑制器会把多余重推吞掉。 ### 4.3 每命令 validate 的**不变式**(v2:从"固定顺序契约"降级) @@ -1275,6 +1314,10 @@ Memcpy(staged.data(), target.buffer->MappedData() + target.start, rangeBytes); 副作用:`:906-914` 的"CPU 模型给出 0 顶点 → 整批捕获丢弃"的诊断**落到应用线程**上,比落在 server 上更有用。计入 Espryt 子系统 7(§5.4)。 +**(P0 实测修正)一个活的陷阱:`EndTransformFeedback` 槽位的 null 被当成能力位在用。** +`MG_Impl/GLImpl/Drawing/GL_Drawing.cpp:1253-1256` 读的不是这个 hook 的**功能**,而是它的**空与非空**:槽位非空即被解释为"该 backend 按 GL 的顶点序捕获,因此跳过 `FixupGsStripCaptureOrder`"。也就是说**任何**出于别的理由注册了 `EndTransformFeedback` 的 backend,会**静默**丢掉几何阶段的 strip 重排——没有编译错误、没有日志、只有错序的捕获结果。这是 §3.1 那条"null 项表示未实现、前端回退"的惯例被**反向**使用了一次:它在这里表达的是一个正向能力断言。 +**MGPipe 下必须转成显式能力位**(例如 `kCapDriverOrderedXfbCapture`,与 §3.4.1 的 `callMask` 同列),由 backend 主动声明,`GL_Drawing.cpp:1253-1256` 改读该位而不是测空。**列为 P8/P9 项**——P8 触到 XFB 动词、P9 触到反向通道与 `on_xfb_scatter_ready`,两处都会重排这段代码;在此之前它是 monolith 上一个真实存在、只是暂时没人踩到的地雷。 + ### 6.3 纹理 dirty 归属反转 **client** 保留 `MipmapStorage` 的模型(96-rect 级联合并 + `summedArea*4 >= unionArea*3` union-box 回退,`MipmapStorage.cpp:300-305`),维护一份**发射游标**,在发射后清自己的标志。**server 从不碰 client 的标志。** @@ -1303,7 +1346,8 @@ v1 把 "`glRenderbufferStorage*`、可能失败的 `glTexImage*`/`glTexStorage*` **修正后的规则**: - **纹理分配的 OOM 在 monolith 里就已经推迟到 sync 时刻,拆分不改变任何可观察行为** —— 这批**不标** `kNeedsAck`,并把这条事实写进文档(避免后人以为是遗漏)。 -- **`kNeedsAck` 只标两项**:`glBufferStorage`(真同步)与 `glRenderbufferStorage*`(**若**决定把它的分配提前到 GL 调用时刻以支持 OOM 探测;否则它也不标,同样写明)。**这个"若"由 P0 回答**:查 MC / Iris 语料里有没有真的 `glRenderbufferStorage` OOM 探测惯用法;没有就不标,省掉整条 ack 路径。 +- **(P0 实测修正)`kNeedsAck` 只标一项**:`glBufferStorage`(真同步)。**`glRenderbufferStorage*` 不标**,保持惰性/异步分配。 + **证据**:41 个 trace fixture 里 OOM 探测惯用法出现 **0 次**——全部语料只有 **9 次 `glRenderbufferStorage` 调用、分布在 5 个 fixture**,且**没有一次**在其后 3 个调用之内跟 `glGetError`;语料里真实的成功性检查是 `glCheckFramebufferStatus`,而它本来就在 client 侧作答。因此整条 ack 路径连同它的往返一起省掉,`RenderbufferStorage` 在 `PipeCalls.def` 里的 flags 是 `kNone`。 - 其余错误一律晚到,走有序的 `on_gl_error`。 **对事件通道的强制条款:`on_log` 必须按严重级分级。** §8.4 的朴素策略把**全部**日志行设为有损(覆盖最旧 + `eventDropped`)。但 §4.7 已确认:**backend program link/compile 失败只以一行日志加一次 bind-program-0 的空 draw 呈现**。统一有损策略下,系统里诊断价值最高的那一行会在日志压力下静默消失。 @@ -1572,7 +1616,7 @@ WAR 用 **per-shadow 64KiB 块发送水位**:若应用写入某块而该块最 ### 8.1 FlatBuffers 用法 **一份 schema `MobileGL/MG_Remote/Protocol/protocol.fbs`,两种用法:** -- **热路径 → 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 `struct`**(flatc 保证定长布局、无 vtable、无偏移间接、无需 verifier walk,只需边界检查),直接放进 ring:`[RecHeader | struct | 可选变长尾]`。`draw_vbo` 的固定头是 8+**56** = **64B**(**P0 实测修正**:`MGPDrawInfo` 的 `sizeof` 是 56 而不是 48,见 §3.5.7 的实测布局表;对比 table-per-command 的 ~90B 与一次 vtable 遍历)。这正是 `Feat/CS-Delta-IPC` 自己的 plan 第 55 行要求而实现没做的事。 - **罕见/变长/需演进 → FlatBuffers `table`**,走 CTRL socket。 ```fbs @@ -1595,7 +1639,7 @@ struct RecDrawVbo { mode:uint; indexSize:ubyte; flags:ubyte; pad:ushort 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 逐条对应 … +// … 共 68 项(P0 实测),与 PipeCalls.def 逐条对应 … // ---------- 控制面 table(走 socket)---------- table SegmentRef { id:uint; kind:ubyte; sizeBytes:ulong; name:string; } @@ -1688,7 +1732,7 @@ server 侧的 `MGLOG` 与延迟诊断按流顺序 replay 进 client 日志流— 1. `glEndTransformFeedback` 的无条件无限 `ClientWaitSync`(`GL_Drawing.cpp:1326-1337`)→ 用既有 `MarkGpuWritten`/`SyncGpuWrites` 推迟到首次读。 2. `glDispatchCompute` 的三次 `GetIntegeri_v` 校验查询(`GL_Drawing.cpp:719`)→ 改读 `CompileEnv::maxComputeWorkGroupCount`(`CompileEnv.h:52-54`)。 -3. 删除 `GetInteger64i_v`/`GetProgramiv` 两个死表项及两个 backend 的实现。 +3. ~~删除 `GetInteger64i_v`/`GetProgramiv` 两个死表项及两个 backend 的实现。~~ **(P0 实测修正)已在 P0 落地**(提交 "retire the two frontend queries that were never asked")。 (另有两项在 §13.4-5 列出:D21 的 XFB 计数槽重键与 `RenderbufferObject::GetLifetimeId()`,同样先独立落 `dev`。) @@ -1793,7 +1837,7 @@ extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int a **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`)不可实现。 - **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)。 + **注意实测的域**:上述 SIGILL 证据是经 `run-as` 取得的,即 `runas_app` 域,而不是 trace Activity 所在的 `untrusted_app` 域。**P0 的 Android spike 必须从应用自身进程 spawn 一次**(见 §14 P0)。**(P0 实测修正)不能用 `posix_spawn`**:bionic 从 API 28 才声明它,minSdk 26 下出货的那条臂是 **`fork` + `execve`**;而且应用进程的 stdout/stderr 是 `/dev/null`,子进程要用 **marker 文件**而不是日志来证明自己活过。真机 exec 本身仍待验证(设备锁)。 - **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"。 @@ -1846,7 +1890,7 @@ asio 1.38.2 在 Win32 上确实定义了 `ASIO_HAS_LOCAL_SOCKETS`(`3rdparty/as | 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) | +| 7 | 分配类入口的 ack | OOM 探测惯用法 | **v2 收窄 +(P0 实测修正)**:**只有 `glBufferStorage`**(真同步)。`glRenderbufferStorage*` 的 OOM 探测惯用法在 41 个 fixture 里出现 0 次(9 次调用 / 5 个 fixture,无一在 3 个调用内跟 `glGetError`;语料里的成功性检查是 `glCheckFramebufferStatus`),故它**不标 `kNeedsAck`**、保持晚到/异步。纹理族在 monolith 里就已经推迟到 sync 时刻,同样**不标**(§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()`) | @@ -1872,7 +1916,7 @@ v1 这张表把今天的每 draw 状态获取写成 "Espryt 124 / Magma 169 次 | | 今天(动态稳态) | 之后(动态稳态) | |---|---|---| -| 每 verb 的分发 | 1 次间接调用 + 3 个寄存器实参(`DrawArrays`) | 1 次间接调用 + **~48 B 固定头**(`MGPDrawInfo`)+ 按 flag 的变长尾。**这是一项新增成本,不是持平** | +| 每 verb 的分发 | 1 次间接调用 + 3 个寄存器实参(`DrawArrays`) | 1 次间接调用 + **56 B 固定头**(`MGPDrawInfo`,**P0 实测修正**,此前写 ~48 B)+ 按 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 的数组下标 | @@ -1927,6 +1971,8 @@ v1 这张表把今天的每 draw 状态获取写成 "Espryt 124 / Magma 169 次 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 划分里要不要这个字段)。 + **(P0 实测修正)调查结论 + 待拍板。** 已查明的事实三条:`FramebufferSrgb` 的**六个 backend 读点全部在消费一个编译期常量 `false`**(`IsCapabilityEnabled` 对它的返回值可被常量折叠),`DepthClamp` **一个读点都没有**;`glEnable(GL_FRAMEBUFFER_SRGB)` / `glEnable(GL_DEPTH_CLAMP)` 被**静默吞掉**——落到 `default:` 分支既不存储也**不报 `GL_INVALID_ENUM`**,应用无法察觉;41 个 trace fixture **无一**开启任一项(所以补上真存储不会改动任何既有 fixture 的渲染输出)。 + **调查方给出的建议(决定权在计划所有者)**:在渲染状态 chunk 表冻结**之前**给两者补上真实存储;并把 `FramebufferSrgb` 放进 D-B1 划分的 **pipeline 那一半**——它改变的是 attachment 的解释与 blend 的工作色彩空间,属于会重铸 pipeline 的子集,不是 `set_dynamic_state` 那一半。`DepthClamp` 的归属随实现方式定,可留到补存储时一并拍板。**在拍板之前不要冻结 chunk 表。** 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 的答案)。 @@ -2046,14 +2092,18 @@ CMake: - `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`**。 +- **三个严格 no-op 的免费收益**:`GetIntegeri_v`/`GetInteger64i_v`/`GetProgramiv` 的纯前端 case 移回 `MG_Impl`(Espryt 14 / Magma ~10 个读点)。**(P0 实测修正)后两项已落地**——`GetInteger64i_v` 与 `GetProgramiv` 的表项与两个 backend 实现已从 `GLFunctionsTable` 删除(提交 "retire the two frontend queries that were never asked");同时确认 **`GL_COMPUTE_WORK_GROUP_SIZE` 由 `GL_Program.cpp:928-946` 纯前端回答,不进 `MGPCaps`**,进 caps 的是 `GL_MAX_COMPUTE_WORK_GROUP_COUNT`/`_SIZE` 两个 compute 限制(§3.4.6);`RenderbufferObject::GetLifetimeId()`(**不加 `GetVersion()`**——推送模型里 `glRenderbufferStorage*` 本身就是一次 pipe 调用);D21 重键——**这一条是潜伏 bug 修复,先独立落 `dev`**。 - 回答两个阻塞问题:`FramebufferSrgb`/`DepthClamp` 无存储是潜伏 bug 还是有意为之(§13.4-6,**必须在渲染状态 chunk 表冻结之前**);**语料里是否存在 `glRenderbufferStorage` 的 OOM 探测惯用法**(决定 `kNeedsAck` 要不要标它,§6.4)。 + **(P0 实测修正)两问均已调查完毕**:(a) OOM 探测惯用法在 41 个 fixture 里 **0 例**(9 次 `glRenderbufferStorage` 调用散在 5 个 fixture,无一在 3 个调用内跟 `glGetError`;实际的成功性检查是 `glCheckFramebufferStatus`)→ **`kNeedsAck` 只由 `glBufferStorage` 承担**,`glRenderbufferStorage*` 保持晚到/异步,整条 ack 路径省掉(§6.4、§12.2-7)。(b) `FramebufferSrgb` 有六个 backend 读点全在消费一个编译期常量 `false`、`DepthClamp` **零读点**,两者的 `glEnable` 被静默吞掉且不报 `GL_INVALID_ENUM`,41 个 fixture 无一开启任一项 → **调查结论 + 待拍板**,见 §13.4-6 与开放问题 10。 - `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`。 + **(P0 实测修正)已证 vs 待证。** **已在主机侧证明**三条:(1) AGP **确实**会把一个被改名成 `lib*.so` 的 `add_executable` 打进 `lib/arm64-v8a/`,前提是把它的 `RUNTIME_OUTPUT_DIRECTORY` 重定向到 AGP 收集原生产物的那个目录(默认 runtime 输出路径 AGP 不看);(2) **`posix_spawn` 在 minSdk 26 上用不了**——bionic 从 **API 28** 才声明它,所以出货形态的那条臂是 **`fork` + `execve`**,本文其余处(§11.3 的注记)写 `posix_spawn` 的地方一并按此读;(3) 应用进程的 stdout/stderr 是 **`/dev/null`**,子进程"打一行日志"证明不了自己活过,**必须改成写一个 marker 文件**再由测试断言它出现。**待证**:`untrusted_app` 域内的真机 exec 本身(设备锁未解,on-device 运行仍欠着)——spike A 的核心结论因此**尚未闭合**。 - **spike B(external memory 可行性,半天)**:最小程序,导出一个 `HOST_VISIBLE|HOST_COHERENT` VkBuffer 的 fd,`mmap` 后回读校验,在 `35d0befa`(Adreno 830)与 `3B159D009VZ00000`(Mali)各跑一次。与 `SCM_RIGHTS` 测试同批。**目的是让 P11 的结论在第一周就有方向**:若两台都不行,P11 缩为"记录并回退",省 6 天。 + **(P0 实测修正)已证 vs 待证。** 探针**已写好并在 lavapipe 上跑通**:**T1**(opaque-fd 的导出/导入)与 **T3**(host-pointer 导入)**两档都能完整往返**(导出 → 导入 → 回读字节相符)。**待证**:`35d0befa`(Adreno 830)与 `3B159D009VZ00000`(Mali)**两台真机都还没跑**(设备锁)。**所以 P11 的规模仍未定**——lavapipe 通过只说明探针本身正确,不构成任何移动端驱动的证据(§7.8 的三档选择、开放问题 3 保持开放)。 **验收**:`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 全绿。 +**(P0 实测修正)本条验收目前的状态**:主机侧(lavapipe/llvmpipe)部分已达成——动态 accessor 基线已取(§2.3.1)、spike B 探针 T1/T3 往返通过、spike A 的打包与 `posix_spawn` 不可用两点已定论;**两台设备的基线数字与两个 spike 的真机运行仍欠着**(设备锁),P0 因此**尚未整体验收通过**。 ### P0.5 — 值头与制品头抽取(6-9 天)★v2 新增,**P1 与 P7 的硬前置** @@ -2270,7 +2320,7 @@ CMake: 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 表冻结之前回答**。 +10. **`FramebufferSrgb` / `DepthClamp` 无存储是潜伏 bug 还是有意为之?** 六个 backend 消费者今天读到恒定 false(`RenderState.cpp:380, 428-429`)。**必须在渲染状态 chunk 表冻结之前回答**。**(P0 实测修正)事实已查清、结论待拍板**:`FramebufferSrgb` 六个读点消费的是编译期常量 `false`,`DepthClamp` 零读点;两者的 `glEnable` 被静默吞掉且不报 `GL_INVALID_ENUM`;41 个 fixture 无一开启。建议是在冻结前补真存储、并把 `FramebufferSrgb` 划进 D-B1 的 pipeline 半边(它改变 attachment/blend 的解释)——**由计划所有者拍板**,详见 §13.4-6。 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`)未做原型。 @@ -2324,7 +2374,9 @@ CMake: > Flags:`A`=`kNeedsAck`、`B`=`kHasBlob`、`V`=`kVarTail`、`H`=`kHostSpan`、`R`=`kReplySlot`、`O`=`kOptional`。 -### `MGPipeScreen`(14) +> **(P0 实测修正)本表按功能分组,合计 68 条唯一调用**(不是"约 74")。按 `.def` 的 Class 列才是权威口径:**screen 10、ctx-query 6、CSO 13、`kCtxState` 17、`kCtxObject` 9、`kCtxVerb` 13**。下面各小标题的括号数是**旧的功能分组数**,其中 CSO 与 `set_*` 对 `bind_sampler_states`/`set_sampler_views` 重复计数、query 族被并进 screen、transfer 标 12 而实列 11。**`PipeCalls.def` 是唯一真相源,线上 opcode 就是行的位置,所以目录必须是唯一记录的集合。** + +### `MGPipeScreen`(14 → **10**,query 族 6 项归 `kCtxQuery`) | 调用 | payload | flags | 取代 | |---|---|---|---| @@ -2336,21 +2388,22 @@ CMake: | `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) +### `MGPipeContext` — CSO(15 → **13**:`create`/`delete` × 5 + `bind` × 3) -`create/bind/delete` × `render_state` / `vertex_elements` / `sampler` / `sampler_view` / `shader`。 +`create/delete` × `render_state` / `vertex_elements` / `sampler` / `sampler_view` / `shader`,`bind` × `render_state` / `vertex_elements` / `shader`。 +**sampler 与 sampler view 的绑定见下一组的 `bind_sampler_states` / `set_sampler_views`,此处不重复计。** `create_render_state` 带 `B`(**只带 pipeline 子集的 chunk**);`create_shader_state` 带 `B`(SPIR-V + `ProgramArtifacts` 归档)。 -### `MGPipeContext` — `set_*`(17 + 1 临时) +### `MGPipeContext` — `set_*`(17 + 1 临时;**`kCtxState` = 16 `set_*` + 1 临时 = 17**,`set_texture_params` 计入 `kCtxObject`) `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) +### `MGPipeContext` — transfer(标 12,**实列 11**;在 `.def` 里分入 `kCtxObject` 9 与 `kCtxVerb` 13) `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) +### `MGPipeContext` — 命令(10;与 transfer 的动词合成 `kCtxVerb` 13) `draw_vbo`(H,V) · `launch_grid` · `memory_barrier` · `begin/end/pause/resume_stream_output` · `flush` · `present` · `set_swap_interval`(O) @@ -2360,7 +2413,7 @@ CMake: ### 显式删除 -`GetIntegeri_v` · `GetInteger64i_v` · `GetProgramiv` · `ShaderStorageBlockBinding`(折进 `MGPProgramDesc`)· `set_pixel_unpack_state`(不存在)· 压缩格式概念(不存在)· `pipe_transfer`(不存在)· `set_sampler_views` 的 stage 维度(不存在)· `kCapPrimitiveRestart` / `kCapPrimitiveRestartFixedIndex` / `kCapMultiDraw` / `kCapMultiDrawIndirect` / `kCapMultiDrawIndirectCount`(**归属不可表达,D-B7**) +`GetIntegeri_v` · `GetInteger64i_v`(**P0 已删表项**)· `GetProgramiv`(**P0 已删表项**)· `ShaderStorageBlockBinding`(折进 `MGPProgramDesc`)· `set_pixel_unpack_state`(不存在)· 压缩格式概念(不存在)· `pipe_transfer`(不存在)· `set_sampler_views` 的 stage 维度(不存在)· `kCapPrimitiveRestart` / `kCapPrimitiveRestartFixedIndex` / `kCapMultiDraw` / `kCapMultiDrawIndirect` / `kCapMultiDrawIndirectCount`(**归属不可表达,D-B7**) --- From 8a239177ac976074f8dded5a1274f0ac6115ed6f Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 20:33:42 -0400 Subject: [PATCH 021/529] [Feat] (Build, TraceApp): ship and exec a second native binary on android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PLAN-B.md §11 P0 lists spike A (the Android delivery chain) as a P0 deliverable, inherited verbatim from PLAN.md §15 P0; §8.1 inherits PLAN.md §11.1-§11.6, whose Android path needs a second process. Android gives an application no writable exec-able directory, so the only supported route is to name the binary lib*.so, let the packager put it in lib//, and exec it out of getApplicationInfo().nativeLibraryDir. This builds that route end to end so the spike can be answered with evidence instead of folklore. - New root option MOBILEGL_BUILD_SERVER_SPIKE (OFF, ANDROID-only) adds the MobileGLServer target from tools/spikes/server_stub/main.cpp with PREFIX "lib" / SUFFIX ".so" and -fPIE/-pie: an .so name does not exempt the file from Android's PIE requirement. Its RUNTIME_OUTPUT_DIRECTORY is pointed at CMAKE_LIBRARY_OUTPUT_DIRECTORY, because AGP packages what lands in the per-ABI library output directory and CMake would otherwise put an executable elsewhere. - The option is opt-in on both sides. The plugin flavour cannot turn it on at all, and the trace flavour builds it only when asked, with `-Pmobilegl.buildServerSpike=ON` or MOBILEGL_BUILD_SERVER_SPIKE=ON in the environment; a flavour that silently carries an executable nothing loads is the kind of thing nobody notices until it ships. Verified both ways: assembleTraceDebug -Pmobilegl.buildServerSpike=ON packages lib/arm64-v8a/libMobileGLServer.so and `file` reports "ELF 64-bit LSB pie executable, ARM aarch64 ... interpreter /system/bin/linker64, for Android 26"; the same task with no property packages only libMobileGL.so and libtrace_replay_runner.so. - The stub prints one line to stdout and writes the same line to the file named by argv[1], then exits 0. The line carries pid/ppid/uid/gid and, decisively, the child's own /proc/self/attr/current: only `u:r:untrusted_app:...` proves an ordinary app process did the exec. An `adb run-as` shell runs in a different SELinux domain, so a success there would prove nothing. - RunSpawnSpike() starts the stub with argv [serverPath, markerPath], redirects the child's stdout/stderr into a captured file (an app process has stdout on /dev/null, so a printed line would otherwise vanish), waits for it, and reports exit status, signal, the exec errno, the parent's own SELinux context, the marker content and the captured stdout - to logcat, to the returned string, and to a .report file, because the Activity finishes immediately afterwards. - The child reports the errno of a REFUSED execve through a close-on-exec pipe. Without it the one datum the spike exists to produce is lost: the parent only ever sees a wait status, in which every reason has already been flattened into one exit code, and EACCES (SELinux, or a noexec mount) versus ENOEXEC (a packager that mangled the file) are opposite verdicts for the design. A successful exec closes the write end for free, so the parent reads EOF and reports execErrno=0. - fork/execve only. The earlier draft also carried a posix_spawn arm behind `__ANDROID_API__ >= 28`, which was dead code in every configuration this repo can build - bionic declares posix_spawn from API 28 and the root CMakeLists.txt pins MOBILEGL_ANDROID_API_LEVEL to 26 and refuses to configure lower - and would have silently become the production path, untested, on a minSdk bump. Keeping the arm that actually ships means the spike measures the code the server would really use. Nothing happens between fork and execve except open/dup2/execve/write/_exit, all async-signal-safe, because the parent is a multi-threaded JVM process. - The spike lives in its own TU, spawn_spike.cpp/.hpp, listed only by the trace APK's CMakeLists. Its sibling trace_replay_core.cpp is compiled verbatim by the DESKTOP mobilegl_trace_replay runner (tools/trace_replay/CMakeLists.txt names the same file), where does not exist, so nothing Android-only may live there; spawn_spike.cpp carries an #error for anyone who adds it to that list. - The Activity runs the spike, and nothing else, when launched with the `mobilegl_spike_spawn` intent extra; that mode needs no trace, no golden and no render surface. It is a separate JNI entry point rather than another parameter on the 30-argument replay call, which it shares nothing with. - Not yet run on a device: both device locks are held by another campaign. The on-device verdict is the coordinator's step. --- CMakeLists.txt | 41 ++++ .../app/src/trace/cpp/CMakeLists.txt | 5 + .../app/src/trace/cpp/spawn_spike.cpp | 227 ++++++++++++++++++ .../app/src/trace/cpp/spawn_spike.hpp | 60 +++++ .../app/src/trace/cpp/trace_replay_jni.cpp | 19 ++ .../plugin/trace/TraceReplayActivity.java | 55 +++++ build.gradle | 17 ++ tools/spikes/server_stub/main.cpp | 89 +++++++ 8 files changed, 513 insertions(+) create mode 100644 android-plugin/app/src/trace/cpp/spawn_spike.cpp create mode 100644 android-plugin/app/src/trace/cpp/spawn_spike.hpp create mode 100644 tools/spikes/server_stub/main.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 6e2dd414b..354cac153 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,6 +21,7 @@ option(MOBILEGL_IOS "Build MobileGL for iOS instead of macOS when # That emptiness is one of the two byte-level equalities the plan's validation # gates keep (section 10.3). option(MOBILEGL_BUILD_DISAGGREGATED "Build the MG_Remote transport layer (two-process shape)" OFF) +option(MOBILEGL_BUILD_SERVER_SPIKE "Build the P0 spike-A MobileGLServer delivery-chain executable (Android only)" OFF) set(MOBILEGL_LOG_ACTIVE_LEVEL "MOBILEGL_LOG_LEVEL_INFO" CACHE STRING "MobileGL active log level macro") set(MOBILEGL_VULKAN_LIBRARY "" CACHE FILEPATH "Vulkan loader/MoltenVK library to link for iOS builds") @@ -760,3 +761,43 @@ endif() if (ANDROID AND MOBILEGL_BUILD_INTEGRATION_TEST) add_subdirectory(MobileGL/MG_IntegrationTest) endif() + +# --------------------------------------------------------------------------- +# P0 spike A: the Android delivery chain for a second native executable. +# +# The disaggregated design needs a server process on Android (PLAN-B.md §8.1, +# inheriting PLAN.md §11.1-§11.6). An APK's only exec-able install location is +# lib//, and the packager only puts a file there if it is named lib*.so - +# so a second executable has to be built with an .so name and exec'd out of +# getApplicationInfo().nativeLibraryDir. This target is the stub that proves the +# chain end to end: it is packaged like a library, exec'd from the app's own +# untrusted_app process, and writes a marker the parent reads back. +# +# Off by default and ANDROID-only, so no shipping configuration builds it. The +# trace flavour of the plugin APK turns it on (android-plugin/build.gradle). +# --------------------------------------------------------------------------- +if (ANDROID AND MOBILEGL_BUILD_SERVER_SPIKE) + add_executable(MobileGLServer + ${CMAKE_CURRENT_SOURCE_DIR}/tools/spikes/server_stub/main.cpp) + + # An executable that is named like a shared library still has to be a real + # PIE executable: Android has refused non-PIE executables since API 21, and + # the name alone does not change what the loader demands of the file. + set_target_properties(MobileGLServer PROPERTIES + PREFIX "lib" + SUFFIX ".so" + OUTPUT_NAME "MobileGLServer" + POSITION_INDEPENDENT_CODE ON) + target_compile_options(MobileGLServer PRIVATE -fPIE) + target_link_options(MobileGLServer PRIVATE -pie) + + # AGP packages what the external native build drops into the per-ABI output + # directory, and it selects by the .so extension. CMake puts executables in + # CMAKE_RUNTIME_OUTPUT_DIRECTORY, which is not the directory AGP hands to + # CMAKE_LIBRARY_OUTPUT_DIRECTORY, so point this target's runtime output at + # the library directory when the generator gave us one. + if (CMAKE_LIBRARY_OUTPUT_DIRECTORY) + set_target_properties(MobileGLServer PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_LIBRARY_OUTPUT_DIRECTORY}") + endif() +endif() diff --git a/android-plugin/app/src/trace/cpp/CMakeLists.txt b/android-plugin/app/src/trace/cpp/CMakeLists.txt index 6474d73df..cdf595e39 100644 --- a/android-plugin/app/src/trace/cpp/CMakeLists.txt +++ b/android-plugin/app/src/trace/cpp/CMakeLists.txt @@ -234,6 +234,10 @@ target_link_libraries(glretrace_common PUBLIC retrace_common glhelpers glproc) add_library(trace_replay_runner SHARED trace_replay_core.cpp trace_replay_jni.cpp + # P0 spike A. Android-only, and deliberately its own TU: trace_replay_core.cpp is + # shared verbatim with the desktop mobilegl_trace_replay runner + # (tools/trace_replay/CMakeLists.txt), which cannot see . + spawn_spike.cpp "${CMAKE_CURRENT_LIST_DIR}/../../../../../tools/trace_replay/apitrace_fbo_dump.cpp") target_compile_features(trace_replay_runner PRIVATE cxx_std_17) @@ -249,4 +253,5 @@ target_link_libraries(trace_replay_runner retrace_common image android + log dl) diff --git a/android-plugin/app/src/trace/cpp/spawn_spike.cpp b/android-plugin/app/src/trace/cpp/spawn_spike.cpp new file mode 100644 index 000000000..b6143cc10 --- /dev/null +++ b/android-plugin/app/src/trace/cpp/spawn_spike.cpp @@ -0,0 +1,227 @@ +// P0 spike A - the Android half of the delivery chain (PLAN-B.md §8.1, inheriting +// PLAN.md §11.1-§11.6). See spawn_spike.hpp for what the spike is asking. +// +// Android-only on purpose: this TU is listed only by +// android-plugin/app/src/trace/cpp/CMakeLists.txt. Its sibling trace_replay_core.cpp is +// shared with the DESKTOP mobilegl_trace_replay runner, which has no , +// so nothing Android-specific may live there. + +#include "spawn_spike.hpp" + +#if !defined(__ANDROID__) +#error "spawn_spike.cpp is Android-only; do not add it to the desktop trace replay build" +#endif + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +// execve needs the environment the parent already has: a server process started from +// the app must inherit it, and handing it an empty one would change what is being tested. +extern "C" char** environ; + +namespace mobilegl_trace { +namespace { + +constexpr const char* kSpikeLogTag = "MobileGLTraceRunner"; + +std::string ReadWholeFile(const std::string& path) { + std::ifstream input(path, std::ios::binary); + if (!input) { + return {}; + } + std::ostringstream contents; + contents << input.rdbuf(); + std::string text = contents.str(); + while (!text.empty() && (text.back() == '\n' || text.back() == '\r' || text.back() == '\0')) { + text.pop_back(); + } + return text; +} + +// The domain this process is in. `u:r:untrusted_app:s0:...` is the whole point of the +// spike: an exec that works from an `adb run-as` shell says nothing about whether the +// app itself is allowed to do it, because that shell is a different SELinux domain. +std::string ReadSelfSelinuxContext() { + const std::string context = ReadWholeFile("/proc/self/attr/current"); + return context.empty() ? "" : context; +} + +// Starts the child with its stdout and stderr redirected into `outputPath`, reports the +// child pid through `childPid` and, when the exec itself was refused, the child's errno +// through `execErrno`. Returns 0, or the errno of a failure that happened before the +// child existed at all. +// +// fork/execve, not posix_spawn: bionic only declares posix_spawn from API 28 while +// MobileGL ships at minSdk 26 (the root CMakeLists.txt pins MOBILEGL_ANDROID_API_LEVEL +// to 26 and refuses to configure lower), so posix_spawn is not available to the shipping +// build and this is the shape the production spawn path has to take. Nothing happens +// between fork and execve except open/dup2/execve/write/_exit, all async-signal-safe, +// because the parent is a multi-threaded JVM process. +int SpawnSpikeChild(const std::string& serverPath, + const std::string& markerPath, + const std::string& outputPath, + pid_t* childPid, + int* execErrno) { + *execErrno = 0; + char* argv[] = {const_cast(serverPath.c_str()), + const_cast(markerPath.c_str()), nullptr}; + + // The errno of a refused exec is the answer this spike is here to bring back, and it + // is raised in a process that cannot return anything: by the time the parent sees a + // wait status the reason has been flattened into an exit code. So the child writes + // the raw errno into a close-on-exec pipe. A successful exec closes the write end for + // free and the parent reads EOF; a refused one leaves the four bytes behind. EACCES + // (SELinux, or a noexec mount) and ENOEXEC (a mangled or non-PIE file) are entirely + // different verdicts for the design and this is the only thing that separates them. + int report[2] = {-1, -1}; + if (pipe2(report, O_CLOEXEC) != 0) { + return errno; + } + + const pid_t forked = fork(); + if (forked < 0) { + const int forkErrno = errno; + close(report[0]); + close(report[1]); + return forkErrno; + } + if (forked == 0) { + close(report[0]); + // Without this the child's output is unobservable: an Android app process has + // stdout on /dev/null, so a printed line would vanish and the spike could not + // tell "ran and printed" apart from "never ran". + const int outputFd = open(outputPath.c_str(), O_CREAT | O_WRONLY | O_TRUNC, 0664); + if (outputFd >= 0) { + dup2(outputFd, STDOUT_FILENO); + dup2(outputFd, STDERR_FILENO); + if (outputFd != STDOUT_FILENO && outputFd != STDERR_FILENO) { + close(outputFd); + } + } + execve(serverPath.c_str(), argv, environ); + const int failure = errno; + // Only reached when the exec was refused - the one outcome this spike is about. + const ssize_t written = write(report[1], &failure, sizeof(failure)); + static_cast(written); + // 127 is the shell's convention for "could not exec" and is distinguishable from + // every status the stub itself can return. + _exit(127); + } + + close(report[1]); + int failure = 0; + ssize_t got = 0; + // Blocks until the child either execs (the write end closes, read returns 0) or + // reports why it could not. + while ((got = read(report[0], &failure, sizeof(failure))) < 0 && errno == EINTR) { + } + close(report[0]); + if (got == static_cast(sizeof(failure))) { + *execErrno = failure; + } + *childPid = forked; + return 0; +} + +} // namespace + +SpawnSpikeResult RunSpawnSpike(const SpawnSpikeRequest& request) { + SpawnSpikeResult result; + result.parentSelinuxContext = ReadSelfSelinuxContext(); + + if (request.serverPath.empty() || request.markerPath.empty()) { + result.message = "spike-spawn: serverPath and markerPath are both required"; + return result; + } + + // A stale marker from a previous run would otherwise be read back as this run's + // proof. Remove it first, so "the marker exists" can only mean the child wrote it. + unlink(request.markerPath.c_str()); + const std::string childOutputPath = request.markerPath + ".stdout"; + unlink(childOutputPath.c_str()); + + struct stat serverStat {}; + if (stat(request.serverPath.c_str(), &serverStat) != 0) { + result.spawnErrno = errno; + result.message = "spike-spawn: " + request.serverPath + " does not exist: " + + std::strerror(errno); + __android_log_print(ANDROID_LOG_ERROR, kSpikeLogTag, "%s", result.message.c_str()); + return result; + } + + pid_t childPid = -1; + int execErrno = 0; + const int spawnStatus = SpawnSpikeChild(request.serverPath, request.markerPath, + childOutputPath, &childPid, &execErrno); + if (spawnStatus != 0) { + result.spawnErrno = spawnStatus; + result.message = "spike-spawn: could not start " + request.serverPath + + ": spawnErrno=" + std::to_string(spawnStatus) + " (" + + std::strerror(spawnStatus) + ")"; + __android_log_print(ANDROID_LOG_ERROR, kSpikeLogTag, "%s (parentSelinux=%s)", + result.message.c_str(), result.parentSelinuxContext.c_str()); + return result; + } + + result.spawned = true; + result.childPid = static_cast(childPid); + result.execErrno = execErrno; + + int waitStatus = 0; + while (waitpid(childPid, &waitStatus, 0) < 0) { + if (errno != EINTR) { + result.message = "spike-spawn: waitpid failed: " + std::string(std::strerror(errno)); + __android_log_print(ANDROID_LOG_ERROR, kSpikeLogTag, "%s", result.message.c_str()); + return result; + } + } + result.waitStatus = waitStatus; + if (WIFEXITED(waitStatus)) { + result.exitCode = WEXITSTATUS(waitStatus); + } + if (WIFSIGNALED(waitStatus)) { + result.termSignal = WTERMSIG(waitStatus); + } + + result.markerContent = ReadWholeFile(request.markerPath); + result.childOutput = ReadWholeFile(childOutputPath); + result.succeeded = + result.execErrno == 0 && result.exitCode == 0 && !result.markerContent.empty(); + + std::ostringstream message; + message << "spike-spawn: " << (result.succeeded ? "OK" : "FAILED") + << " server=" << request.serverPath + << " pid=" << result.childPid + << " exit=" << result.exitCode + << " signal=" << result.termSignal + // Always printed, including on the success path, so a reader never has to + // guess whether the field was collected or merely absent. + << " execErrno=" << result.execErrno + << " (" << (result.execErrno == 0 ? "exec succeeded" + : std::strerror(result.execErrno)) << ")" + << " parentSelinux=" << result.parentSelinuxContext + << " marker=[" << result.markerContent << "]" + << " childStdout=[" << result.childOutput << "]"; + result.message = message.str(); + __android_log_print(result.succeeded ? ANDROID_LOG_INFO : ANDROID_LOG_ERROR, kSpikeLogTag, + "%s", result.message.c_str()); + + // The Activity is normally gone as soon as the run finishes, so the verdict also goes + // to a file next to the marker; that is what a device lane copies out. + std::ofstream report(request.markerPath + ".report", std::ios::trunc); + if (report) { + report << result.message << "\n"; + } + return result; +} + +} // namespace mobilegl_trace diff --git a/android-plugin/app/src/trace/cpp/spawn_spike.hpp b/android-plugin/app/src/trace/cpp/spawn_spike.hpp new file mode 100644 index 000000000..42426cbc7 --- /dev/null +++ b/android-plugin/app/src/trace/cpp/spawn_spike.hpp @@ -0,0 +1,60 @@ +#pragma once + +// --------------------------------------------------------------------------- +// P0 spike A: exec a second packaged native executable from this process. +// +// Answers one question and nothing else: can an ordinary Android application +// process (untrusted_app, NOT an `adb run-as` shell, which runs in a different +// SELinux domain and would prove nothing) exec a binary that was shipped inside +// its own APK as lib//lib*.so? The disaggregated design needs a server +// process on Android and this is its only supported delivery route (PLAN-B.md +// §8.1, inheriting PLAN.md §11.1-§11.6). +// +// This lives beside trace_replay_core.hpp rather than inside it because +// trace_replay_core.cpp is ALSO compiled by the desktop mobilegl_trace_replay +// runner (tools/trace_replay/CMakeLists.txt names it directly), where +// does not exist. The spike is Android-only, so it gets an Android-only TU; +// spawn_spike.cpp is listed only by the trace APK's CMakeLists. +// +// Nothing in the replay path calls this; it runs only when the trace Activity is +// launched with the `mobilegl_spike_spawn` intent extra. +// --------------------------------------------------------------------------- + +#include + +namespace mobilegl_trace { + +struct SpawnSpikeRequest { + // Absolute path of the executable, normally + // getApplicationInfo().nativeLibraryDir + "/libMobileGLServer.so". + std::string serverPath; + // Marker file the child is asked to write, passed to it as argv[1]. The child's + // stdout and stderr are captured next to it, with ".stdout" appended. + std::string markerPath; +}; + +struct SpawnSpikeResult { + bool spawned = false; + // Exec'd, waited for, exited 0, and the marker file came back non-empty. + bool succeeded = false; + // errno of the pre-fork or fork failure - the parent could not even try. + int spawnErrno = 0; + // errno of a REFUSED execve, carried out of the child over a close-on-exec pipe. + // This is the one datum the spike exists to produce: EACCES (SELinux or the mount's + // noexec) and ENOEXEC (the packager mangled the file) are different verdicts, and + // the exit status alone cannot tell them apart. + int execErrno = 0; + int childPid = -1; + int waitStatus = -1; + int exitCode = -1; + int termSignal = -1; + // /proc/self/attr/current of THIS process - the domain the exec was attempted from. + std::string parentSelinuxContext; + std::string markerContent; + std::string childOutput; + std::string message; +}; + +SpawnSpikeResult RunSpawnSpike(const SpawnSpikeRequest& request); + +} // namespace mobilegl_trace diff --git a/android-plugin/app/src/trace/cpp/trace_replay_jni.cpp b/android-plugin/app/src/trace/cpp/trace_replay_jni.cpp index f6d61b790..2f4b065c4 100644 --- a/android-plugin/app/src/trace/cpp/trace_replay_jni.cpp +++ b/android-plugin/app/src/trace/cpp/trace_replay_jni.cpp @@ -1,5 +1,7 @@ #include "trace_replay_core.hpp" +#include "spawn_spike.hpp" + #include #include #include @@ -196,3 +198,20 @@ Java_top_mobilegl_plugin_trace_TraceReplayActivity_nativeRunTraceReplay(JNIEnv* } return MakeResult(env, result); } + +// P0 spike A: exec the packaged MobileGLServer stub from this app process and report what +// happened. Deliberately a separate entry point rather than another parameter on the +// replay call - it shares nothing with a replay, and the trace lane must be able to run +// it without a trace, a golden or a surface. +extern "C" JNIEXPORT jstring JNICALL +Java_top_mobilegl_plugin_trace_TraceReplayActivity_nativeRunSpawnSpike(JNIEnv* env, + jclass, + jstring serverPath, + jstring markerPath) { + mobilegl_trace::SpawnSpikeRequest request; + request.serverPath = ToString(env, serverPath); + request.markerPath = ToString(env, markerPath); + + const mobilegl_trace::SpawnSpikeResult result = mobilegl_trace::RunSpawnSpike(request); + return env->NewStringUTF(result.message.c_str()); +} diff --git a/android-plugin/app/src/trace/java/top/mobilegl/plugin/trace/TraceReplayActivity.java b/android-plugin/app/src/trace/java/top/mobilegl/plugin/trace/TraceReplayActivity.java index 86754130c..9d07e23bc 100644 --- a/android-plugin/app/src/trace/java/top/mobilegl/plugin/trace/TraceReplayActivity.java +++ b/android-plugin/app/src/trace/java/top/mobilegl/plugin/trace/TraceReplayActivity.java @@ -54,6 +54,14 @@ protected void onCreate(Bundle savedInstanceState) { android.view.ViewGroup.LayoutParams.WRAP_CONTENT )); + // P0 spike A: when asked, exec the packaged server stub out of nativeLibraryDir + // instead of replaying anything. This mode needs no trace and no render surface. + String spikeLibrary = spawnSpikeLibrary(intent); + if (spikeLibrary != null) { + runSpawnSpike(spikeLibrary); + return; + } + SurfaceHolder holder = surfaceView.getHolder(); if (request.width > 0 && request.height > 0) { holder.setFixedSize(request.width, request.height); @@ -166,6 +174,53 @@ private static native TraceReplayResult nativeRunTraceReplay( String benchmarkResultPath ); + + // --------------------------------------------------------------------------- + // P0 spike A: prove an APK can ship a second native executable and exec it. + // + // The exec has to happen here, in the application's own process: an `adb shell + // run-as` invocation runs in a different SELinux domain, so it can succeed while + // the real app is denied. The child reports the domain it ended up in, and the + // parent reports the domain it spawned from, so the log line stands on its own. + // --------------------------------------------------------------------------- + private static final String EXTRA_SPAWN_SPIKE = "mobilegl_spike_spawn"; + private static final String DEFAULT_SPAWN_SPIKE_LIBRARY = "libMobileGLServer.so"; + + private static String spawnSpikeLibrary(Intent intent) { + if (!intent.hasExtra(EXTRA_SPAWN_SPIKE)) { + return null; + } + // Accepts --ez (boolean, arrives as a null string) and --es with either a truthy + // marker or the library file name to exec. + String value = intent.getStringExtra(EXTRA_SPAWN_SPIKE); + if (value == null || value.isEmpty() || "1".equals(value) || "true".equals(value)) { + return DEFAULT_SPAWN_SPIKE_LIBRARY; + } + return value; + } + + private void runSpawnSpike(String libraryName) { + // The surface callbacks fire regardless; this keeps them from starting a replay + // underneath the spike. + started = true; + File outputDir = new File(request.outputDir); + String serverPath = new File(getApplicationInfo().nativeLibraryDir, libraryName) + .getAbsolutePath(); + String markerPath = new File(outputDir, "spike-spawn.txt").getAbsolutePath(); + statusView.setText("Running spawn spike\n" + serverPath); + new Thread(() -> { + outputDir.mkdirs(); + String message = nativeRunSpawnSpike(serverPath, markerPath); + Log.i(TAG, message); + runOnUiThread(() -> { + statusView.setText(message); + finish(); + }); + }, "MobileGLSpawnSpike").start(); + } + + private static native String nativeRunSpawnSpike(String serverPath, String markerPath); + private static final class TraceReplayRequest { final String tracePath; final String goldenPath; diff --git a/build.gradle b/build.gradle index c724fcd1d..8552b9f8e 100644 --- a/build.gradle +++ b/build.gradle @@ -5,6 +5,16 @@ def mobileGlLogActiveLevel = { } def standalonePluginBuild = rootProject.name == 'MobileGLPlugin' +// P0 spike A opt-in. OFF by default: the spike binary is dead weight in every trace APK +// that is not running the spike, and a flavour that silently carries an extra executable +// is exactly the kind of thing nobody notices until it ships. Turn it on for the spike +// build only, with either +// ./gradlew :app:assembleTraceDebug -Pmobilegl.buildServerSpike=ON +// or MOBILEGL_BUILD_SERVER_SPIKE=ON in the environment. +def mobileGlBuildServerSpike = { + (rootProject.findProperty('mobilegl.buildServerSpike') ?: System.getenv('MOBILEGL_BUILD_SERVER_SPIKE') ?: 'OFF') as String +} + android { namespace 'top.mobilegl.mobilegl' compileSdk 34 @@ -53,6 +63,13 @@ android { externalNativeBuild { cmake { arguments '-DMOBILEGL_TRACE_ANGLE_VARIANTS=ON' + // P0 spike A: the second native executable that proves an APK + // can ship one and exec it from nativeLibraryDir. Off unless + // asked for, and only offered here - the shipping plugin + // flavour cannot turn it on at all, so no released artifact can + // grow a binary nothing loads. See mobileGlBuildServerSpike + // above for the two ways to enable it. + arguments "-DMOBILEGL_BUILD_SERVER_SPIKE=${mobileGlBuildServerSpike()}" } } } diff --git a/tools/spikes/server_stub/main.cpp b/tools/spikes/server_stub/main.cpp new file mode 100644 index 000000000..15280d020 --- /dev/null +++ b/tools/spikes/server_stub/main.cpp @@ -0,0 +1,89 @@ +// P0 spike A - the Android delivery chain for a second native executable. +// +// The disaggregated design (PLAN-B.md §8.1, inheriting PLAN.md §11.1-§11.6) needs a +// second process on Android. Android has no exec-able install location an application +// can write to, so the only supported way to ship an executable inside an APK is to +// name it lib*.so, let the packager put it in lib// and exec it out of +// getApplicationInfo().nativeLibraryDir. Whether that actually works from the app's own +// untrusted_app SELinux domain - as opposed to from an adb `run-as` shell, which runs in +// a different domain and proves nothing - is the question this spike answers. +// +// This binary is deliberately the smallest thing that can answer it: it prints one line +// describing the process it ended up being (pid, uid, and its own SELinux context) to +// stdout and writes the same line to the file named by argv[1], then exits 0. The parent +// reads both back; see RunSpawnSpike() in +// android-plugin/app/src/trace/cpp/trace_replay_core.cpp. +// +// It is built only when MOBILEGL_BUILD_SERVER_SPIKE=ON on an ANDROID configure, so it is +// absent from every shipping build. It is not the future server, and nothing links it. + +#include +#include +#include + +#include + +namespace { + +// The single fact that makes this spike conclusive rather than suggestive: the exec'd +// child reports the domain it is running in. `u:r:untrusted_app:s0:...` means an ordinary +// application process really did exec this file; anything else (shell, adb, a platform +// domain) means the test was run the wrong way and its verdict does not transfer. +void ReadSelinuxContext(char* out, size_t size) { + out[0] = '\0'; + FILE* file = std::fopen("/proc/self/attr/current", "r"); + if (file == nullptr) { + std::snprintf(out, size, ""); + return; + } + const size_t read = std::fread(out, 1, size - 1, file); + std::fclose(file); + out[read] = '\0'; + // The kernel returns the context NUL-terminated inside the read; trim anything after. + for (size_t index = 0; index < read; ++index) { + if (out[index] == '\n' || out[index] == '\0') { + out[index] = '\0'; + break; + } + } + if (out[0] == '\0') { + std::snprintf(out, size, ""); + } +} + +} // namespace + +int main(int argc, char** argv) { + char context[256]; + ReadSelinuxContext(context, sizeof(context)); + + char line[1024]; + std::snprintf(line, sizeof(line), + "MobileGLServer-spike ok argv0=%s pid=%d ppid=%d uid=%d gid=%d argc=%d " + "selinux=%s\n", + argc > 0 && argv[0] != nullptr ? argv[0] : "", + static_cast(getpid()), static_cast(getppid()), + static_cast(getuid()), static_cast(getgid()), argc, context); + + std::fputs(line, stdout); + std::fflush(stdout); + + if (argc < 2 || argv[1] == nullptr || argv[1][0] == '\0') { + std::fputs("MobileGLServer-spike: argv[1] (marker path) missing\n", stderr); + return 2; + } + + FILE* marker = std::fopen(argv[1], "w"); + if (marker == nullptr) { + std::fprintf(stderr, "MobileGLServer-spike: cannot open marker %s: %s\n", argv[1], + std::strerror(errno)); + return 3; + } + std::fputs(line, marker); + if (std::fclose(marker) != 0) { + std::fprintf(stderr, "MobileGLServer-spike: cannot write marker %s: %s\n", argv[1], + std::strerror(errno)); + return 4; + } + return 0; +} From 38d4c2372c2267c55d331e8a1b266ce515695345 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 20:34:22 -0400 Subject: [PATCH 022/529] [Feat] (TraceApp, CI): pass arbitrary env vars through the retrace lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PLAN-B.md §8.2 and appendix B add a batch of new runtime switches (MOBILEGL_PIPE_PUSH / _VERIFY / _STATS / _LEGACY_MEMOS / _TEXEL_RETAIN_MB / _INDEX_MIRROR_MB, plus MOBILEGL_IPC_* later), and §11 P0 wants them parsed beside the existing ones. Today every knob that has to reach an Android replay costs an edit in five files - run_android_retrace_local.py, trace-replay-ci.sh, TraceReplayActivity's request record, the JNI marshalling, and the setenv block in trace_replay_core.cpp. That per-knob tax is what this replaces: one extra, `--es mobilegl_env "K=V;K=V"`, carries all of them. - Applied last, immediately before dlopen(libMobileGL.so), so it can also override the dedicated fields above it - MobileGL's config is read during the load, and an escape hatch that cannot beat the defaults is not one. An entry with no '=' unsets the variable, which is the only way to clear a default the marshalling sets. - The existing per-knob flags stay: they carry semantics beyond a setenv (use_angle also selects a variant, the dump lists are joined, DirectVulkan forces the R11G11B10F fallback), and rewriting them as env strings would move that logic into the callers. - Surface: --env / MOBILEGL_TRACE_ENV in trace-replay-ci.sh, repeatable --env KEY=VALUE in run_android_retrace_local.py, `mobilegl_env` intent extra, Request::envOverrides. - The two-level parse now lives in trace_env_overrides.hpp, beside the semicolon splitter it shares with the texture and FBO dump lists, and tools/trace_replay/trace_env_overrides_test.cpp pins it: the empty entries a trailing ';' leaves behind must not become unsetenv(""), `K=` must stay a Set of the empty string rather than an Unset (a knob read with getenv() != nullptr sees those as opposite answers), and only the FIRST '=' may separate, or a value carrying '=' is truncated without a word of warning. The whole MOBILEGL_PIPE_* batch rides on this parse, and the only lane that exercised it end to end was an on-device retrace, which would have reported a splitting bug as "the knob had no effect". - The check is built and RUN at build time and mobilegl_trace_replay depends on it, so `cmake --build ... --target mobilegl_trace_replay` - the exact command of test.yml's "Build trace replay" job, which never invokes ctest - runs it. It is assert-free on purpose: that lane configures Release, and under NDEBUG would compile every check into a green run that checked nothing. Negative control: swapping find('=') for rfind('=') fails 2 checks, and keeping the splitter's empty entries fails 2 more. --- .../app/src/trace/cpp/trace_env_overrides.hpp | 74 ++++++++++++ .../app/src/trace/cpp/trace_replay_core.cpp | 29 +++++ .../app/src/trace/cpp/trace_replay_core.hpp | 5 + .../app/src/trace/cpp/trace_replay_jni.cpp | 22 +--- .../plugin/trace/TraceReplayActivity.java | 16 ++- android-plugin/trace-replay-ci.sh | 14 +++ tools/trace_replay/CMakeLists.txt | 19 +++ .../trace_replay/run_android_retrace_local.py | 26 ++++- .../trace_replay/trace_env_overrides_test.cpp | 108 ++++++++++++++++++ 9 files changed, 289 insertions(+), 24 deletions(-) create mode 100644 android-plugin/app/src/trace/cpp/trace_env_overrides.hpp create mode 100644 tools/trace_replay/trace_env_overrides_test.cpp diff --git a/android-plugin/app/src/trace/cpp/trace_env_overrides.hpp b/android-plugin/app/src/trace/cpp/trace_env_overrides.hpp new file mode 100644 index 000000000..6b4cf30c4 --- /dev/null +++ b/android-plugin/app/src/trace/cpp/trace_env_overrides.hpp @@ -0,0 +1,74 @@ +#pragma once + +// The generic environment passthrough of the retrace lane, split out of +// trace_replay_jni.cpp and trace_replay_core.cpp so a host-side test can pin it. +// +// One intent extra (`--es mobilegl_env "K=V;K=V"`) carries every MOBILEGL_* knob that has +// no dedicated flag, which is what PLAN-B.md §11 P0 needs when it adds MOBILEGL_PIPE_*. +// That makes this hand-rolled two-level parse the single point where the whole batch can +// be silently misread, and the only lane that exercises it end to end runs on a device - +// hence tools/trace_replay/trace_env_overrides_test.cpp, which every desktop configure +// that builds the replay runner runs at build time. + +#include +#include +#include + +namespace mobilegl_trace { + +// Splits `A;B;C` into its entries, dropping empty ones. Also used for the texture and +// FBO dump lists, whose entries carry their own ',' and ':' separators. A value that +// itself contains ';' therefore cannot be expressed - that is the format's limit, not a +// bug to work around here. +inline std::vector SplitSemicolonList(const std::string& value) { + std::vector values; + std::size_t begin = 0; + while (begin < value.size()) { + const std::size_t end = value.find(';', begin); + const std::string entry = value.substr(begin, end - begin); + if (!entry.empty()) { + values.push_back(entry); + } + if (end == std::string::npos) { + break; + } + begin = end + 1; + } + return values; +} + +enum class EnvOverrideAction { + // Nothing to do: the entry is empty, or names an empty key. + Ignore, + // setenv(key, value, 1). `K=` is a Set of the empty string, deliberately distinct + // from Unset: a knob read with getenv() != nullptr treats them differently. + Set, + // unsetenv(key). An entry with no '=' means this, and it is the only way for a + // caller to clear a variable the per-knob marshalling above it already set. + Unset, +}; + +// Classifies one `KEY=VALUE` / `KEY` entry. The first '=' separates; later ones belong to +// the value, so `KEY=a=b` sets KEY to `a=b`. +inline EnvOverrideAction ParseEnvOverride(const std::string& entry, + std::string* key, + std::string* value) { + key->clear(); + value->clear(); + const std::size_t separator = entry.find('='); + if (separator == std::string::npos) { + if (entry.empty()) { + return EnvOverrideAction::Ignore; + } + *key = entry; + return EnvOverrideAction::Unset; + } + if (separator == 0) { + return EnvOverrideAction::Ignore; + } + *key = entry.substr(0, separator); + *value = entry.substr(separator + 1); + return EnvOverrideAction::Set; +} + +} // namespace mobilegl_trace diff --git a/android-plugin/app/src/trace/cpp/trace_replay_core.cpp b/android-plugin/app/src/trace/cpp/trace_replay_core.cpp index 41667758a..f36d9b401 100644 --- a/android-plugin/app/src/trace/cpp/trace_replay_core.cpp +++ b/android-plugin/app/src/trace/cpp/trace_replay_core.cpp @@ -4,6 +4,7 @@ #include "apitrace_exit.hpp" #include "png.h" #include "trace_benchmark.hpp" +#include "trace_env_overrides.hpp" #include #include @@ -130,6 +131,32 @@ std::string JsonEscape(const std::string& value) { return out.str(); } +// Generic environment passthrough. One intent extra carries `K=V;K=V`, so a new +// MOBILEGL_* knob costs nothing in the five files between the CI script and this +// setenv - the per-knob plumbing above is what this replaces going forward +// (PLAN-B.md §11 P0, which adds a batch of MOBILEGL_PIPE_* switches). +// +// Applied last, immediately before the library is loaded: it is the escape hatch, so it +// has to be able to override the fields marshalled above, and MobileGL's ConfigLoader +// reads the environment during dlopen. The decision of what each entry means lives in +// trace_env_overrides.hpp so a host-side test can pin it; this is only the setenv. +void ApplyEnvOverrides(const std::vector& entries) { + for (const std::string& entry : entries) { + std::string key; + std::string value; + switch (ParseEnvOverride(entry, &key, &value)) { + case EnvOverrideAction::Set: + setenv(key.c_str(), value.c_str(), 1); + break; + case EnvOverrideAction::Unset: + unsetenv(key.c_str()); + break; + case EnvOverrideAction::Ignore: + break; + } + } +} + bool LoadMobileGL(const Request& request, std::string& error) { setenv("MOBILEGL_BACKEND_TYPE", request.backend.c_str(), 1); setenv("MOBILEGL_TRACE_LIBRARY", request.mobileGlLibrary.c_str(), 1); @@ -207,6 +234,8 @@ bool LoadMobileGL(const Request& request, std::string& error) { setenv("MOBILEGL_TRACE_DUMP_TEXTURE_2D", dumpPoints.c_str(), 1); } + ApplyEnvOverrides(request.envOverrides); + void* handle = dlopen(request.mobileGlLibrary.c_str(), RTLD_NOW | RTLD_GLOBAL); if (handle == nullptr) { const char* dlError = dlerror(); diff --git a/android-plugin/app/src/trace/cpp/trace_replay_core.hpp b/android-plugin/app/src/trace/cpp/trace_replay_core.hpp index 8c86a71bb..0f95c0398 100644 --- a/android-plugin/app/src/trace/cpp/trace_replay_core.hpp +++ b/android-plugin/app/src/trace/cpp/trace_replay_core.hpp @@ -63,6 +63,11 @@ struct Request { bool deriveNumSubgroups = false; bool iterationRPFixBarrier = false; int holdMs = 0; + // Generic environment passthrough, each entry `KEY=VALUE` (an entry with no '=' + // unsets KEY). Applied last, right before libMobileGL.so is loaded, so a knob that + // has no dedicated field above can still be forwarded from the CI script without + // touching this struct again. + std::vector envOverrides; }; struct Result { diff --git a/android-plugin/app/src/trace/cpp/trace_replay_jni.cpp b/android-plugin/app/src/trace/cpp/trace_replay_jni.cpp index 2f4b065c4..4dc91ca80 100644 --- a/android-plugin/app/src/trace/cpp/trace_replay_jni.cpp +++ b/android-plugin/app/src/trace/cpp/trace_replay_jni.cpp @@ -1,6 +1,7 @@ #include "trace_replay_core.hpp" #include "spawn_spike.hpp" +#include "trace_env_overrides.hpp" #include #include @@ -28,22 +29,7 @@ std::string ToString(JNIEnv* env, jstring value) { return out; } -std::vector SplitSemicolonList(const std::string& value) { - std::vector values; - std::size_t begin = 0; - while (begin < value.size()) { - const std::size_t end = value.find(';', begin); - const std::string entry = value.substr(begin, end - begin); - if (!entry.empty()) { - values.push_back(entry); - } - if (end == std::string::npos) { - break; - } - begin = end + 1; - } - return values; -} +using mobilegl_trace::SplitSemicolonList; jobject MakeResult(JNIEnv* env, const mobilegl_trace::Result& result) { jclass clazz = env->FindClass("top/mobilegl/plugin/trace/TraceReplayActivity$TraceReplayResult"); @@ -131,7 +117,8 @@ Java_top_mobilegl_plugin_trace_TraceReplayActivity_nativeRunTraceReplay(JNIEnv* jboolean benchmarkMode, jint benchmarkTailFrames, jboolean benchmarkFinish, - jstring benchmarkResultPath) { + jstring benchmarkResultPath, + jstring envOverrides) { mobilegl_trace::Request request; request.tracePath = ToString(env, tracePath); request.goldenPath = ToString(env, goldenPath); @@ -168,6 +155,7 @@ Java_top_mobilegl_plugin_trace_TraceReplayActivity_nativeRunTraceReplay(JNIEnv* : mobilegl_trace::kDefaultBenchmarkTailFrames; request.benchmarkFinish = benchmarkFinish == JNI_TRUE; request.benchmarkResultPath = ToString(env, benchmarkResultPath); + request.envOverrides = SplitSemicolonList(ToString(env, envOverrides)); ScopedTraceReplayState replayState; mobilegl_trace_set_requested_size(request.width, request.height); diff --git a/android-plugin/app/src/trace/java/top/mobilegl/plugin/trace/TraceReplayActivity.java b/android-plugin/app/src/trace/java/top/mobilegl/plugin/trace/TraceReplayActivity.java index 9d07e23bc..a7ef470ca 100644 --- a/android-plugin/app/src/trace/java/top/mobilegl/plugin/trace/TraceReplayActivity.java +++ b/android-plugin/app/src/trace/java/top/mobilegl/plugin/trace/TraceReplayActivity.java @@ -131,7 +131,8 @@ private void runRequest(TraceReplayRequest request, Surface surface) { request.benchmark, request.benchmarkTailFrames, request.benchmarkFinish, - request.benchmarkResultPath + request.benchmarkResultPath, + request.envOverrides ); Log.i(TAG, result.toString()); TraceReplayResult finalResult = result; @@ -171,7 +172,8 @@ private static native TraceReplayResult nativeRunTraceReplay( boolean benchmark, int benchmarkTailFrames, boolean benchmarkFinish, - String benchmarkResultPath + String benchmarkResultPath, + String envOverrides ); @@ -254,6 +256,9 @@ private static final class TraceReplayRequest { final int benchmarkTailFrames; final boolean benchmarkFinish; final String benchmarkResultPath; + // Generic environment passthrough, `K=V;K=V`. A future MOBILEGL_* knob needs no + // new intent extra, no new JNI parameter and no new field beside this one. + final String envOverrides; private TraceReplayRequest( String tracePath, @@ -284,7 +289,8 @@ private TraceReplayRequest( boolean benchmark, int benchmarkTailFrames, boolean benchmarkFinish, - String benchmarkResultPath + String benchmarkResultPath, + String envOverrides ) { this.tracePath = tracePath; this.goldenPath = goldenPath; @@ -315,6 +321,7 @@ private TraceReplayRequest( this.benchmarkTailFrames = benchmarkTailFrames; this.benchmarkFinish = benchmarkFinish; this.benchmarkResultPath = benchmarkResultPath; + this.envOverrides = envOverrides; } static TraceReplayRequest from(Intent intent, File filesDir, String defaultBackend) { @@ -351,7 +358,8 @@ static TraceReplayRequest from(Intent intent, File filesDir, String defaultBacke intent.getBooleanExtra("benchmark", false), intent.getIntExtra("benchmark_tail_frames", 200), intent.getBooleanExtra("benchmark_finish", true), - benchmarkResultPath + benchmarkResultPath, + readString(intent, "mobilegl_env", "") ); } diff --git a/android-plugin/trace-replay-ci.sh b/android-plugin/trace-replay-ci.sh index 40135dd50..7b229923b 100644 --- a/android-plugin/trace-replay-ci.sh +++ b/android-plugin/trace-replay-ci.sh @@ -32,6 +32,7 @@ Usage: [--avoid-angle-llvmpipe-explicit-lod-bias] \ [--coherent-as-flush] \ [--dump-texture-2d CALL,TEXTURE,LEVEL,DIR] \ + [--env "K=V;K=V"] \ [--benchmark] \ [--benchmark-tail-frames N] \ [--benchmark-finish 0|1] \ @@ -60,6 +61,11 @@ copies benchmark.json (per-frame times plus mean/median/p95) out of the app, and "passed" only means the replay reached the end of the trace without an error. Pass --reuse-fixture to skip re-extracting and re-pushing the trace, for repeat runs of a case whose fixture is already in /data/local/tmp. +Pass --env "K=V;K=V" (or set MOBILEGL_TRACE_ENV) to hand arbitrary environment +variables to the replay process. They are applied last, immediately before +libMobileGL.so is loaded, so they override every flag above; an entry with no "=" +unsets the variable instead. This is the generic passthrough: a MOBILEGL_* knob +that has no flag of its own needs no plumbing to be forwarded. EOF } @@ -119,6 +125,7 @@ avoid_angle_llvmpipe_sampler_mipmap_min_filter=0 avoid_angle_llvmpipe_explicit_lod_bias=0 coherent_as_flush=0 texture_2d_dumps="" +env_overrides="${MOBILEGL_TRACE_ENV:-}" benchmark=0 benchmark_tail_frames=200 benchmark_finish=1 @@ -164,6 +171,7 @@ while [ "$#" -gt 0 ]; do ;; --coherent-as-flush) coherent_as_flush=1; shift 1 ;; --dump-texture-2d) texture_2d_dumps="$(next_arg "$@")"; shift 2 ;; + --env) env_overrides="$(next_arg "$@")"; shift 2 ;; --benchmark) benchmark=1; shift 1 ;; --benchmark-tail-frames) benchmark_tail_frames="$(next_arg "$@")"; shift 2 ;; --benchmark-finish) benchmark_finish="$(next_arg "$@")"; shift 2 ;; @@ -400,6 +408,12 @@ run_retrace() { if [ -n "${texture_2d_dumps}" ]; then set -- "$@" --es texture_2d_dumps "${texture_2d_dumps}" fi + if [ -n "${env_overrides}" ]; then + # adb joins the argv with spaces and hands the result to the device shell, so a value + # holding the ';' that separates entries would otherwise be read there as a command + # separator. The single quotes make it one token again. + set -- "$@" --es mobilegl_env "'${env_overrides}'" + fi if [ "${benchmark}" -eq 1 ]; then set -- "$@" --ez benchmark true set -- "$@" --ei benchmark_tail_frames "${benchmark_tail_frames}" diff --git a/tools/trace_replay/CMakeLists.txt b/tools/trace_replay/CMakeLists.txt index 9d7d1dc52..c52ca3745 100644 --- a/tools/trace_replay/CMakeLists.txt +++ b/tools/trace_replay/CMakeLists.txt @@ -281,6 +281,25 @@ else() "-Wl,--end-group") endif() +# The `K=V;K=V` environment passthrough is the only hand-rolled parse between a CI flag +# and setenv(), and the lane that exercises it end to end is an on-device retrace, which +# would report a splitting bug as "the knob had no effect". This pins it instead, in the +# cheapest place that no configuration can skip: the check is built and RUN at build time, +# and mobilegl_trace_replay depends on it, so `cmake --build ... --target +# mobilegl_trace_replay` - the exact command of test.yml's "Build trace replay" - runs it +# even though that job never invokes ctest. +if(NOT CMAKE_CROSSCOMPILING) + add_executable(mobilegl_trace_env_overrides_test + "${MOBILEGL_TRACE_ROOT}/trace_env_overrides_test.cpp") + target_compile_features(mobilegl_trace_env_overrides_test PRIVATE cxx_std_17) + target_include_directories(mobilegl_trace_env_overrides_test PRIVATE + "${MOBILEGL_TRACE_SHARED_CPP_DIR}") + add_custom_command(TARGET mobilegl_trace_env_overrides_test POST_BUILD + COMMAND mobilegl_trace_env_overrides_test + COMMENT "Checking the retrace env passthrough parser") + add_dependencies(mobilegl_trace_replay mobilegl_trace_env_overrides_test) +endif() + if(MOBILEGL_TRACE_REPLAY_MOBILEGL_LIBRARY) set(mobilegl_trace_replay_mobilegl_library "${MOBILEGL_TRACE_REPLAY_MOBILEGL_LIBRARY}") else() diff --git a/tools/trace_replay/run_android_retrace_local.py b/tools/trace_replay/run_android_retrace_local.py index 40ded0b54..303377480 100644 --- a/tools/trace_replay/run_android_retrace_local.py +++ b/tools/trace_replay/run_android_retrace_local.py @@ -119,7 +119,7 @@ def render_summary(): shutil.copyfile(SUMMARY_DIR / SUMMARY_HTML, SUMMARY_DIR / "index.html") -def run_case(case, backend, extra_args=None, timeout_seconds=None): +def run_case(case, backend, extra_args=None, timeout_seconds=None, env_overrides=None): backend_info = BACKENDS[backend] apk = find_trace_apk() trace_archive = FIXTURES / case["trace_archive"] @@ -189,6 +189,11 @@ def run_case(case, backend, extra_args=None, timeout_seconds=None): command.append("--avoid-angle-llvmpipe-explicit-lod-bias") if case.get("coherent_as_flush"): command.append("--coherent-as-flush") + # Generic environment passthrough: --env MOBILEGL_FOO=1 needs no per-knob plumbing in + # this script, in trace-replay-ci.sh, in the Activity, in the JNI marshalling or in the + # runner - one extra carries them all. + if env_overrides: + command.extend(["--env", ";".join(env_overrides)]) env = dict(**__import__("os").environ) env["PYTHON"] = "python" env["MSYS2_ARG_CONV_EXCL"] = "/data/*" @@ -255,7 +260,13 @@ def run_benchmark_case(case, backend, args): stale = RESULT_ROOT / f"{safe_case(case['name'])}-{backend}" / "benchmark.json" if stale.exists(): stale.unlink() - rc = run_case(case, backend, extra_args=extra_args, timeout_seconds=args.benchmark_timeout_seconds) + rc = run_case( + case, + backend, + extra_args=extra_args, + timeout_seconds=args.benchmark_timeout_seconds, + env_overrides=args.env, + ) report = read_benchmark(case, backend, run_index) if rc != 0 or report is None: print(f"{label} run {run_index}/{args.benchmark_repeats}: FAILED (exit {rc})", flush=True) @@ -287,6 +298,15 @@ def parse_args(): parser.add_argument("--backend", action="append", choices=sorted(BACKENDS), help="Backend to run; may be repeated.") parser.add_argument("--all", action="store_true", help="Run every case in the APK workflow matrix.") parser.add_argument("--keep-results", action="store_true", help="Do not clear the previous result root.") + parser.add_argument( + "--env", + action="append", + default=[], + metavar="KEY=VALUE", + help="Environment variable to set in the replay process, applied just before " + "libMobileGL.so is loaded; may be repeated. A KEY with no '=' unsets it. This " + "is the generic passthrough for MOBILEGL_* knobs that have no flag of their own.", + ) parser.add_argument( "--benchmark", action="store_true", @@ -343,7 +363,7 @@ def main(): failures += run_benchmark_case(case, backend, args) continue print(f"=== Android retrace: {case['name']} / {backend} ===", flush=True) - rc = run_case(case, backend) + rc = run_case(case, backend, env_overrides=args.env) try: render_summary() except Exception as error: diff --git a/tools/trace_replay/trace_env_overrides_test.cpp b/tools/trace_replay/trace_env_overrides_test.cpp new file mode 100644 index 000000000..685d7916f --- /dev/null +++ b/tools/trace_replay/trace_env_overrides_test.cpp @@ -0,0 +1,108 @@ +// Host-side check for the retrace lane's `K=V;K=V` environment passthrough. +// +// The passthrough is the one hand-rolled parse between a CI flag and setenv(), and the +// only lane that runs it end to end is an on-device retrace - too slow and too indirect +// to notice a splitting bug, and it would report the bug as "the knob had no effect". +// This program is built and RUN at build time by every desktop configure that builds +// mobilegl_trace_replay (tools/trace_replay/CMakeLists.txt), so the CI job that only +// builds the runner still exercises it. +// +// Deliberately assert-free: the retrace lane configures Release, NDEBUG is defined, and +// would compile every check away into a green run that checked nothing. + +#include "trace_env_overrides.hpp" + +#include +#include +#include + +namespace { + +int gFailures = 0; + +void ExpectSplit(const std::string& input, const std::vector& expected) { + const std::vector actual = mobilegl_trace::SplitSemicolonList(input); + if (actual == expected) { + return; + } + ++gFailures; + std::cerr << "SplitSemicolonList(\"" << input << "\") gave " << actual.size() + << " entries, expected " << expected.size() << ":"; + for (const std::string& entry : actual) { + std::cerr << " [" << entry << "]"; + } + std::cerr << "\n"; +} + +const char* ActionName(mobilegl_trace::EnvOverrideAction action) { + switch (action) { + case mobilegl_trace::EnvOverrideAction::Ignore: + return "Ignore"; + case mobilegl_trace::EnvOverrideAction::Set: + return "Set"; + case mobilegl_trace::EnvOverrideAction::Unset: + return "Unset"; + } + return "?"; +} + +void ExpectParse(const std::string& entry, + mobilegl_trace::EnvOverrideAction expectedAction, + const std::string& expectedKey, + const std::string& expectedValue) { + std::string key = ""; + std::string value = ""; + const mobilegl_trace::EnvOverrideAction action = + mobilegl_trace::ParseEnvOverride(entry, &key, &value); + if (action == expectedAction && key == expectedKey && value == expectedValue) { + return; + } + ++gFailures; + std::cerr << "ParseEnvOverride(\"" << entry << "\") gave " << ActionName(action) << " key=[" + << key << "] value=[" << value << "], expected " << ActionName(expectedAction) + << " key=[" << expectedKey << "] value=[" << expectedValue << "]\n"; +} + +} // namespace + +int main() { + using mobilegl_trace::EnvOverrideAction; + + // Splitting. + ExpectSplit("", {}); + ExpectSplit("MOBILEGL_PIPE_PUSH=1", {"MOBILEGL_PIPE_PUSH=1"}); + ExpectSplit("MOBILEGL_PIPE_PUSH=1;MOBILEGL_PIPE_VERIFY=1", + {"MOBILEGL_PIPE_PUSH=1", "MOBILEGL_PIPE_VERIFY=1"}); + // A trailing ';' is what a caller that joins a list gets for free, and an empty entry + // must not be turned into an unsetenv("") - the whole passthrough would then depend on + // how carefully the shell script trimmed its own string. + ExpectSplit("A=1;", {"A=1"}); + ExpectSplit(";;A=1;;B=2;;", {"A=1", "B=2"}); + ExpectSplit(";", {}); + // The values the plan's knobs actually carry: a path, a size, a comma list. + ExpectSplit("MOBILEGL_PIPE_TEXEL_RETAIN_MB=64;MOBILEGL_LOG_FILE_PATH=/sdcard/MG/a.log", + {"MOBILEGL_PIPE_TEXEL_RETAIN_MB=64", "MOBILEGL_LOG_FILE_PATH=/sdcard/MG/a.log"}); + + // Classification. + ExpectParse("MOBILEGL_PIPE_PUSH=1", EnvOverrideAction::Set, "MOBILEGL_PIPE_PUSH", "1"); + // `K=` is an empty value, NOT an unset: a knob tested with getenv() != nullptr sees + // those two as opposite answers. + ExpectParse("MOBILEGL_PIPE_PUSH=", EnvOverrideAction::Set, "MOBILEGL_PIPE_PUSH", ""); + // No '=' means unset - the only way to clear a default the per-knob marshalling set. + ExpectParse("MOBILEGL_PIPE_PUSH", EnvOverrideAction::Unset, "MOBILEGL_PIPE_PUSH", ""); + // Only the FIRST '=' separates, so a value may contain '='. Anything else would + // truncate a base64 or a query-string-shaped value without a word of warning. + ExpectParse("MOBILEGL_A=b=c", EnvOverrideAction::Set, "MOBILEGL_A", "b=c"); + ExpectParse("MOBILEGL_A==", EnvOverrideAction::Set, "MOBILEGL_A", "="); + // An empty key must never reach setenv/unsetenv, which would be EINVAL at best. + ExpectParse("", EnvOverrideAction::Ignore, "", ""); + ExpectParse("=1", EnvOverrideAction::Ignore, "", ""); + ExpectParse("=", EnvOverrideAction::Ignore, "", ""); + + if (gFailures != 0) { + std::cerr << "trace env passthrough: " << gFailures << " check(s) failed\n"; + return 1; + } + std::cout << "trace env passthrough: all checks passed\n"; + return 0; +} From 6c7ad0a1bfe1e4359200d4559fd07818253e48ba Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 13:48:27 -0400 Subject: [PATCH 023/529] [Feat] (Spikes): add the standalone external-memory probe that decides the persistent-map tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Plan B §11 P0 requires spike B ("external memory 导出,两台设备") to run before the persistent-map decision of §8.3 can be taken: T0 (server imports a client allocation), T1 (server exports its own HOST_VISIBLE|HOST_COHERENT allocation) or T2 (AcquirePersistentMap returns nullptr, making the §5.10 client-side block push mandatory). §8.3 says the answer must be measured on the two campaign devices, and that a platform unknown must not block the interface work. - tools/spikes/extmem_probe/ is a self-contained NDK command-line program: it links vulkan/EGL/GLESv3/android/log and nothing from MobileGL, is configured by its own CMakeLists with the android toolchain file, and is deliberately absent from the project's build graph (the root CMakeLists only pulls in tools/trace_replay), so the default ALL target is untouched. - Phase A enumerates VK_KHR_external_memory_fd, VK_EXT_external_memory_dma_buf, VK_EXT_external_memory_host, VK_ANDROID_external_memory_android_hardware_buffer and, through a headless EGL pbuffer context, GL_EXT_memory_object{,_fd}, GL_EXT_external_buffer, GL_EXT_buffer_storage, GL_OES_EGL_image_external{,_essl3} and EGL_ANDROID_get_native_client_buffer, plus the memory-type table and the vkGetPhysicalDeviceExternalBufferProperties verdict per handle type for the exact buffer usage set MobileGL needs. - Route T1 allocates a HOST_VISIBLE|HOST_COHERENT buffer memory with VkExportMemoryAllocateInfo, writes a pattern through vkMapMemory, exports an fd with vkGetMemoryFdKHR (opaque-fd and, where advertised, dma-buf), hands it to a second process over SCM_RIGHTS, and has that process both mmap() the fd and import it into its own VkDeviceMemory + vkMapMemory. Both sides write and both sides compare, so a copy-on-import or one-directional mapping is reported as PARTIAL rather than as success. - Route T0 has the second process allocate an AHardwareBuffer BLOB (CPU_READ_OFTEN|CPU_WRITE_OFTEN|GPU_DATA_BUFFER), send it with AHardwareBuffer_sendHandleToUnixSocket, and the first process import it three ways -- AHardwareBuffer_lock, VkDeviceMemory via VK_ANDROID_external_memory_android_hardware_buffer, and a GL buffer via eglGetNativeClientBufferANDROID + glBufferStorageExternalEXT mapped persistent/coherent -- with a write-back leg the allocating process verifies. - Route T3 covers VK_EXT_external_memory_host: a memfd-backed mmap region aligned to minImportedHostPointerAlignment, imported through VkImportMemoryHostPointerInfoEXT, plus the same memfd handed to another process. - The second process is /proc/self/exe re-exec'd with --child= and one end of a socketpair on fd 3. A bare fork() is not usable: neither side's Vulkan driver survives fork, and both sides need live Vulkan. It is also the topology the transport actually has (§8.1, inheriting PLAN.md §11.1-§11.6: the client spawns the server), so the probe measures the arrangement that would ship. - The probe also builds for the host with T0 compiled out. That is not scope creep: a negative device result is only worth something if the harness is known to report a working route as working. Running it on lavapipe did that, and paid for itself immediately by exposing two harness bugs that would have produced false negatives on the devices -- (a) the child wrote through its plain mmap before reading through the Vulkan import, so on a driver whose exported fd maps at an offset the probe overwrote the very payload the second read compares (lavapipe reports payloadAt=4096); reads through both mappings now precede writes through either, and the offset is searched for and reported; (b) an export failure on a handle type the driver never advertised as EXPORTABLE was classified FAIL instead of UNSUPPORTED (lavapipe's dma-buf answer). - Output is a RESULT/summary table carrying the raw driver verdicts (VkResult names, errno, GL enums) because those codes -- not a pass/fail bit -- are what §8.3 needs in order to pick the tier. - Built with NDK 27.3.13750724 for arm64-v8a / android-30, RelWithDebInfo, PIE, warning-clean; host build clang/RelWithDebInfo, warning-clean. --- tools/spikes/extmem_probe/CMakeLists.txt | 34 + tools/spikes/extmem_probe/README.md | 80 + tools/spikes/extmem_probe/build_android.sh | 25 + tools/spikes/extmem_probe/extmem_probe.cpp | 1871 ++++++++++++++++++++ 4 files changed, 2010 insertions(+) create mode 100644 tools/spikes/extmem_probe/CMakeLists.txt create mode 100644 tools/spikes/extmem_probe/README.md create mode 100755 tools/spikes/extmem_probe/build_android.sh create mode 100644 tools/spikes/extmem_probe/extmem_probe.cpp diff --git a/tools/spikes/extmem_probe/CMakeLists.txt b/tools/spikes/extmem_probe/CMakeLists.txt new file mode 100644 index 000000000..2f8242d37 --- /dev/null +++ b/tools/spikes/extmem_probe/CMakeLists.txt @@ -0,0 +1,34 @@ +# Standalone NDK command-line probe for MobileGL disaggregation spike B +# (plan-B §8.3 / §11 P0). Deliberately NOT part of the MobileGL build graph: +# it links nothing from the project and is configured on its own, e.g. +# +# cmake -S tools/spikes/extmem_probe -B /tmp/extmem-build -G Ninja \ +# -DCMAKE_TOOLCHAIN_FILE=$NDK/build/cmake/android.toolchain.cmake \ +# -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-30 \ +# -DCMAKE_BUILD_TYPE=RelWithDebInfo +# +# See build_android.sh for the exact invocation used on the devices. + +cmake_minimum_required(VERSION 3.16) +project(extmem_probe LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(extmem_probe extmem_probe.cpp) + +# PIE is the NDK default for executables; make it explicit so a stale toolchain +# cannot produce something Android's linker refuses to run. +target_compile_options(extmem_probe PRIVATE -fPIE -Wall -Wextra -Wno-unused-parameter) +target_link_options(extmem_probe PRIVATE -pie) + +if(ANDROID) + target_link_libraries(extmem_probe PRIVATE vulkan EGL GLESv3 android log) +else() + # Host build: T0 (AHardwareBuffer) compiles out, T1/T3 stay. Its only purpose is + # to validate this harness against a driver that is known to implement the + # routes (lavapipe), so that a device-side FAIL is attributable to the device + # driver and not to the probe. It is NOT a substitute for a device run. + message(STATUS "extmem_probe: host build -- T1/T3 harness validation only, T0 disabled") + target_link_libraries(extmem_probe PRIVATE vulkan EGL GLESv2) +endif() diff --git a/tools/spikes/extmem_probe/README.md b/tools/spikes/extmem_probe/README.md new file mode 100644 index 000000000..a9a6b74fb --- /dev/null +++ b/tools/spikes/extmem_probe/README.md @@ -0,0 +1,80 @@ +# extmem_probe — disaggregation spike B (external memory) + +A standalone Android command-line probe that answers one question per device: + +> Can a server-allocated `HOST_VISIBLE|HOST_COHERENT` `VkDeviceMemory` be shared +> with another process and mapped there, and by which route? + +This is the P0 spike that decides the `AcquirePersistentMap` tier in plan B §8.3 +(T0 = server imports a client allocation, T1 = server exports its own, T2 = give +up and return `nullptr`). It links nothing from MobileGL and is not part of the +project's CMake build graph. + +## What it does + +* **phase A — enumeration.** Vulkan device identity + memory types, and per + handle type (`OPAQUE_FD`, `DMA_BUF`, `HOST_ALLOCATION`, `AHARDWAREBUFFER`) the + `vkGetPhysicalDeviceExternalBufferProperties` verdict for the buffer usage + MobileGL actually needs. Then a headless EGL pbuffer context reports + `GL_EXT_memory_object{,_fd}`, `GL_EXT_external_buffer`, `GL_EXT_buffer_storage`, + `GL_OES_EGL_image_external{,_essl3}` and `EGL_ANDROID_get_native_client_buffer`. +* **T1 — server exports.** Allocates a `HOST_VISIBLE|HOST_COHERENT` buffer memory + with `VkExportMemoryAllocateInfo`, maps it, writes a pattern, exports an fd with + `vkGetMemoryFdKHR` (opaque-fd, then dma-buf), hands the fd to a second process + over `SCM_RIGHTS`, and has that process (a) `mmap()` the fd and (b) import it + into its own `VkDeviceMemory` and `vkMapMemory` it. Both sides write and both + sides compare, so a one-directional or copy-on-import mapping is caught. +* **T0 — server imports.** The second process allocates an `AHardwareBuffer` BLOB + (`CPU_READ_OFTEN|CPU_WRITE_OFTEN|GPU_DATA_BUFFER`), writes a pattern under + `AHardwareBuffer_lock`, and sends it with + `AHardwareBuffer_sendHandleToUnixSocket`. The first process reads it back three + ways — CPU lock, `VkDeviceMemory` imported through + `VK_ANDROID_external_memory_android_hardware_buffer`, and a GL buffer created + with `eglGetNativeClientBufferANDROID` + `glBufferStorageExternalEXT` mapped + persistent/coherent — writes through each, and the allocating process verifies + every write. +* **T3 — host pointer import.** If `VK_EXT_external_memory_host` is advertised, + imports a memfd-backed, alignment-corrected `mmap` region as a `VkDeviceMemory` + and maps it; also passes the memfd to the second process for a cross-process + round trip. + +Process topology mirrors the target design (the client spawns the server): the +probe re-execs `/proc/self/exe --child=` and hands the child one end of a +`socketpair` on fd 3. A bare `fork()` is not usable — neither side's Vulkan +driver survives it, and both sides need live Vulkan. + +## Build and run + +```sh +ANDROID_NDK=$HOME/android-sdk/ndk/27.3.13750724 ./build_android.sh /tmp/extmem-build +adb -s push /tmp/extmem-build/extmem_probe /data/local/tmp/p0-extmem/ +adb -s shell /data/local/tmp/p0-extmem/extmem_probe +``` + +There is also a host build (`cmake -S . -B ` with no toolchain file). It +compiles T0 out — `AHardwareBuffer` is Android-only — and exists for exactly one +reason: running T1/T3 against a driver that is known to implement them +(lavapipe: `VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.json +EGL_PLATFORM=surfaceless`) proves the harness reports a working route as +working, which is what makes a device-side `FAIL` attributable to the device +driver rather than to this program. It is not a substitute for a device run. + +Options: `--size=BYTES` (default 65536; the payload is split into 4 KiB regions, +one per writer), `--only-t0` / `--only-t1` / `--only-t3`. + +Output is a per-route `RESULT ` line stream plus a +summary table; `status` is one of `OK`, `PARTIAL`, `UNSUPPORTED`, `FAIL`, `SKIP`. +Driver error codes are printed verbatim (`VkResult` names, `errno`, GL enums) — +that is the payload of the spike, so do not summarise them away. + +Two details worth knowing when reading T1 output: + +* the child reads through *both* the plain `mmap` and the imported + `VkDeviceMemory` before it writes through either, because a driver whose + exported fd maps at an offset would otherwise have its payload overwritten by + the probe's own first write, and the second read would report a false failure; +* when the direct compare fails, the child scans the mapping for the exporter's + payload and reports `payloadAt=`. `payloadAt=4096` with a clean Vulkan + import (lavapipe's answer) means the fd is shareable but its offset-0 is not + the allocation's base — a route that only works if that offset is + discoverable, which opaque-fd does not promise. diff --git a/tools/spikes/extmem_probe/build_android.sh b/tools/spikes/extmem_probe/build_android.sh new file mode 100755 index 000000000..acebf315a --- /dev/null +++ b/tools/spikes/extmem_probe/build_android.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Build the spike-B external-memory probe for arm64 Android. +# +# ANDROID_NDK=/path/to/ndk ./build_android.sh [build-dir] +# +# Produces /extmem_probe, a PIE arm64-v8a executable that depends +# only on the platform (libvulkan / libEGL / libGLESv3 / libandroid / liblog). +set -e + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD="${1:-$HERE/build-android}" +NDK="${ANDROID_NDK:-$HOME/android-sdk/ndk/27.3.13750724}" + +if [ ! -f "$NDK/build/cmake/android.toolchain.cmake" ]; then + echo "NDK not found at $NDK (set ANDROID_NDK)" >&2 + exit 1 +fi + +cmake -S "$HERE" -B "$BUILD" -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI=arm64-v8a \ + -DANDROID_PLATFORM=android-30 \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo +cmake --build "$BUILD" -j "$(nproc)" +echo "built: $BUILD/extmem_probe" diff --git a/tools/spikes/extmem_probe/extmem_probe.cpp b/tools/spikes/extmem_probe/extmem_probe.cpp new file mode 100644 index 000000000..2e28f0e9c --- /dev/null +++ b/tools/spikes/extmem_probe/extmem_probe.cpp @@ -0,0 +1,1871 @@ +// extmem_probe -- MobileGL disaggregation P0 spike B (plan-B §8.3, §11 P0). +// +// Question this program answers, per device: +// Can a server-allocated HOST_VISIBLE|HOST_COHERENT VkDeviceMemory be shared +// with another process and mapped there, and by which route? +// +// T1 server exports its own allocation (VkExportMemoryAllocateInfo + +// vkGetMemoryFdKHR, opaque-fd and dma-buf, handed over SCM_RIGHTS; the +// importer tries plain mmap() *and* a Vulkan import + vkMapMemory) +// T0 server imports a client allocation (AHardwareBuffer BLOB sent over a +// unix socket, imported into VkDeviceMemory via +// VK_ANDROID_external_memory_android_hardware_buffer and into a GL buffer +// via EGL_ANDROID_get_native_client_buffer + glBufferStorageExternalEXT) +// T3 server imports a client host mapping (VK_EXT_external_memory_host over +// a memfd-backed mmap region) +// +// Standalone: depends on nothing from MobileGL. Build with the NDK toolchain +// (see CMakeLists.txt / build_android.sh), push to /data/local/tmp and run. +// +// Process topology mirrors the target design (client spawns the server as a +// separate process): the probe re-execs /proc/self/exe with --child= and +// hands it one end of a socketpair on fd 3. A plain fork() without exec is not +// usable here -- the Vulkan driver's own threads and device state do not +// survive fork, and both routes need live Vulkan on both sides. + +#ifdef __ANDROID__ +# define VK_USE_PLATFORM_ANDROID_KHR 1 +# define PROBE_HAVE_AHB 1 +#else +// The probe is an Android deliverable; the host build exists only so the T1/T3 +// harness itself can be validated against a driver that is known to implement +// those routes (lavapipe), which is what makes a device-side FAIL attributable +// to the driver rather than to this program. T0 is Android-only by nature. +# define PROBE_HAVE_AHB 0 +#endif + +#include + +#include +#include +#include +#include + +#if PROBE_HAVE_AHB +# include +# include +#else +# define PROP_VALUE_MAX 92 +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +// --------------------------------------------------------------------------- +// tiny logging / result table +// --------------------------------------------------------------------------- + +static const char* gRole = "parent"; + +// ro.* on Android, empty elsewhere +static void getProp(const char* name, char* out, size_t n) { + out[0] = 0; +#if PROBE_HAVE_AHB + __system_property_get(name, out); +#else + (void)name; + (void)n; +#endif +} + +static void pr(const char* fmt, ...) { + char buf[4096]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + fprintf(stdout, "[%s] %s\n", gRole, buf); + fflush(stdout); +} + +struct RouteResult { + std::string route; + std::string status; // OK / PARTIAL / UNSUPPORTED / FAIL / SKIP + std::string detail; +}; +static std::vector gResults; + +static void record(const char* route, const char* status, const std::string& detail) { + gResults.push_back(RouteResult{route, status, detail}); + pr("RESULT %-34s %-12s %s", route, status, detail.c_str()); +} + +static std::string fmt(const char* f, ...) { + char buf[1024]; + va_list ap; + va_start(ap, f); + vsnprintf(buf, sizeof(buf), f, ap); + va_end(ap); + return std::string(buf); +} + +static const char* vkStr(VkResult r) { + switch (r) { + case VK_SUCCESS: return "VK_SUCCESS"; + case VK_NOT_READY: return "VK_NOT_READY"; + case VK_TIMEOUT: return "VK_TIMEOUT"; + case VK_INCOMPLETE: return "VK_INCOMPLETE"; + case VK_ERROR_OUT_OF_HOST_MEMORY: return "VK_ERROR_OUT_OF_HOST_MEMORY"; + case VK_ERROR_OUT_OF_DEVICE_MEMORY: return "VK_ERROR_OUT_OF_DEVICE_MEMORY"; + case VK_ERROR_INITIALIZATION_FAILED: return "VK_ERROR_INITIALIZATION_FAILED"; + case VK_ERROR_DEVICE_LOST: return "VK_ERROR_DEVICE_LOST"; + case VK_ERROR_MEMORY_MAP_FAILED: return "VK_ERROR_MEMORY_MAP_FAILED"; + case VK_ERROR_LAYER_NOT_PRESENT: return "VK_ERROR_LAYER_NOT_PRESENT"; + case VK_ERROR_EXTENSION_NOT_PRESENT: return "VK_ERROR_EXTENSION_NOT_PRESENT"; + case VK_ERROR_FEATURE_NOT_PRESENT: return "VK_ERROR_FEATURE_NOT_PRESENT"; + case VK_ERROR_INCOMPATIBLE_DRIVER: return "VK_ERROR_INCOMPATIBLE_DRIVER"; + case VK_ERROR_TOO_MANY_OBJECTS: return "VK_ERROR_TOO_MANY_OBJECTS"; + case VK_ERROR_FORMAT_NOT_SUPPORTED: return "VK_ERROR_FORMAT_NOT_SUPPORTED"; + case VK_ERROR_FRAGMENTED_POOL: return "VK_ERROR_FRAGMENTED_POOL"; + case VK_ERROR_UNKNOWN: return "VK_ERROR_UNKNOWN"; + case VK_ERROR_OUT_OF_POOL_MEMORY: return "VK_ERROR_OUT_OF_POOL_MEMORY"; + case VK_ERROR_INVALID_EXTERNAL_HANDLE: return "VK_ERROR_INVALID_EXTERNAL_HANDLE"; + case VK_ERROR_FRAGMENTATION: return "VK_ERROR_FRAGMENTATION"; + case VK_ERROR_INVALID_OPAQUE_CAPTURE_ADDRESS: return "VK_ERROR_INVALID_OPAQUE_CAPTURE_ADDRESS"; + default: { + static char tmp[32]; + snprintf(tmp, sizeof(tmp), "VkResult(%d)", (int)r); + return tmp; + } + } +} + +// --------------------------------------------------------------------------- +// payload patterns +// --------------------------------------------------------------------------- + +static const uint64_t kRegion = 4096; // bytes per verification region +static const uint64_t kDefaultSize = 65536; + +// region indices inside the shared allocation +enum { + REG_A = 0, // first writer's payload + REG_B = 1, // importer write through the plain host mapping (mmap / AHB lock) + REG_C = 2, // importer write through the imported Vulkan mapping + REG_D = 3, // importer write through the imported GL mapping +}; + +static void fillPattern(void* p, uint64_t bytes, uint32_t seed) { + uint8_t* b = (uint8_t*)p; + for (uint64_t i = 0; i < bytes; ++i) { + b[i] = (uint8_t)((seed * 2654435761u + (uint32_t)i * 31u + (uint32_t)(i >> 8) * 7u) & 0xFF); + } +} + +// returns -1 on match, else the index of the first mismatching byte +static int64_t checkPattern(const void* p, uint64_t bytes, uint32_t seed) { + const uint8_t* b = (const uint8_t*)p; + for (uint64_t i = 0; i < bytes; ++i) { + uint8_t want = (uint8_t)((seed * 2654435761u + (uint32_t)i * 31u + (uint32_t)(i >> 8) * 7u) & 0xFF); + if (b[i] != want) return (int64_t)i; + } + return -1; +} + +static void writeRegion(void* base, int region, uint32_t seed) { + fillPattern((uint8_t*)base + region * kRegion, kRegion, seed); +} +static int64_t checkRegion(const void* base, int region, uint32_t seed) { + return checkPattern((const uint8_t*)base + region * kRegion, kRegion, seed); +} + +// --------------------------------------------------------------------------- +// socket message plumbing +// --------------------------------------------------------------------------- + +enum MsgTag : uint32_t { + MSG_T1_OFFER = 1, + MSG_T1_RESULT = 2, + MSG_T0_REQUEST = 3, + MSG_T0_ALLOC = 4, + MSG_T0_VERIFY = 5, + MSG_T0_RESULT = 6, + MSG_T3_OFFER = 7, + MSG_T3_RESULT = 8, + MSG_BYE = 99, +}; + +struct MsgHeader { + uint32_t tag; + uint32_t len; +}; + +static bool writeAll(int fd, const void* p, size_t n) { + const uint8_t* b = (const uint8_t*)p; + while (n) { + ssize_t w = write(fd, b, n); + if (w <= 0) { + if (w < 0 && errno == EINTR) continue; + return false; + } + b += w; + n -= (size_t)w; + } + return true; +} + +static bool readAll(int fd, void* p, size_t n) { + uint8_t* b = (uint8_t*)p; + while (n) { + ssize_t r = read(fd, b, n); + if (r <= 0) { + if (r < 0 && errno == EINTR) continue; + return false; + } + b += r; + n -= (size_t)r; + } + return true; +} + +// header + payload go out in one sendmsg so SCM_RIGHTS lands with the header byte +static bool sendMsg(int sock, uint32_t tag, const void* payload, size_t len, int fdToPass) { + MsgHeader h{tag, (uint32_t)len}; + struct iovec iov[2]; + iov[0].iov_base = &h; + iov[0].iov_len = sizeof(h); + iov[1].iov_base = (void*)payload; + iov[1].iov_len = len; + + char cbuf[CMSG_SPACE(sizeof(int))]; + memset(cbuf, 0, sizeof(cbuf)); + + struct msghdr msg; + memset(&msg, 0, sizeof(msg)); + msg.msg_iov = iov; + msg.msg_iovlen = len ? 2 : 1; + if (fdToPass >= 0) { + msg.msg_control = cbuf; + msg.msg_controllen = sizeof(cbuf); + struct cmsghdr* cm = CMSG_FIRSTHDR(&msg); + cm->cmsg_level = SOL_SOCKET; + cm->cmsg_type = SCM_RIGHTS; + cm->cmsg_len = CMSG_LEN(sizeof(int)); + memcpy(CMSG_DATA(cm), &fdToPass, sizeof(int)); + } + ssize_t s; + do { + s = sendmsg(sock, &msg, 0); + } while (s < 0 && errno == EINTR); + if (s < 0) return false; + size_t total = sizeof(h) + len; + if ((size_t)s == total) return true; + // partial: finish the tail with plain writes (control data already delivered) + size_t done = (size_t)s; + if (done < sizeof(h)) return false; // should not happen for such small headers + return writeAll(sock, (const uint8_t*)payload + (done - sizeof(h)), total - done); +} + +static bool recvMsg(int sock, uint32_t* tag, void* payload, size_t maxLen, size_t* outLen, int* fdOut) { + if (fdOut) *fdOut = -1; + MsgHeader h{}; + struct iovec iov; + iov.iov_base = &h; + iov.iov_len = sizeof(h); + + char cbuf[CMSG_SPACE(sizeof(int))]; + memset(cbuf, 0, sizeof(cbuf)); + + struct msghdr msg; + memset(&msg, 0, sizeof(msg)); + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + msg.msg_control = cbuf; + msg.msg_controllen = sizeof(cbuf); + + ssize_t r; + do { + r = recvmsg(sock, &msg, MSG_WAITALL); + } while (r < 0 && errno == EINTR); + if (r != (ssize_t)sizeof(h)) return false; + + for (struct cmsghdr* cm = CMSG_FIRSTHDR(&msg); cm; cm = CMSG_NXTHDR(&msg, cm)) { + if (cm->cmsg_level == SOL_SOCKET && cm->cmsg_type == SCM_RIGHTS) { + int got = -1; + memcpy(&got, CMSG_DATA(cm), sizeof(int)); + if (fdOut) { + *fdOut = got; + } else if (got >= 0) { + close(got); + } + } + } + *tag = h.tag; + if (outLen) *outLen = h.len; + if (h.len > maxLen) return false; + if (h.len && !readAll(sock, payload, h.len)) return false; + return true; +} + +static void setRecvTimeout(int sock, int seconds) { + struct timeval tv; + tv.tv_sec = seconds; + tv.tv_usec = 0; + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); +} + +static std::string describeFd(int fd) { + if (fd < 0) return "no-fd"; + char path[64]; + snprintf(path, sizeof(path), "/proc/self/fd/%d", fd); + char link[512]; + ssize_t n = readlink(path, link, sizeof(link) - 1); + std::string desc; + if (n > 0) { + link[n] = 0; + desc = link; + } else { + desc = ""; + } + off_t sz = lseek(fd, 0, SEEK_END); + if (sz >= 0) { + desc += fmt(" size=%lld", (long long)sz); + lseek(fd, 0, SEEK_SET); + } else { + desc += fmt(" lseek-errno=%d(%s)", errno, strerror(errno)); + } + struct stat st; + if (fstat(fd, &st) == 0) { + const char* kind = S_ISREG(st.st_mode) ? "reg" : S_ISCHR(st.st_mode) ? "chr" + : S_ISFIFO(st.st_mode) ? "fifo" : S_ISSOCK(st.st_mode) ? "sock" : "other"; + desc += fmt(" kind=%s stsize=%lld", kind, (long long)st.st_size); + } + return desc; +} + +// --------------------------------------------------------------------------- +// Vulkan context +// --------------------------------------------------------------------------- + +struct VkCtx { + VkInstance instance = VK_NULL_HANDLE; + VkPhysicalDevice phys = VK_NULL_HANDLE; + VkDevice device = VK_NULL_HANDLE; + uint32_t queueFamily = 0; + VkPhysicalDeviceMemoryProperties memProps{}; + VkPhysicalDeviceProperties props{}; + uint8_t deviceUUID[VK_UUID_SIZE]{}; + std::vector deviceExts; + + bool hasExtMemFd = false; + bool hasDmaBuf = false; + bool hasExtMemHost = false; + bool hasAhb = false; + bool hasQueueFamilyForeign = false; + + PFN_vkGetMemoryFdKHR pGetMemoryFdKHR = nullptr; + PFN_vkGetMemoryFdPropertiesKHR pGetMemoryFdPropertiesKHR = nullptr; +#if PROBE_HAVE_AHB + PFN_vkGetAndroidHardwareBufferPropertiesANDROID pGetAhbProps = nullptr; +#endif + PFN_vkGetMemoryHostPointerPropertiesEXT pGetHostPtrProps = nullptr; + + VkDeviceSize minImportedHostPointerAlignment = 0; + + bool hasExt(const char* name) const { + for (const std::string& s : deviceExts) + if (s == name) return true; + return false; + } +}; + +static bool vkCtxInit(VkCtx& c, bool verbose) { + VkApplicationInfo app{}; + app.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; + app.pApplicationName = "extmem_probe"; + app.apiVersion = VK_API_VERSION_1_1; + + uint32_t instExtCount = 0; + vkEnumerateInstanceExtensionProperties(nullptr, &instExtCount, nullptr); + std::vector instExts(instExtCount); + if (instExtCount) vkEnumerateInstanceExtensionProperties(nullptr, &instExtCount, instExts.data()); + + std::vector wanted; + auto haveInst = [&](const char* n) { + for (auto& e : instExts) + if (!strcmp(e.extensionName, n)) return true; + return false; + }; + if (haveInst(VK_KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME)) + wanted.push_back(VK_KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME); + if (haveInst(VK_KHR_EXTERNAL_MEMORY_CAPABILITIES_EXTENSION_NAME)) + wanted.push_back(VK_KHR_EXTERNAL_MEMORY_CAPABILITIES_EXTENSION_NAME); + + VkInstanceCreateInfo ici{}; + ici.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO; + ici.pApplicationInfo = &app; + ici.enabledExtensionCount = (uint32_t)wanted.size(); + ici.ppEnabledExtensionNames = wanted.empty() ? nullptr : wanted.data(); + + VkResult r = vkCreateInstance(&ici, nullptr, &c.instance); + if (r != VK_SUCCESS) { + pr("vkCreateInstance failed: %s", vkStr(r)); + return false; + } + + uint32_t n = 0; + vkEnumeratePhysicalDevices(c.instance, &n, nullptr); + if (!n) { + pr("no physical devices"); + return false; + } + std::vector devs(n); + vkEnumeratePhysicalDevices(c.instance, &n, devs.data()); + c.phys = devs[0]; + + VkPhysicalDeviceIDProperties idp{}; + idp.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ID_PROPERTIES; + VkPhysicalDeviceExternalMemoryHostPropertiesEXT hostProps{}; + hostProps.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_MEMORY_HOST_PROPERTIES_EXT; + idp.pNext = &hostProps; + VkPhysicalDeviceProperties2 p2{}; + p2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2; + p2.pNext = &idp; + vkGetPhysicalDeviceProperties2(c.phys, &p2); + c.props = p2.properties; + memcpy(c.deviceUUID, idp.deviceUUID, VK_UUID_SIZE); + c.minImportedHostPointerAlignment = hostProps.minImportedHostPointerAlignment; + + vkGetPhysicalDeviceMemoryProperties(c.phys, &c.memProps); + + uint32_t extCount = 0; + vkEnumerateDeviceExtensionProperties(c.phys, nullptr, &extCount, nullptr); + std::vector exts(extCount); + if (extCount) vkEnumerateDeviceExtensionProperties(c.phys, nullptr, &extCount, exts.data()); + for (auto& e : exts) c.deviceExts.push_back(e.extensionName); + + c.hasExtMemFd = c.hasExt(VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME); + c.hasDmaBuf = c.hasExt(VK_EXT_EXTERNAL_MEMORY_DMA_BUF_EXTENSION_NAME); + c.hasExtMemHost = c.hasExt(VK_EXT_EXTERNAL_MEMORY_HOST_EXTENSION_NAME); + c.hasAhb = c.hasExt("VK_ANDROID_external_memory_android_hardware_buffer"); + c.hasQueueFamilyForeign = c.hasExt(VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME); + + uint32_t qf = 0; + vkGetPhysicalDeviceQueueFamilyProperties(c.phys, &qf, nullptr); + std::vector qfp(qf); + vkGetPhysicalDeviceQueueFamilyProperties(c.phys, &qf, qfp.data()); + c.queueFamily = 0; + for (uint32_t i = 0; i < qf; ++i) { + if (qfp[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) { + c.queueFamily = i; + break; + } + } + + std::vector devExts; + if (c.hasExt(VK_KHR_EXTERNAL_MEMORY_EXTENSION_NAME)) devExts.push_back(VK_KHR_EXTERNAL_MEMORY_EXTENSION_NAME); + if (c.hasExtMemFd) devExts.push_back(VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME); + if (c.hasDmaBuf) devExts.push_back(VK_EXT_EXTERNAL_MEMORY_DMA_BUF_EXTENSION_NAME); + if (c.hasExtMemHost) devExts.push_back(VK_EXT_EXTERNAL_MEMORY_HOST_EXTENSION_NAME); + if (c.hasAhb) { + devExts.push_back("VK_ANDROID_external_memory_android_hardware_buffer"); + if (c.hasQueueFamilyForeign) devExts.push_back(VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME); + if (c.hasExt(VK_KHR_SAMPLER_YCBCR_CONVERSION_EXTENSION_NAME)) + devExts.push_back(VK_KHR_SAMPLER_YCBCR_CONVERSION_EXTENSION_NAME); + if (c.hasExt(VK_KHR_DEDICATED_ALLOCATION_EXTENSION_NAME)) + devExts.push_back(VK_KHR_DEDICATED_ALLOCATION_EXTENSION_NAME); + if (c.hasExt(VK_KHR_GET_MEMORY_REQUIREMENTS_2_EXTENSION_NAME)) + devExts.push_back(VK_KHR_GET_MEMORY_REQUIREMENTS_2_EXTENSION_NAME); + if (c.hasExt(VK_KHR_BIND_MEMORY_2_EXTENSION_NAME)) + devExts.push_back(VK_KHR_BIND_MEMORY_2_EXTENSION_NAME); + if (c.hasExt(VK_KHR_MAINTENANCE_1_EXTENSION_NAME)) + devExts.push_back(VK_KHR_MAINTENANCE_1_EXTENSION_NAME); + } + + float prio = 1.0f; + VkDeviceQueueCreateInfo q{}; + q.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO; + q.queueFamilyIndex = c.queueFamily; + q.queueCount = 1; + q.pQueuePriorities = &prio; + + VkDeviceCreateInfo dci{}; + dci.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; + dci.queueCreateInfoCount = 1; + dci.pQueueCreateInfos = &q; + dci.enabledExtensionCount = (uint32_t)devExts.size(); + dci.ppEnabledExtensionNames = devExts.empty() ? nullptr : devExts.data(); + + r = vkCreateDevice(c.phys, &dci, nullptr, &c.device); + if (r != VK_SUCCESS) { + pr("vkCreateDevice failed: %s", vkStr(r)); + return false; + } + + c.pGetMemoryFdKHR = (PFN_vkGetMemoryFdKHR)vkGetDeviceProcAddr(c.device, "vkGetMemoryFdKHR"); + c.pGetMemoryFdPropertiesKHR = + (PFN_vkGetMemoryFdPropertiesKHR)vkGetDeviceProcAddr(c.device, "vkGetMemoryFdPropertiesKHR"); +#if PROBE_HAVE_AHB + c.pGetAhbProps = (PFN_vkGetAndroidHardwareBufferPropertiesANDROID)vkGetDeviceProcAddr( + c.device, "vkGetAndroidHardwareBufferPropertiesANDROID"); +#endif + c.pGetHostPtrProps = (PFN_vkGetMemoryHostPointerPropertiesEXT)vkGetDeviceProcAddr( + c.device, "vkGetMemoryHostPointerPropertiesEXT"); + + if (verbose) { + pr("vulkan device: %s api=%u.%u.%u driverVersion=0x%08x vendor=0x%04x", c.props.deviceName, + VK_VERSION_MAJOR(c.props.apiVersion), VK_VERSION_MINOR(c.props.apiVersion), + VK_VERSION_PATCH(c.props.apiVersion), c.props.driverVersion, c.props.vendorID); + char uuid[64] = {0}; + for (uint32_t i = 0; i < VK_UUID_SIZE; ++i) snprintf(uuid + i * 2, 3, "%02x", c.deviceUUID[i]); + pr("deviceUUID=%s minImportedHostPointerAlignment=%llu", uuid, + (unsigned long long)c.minImportedHostPointerAlignment); + } + return true; +} + +static void vkCtxDestroy(VkCtx& c) { + if (c.device) vkDestroyDevice(c.device, nullptr); + if (c.instance) vkDestroyInstance(c.instance, nullptr); + c.device = VK_NULL_HANDLE; + c.instance = VK_NULL_HANDLE; +} + +// index of a memory type in `bits` that has all of `want`, or -1 +static int pickMemType(const VkPhysicalDeviceMemoryProperties& mp, uint32_t bits, VkMemoryPropertyFlags want) { + for (uint32_t i = 0; i < mp.memoryTypeCount; ++i) { + if (!(bits & (1u << i))) continue; + if ((mp.memoryTypes[i].propertyFlags & want) == want) return (int)i; + } + return -1; +} + +static const VkBufferUsageFlags kProbeBufferUsage = + VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT | + VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | + VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT; + +// --------------------------------------------------------------------------- +// GLES / EGL context +// --------------------------------------------------------------------------- + +struct GlCtx { + EGLDisplay dpy = EGL_NO_DISPLAY; + EGLContext ctx = EGL_NO_CONTEXT; + EGLSurface surf = EGL_NO_SURFACE; + std::vector glExts; + std::vector eglExts; + std::string vendor, renderer, version; + + PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC pGetNativeClientBuffer = nullptr; + PFNGLBUFFERSTORAGEEXTERNALEXTPROC pBufferStorageExternal = nullptr; + + bool hasGl(const char* n) const { + for (auto& s : glExts) + if (s == n) return true; + return false; + } + bool hasEgl(const char* n) const { + for (auto& s : eglExts) + if (s == n) return true; + return false; + } +}; + +static void splitExts(const char* s, std::vector& out) { + if (!s) return; + std::string cur; + for (const char* p = s; *p; ++p) { + if (*p == ' ') { + if (!cur.empty()) out.push_back(cur); + cur.clear(); + } else { + cur.push_back(*p); + } + } + if (!cur.empty()) out.push_back(cur); +} + +static bool glCtxInit(GlCtx& g) { + g.dpy = eglGetDisplay(EGL_DEFAULT_DISPLAY); + if (g.dpy == EGL_NO_DISPLAY) { + pr("eglGetDisplay failed"); + return false; + } + EGLint major = 0, minor = 0; + if (!eglInitialize(g.dpy, &major, &minor)) { + pr("eglInitialize failed 0x%04x", eglGetError()); + return false; + } + pr("EGL %d.%d vendor=%s", major, minor, eglQueryString(g.dpy, EGL_VENDOR)); + splitExts(eglQueryString(g.dpy, EGL_EXTENSIONS), g.eglExts); + const char* clientExts = eglQueryString(EGL_NO_DISPLAY, EGL_EXTENSIONS); + splitExts(clientExts, g.eglExts); + + const EGLint cfgAttr[] = {EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, + EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, + EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, + EGL_NONE}; + EGLConfig cfg = nullptr; + EGLint numCfg = 0; + if (!eglChooseConfig(g.dpy, cfgAttr, &cfg, 1, &numCfg) || numCfg == 0) { + pr("eglChooseConfig failed 0x%04x", eglGetError()); + return false; + } + const EGLint pbAttr[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE}; + g.surf = eglCreatePbufferSurface(g.dpy, cfg, pbAttr); + if (g.surf == EGL_NO_SURFACE) { + pr("eglCreatePbufferSurface failed 0x%04x", eglGetError()); + return false; + } + eglBindAPI(EGL_OPENGL_ES_API); + const EGLint versions[][2] = {{3, 2}, {3, 1}, {3, 0}}; + for (auto& v : versions) { + const EGLint ctxAttr[] = {EGL_CONTEXT_MAJOR_VERSION, v[0], EGL_CONTEXT_MINOR_VERSION, v[1], EGL_NONE}; + g.ctx = eglCreateContext(g.dpy, cfg, EGL_NO_CONTEXT, ctxAttr); + if (g.ctx != EGL_NO_CONTEXT) break; + } + if (g.ctx == EGL_NO_CONTEXT) { + pr("eglCreateContext failed 0x%04x", eglGetError()); + return false; + } + if (!eglMakeCurrent(g.dpy, g.surf, g.surf, g.ctx)) { + pr("eglMakeCurrent failed 0x%04x", eglGetError()); + return false; + } + + const char* vd = (const char*)glGetString(GL_VENDOR); + const char* rd = (const char*)glGetString(GL_RENDERER); + const char* vr = (const char*)glGetString(GL_VERSION); + g.vendor = vd ? vd : ""; + g.renderer = rd ? rd : ""; + g.version = vr ? vr : ""; + + GLint numExt = 0; + glGetIntegerv(GL_NUM_EXTENSIONS, &numExt); + for (GLint i = 0; i < numExt; ++i) { + const char* e = (const char*)glGetStringi(GL_EXTENSIONS, (GLuint)i); + if (e) g.glExts.push_back(e); + } + if (g.glExts.empty()) splitExts((const char*)glGetString(GL_EXTENSIONS), g.glExts); + + g.pGetNativeClientBuffer = + (PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC)eglGetProcAddress("eglGetNativeClientBufferANDROID"); + g.pBufferStorageExternal = + (PFNGLBUFFERSTORAGEEXTERNALEXTPROC)eglGetProcAddress("glBufferStorageExternalEXT"); + return true; +} + +static void glCtxDestroy(GlCtx& g) { + if (g.dpy != EGL_NO_DISPLAY) { + eglMakeCurrent(g.dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + if (g.ctx != EGL_NO_CONTEXT) eglDestroyContext(g.dpy, g.ctx); + if (g.surf != EGL_NO_SURFACE) eglDestroySurface(g.dpy, g.surf); + eglTerminate(g.dpy); + } + g.dpy = EGL_NO_DISPLAY; +} + +// --------------------------------------------------------------------------- +// child spawn +// --------------------------------------------------------------------------- + +// Spawns /proc/self/exe --child=; the child gets `sock` on fd 3. +static pid_t spawnChild(const char* route, int* parentSock) { + int sv[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) { + pr("socketpair failed errno=%d", errno); + return -1; + } + pid_t pid = fork(); + if (pid < 0) { + pr("fork failed errno=%d", errno); + close(sv[0]); + close(sv[1]); + return -1; + } + if (pid == 0) { + close(sv[0]); + if (sv[1] != 3) { + dup2(sv[1], 3); + close(sv[1]); + } + char arg[64]; + snprintf(arg, sizeof(arg), "--child=%s", route); + char self[512]; + ssize_t n = readlink("/proc/self/exe", self, sizeof(self) - 1); + if (n <= 0) _exit(90); + self[n] = 0; + char* argv[] = {self, arg, nullptr}; + execv(self, argv); + _exit(91); + } + close(sv[1]); + *parentSock = sv[0]; + setRecvTimeout(sv[0], 60); + return pid; +} + +// Bounded: a child wedged inside a driver call must not hold the probe (and the +// device) forever. Poll for a few seconds, then kill it and report that. +static std::string reapChild(pid_t pid) { + int status = 0; + bool killed = false; + for (int i = 0; i < 100; ++i) { + pid_t w = waitpid(pid, &status, WNOHANG); + if (w == pid) { + if (WIFEXITED(status)) return fmt("child exit=%d%s", WEXITSTATUS(status), killed ? " (killed)" : ""); + if (WIFSIGNALED(status)) return fmt("child signal=%d%s", WTERMSIG(status), killed ? " (killed)" : ""); + return "child ?"; + } + if (w < 0) return fmt("waitpid errno=%d", errno); + if (i == 60 && !killed) { + kill(pid, SIGKILL); + killed = true; + } + usleep(50000); + } + kill(pid, SIGKILL); + if (waitpid(pid, &status, 0) < 0) return fmt("child unreaped, waitpid errno=%d", errno); + return fmt("child killed after hang (signal=%d)", WIFSIGNALED(status) ? WTERMSIG(status) : 0); +} + +// --------------------------------------------------------------------------- +// T1 payloads +// --------------------------------------------------------------------------- + +struct T1Offer { + uint64_t allocationSize; + uint64_t bufferSize; + uint32_t handleType; // VkExternalMemoryHandleTypeFlagBits used to export + uint32_t seedA; // pattern the parent wrote in REG_A + uint32_t seedB; // pattern the child must write in REG_B (via mmap) + uint32_t seedC; // pattern the child must write in REG_C (via imported vkMapMemory) + uint32_t memoryTypeIndex; + uint32_t memoryTypeBits; +}; + +struct T1Result { + int32_t gotFd; + int32_t mmapOk; + int32_t mmapErrno; + int64_t mmapMismatch; // -1 == data matched + int64_t mmapPatternOffset; // where the exporter's payload really starts in the mapping, -1 = not found + int32_t vkInitOk; + int32_t fdPropsResult; // VkResult of vkGetMemoryFdPropertiesKHR + uint32_t fdMemoryTypeBits; + int32_t importResult; // VkResult of vkAllocateMemory with the import struct + int32_t bindResult; + int32_t mapResult; + int64_t vkMismatch; // -1 == data matched + int32_t wroteB; + int32_t wroteC; + char note[384]; +}; + +struct T0Request { + uint64_t size; + uint32_t seedA; // pattern the child writes through AHardwareBuffer_lock +}; + +struct T0Alloc { + int32_t allocOk; + int32_t allocErr; + uint64_t size; + uint32_t stride; + char note[192]; +}; + +struct T0Verify { + uint32_t seedB; // parent wrote REG_B through the imported vkMapMemory + uint32_t seedC; // parent wrote REG_C through the imported GL mapping + uint32_t seedD; // parent wrote REG_D through AHardwareBuffer_lock + uint32_t writtenMask; // bit0=B bit1=C bit2=D +}; + +struct T0Result { + int32_t lockOk; + int32_t lockErr; + int64_t mismatchB; + int64_t mismatchC; + int64_t mismatchD; + char note[192]; +}; + +struct T3Offer { + uint64_t size; + uint32_t seedA; + uint32_t seedB; +}; + +struct T3Result { + int32_t mmapOk; + int32_t mmapErrno; + int64_t mismatch; + char note[192]; +}; + +// --------------------------------------------------------------------------- +// Phase A: enumeration +// --------------------------------------------------------------------------- + +static const char* memFlagStr(VkMemoryPropertyFlags f) { + static char b[128]; + b[0] = 0; + if (f & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT) strcat(b, "DEVICE_LOCAL "); + if (f & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) strcat(b, "HOST_VISIBLE "); + if (f & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) strcat(b, "HOST_COHERENT "); + if (f & VK_MEMORY_PROPERTY_HOST_CACHED_BIT) strcat(b, "HOST_CACHED "); + if (f & VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT) strcat(b, "LAZY "); + if (f & VK_MEMORY_PROPERTY_PROTECTED_BIT) strcat(b, "PROTECTED "); + return b; +} + +static void reportExternalBufferCaps(VkCtx& c, VkExternalMemoryHandleTypeFlagBits ht, const char* name) { + VkPhysicalDeviceExternalBufferInfo info{}; + info.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_BUFFER_INFO; + info.usage = kProbeBufferUsage; + info.handleType = ht; + VkExternalBufferProperties out{}; + out.sType = VK_STRUCTURE_TYPE_EXTERNAL_BUFFER_PROPERTIES; + vkGetPhysicalDeviceExternalBufferProperties(c.phys, &info, &out); + const VkExternalMemoryProperties& p = out.externalMemoryProperties; + char feat[96]; + feat[0] = 0; + if (p.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT) strcat(feat, "DEDICATED_ONLY "); + if (p.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_EXPORTABLE_BIT) strcat(feat, "EXPORTABLE "); + if (p.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_IMPORTABLE_BIT) strcat(feat, "IMPORTABLE "); + if (!feat[0]) strcat(feat, ""); + pr(" externalBuffer[%s]: features=%s exportFrom=0x%x compatible=0x%x", name, feat, + p.exportFromImportedHandleTypes, p.compatibleHandleTypes); +} + +static void phaseEnumerate(VkCtx& c, GlCtx& g, bool glOk) { + pr("=== phase A: capability enumeration ==="); + + char model[PROP_VALUE_MAX] = {0}, dev[PROP_VALUE_MAX] = {0}, rel[PROP_VALUE_MAX] = {0}; + getProp("ro.product.model", model, sizeof(model)); + getProp("ro.product.device", dev, sizeof(dev)); + getProp("ro.build.version.release", rel, sizeof(rel)); + pr("device: model=%s device=%s android=%s", model, dev, rel); + + pr("vulkan: %s (api %u.%u.%u, driver 0x%08x, vendor 0x%04x)", c.props.deviceName, + VK_VERSION_MAJOR(c.props.apiVersion), VK_VERSION_MINOR(c.props.apiVersion), + VK_VERSION_PATCH(c.props.apiVersion), c.props.driverVersion, c.props.vendorID); + + struct { + const char* name; + bool present; + } probe[] = { + {VK_KHR_EXTERNAL_MEMORY_EXTENSION_NAME, c.hasExt(VK_KHR_EXTERNAL_MEMORY_EXTENSION_NAME)}, + {VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME, c.hasExtMemFd}, + {VK_EXT_EXTERNAL_MEMORY_DMA_BUF_EXTENSION_NAME, c.hasDmaBuf}, + {VK_EXT_EXTERNAL_MEMORY_HOST_EXTENSION_NAME, c.hasExtMemHost}, + {"VK_ANDROID_external_memory_android_hardware_buffer", c.hasAhb}, + {VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME, c.hasQueueFamilyForeign}, + {VK_KHR_DEDICATED_ALLOCATION_EXTENSION_NAME, c.hasExt(VK_KHR_DEDICATED_ALLOCATION_EXTENSION_NAME)}, + }; + for (auto& e : probe) pr(" VK ext %-58s %s", e.name, e.present ? "YES" : "no"); + + pr("memory types (%u):", c.memProps.memoryTypeCount); + for (uint32_t i = 0; i < c.memProps.memoryTypeCount; ++i) { + const VkMemoryType& mt = c.memProps.memoryTypes[i]; + pr(" [%u] heap=%u size=%lluMiB flags=%s", i, mt.heapIndex, + (unsigned long long)(c.memProps.memoryHeaps[mt.heapIndex].size >> 20), memFlagStr(mt.propertyFlags)); + } + + // Only query handle types the driver actually claims: a handle type whose + // extension is absent is not required to be understood by this entry point. + reportExternalBufferCaps(c, VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT, "OPAQUE_FD"); + if (c.hasDmaBuf) reportExternalBufferCaps(c, VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT, "DMA_BUF"); + if (c.hasExtMemHost) + reportExternalBufferCaps(c, VK_EXTERNAL_MEMORY_HANDLE_TYPE_HOST_ALLOCATION_BIT_EXT, "HOST_ALLOCATION"); + if (c.hasAhb) + reportExternalBufferCaps(c, VK_EXTERNAL_MEMORY_HANDLE_TYPE_ANDROID_HARDWARE_BUFFER_BIT_ANDROID, + "AHARDWAREBUFFER"); + + if (!glOk) { + pr("gles: context unavailable, GL extension probe skipped"); + record("A-gles-context", "FAIL", "no headless EGL context"); + return; + } + pr("gles: vendor=%s renderer=%s version=%s", g.vendor.c_str(), g.renderer.c_str(), g.version.c_str()); + const char* glWanted[] = { + "GL_EXT_memory_object", "GL_EXT_memory_object_fd", "GL_EXT_external_buffer", + "GL_EXT_buffer_storage", "GL_OES_EGL_image", "GL_OES_EGL_image_external", + "GL_OES_EGL_image_external_essl3", "GL_EXT_memory_object_win32", + }; + for (const char* n : glWanted) pr(" GL ext %-40s %s", n, g.hasGl(n) ? "YES" : "no"); + const char* eglWanted[] = { + "EGL_ANDROID_get_native_client_buffer", "EGL_KHR_image_base", "EGL_ANDROID_image_native_buffer", + "EGL_EXT_image_dma_buf_import", "EGL_KHR_gl_texture_2D_image", + }; + for (const char* n : eglWanted) pr(" EGL ext %-40s %s", n, g.hasEgl(n) ? "YES" : "no"); + pr(" eglGetNativeClientBufferANDROID=%p glBufferStorageExternalEXT=%p", + (void*)g.pGetNativeClientBuffer, (void*)g.pBufferStorageExternal); +} + +// --------------------------------------------------------------------------- +// T1 parent +// --------------------------------------------------------------------------- + +static void runT1Parent(VkCtx& c, VkExternalMemoryHandleTypeFlagBits handleType, const char* routeName, + uint64_t size) { + if (!c.hasExtMemFd || !c.pGetMemoryFdKHR) { + record(routeName, "UNSUPPORTED", "VK_KHR_external_memory_fd absent"); + return; + } + if (handleType == VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT && !c.hasDmaBuf) { + record(routeName, "UNSUPPORTED", "VK_EXT_external_memory_dma_buf absent"); + return; + } + + // exportability report first -- a driver that says "not exportable" here and + // still returns an fd is a driver bug we want on the record. + VkPhysicalDeviceExternalBufferInfo ebi{}; + ebi.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_BUFFER_INFO; + ebi.usage = kProbeBufferUsage; + ebi.handleType = handleType; + VkExternalBufferProperties ebp{}; + ebp.sType = VK_STRUCTURE_TYPE_EXTERNAL_BUFFER_PROPERTIES; + vkGetPhysicalDeviceExternalBufferProperties(c.phys, &ebi, &ebp); + bool advertisedExportable = + (ebp.externalMemoryProperties.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_EXPORTABLE_BIT) != 0; + pr("T1[%s] advertisedExportable=%d importable=%d", routeName, (int)advertisedExportable, + (int)((ebp.externalMemoryProperties.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_IMPORTABLE_BIT) != 0)); + + VkExternalMemoryBufferCreateInfo ext{}; + ext.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_BUFFER_CREATE_INFO; + ext.handleTypes = handleType; + VkBufferCreateInfo bci{}; + bci.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bci.pNext = &ext; + bci.size = size; + bci.usage = kProbeBufferUsage; + bci.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + + VkBuffer buf = VK_NULL_HANDLE; + VkResult r = vkCreateBuffer(c.device, &bci, nullptr, &buf); + if (r != VK_SUCCESS) { + record(routeName, "FAIL", fmt("vkCreateBuffer(external)=%s", vkStr(r))); + return; + } + VkMemoryRequirements req{}; + vkGetBufferMemoryRequirements(c.device, buf, &req); + int typeIdx = pickMemType(c.memProps, req.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + if (typeIdx < 0) { + vkDestroyBuffer(c.device, buf, nullptr); + record(routeName, "FAIL", fmt("no HOST_VISIBLE|HOST_COHERENT type in bits=0x%x", req.memoryTypeBits)); + return; + } + pr("T1[%s] memReq size=%llu align=%llu typeBits=0x%x -> type %d", routeName, + (unsigned long long)req.size, (unsigned long long)req.alignment, req.memoryTypeBits, typeIdx); + + VkExportMemoryAllocateInfo exportInfo{}; + exportInfo.sType = VK_STRUCTURE_TYPE_EXPORT_MEMORY_ALLOCATE_INFO; + exportInfo.handleTypes = handleType; + VkMemoryDedicatedAllocateInfo dedicated{}; + dedicated.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO; + dedicated.buffer = buf; + bool needDedicated = + (ebp.externalMemoryProperties.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT) != 0; + if (needDedicated) exportInfo.pNext = &dedicated; + + VkMemoryAllocateInfo mai{}; + mai.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + mai.pNext = &exportInfo; + mai.allocationSize = req.size; + mai.memoryTypeIndex = (uint32_t)typeIdx; + + VkDeviceMemory mem = VK_NULL_HANDLE; + r = vkAllocateMemory(c.device, &mai, nullptr, &mem); + if (r != VK_SUCCESS) { + vkDestroyBuffer(c.device, buf, nullptr); + record(routeName, advertisedExportable ? "FAIL" : "UNSUPPORTED", + fmt("vkAllocateMemory(export)=%s (advertisedExportable=%d)", vkStr(r), (int)advertisedExportable)); + return; + } + r = vkBindBufferMemory(c.device, buf, mem, 0); + if (r != VK_SUCCESS) pr("T1[%s] vkBindBufferMemory=%s (continuing)", routeName, vkStr(r)); + + void* host = nullptr; + r = vkMapMemory(c.device, mem, 0, VK_WHOLE_SIZE, 0, &host); + if (r != VK_SUCCESS) { + vkFreeMemory(c.device, mem, nullptr); + vkDestroyBuffer(c.device, buf, nullptr); + record(routeName, "FAIL", fmt("server-side vkMapMemory=%s", vkStr(r))); + return; + } + const uint32_t seedA = 0xA5A50001u, seedB = 0xB0B00002u, seedC = 0xC0C00003u; + memset(host, 0, (size_t)size); + writeRegion(host, REG_A, seedA); + + VkMemoryGetFdInfoKHR gfi{}; + gfi.sType = VK_STRUCTURE_TYPE_MEMORY_GET_FD_INFO_KHR; + gfi.memory = mem; + gfi.handleType = handleType; + int fd = -1; + r = c.pGetMemoryFdKHR(c.device, &gfi, &fd); + if (r != VK_SUCCESS || fd < 0) { + vkUnmapMemory(c.device, mem); + vkFreeMemory(c.device, mem, nullptr); + vkDestroyBuffer(c.device, buf, nullptr); + record(routeName, "UNSUPPORTED", fmt("vkGetMemoryFdKHR=%s fd=%d (advertisedExportable=%d)", vkStr(r), fd, + (int)advertisedExportable)); + return; + } + pr("T1[%s] exported fd=%d -> %s", routeName, fd, describeFd(fd).c_str()); + + int sock = -1; + pid_t pid = spawnChild("t1", &sock); + if (pid < 0) { + close(fd); + vkUnmapMemory(c.device, mem); + vkFreeMemory(c.device, mem, nullptr); + vkDestroyBuffer(c.device, buf, nullptr); + record(routeName, "FAIL", "spawnChild failed"); + return; + } + + T1Offer offer{}; + offer.allocationSize = req.size; + offer.bufferSize = size; + offer.handleType = (uint32_t)handleType; + offer.seedA = seedA; + offer.seedB = seedB; + offer.seedC = seedC; + offer.memoryTypeIndex = (uint32_t)typeIdx; + offer.memoryTypeBits = req.memoryTypeBits; + + std::string detail; + const char* status = "FAIL"; + if (!sendMsg(sock, MSG_T1_OFFER, &offer, sizeof(offer), fd)) { + detail = fmt("sendMsg(offer) errno=%d", errno); + } else { + close(fd); + fd = -1; + uint32_t tag = 0; + T1Result res{}; + size_t got = 0; + if (!recvMsg(sock, &tag, &res, sizeof(res), &got, nullptr) || tag != MSG_T1_RESULT || + got != sizeof(res)) { + detail = fmt("no T1 result from child (errno=%d, %s)", errno, reapChild(pid).c_str()); + pid = -1; + } else { + // The child wrote REG_B (mmap) and REG_C (imported vkMapMemory); check + // that the writes are visible through the *server's* own mapping. + int64_t backB = res.wroteB ? checkRegion(host, REG_B, seedB) : -2; + int64_t backC = res.wroteC ? checkRegion(host, REG_C, seedC) : -2; + + detail = fmt( + "mmap=%s(errno=%d,cmp=%lld,payloadAt=%lld,back=%lld) vkimport=%s(fdProps=%s bits=0x%x bind=%s " + "map=%s cmp=%lld back=%lld) %s", + res.mmapOk ? "ok" : "fail", res.mmapErrno, (long long)res.mmapMismatch, + (long long)res.mmapPatternOffset, (long long)backB, + res.importResult == VK_SUCCESS ? "ok" : vkStr((VkResult)res.importResult), + vkStr((VkResult)res.fdPropsResult), res.fdMemoryTypeBits, vkStr((VkResult)res.bindResult), + vkStr((VkResult)res.mapResult), (long long)res.vkMismatch, (long long)backC, res.note); + + bool mmapPath = res.mmapOk && res.mmapMismatch == -1 && backB == -1; + bool vkPath = res.importResult == VK_SUCCESS && res.mapResult == VK_SUCCESS && res.vkMismatch == -1 && + backC == -1; + if (mmapPath && vkPath) { + status = "OK"; + } else if (mmapPath || vkPath) { + status = "PARTIAL"; + } else if (!res.mmapOk && res.importResult != VK_SUCCESS) { + status = "FAIL"; + } else { + status = "PARTIAL"; + } + } + } + if (pid > 0) { + sendMsg(sock, MSG_BYE, nullptr, 0, -1); + detail += " "; + detail += reapChild(pid); + } + close(sock); + if (fd >= 0) close(fd); + vkUnmapMemory(c.device, mem); + vkFreeMemory(c.device, mem, nullptr); + vkDestroyBuffer(c.device, buf, nullptr); + record(routeName, status, detail); +} + +// --------------------------------------------------------------------------- +// T1 child +// --------------------------------------------------------------------------- + +static int childT1(int sock) { + setRecvTimeout(sock, 30); + T1Offer offer{}; + uint32_t tag = 0; + size_t got = 0; + int fd = -1; + if (!recvMsg(sock, &tag, &offer, sizeof(offer), &got, &fd) || tag != MSG_T1_OFFER) { + pr("child: bad offer (errno=%d)", errno); + return 2; + } + T1Result res{}; + res.mmapMismatch = -3; + res.vkMismatch = -3; + res.gotFd = fd; + if (fd < 0) { + snprintf(res.note, sizeof(res.note), "no fd received over SCM_RIGHTS"); + sendMsg(sock, MSG_T1_RESULT, &res, sizeof(res), -1); + return 3; + } + pr("child: got fd=%d -> %s", fd, describeFd(fd).c_str()); + std::string note = describeFd(fd); + + // (1) plain mmap of the exported fd + size_t mappedLen = (size_t)offer.allocationSize; + void* p = mmap(nullptr, mappedLen, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (p == MAP_FAILED) { + res.mmapOk = 0; + res.mmapErrno = errno; + pr("child: mmap(MAP_SHARED) failed errno=%d (%s)", errno, strerror(errno)); + // second chance: some allocators only allow the buffer size, not the padded size + mappedLen = (size_t)offer.bufferSize; + p = mmap(nullptr, mappedLen, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (p != MAP_FAILED) { + res.mmapOk = 2; + note += " [mmap needed bufferSize not allocationSize]"; + } + } else { + res.mmapOk = 1; + } + // Read through the plain mapping, but do NOT write through it yet: if this + // mapping is offset-shifted relative to the driver's view of the same + // allocation, an early write here lands on top of the exporter's payload and + // poisons the Vulkan-import read below. Reads first, writes afterwards. + res.mmapPatternOffset = -1; + if (p != MAP_FAILED) { + res.mmapMismatch = checkRegion(p, REG_A, offer.seedA); + if (res.mmapMismatch != -1) { + // Locate the exporter's payload: an fd that maps at a fixed offset from + // the driver's base is still usable, but only if that offset is + // discoverable, which opaque-fd does not promise. Report it either way. + uint8_t want[64]; + fillPattern(want, sizeof(want), offer.seedA); + const uint8_t* hay = (const uint8_t*)p; + for (uint64_t off = 0; off + sizeof(want) <= mappedLen; ++off) { + if (!memcmp(hay + off, want, sizeof(want))) { + res.mmapPatternOffset = (int64_t)off; + break; + } + } + } + } + + // (2) import the same fd into a child-side VkDeviceMemory and map it + VkCtx c; + if (!vkCtxInit(c, false)) { + if (p != MAP_FAILED) { + writeRegion(p, REG_B, offer.seedB); + res.wroteB = 1; + msync(p, (size_t)mappedLen, MS_SYNC); + } + snprintf(res.note, sizeof(res.note), "%s | child vulkan init failed", note.c_str()); + sendMsg(sock, MSG_T1_RESULT, &res, sizeof(res), -1); + return 4; + } + res.vkInitOk = 1; + VkExternalMemoryHandleTypeFlagBits ht = (VkExternalMemoryHandleTypeFlagBits)offer.handleType; + + uint32_t fdTypeBits = 0xFFFFFFFFu; + if (c.pGetMemoryFdPropertiesKHR) { + VkMemoryFdPropertiesKHR fdProps{}; + fdProps.sType = VK_STRUCTURE_TYPE_MEMORY_FD_PROPERTIES_KHR; + VkResult fr = c.pGetMemoryFdPropertiesKHR(c.device, ht, fd, &fdProps); + res.fdPropsResult = (int32_t)fr; + res.fdMemoryTypeBits = fdProps.memoryTypeBits; + // OPAQUE_FD does not permit vkGetMemoryFdPropertiesKHR; only DMA_BUF does. + if (fr == VK_SUCCESS && ht == VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT) + fdTypeBits = fdProps.memoryTypeBits; + } else { + res.fdPropsResult = (int32_t)VK_ERROR_EXTENSION_NOT_PRESENT; + } + + VkExternalMemoryBufferCreateInfo ext{}; + ext.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_BUFFER_CREATE_INFO; + ext.handleTypes = ht; + VkBufferCreateInfo bci{}; + bci.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bci.pNext = &ext; + bci.size = offer.bufferSize; + bci.usage = kProbeBufferUsage; + VkBuffer buf = VK_NULL_HANDLE; + VkResult r = vkCreateBuffer(c.device, &bci, nullptr, &buf); + if (r != VK_SUCCESS) { + res.importResult = (int32_t)r; + if (p != MAP_FAILED) { + writeRegion(p, REG_B, offer.seedB); + res.wroteB = 1; + msync(p, (size_t)mappedLen, MS_SYNC); + } + snprintf(res.note, sizeof(res.note), "%s | child vkCreateBuffer=%s", note.c_str(), vkStr(r)); + sendMsg(sock, MSG_T1_RESULT, &res, sizeof(res), -1); + vkCtxDestroy(c); + return 5; + } + VkMemoryRequirements req{}; + vkGetBufferMemoryRequirements(c.device, buf, &req); + uint32_t bits = req.memoryTypeBits & fdTypeBits; + // For OPAQUE_FD the spec requires the importer to name the *same* memory type + // index the exporter allocated from; only DMA_BUF lets the importer choose + // from vkGetMemoryFdPropertiesKHR. + int typeIdx; + if (ht == VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT) { + typeIdx = (int)offer.memoryTypeIndex; + } else { + typeIdx = pickMemType(c.memProps, bits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + if (typeIdx < 0) typeIdx = (int)offer.memoryTypeIndex; // fall back to the exporter's choice + } + + // the import consumes the fd on success, so hand over a duplicate + int importFd = dup(fd); + VkImportMemoryFdInfoKHR imp{}; + imp.sType = VK_STRUCTURE_TYPE_IMPORT_MEMORY_FD_INFO_KHR; + imp.handleType = ht; + imp.fd = importFd; + VkMemoryDedicatedAllocateInfo dedicated{}; + dedicated.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO; + dedicated.buffer = buf; + imp.pNext = &dedicated; + + VkMemoryAllocateInfo mai{}; + mai.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + mai.pNext = &imp; + mai.allocationSize = offer.allocationSize; + mai.memoryTypeIndex = (uint32_t)typeIdx; + + VkDeviceMemory mem = VK_NULL_HANDLE; + r = vkAllocateMemory(c.device, &mai, nullptr, &mem); + res.importResult = (int32_t)r; + if (r != VK_SUCCESS) { + // retry without the dedicated-allocation chain -- some drivers reject it + close(importFd); + importFd = dup(fd); + imp.fd = importFd; + imp.pNext = nullptr; + r = vkAllocateMemory(c.device, &mai, nullptr, &mem); + if (r == VK_SUCCESS) { + note += " [import needed no dedicated info]"; + res.importResult = (int32_t)r; + } else { + close(importFd); + if (p != MAP_FAILED) { + writeRegion(p, REG_B, offer.seedB); + res.wroteB = 1; + msync(p, (size_t)mappedLen, MS_SYNC); + } + snprintf(res.note, sizeof(res.note), "%s | import=%s type=%d bits=0x%x", note.c_str(), vkStr(r), + typeIdx, bits); + vkDestroyBuffer(c.device, buf, nullptr); + sendMsg(sock, MSG_T1_RESULT, &res, sizeof(res), -1); + vkCtxDestroy(c); + return 0; + } + } + res.bindResult = (int32_t)vkBindBufferMemory(c.device, buf, mem, 0); + void* host = nullptr; + r = vkMapMemory(c.device, mem, 0, VK_WHOLE_SIZE, 0, &host); + res.mapResult = (int32_t)r; + if (r == VK_SUCCESS && host) { + res.vkMismatch = checkRegion(host, REG_A, offer.seedA); + writeRegion(host, REG_C, offer.seedC); + res.wroteC = 1; + vkUnmapMemory(c.device, mem); + } + // now that both mappings have been read, write through the plain one too + if (p != MAP_FAILED) { + writeRegion(p, REG_B, offer.seedB); + res.wroteB = 1; + msync(p, (size_t)mappedLen, MS_SYNC); + } + snprintf(res.note, sizeof(res.note), "%s | childType=%d bits=0x%x", note.c_str(), typeIdx, bits); + vkFreeMemory(c.device, mem, nullptr); + vkDestroyBuffer(c.device, buf, nullptr); + sendMsg(sock, MSG_T1_RESULT, &res, sizeof(res), -1); + vkCtxDestroy(c); + if (p != MAP_FAILED) munmap(p, mappedLen); + close(fd); + return 0; +} + +// --------------------------------------------------------------------------- +// T0: child allocates an AHardwareBuffer BLOB, parent imports it +// --------------------------------------------------------------------------- + +#if PROBE_HAVE_AHB + +static int childT0(int sock) { + setRecvTimeout(sock, 30); + T0Request rq{}; + uint32_t tag = 0; + size_t got = 0; + if (!recvMsg(sock, &tag, &rq, sizeof(rq), &got, nullptr) || tag != MSG_T0_REQUEST) { + pr("child: bad T0 request errno=%d", errno); + return 2; + } + AHardwareBuffer_Desc desc{}; + desc.width = (uint32_t)rq.size; + desc.height = 1; + desc.layers = 1; + desc.format = AHARDWAREBUFFER_FORMAT_BLOB; + desc.usage = AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN | AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN | + AHARDWAREBUFFER_USAGE_GPU_DATA_BUFFER; + AHardwareBuffer* ahb = nullptr; + int rc = AHardwareBuffer_allocate(&desc, &ahb); + T0Alloc alloc{}; + alloc.allocOk = (rc == 0 && ahb) ? 1 : 0; + alloc.allocErr = rc; + alloc.size = rq.size; + if (!alloc.allocOk) { + snprintf(alloc.note, sizeof(alloc.note), "AHardwareBuffer_allocate rc=%d errno=%d", rc, errno); + sendMsg(sock, MSG_T0_ALLOC, &alloc, sizeof(alloc), -1); + return 3; + } + AHardwareBuffer_Desc back{}; + AHardwareBuffer_describe(ahb, &back); + alloc.stride = back.stride; + snprintf(alloc.note, sizeof(alloc.note), "desc w=%u h=%u layers=%u fmt=0x%x usage=0x%llx stride=%u", back.width, + back.height, back.layers, back.format, (unsigned long long)back.usage, back.stride); + + void* p = nullptr; + rc = AHardwareBuffer_lock(ahb, AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN, -1, nullptr, &p); + if (rc != 0 || !p) { + alloc.allocOk = 2; + snprintf(alloc.note + strlen(alloc.note), sizeof(alloc.note) - strlen(alloc.note), " | lock rc=%d", rc); + sendMsg(sock, MSG_T0_ALLOC, &alloc, sizeof(alloc), -1); + return 4; + } + memset(p, 0, (size_t)rq.size); + writeRegion(p, REG_A, rq.seedA); + AHardwareBuffer_unlock(ahb, nullptr); + + if (!sendMsg(sock, MSG_T0_ALLOC, &alloc, sizeof(alloc), -1)) return 5; + int sendRc = AHardwareBuffer_sendHandleToUnixSocket(ahb, sock); + pr("child: AHardwareBuffer_sendHandleToUnixSocket rc=%d", sendRc); + if (sendRc != 0) return 6; + + T0Verify ver{}; + if (!recvMsg(sock, &tag, &ver, sizeof(ver), &got, nullptr) || tag != MSG_T0_VERIFY) { + pr("child: no T0 verify errno=%d", errno); + AHardwareBuffer_release(ahb); + return 7; + } + T0Result res{}; + res.mismatchB = res.mismatchC = res.mismatchD = -2; + void* q = nullptr; + rc = AHardwareBuffer_lock(ahb, AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN, -1, nullptr, &q); + res.lockOk = (rc == 0 && q) ? 1 : 0; + res.lockErr = rc; + if (res.lockOk) { + if (ver.writtenMask & 1) res.mismatchB = checkRegion(q, REG_B, ver.seedB); + if (ver.writtenMask & 2) res.mismatchC = checkRegion(q, REG_C, ver.seedC); + if (ver.writtenMask & 4) res.mismatchD = checkRegion(q, REG_D, ver.seedD); + AHardwareBuffer_unlock(ahb, nullptr); + } + snprintf(res.note, sizeof(res.note), "mask=0x%x", ver.writtenMask); + sendMsg(sock, MSG_T0_RESULT, &res, sizeof(res), -1); + AHardwareBuffer_release(ahb); + return 0; +} + +static void runT0Parent(VkCtx& c, GlCtx& g, bool glOk, uint64_t size) { + const uint32_t seedA = 0x0A0A0011u, seedB = 0x0B0B0022u, seedC = 0x0C0C0033u, seedD = 0x0D0D0044u; + + int sock = -1; + pid_t pid = spawnChild("t0", &sock); + if (pid < 0) { + record("T0-ahb-blob-transfer", "FAIL", "spawnChild failed"); + return; + } + T0Request rq{}; + rq.size = size; + rq.seedA = seedA; + if (!sendMsg(sock, MSG_T0_REQUEST, &rq, sizeof(rq), -1)) { + record("T0-ahb-blob-transfer", "FAIL", fmt("sendMsg errno=%d", errno)); + close(sock); + reapChild(pid); + return; + } + T0Alloc alloc{}; + uint32_t tag = 0; + size_t got = 0; + if (!recvMsg(sock, &tag, &alloc, sizeof(alloc), &got, nullptr) || tag != MSG_T0_ALLOC) { + record("T0-ahb-blob-transfer", "FAIL", fmt("no alloc reply errno=%d %s", errno, reapChild(pid).c_str())); + close(sock); + return; + } + if (alloc.allocOk != 1) { + record("T0-ahb-blob-transfer", "FAIL", fmt("child alloc failed rc=%d %s", alloc.allocErr, alloc.note)); + close(sock); + reapChild(pid); + return; + } + pr("T0 child allocated: %s", alloc.note); + + AHardwareBuffer* ahb = nullptr; + int rc = AHardwareBuffer_recvHandleFromUnixSocket(sock, &ahb); + if (rc != 0 || !ahb) { + record("T0-ahb-blob-transfer", "FAIL", fmt("recvHandleFromUnixSocket rc=%d errno=%d", rc, errno)); + close(sock); + reapChild(pid); + return; + } + AHardwareBuffer_Desc desc{}; + AHardwareBuffer_describe(ahb, &desc); + pr("T0 parent received AHB: w=%u h=%u fmt=0x%x usage=0x%llx stride=%u", desc.width, desc.height, desc.format, + (unsigned long long)desc.usage, desc.stride); + record("T0-ahb-blob-transfer", "OK", fmt("socket handoff of a %llu-byte BLOB works (%s)", + (unsigned long long)size, alloc.note)); + + // (a) CPU path: AHardwareBuffer_lock on the receiving side + uint32_t writtenMask = 0; + { + void* p = nullptr; + rc = AHardwareBuffer_lock(ahb, AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN | AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN, + -1, nullptr, &p); + if (rc != 0 || !p) { + record("T0-ahb-cpu-lock", "FAIL", fmt("AHardwareBuffer_lock rc=%d errno=%d", rc, errno)); + } else { + int64_t cmp = checkRegion(p, REG_A, seedA); + writeRegion(p, REG_D, seedD); + writtenMask |= 4; + AHardwareBuffer_unlock(ahb, nullptr); + record("T0-ahb-cpu-lock", cmp == -1 ? "OK" : "FAIL", + fmt("cross-process CPU read of the child's payload, mismatch=%lld", (long long)cmp)); + } + } + + // (b) Vulkan import + if (!c.hasAhb || !c.pGetAhbProps) { + record("T0-ahb-vulkan-import", "UNSUPPORTED", + "VK_ANDROID_external_memory_android_hardware_buffer absent"); + } else { + VkAndroidHardwareBufferPropertiesANDROID props{}; + props.sType = VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_PROPERTIES_ANDROID; + VkResult r = c.pGetAhbProps(c.device, ahb, &props); + if (r != VK_SUCCESS) { + record("T0-ahb-vulkan-import", "FAIL", fmt("vkGetAndroidHardwareBufferPropertiesANDROID=%s", vkStr(r))); + } else { + pr("T0 AHB props: allocationSize=%llu memoryTypeBits=0x%x", (unsigned long long)props.allocationSize, + props.memoryTypeBits); + VkExternalMemoryBufferCreateInfo ext{}; + ext.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_BUFFER_CREATE_INFO; + ext.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_ANDROID_HARDWARE_BUFFER_BIT_ANDROID; + VkBufferCreateInfo bci{}; + bci.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bci.pNext = &ext; + bci.size = size; + bci.usage = kProbeBufferUsage; + VkBuffer buf = VK_NULL_HANDLE; + r = vkCreateBuffer(c.device, &bci, nullptr, &buf); + if (r != VK_SUCCESS) { + record("T0-ahb-vulkan-import", "FAIL", fmt("vkCreateBuffer(AHB external)=%s", vkStr(r))); + } else { + int typeIdx = pickMemType(c.memProps, props.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + bool hostVisible = typeIdx >= 0; + if (typeIdx < 0) typeIdx = pickMemType(c.memProps, props.memoryTypeBits, 0); + VkImportAndroidHardwareBufferInfoANDROID imp{}; + imp.sType = VK_STRUCTURE_TYPE_IMPORT_ANDROID_HARDWARE_BUFFER_INFO_ANDROID; + imp.buffer = ahb; + VkMemoryDedicatedAllocateInfo ded{}; + ded.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO; + ded.buffer = buf; + imp.pNext = &ded; + VkMemoryAllocateInfo mai{}; + mai.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + mai.pNext = &imp; + mai.allocationSize = props.allocationSize; + mai.memoryTypeIndex = (uint32_t)(typeIdx < 0 ? 0 : typeIdx); + VkDeviceMemory mem = VK_NULL_HANDLE; + r = vkAllocateMemory(c.device, &mai, nullptr, &mem); + if (r != VK_SUCCESS) { + record("T0-ahb-vulkan-import", "FAIL", + fmt("vkAllocateMemory(import AHB)=%s typeIdx=%d bits=0x%x", vkStr(r), typeIdx, + props.memoryTypeBits)); + } else { + VkResult br = vkBindBufferMemory(c.device, buf, mem, 0); + void* host = nullptr; + VkResult mr = vkMapMemory(c.device, mem, 0, VK_WHOLE_SIZE, 0, &host); + if (mr == VK_SUCCESS && host) { + int64_t cmp = checkRegion(host, REG_A, seedA); + writeRegion(host, REG_B, seedB); + writtenMask |= 1; + vkUnmapMemory(c.device, mem); + record("T0-ahb-vulkan-import", cmp == -1 ? "OK" : "PARTIAL", + fmt("imported+mapped (hostVisibleType=%d bind=%s) payload mismatch=%lld", + (int)hostVisible, vkStr(br), (long long)cmp)); + } else { + record("T0-ahb-vulkan-import", "PARTIAL", + fmt("import ok, vkMapMemory=%s (bind=%s hostVisibleType=%d bits=0x%x)", vkStr(mr), + vkStr(br), (int)hostVisible, props.memoryTypeBits)); + } + vkFreeMemory(c.device, mem, nullptr); + } + vkDestroyBuffer(c.device, buf, nullptr); + } + } + } + + // (c) GL import through EGL_ANDROID_get_native_client_buffer + EXT_external_buffer + if (!glOk) { + record("T0-ahb-gl-import", "SKIP", "no GL context"); + } else if (!g.hasGl("GL_EXT_external_buffer") || !g.pBufferStorageExternal || !g.pGetNativeClientBuffer) { + record("T0-ahb-gl-import", "UNSUPPORTED", + fmt("GL_EXT_external_buffer=%d GL_EXT_buffer_storage=%d eglGetNativeClientBufferANDROID=%d " + "glBufferStorageExternalEXT=%d", + (int)g.hasGl("GL_EXT_external_buffer"), (int)g.hasGl("GL_EXT_buffer_storage"), + (int)(g.pGetNativeClientBuffer != nullptr), (int)(g.pBufferStorageExternal != nullptr))); + } else { + EGLClientBuffer cb = g.pGetNativeClientBuffer(ahb); + if (!cb) { + record("T0-ahb-gl-import", "FAIL", fmt("eglGetNativeClientBufferANDROID=NULL egl=0x%04x", eglGetError())); + } else { + GLuint b = 0; + glGenBuffers(1, &b); + glBindBuffer(GL_ARRAY_BUFFER, b); + while (glGetError() != GL_NO_ERROR) {} + g.pBufferStorageExternal(GL_ARRAY_BUFFER, 0, (GLsizeiptr)size, cb, + GL_MAP_READ_BIT | GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT_EXT | + GL_MAP_COHERENT_BIT_EXT | GL_DYNAMIC_STORAGE_BIT_EXT); + GLenum err = glGetError(); + if (err != GL_NO_ERROR) { + record("T0-ahb-gl-import", "FAIL", fmt("glBufferStorageExternalEXT -> GL error 0x%04x", err)); + } else { + void* m = glMapBufferRange(GL_ARRAY_BUFFER, 0, (GLsizeiptr)size, + GL_MAP_READ_BIT | GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT_EXT | + GL_MAP_COHERENT_BIT_EXT); + GLenum merr = glGetError(); + if (!m) { + record("T0-ahb-gl-import", "PARTIAL", + fmt("storage ok, glMapBufferRange returned NULL (GL error 0x%04x)", merr)); + } else { + int64_t cmp = checkRegion(m, REG_A, seedA); + writeRegion(m, REG_C, seedC); + writtenMask |= 2; + glUnmapBuffer(GL_ARRAY_BUFFER); + glFinish(); + record("T0-ahb-gl-import", cmp == -1 ? "OK" : "PARTIAL", + fmt("persistent-coherent GL map of the client AHB, payload mismatch=%lld", + (long long)cmp)); + } + } + glBindBuffer(GL_ARRAY_BUFFER, 0); + glDeleteBuffers(1, &b); + } + } + + // (d) ask the child to verify everything the parent wrote + T0Verify ver{}; + ver.seedB = seedB; + ver.seedC = seedC; + ver.seedD = seedD; + ver.writtenMask = writtenMask; + std::string wbDetail; + const char* wbStatus = "FAIL"; + if (!sendMsg(sock, MSG_T0_VERIFY, &ver, sizeof(ver), -1)) { + wbDetail = fmt("sendMsg(verify) errno=%d", errno); + } else { + T0Result res{}; + if (!recvMsg(sock, &tag, &res, sizeof(res), &got, nullptr) || tag != MSG_T0_RESULT) { + wbDetail = fmt("no verify reply errno=%d", errno); + } else { + bool anyChecked = false, allOk = true; + auto acc = [&](int64_t v) { + if (v == -2) return; + anyChecked = true; + if (v != -1) allOk = false; + }; + acc(res.mismatchB); + acc(res.mismatchC); + acc(res.mismatchD); + wbStatus = !anyChecked ? "SKIP" : (allOk ? "OK" : "FAIL"); + wbDetail = fmt("mask=0x%x vkWrite=%lld glWrite=%lld cpuWrite=%lld (lock=%d)", writtenMask, + (long long)res.mismatchB, (long long)res.mismatchC, (long long)res.mismatchD, + res.lockOk); + } + } + record("T0-ahb-writeback-to-client", wbStatus, wbDetail); + + sendMsg(sock, MSG_BYE, nullptr, 0, -1); + std::string reap = reapChild(pid); + pr("T0 %s", reap.c_str()); + AHardwareBuffer_release(ahb); + close(sock); +} + +#else // !PROBE_HAVE_AHB + +static int childT0(int) { + pr("T0 is Android-only"); + return 1; +} +static void runT0Parent(VkCtx&, GlCtx&, bool, uint64_t) { + record("T0-ahb-blob-transfer", "SKIP", "AHardwareBuffer is Android-only; host build cannot run T0"); +} + +#endif // PROBE_HAVE_AHB + +// --------------------------------------------------------------------------- +// T3: VK_EXT_external_memory_host over a memfd-backed mapping +// --------------------------------------------------------------------------- + +static int childT3(int sock) { + setRecvTimeout(sock, 30); + T3Offer offer{}; + uint32_t tag = 0; + size_t got = 0; + int fd = -1; + if (!recvMsg(sock, &tag, &offer, sizeof(offer), &got, &fd) || tag != MSG_T3_OFFER) return 2; + T3Result res{}; + res.mismatch = -3; + if (fd < 0) { + snprintf(res.note, sizeof(res.note), "no fd"); + sendMsg(sock, MSG_T3_RESULT, &res, sizeof(res), -1); + return 3; + } + snprintf(res.note, sizeof(res.note), "%s", describeFd(fd).c_str()); + void* p = mmap(nullptr, (size_t)offer.size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (p == MAP_FAILED) { + res.mmapOk = 0; + res.mmapErrno = errno; + } else { + res.mmapOk = 1; + res.mismatch = checkRegion(p, REG_A, offer.seedA); + writeRegion(p, REG_B, offer.seedB); + munmap(p, (size_t)offer.size); + } + sendMsg(sock, MSG_T3_RESULT, &res, sizeof(res), -1); + close(fd); + return 0; +} + +static void runT3Parent(VkCtx& c, uint64_t size) { + if (!c.hasExtMemHost || !c.pGetHostPtrProps) { + record("T3-external-memory-host", "UNSUPPORTED", "VK_EXT_external_memory_host absent"); + return; + } + uint64_t align = c.minImportedHostPointerAlignment ? c.minImportedHostPointerAlignment : 4096; + uint64_t mapSize = (size + align - 1) & ~(align - 1); + + int memfd = memfd_create("extmem_probe", 0); + if (memfd < 0) { + record("T3-external-memory-host", "FAIL", fmt("memfd_create errno=%d", errno)); + return; + } + if (ftruncate(memfd, (off_t)mapSize) != 0) { + record("T3-external-memory-host", "FAIL", fmt("ftruncate errno=%d", errno)); + close(memfd); + return; + } + // reserve an aligned window, then place the memfd inside it + void* reserve = mmap(nullptr, (size_t)(mapSize + align), PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reserve == MAP_FAILED) { + record("T3-external-memory-host", "FAIL", fmt("reserve mmap errno=%d", errno)); + close(memfd); + return; + } + uintptr_t base = ((uintptr_t)reserve + align - 1) & ~(uintptr_t)(align - 1); + void* host = mmap((void*)base, (size_t)mapSize, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_FIXED, memfd, 0); + if (host == MAP_FAILED) { + record("T3-external-memory-host", "FAIL", fmt("mmap(memfd, MAP_FIXED) errno=%d", errno)); + munmap(reserve, (size_t)(mapSize + align)); + close(memfd); + return; + } + const uint32_t seedA = 0x33330001u, seedB = 0x33330002u; + memset(host, 0, (size_t)mapSize); + writeRegion(host, REG_A, seedA); + + VkMemoryHostPointerPropertiesEXT hp{}; + hp.sType = VK_STRUCTURE_TYPE_MEMORY_HOST_POINTER_PROPERTIES_EXT; + VkResult r = c.pGetHostPtrProps(c.device, VK_EXTERNAL_MEMORY_HANDLE_TYPE_HOST_ALLOCATION_BIT_EXT, host, &hp); + if (r != VK_SUCCESS) { + record("T3-external-memory-host", "FAIL", fmt("vkGetMemoryHostPointerPropertiesEXT=%s align=%llu", vkStr(r), + (unsigned long long)align)); + } else { + VkExternalMemoryBufferCreateInfo ext{}; + ext.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_BUFFER_CREATE_INFO; + ext.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_HOST_ALLOCATION_BIT_EXT; + VkBufferCreateInfo bci{}; + bci.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bci.pNext = &ext; + bci.size = mapSize; + bci.usage = kProbeBufferUsage; + VkBuffer buf = VK_NULL_HANDLE; + VkResult cr = vkCreateBuffer(c.device, &bci, nullptr, &buf); + VkMemoryRequirements req{}; + if (cr == VK_SUCCESS) vkGetBufferMemoryRequirements(c.device, buf, &req); + uint32_t bits = hp.memoryTypeBits & (cr == VK_SUCCESS ? req.memoryTypeBits : 0xFFFFFFFFu); + int typeIdx = pickMemType(c.memProps, bits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + if (typeIdx < 0) typeIdx = pickMemType(c.memProps, bits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT); + if (typeIdx < 0) { + record("T3-external-memory-host", "FAIL", + fmt("no host-visible type in hostPtrBits=0x%x & reqBits=0x%x", hp.memoryTypeBits, + req.memoryTypeBits)); + } else { + VkImportMemoryHostPointerInfoEXT imp{}; + imp.sType = VK_STRUCTURE_TYPE_IMPORT_MEMORY_HOST_POINTER_INFO_EXT; + imp.handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_HOST_ALLOCATION_BIT_EXT; + imp.pHostPointer = host; + VkMemoryAllocateInfo mai{}; + mai.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + mai.pNext = &imp; + mai.allocationSize = mapSize; + mai.memoryTypeIndex = (uint32_t)typeIdx; + VkDeviceMemory mem = VK_NULL_HANDLE; + VkResult ar = vkAllocateMemory(c.device, &mai, nullptr, &mem); + if (ar != VK_SUCCESS) { + record("T3-external-memory-host", "FAIL", + fmt("vkAllocateMemory(import host ptr)=%s type=%d bits=0x%x align=%llu", vkStr(ar), typeIdx, + bits, (unsigned long long)align)); + } else { + VkResult br = (cr == VK_SUCCESS) ? vkBindBufferMemory(c.device, buf, mem, 0) : VK_SUCCESS; + void* mapped = nullptr; + VkResult mr = vkMapMemory(c.device, mem, 0, VK_WHOLE_SIZE, 0, &mapped); + int64_t cmp = -3; + if (mr == VK_SUCCESS && mapped) cmp = checkRegion(mapped, REG_A, seedA); + if (mr == VK_SUCCESS) vkUnmapMemory(c.device, mem); + record("T3-external-memory-host", (mr == VK_SUCCESS && cmp == -1) ? "OK" : "PARTIAL", + fmt("import ok (align=%llu type=%d bind=%s) vkMapMemory=%s mismatch=%lld", + (unsigned long long)align, typeIdx, vkStr(br), vkStr(mr), (long long)cmp)); + vkFreeMemory(c.device, mem, nullptr); + } + } + if (cr == VK_SUCCESS) vkDestroyBuffer(c.device, buf, nullptr); + } + + // the same memfd handed to another process + int sock = -1; + pid_t pid = spawnChild("t3", &sock); + if (pid < 0) { + record("T3-memfd-cross-process", "FAIL", "spawnChild failed"); + } else { + T3Offer off{}; + off.size = mapSize; + off.seedA = seedA; + off.seedB = seedB; + if (!sendMsg(sock, MSG_T3_OFFER, &off, sizeof(off), memfd)) { + record("T3-memfd-cross-process", "FAIL", fmt("sendMsg errno=%d", errno)); + } else { + T3Result res{}; + uint32_t tag = 0; + size_t got = 0; + if (!recvMsg(sock, &tag, &res, sizeof(res), &got, nullptr) || tag != MSG_T3_RESULT) { + record("T3-memfd-cross-process", "FAIL", fmt("no reply errno=%d", errno)); + } else { + int64_t back = res.mmapOk ? checkRegion(host, REG_B, seedB) : -3; + record("T3-memfd-cross-process", (res.mmapOk && res.mismatch == -1 && back == -1) ? "OK" : "FAIL", + fmt("child mmap=%d errno=%d cmp=%lld writeback=%lld [%s]", res.mmapOk, res.mmapErrno, + (long long)res.mismatch, (long long)back, res.note)); + } + } + sendMsg(sock, MSG_BYE, nullptr, 0, -1); + reapChild(pid); + close(sock); + } + + munmap(host, (size_t)mapSize); + munmap(reserve, (size_t)(mapSize + align)); + close(memfd); +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +static void printSummary() { + char model[PROP_VALUE_MAX] = {0}; + getProp("ro.product.model", model, sizeof(model)); + printf("\n=== extmem_probe summary (model=%s) ===\n", model); + printf("%-34s %-12s %s\n", "ROUTE", "STATUS", "DETAIL"); + for (const RouteResult& r : gResults) + printf("%-34s %-12s %s\n", r.route.c_str(), r.status.c_str(), r.detail.c_str()); + printf("=== end ===\n"); + fflush(stdout); +} + +int main(int argc, char** argv) { + uint64_t size = kDefaultSize; + const char* childRoute = nullptr; + bool doT1 = true, doT0 = true, doT3 = true; + for (int i = 1; i < argc; ++i) { + if (!strncmp(argv[i], "--child=", 8)) { + childRoute = argv[i] + 8; + } else if (!strncmp(argv[i], "--size=", 7)) { + size = strtoull(argv[i] + 7, nullptr, 0); + } else if (!strcmp(argv[i], "--only-t1")) { + doT0 = doT3 = false; + } else if (!strcmp(argv[i], "--only-t0")) { + doT1 = doT3 = false; + } else if (!strcmp(argv[i], "--only-t3")) { + doT1 = doT0 = false; + } else if (!strcmp(argv[i], "--help")) { + printf("usage: extmem_probe [--size=BYTES] [--only-t0|--only-t1|--only-t3]\n"); + return 0; + } + } + if (size < 4 * kRegion) size = 4 * kRegion; + + // A peer that has already exited must not take this process down with it. + signal(SIGPIPE, SIG_IGN); + + if (childRoute) { + static char roleBuf[32]; + snprintf(roleBuf, sizeof(roleBuf), "child:%s", childRoute); + gRole = roleBuf; + int sock = 3; + if (!strcmp(childRoute, "t1")) return childT1(sock); + if (!strcmp(childRoute, "t0")) return childT0(sock); + if (!strcmp(childRoute, "t3")) return childT3(sock); + pr("unknown child route %s", childRoute); + return 1; + } + + pr("extmem_probe: MobileGL disaggregation spike B, size=%llu bytes", (unsigned long long)size); + + VkCtx c; + bool vkOk = vkCtxInit(c, true); + GlCtx g; + bool glOk = glCtxInit(g); + + if (!vkOk) { + record("vulkan-init", "FAIL", "no usable Vulkan device"); + printSummary(); + return 1; + } + phaseEnumerate(c, g, glOk); + + pr("=== phase T1: server-exported allocation (opaque fd / dma-buf) ==="); + if (doT1) { + runT1Parent(c, VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT, "T1-opaque-fd", size); + runT1Parent(c, VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT, "T1-dma-buf", size); + } + pr("=== phase T0: client-allocated AHardwareBuffer BLOB ==="); + if (doT0) runT0Parent(c, g, glOk, size); + pr("=== phase T3: VK_EXT_external_memory_host ==="); + if (doT3) runT3Parent(c, size); + + glCtxDestroy(g); + vkCtxDestroy(c); + printSummary(); + return 0; +} From 7ef7c7e54381af79f8d9e6635d883b9a03616ae6 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 20:45:21 -0400 Subject: [PATCH 024/529] [Fix] (Spikes): answer the tier question for DirectGLES too, make an OK mean bytes round-tripped through a real GPU access, and exercise T3 in the direction that makes it a tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plan-B §8.3 asks which tier `AcquirePersistentMap` lands in, but the probe only asked Vulkan. DirectGLES ("Espryt") reaches a persistent map through `glBufferStorageEXT` + `glMapBufferRange(PERSISTENT|COHERENT)`, not a `VkDeviceMemory` map, so a Vulkan-only answer decides nothing for that backend. Add a GLES leg to T1: the exported fd imported with `glCreateMemoryObjectsEXT` + `glImportMemoryFdEXT` + `glBufferStorageMemEXT`, then mapped PERSISTENT|COHERENT -- in-process first (isolates "GL can import this fd" from "the fd survives a process boundary"), then cross-process (new `t1gl` child). Drivers disagree about how the import must be phrased, so each attempt walks a ladder over {dedicated flag} x {import size = memory requirement or the fd's own size} x {buffer size} and reports the rung the driver accepted plus every rejected rung with its GL error -- a driver *preference* must never be reported as a missing capability. A driver that backs the storage but refuses PERSISTENT|COHERENT is reported separately from one that refuses the storage: that distinction is exactly T1 vs T2 for DirectGLES. The T0 GLES leg (`EGL_ANDROID_get_native_client_buffer` + `glBufferStorageExternalEXT` + persistent map, verified by `AHardwareBuffer_lock` on the client side) now reports every step's GL enum and requires the persistent flags for OK. - the verdict was unfalsifiable: T1 reported PARTIAL when neither leg had moved a byte. Replace it with an explicit decisive-leg model -- OK only when every decisive leg round-tripped in both directions, PARTIAL when at least one did, FAIL otherwise with the failing step and its driver error named in `why:`. Every row now opens with a per-leg trace (`vkimport[D]=rt gpu[D]=rt`). The raw `mmap` leg is informational for opaque-fd (Vulkan forbids interpreting that payload outside the driver, so a refusal is conformant) and decisive for dma-buf, where a CPU mapping is the point of the handle type. - T3 never ran the direction that would make it a tier: both ends were the importing process. Add `T3-client-memfd-server-import` (new `t3c` child) -- the client creates and writes the memfd, the server mmaps the received fd, imports the client's host pointer into a `VkDeviceMemory`, reads what the client wrote, writes back, and takes a GPU access on the client's memory, which the client then verifies through its own mapping. - no route touched the GPU, so an OK proved only that a map call returned a pointer. Every tier row now takes a real GPU access before it can be OK: `vkCmdCopyBuffer` out of the shared allocation into private staging (mismatch = the GPU could not read what the peer wrote) plus `vkCmdFillBuffer` into it, queue-idle and an explicit host-read barrier, with the peer checking the filled region through its own mapping. VkCtx grows a queue and command pool for it. - the device run executes in the `shell` SELinux domain, not the `untrusted_app` domain MobileGL runs in, and the two do not share dmabuf/gralloc rules. Print uid/pid/`/proc/self/attr/current` in a run-context header, repeat the caveat in the summary, and document in README.md how to answer it for the real domain later (exec the same binary from the trace app's spike hook, spike-A package) without implementing that here. - `vkStr()` returned a pointer into one static buffer while several results routinely appear in one format call, so all of them showed the last one; it returns std::string now, `memFlagStr` likewise, and `fmt`/`pr` carry `format(printf)` so a missed `.c_str()` is a compile error rather than UB. - `advertisedExportable` decided the status at the allocate site but not at the `vkGetMemoryFdKHR` site. One rule at every export failure now (`exportFailStatus`): advertised EXPORTABLE and then declining is FAIL, never advertised is UNSUPPORTED. Export + map + fd is factored into `exportHostVisible`. - `T0-ahb-blob-transfer` was recorded OK on the socket handoff alone. The handoff keeps its own informational row; the tier row is now composed at the end from the full import+map+compare+writeback chain over the Vulkan, GL and GPU legs. - `mmapErrno` kept the first attempt's errno after the second-chance mmap succeeded, so a working mapping carried a failure code; it is cleared on success and the first errno moves into the note. - a failed `glImportMemoryFdEXT` no longer closes the fd: EXT_memory_object_fd does not say whether ownership still transfers on failure and Mesa closes it either way, so closing risks a double close landing on the socket. Leaking a handful of dups in a short-lived probe is the safe side of that trade. - validated end to end on the host harness (lavapipe + llvmpipe, `VK_DRIVER_FILES=lvp_icd.json EGL_PLATFORM=surfaceless`): T1-opaque-fd OK, T3-external-memory-host OK, T3-memfd-cross-process OK, T3-client-memfd-server-import OK, T1-dma-buf UNSUPPORTED (not advertised exportable). The two T1-gles rows FAIL there with GL_OUT_OF_MEMORY on every ladder rung although GL_DEVICE_UUID_EXT matches the Vulkan deviceUUID -- llvmpipe's GL does not implement importing a lavapipe opaque-fd allocation, a Mesa interop gap recorded in README.md so a device FAIL stays attributable. Rebuilt for arm64-v8a with NDK r27d (PIE, android-30); the device run is pending, both device locks are held by another campaign. --- tools/spikes/extmem_probe/README.md | 162 +- tools/spikes/extmem_probe/extmem_probe.cpp | 2122 ++++++++++++++++---- 2 files changed, 1883 insertions(+), 401 deletions(-) diff --git a/tools/spikes/extmem_probe/README.md b/tools/spikes/extmem_probe/README.md index a9a6b74fb..5017ba98c 100644 --- a/tools/spikes/extmem_probe/README.md +++ b/tools/spikes/extmem_probe/README.md @@ -2,28 +2,51 @@ A standalone Android command-line probe that answers one question per device: -> Can a server-allocated `HOST_VISIBLE|HOST_COHERENT` `VkDeviceMemory` be shared -> with another process and mapped there, and by which route? +> Can the memory behind `AcquirePersistentMap` be shared with another process +> and mapped there — for **both** backends — and by which route? This is the P0 spike that decides the `AcquirePersistentMap` tier in plan B §8.3 (T0 = server imports a client allocation, T1 = server exports its own, T2 = give up and return `nullptr`). It links nothing from MobileGL and is not part of the project's CMake build graph. +Both backends are asked, because they reach a persistent map by different APIs: +DirectVulkan ("Magma") maps a `VkDeviceMemory`, while DirectGLES ("Espryt") +calls `glBufferStorageEXT` + `glMapBufferRange(PERSISTENT|COHERENT)`. A Vulkan +answer alone does not decide the tier for DirectGLES, so every tier has a GLES +leg. + ## What it does -* **phase A — enumeration.** Vulkan device identity + memory types, and per - handle type (`OPAQUE_FD`, `DMA_BUF`, `HOST_ALLOCATION`, `AHARDWAREBUFFER`) the +* **phase A — enumeration.** Run context (uid, pid, SELinux domain — see the + caveat below), Vulkan device identity + memory types, and per handle type + (`OPAQUE_FD`, `DMA_BUF`, `HOST_ALLOCATION`, `AHARDWAREBUFFER`) the `vkGetPhysicalDeviceExternalBufferProperties` verdict for the buffer usage MobileGL actually needs. Then a headless EGL pbuffer context reports `GL_EXT_memory_object{,_fd}`, `GL_EXT_external_buffer`, `GL_EXT_buffer_storage`, - `GL_OES_EGL_image_external{,_essl3}` and `EGL_ANDROID_get_native_client_buffer`. -* **T1 — server exports.** Allocates a `HOST_VISIBLE|HOST_COHERENT` buffer memory - with `VkExportMemoryAllocateInfo`, maps it, writes a pattern, exports an fd with - `vkGetMemoryFdKHR` (opaque-fd, then dma-buf), hands the fd to a second process - over `SCM_RIGHTS`, and has that process (a) `mmap()` the fd and (b) import it - into its own `VkDeviceMemory` and `vkMapMemory` it. Both sides write and both - sides compare, so a one-directional or copy-on-import mapping is caught. + `GL_OES_EGL_image_external{,_essl3}`, `EGL_ANDROID_get_native_client_buffer`, + and `GL_DEVICE_UUID_EXT` against the Vulkan `deviceUUID` (they must match for + an fd import to be legal, so a mismatch explains a later decline). +* **T1 — server exports (Vulkan).** Allocates a `HOST_VISIBLE|HOST_COHERENT` + buffer memory with `VkExportMemoryAllocateInfo`, maps it, writes a pattern, + takes a **GPU access** on it (below), exports an fd with `vkGetMemoryFdKHR` + (opaque-fd, then dma-buf), hands the fd to a second process over `SCM_RIGHTS`, + and has that process (a) `mmap()` the fd and (b) import it into its own + `VkDeviceMemory` and `vkMapMemory` it. Both sides write and both sides + compare, so a one-directional or copy-on-import mapping is caught. +* **T1-gles — server exports (GLES).** The same exported fd, imported as GL + buffer storage: `glCreateMemoryObjectsEXT` + `glImportMemoryFdEXT` + + `glBufferStorageMemEXT`, then `glMapBufferRange(PERSISTENT|COHERENT)` — first + **in-process** (isolates "GL can import this fd at all" from "the fd survives + a process boundary"), then **cross-process**. Because drivers disagree about + how the import must be phrased, each attempt walks a ladder over + {dedicated flag} × {import size = `VkMemoryRequirements::size` or the fd's own + size} × {buffer size}, and the report names the rung the driver accepted + (`accepted=…`) plus every rung it rejected with its GL error (`ladder: …`), so + a driver *preference* is never reported as a missing capability. A driver that + backs the storage but refuses `PERSISTENT|COHERENT` is reported separately + from one that refuses the storage — that distinction is exactly T1 vs T2 for + DirectGLES. * **T0 — server imports.** The second process allocates an `AHardwareBuffer` BLOB (`CPU_READ_OFTEN|CPU_WRITE_OFTEN|GPU_DATA_BUFFER`), writes a pattern under `AHardwareBuffer_lock`, and sends it with @@ -31,24 +54,99 @@ project's CMake build graph. ways — CPU lock, `VkDeviceMemory` imported through `VK_ANDROID_external_memory_android_hardware_buffer`, and a GL buffer created with `eglGetNativeClientBufferANDROID` + `glBufferStorageExternalEXT` mapped - persistent/coherent — writes through each, and the allocating process verifies - every write. + persistent/coherent (the DirectGLES form of T0) — takes a GPU access, writes + through each, and the allocating process verifies every write with + `AHardwareBuffer_lock`. * **T3 — host pointer import.** If `VK_EXT_external_memory_host` is advertised, - imports a memfd-backed, alignment-corrected `mmap` region as a `VkDeviceMemory` - and maps it; also passes the memfd to the second process for a cross-process - round trip. + both directions are exercised: the importing process allocates the memfd + (`T3-external-memory-host`, plus a plain cross-process memfd round trip), and — + the direction that actually makes T3 a tier — the **client** allocates the + memfd, writes to it, and the **server** mmaps the received fd, imports the + client's host pointer into a `VkDeviceMemory`, reads what the client wrote, + writes back, and takes a GPU access on the client's memory + (`T3-client-memfd-server-import`). + +**Every tier row takes a real GPU access** before it can be `OK`: +`vkCmdCopyBuffer` out of the shared allocation into a private staging buffer +(mismatch ⇒ the GPU could not read what the peer wrote) plus `vkCmdFillBuffer` +into it, `vkQueueWaitIdle`, and an explicit host-read barrier; the peer then +checks the filled region through *its* mapping. Without it an `OK` would only +mean that a map call returned a pointer, not that the tier survives GPU use. Process topology mirrors the target design (the client spawns the server): the probe re-execs `/proc/self/exe --child=` and hands the child one end of a `socketpair` on fd 3. A bare `fork()` is not usable — neither side's Vulkan driver survives it, and both sides need live Vulkan. +## Reading the verdict + +`status` is one of `OK`, `PARTIAL`, `UNSUPPORTED`, `FAIL`, `SKIP`, and the rule +is deliberately strict: + +* **`OK`** — every *decisive* leg round-tripped bytes **in both directions** + (the allocating side's payload was visible to the other side, and the other + side's write came back). A successful map call with no byte ever compared is + never `OK`. +* **`PARTIAL`** — at least one decisive leg round-tripped, but not all. +* **`FAIL`** — no decisive leg round-tripped. A `FAIL` always names the failing + step and its driver error code in `why: …`. +* **`UNSUPPORTED`** — the route's extension is absent, or the driver never + advertised the handle type as `EXPORTABLE` and then declined it. The same rule + is applied at *every* export failure site: a decline on a handle type the + driver advertised as `EXPORTABLE` is a driver bug and reports `FAIL`; the same + decline on one it never advertised reports `UNSUPPORTED`. + +Each row starts with a per-leg trace, e.g. +`rawmmap[i]=no vkimport[D]=rt gpu[D]=rt` — `[D]` decisive, `[i]` informational, +`rt` = round-tripped, `read-only`/`write-only`/`no`/`notrun` otherwise. Driver +error codes are printed verbatim (`VkResult` names, `errno`, GL enums) — that is +the payload of the spike, so do not summarise them away. + +Two details worth knowing when reading T1 output: + +* the child reads through *both* the plain `mmap` and the imported + `VkDeviceMemory` before it writes through either, because a driver whose + exported fd maps at an offset would otherwise have its payload overwritten by + the probe's own first write, and the second read would report a false failure; +* the raw `mmap` leg is **informational for opaque-fd** and decisive only for + dma-buf. Vulkan forbids interpreting an opaque-fd payload outside the driver, + so a driver that refuses it is conformant and MobileGL would never take that + route; dma-buf is the opposite — a CPU mapping is the point of the handle type. + When the direct compare fails the child scans the mapping for the exporter's + payload and reports `payloadAt=`; `payloadAt=4096` with a clean Vulkan + import (lavapipe's answer) means the fd is shareable but its offset-0 is not + the allocation's base. + +## SELinux domain caveat (important) + +Run as `adb shell /data/local/tmp/extmem_probe`, this executes in the **`shell`** +SELinux domain (`u:r:shell:s0`), **not** the `untrusted_app` domain MobileGL +actually runs in. `shell` and `untrusted_app` do not share the same rules for +dmabuf/ashmem allocators, gralloc, and device nodes, so a route that works here +can still be denied in the app — and, less often, the reverse. The probe prints +the domain it actually got in the run-context header and repeats the caveat in +the summary; record it with the results. + +To answer the question for the real domain, the same binary has to be executed +from an app process. That is **not implemented here**: the intended vehicle is +the trace app's spike hook from the spike-A package — ship `extmem_probe` as a +`jniLib`/asset, exec it from the app's own uid with its stdout redirected to +`/sdcard/MG/extmem-probe.log`, and compare the summary table with the `adb +shell` one. Any row that differs between the two is an SELinux/domain finding, +not a driver finding. + ## Build and run ```sh ANDROID_NDK=$HOME/android-sdk/ndk/27.3.13750724 ./build_android.sh /tmp/extmem-build -adb -s push /tmp/extmem-build/extmem_probe /data/local/tmp/p0-extmem/ -adb -s shell /data/local/tmp/p0-extmem/extmem_probe +``` + +One line to push, run and collect on a device: + +```sh +S=; adb -s $S push /tmp/extmem-build/extmem_probe /data/local/tmp/extmem_probe \ + && adb -s $S shell "chmod 755 /data/local/tmp/extmem_probe && /data/local/tmp/extmem_probe; echo EXIT=\$?" \ + | tee out-$S.txt ``` There is also a host build (`cmake -S . -B ` with no toolchain file). It @@ -59,22 +157,14 @@ EGL_PLATFORM=surfaceless`) proves the harness reports a working route as working, which is what makes a device-side `FAIL` attributable to the device driver rather than to this program. It is not a substitute for a device run. -Options: `--size=BYTES` (default 65536; the payload is split into 4 KiB regions, -one per writer), `--only-t0` / `--only-t1` / `--only-t3`. - -Output is a per-route `RESULT ` line stream plus a -summary table; `status` is one of `OK`, `PARTIAL`, `UNSUPPORTED`, `FAIL`, `SKIP`. -Driver error codes are printed verbatim (`VkResult` names, `errno`, GL enums) — -that is the payload of the spike, so do not summarise them away. +**Known host-build limitation.** On lavapipe + llvmpipe the two `T1-gles` rows +report `FAIL` with `glBufferStorageMemEXT -> GL_OUT_OF_MEMORY` on every rung of +the ladder, even though `GL_DEVICE_UUID_EXT` matches the Vulkan `deviceUUID`: +llvmpipe's GL does not implement importing a lavapipe opaque-fd allocation. +That is a Mesa interop gap, not a harness defect — the T1/T3 rows are the ones +the host run validates, and they must all read `OK`. The GLES legs are validated +only on the devices. -Two details worth knowing when reading T1 output: - -* the child reads through *both* the plain `mmap` and the imported - `VkDeviceMemory` before it writes through either, because a driver whose - exported fd maps at an offset would otherwise have its payload overwritten by - the probe's own first write, and the second read would report a false failure; -* when the direct compare fails, the child scans the mapping for the exporter's - payload and reports `payloadAt=`. `payloadAt=4096` with a clean Vulkan - import (lavapipe's answer) means the fd is shareable but its offset-0 is not - the allocation's base — a route that only works if that offset is - discoverable, which opaque-fd does not promise. +Options: `--size=BYTES` (default 65536; the payload is split into 4 KiB regions, +one per writer — A payload, B/C/D importer writes, E GPU fill, F in-process GL +write), `--only-t0` / `--only-t1` / `--only-t3` / `--only-gles`, `--no-gles`. diff --git a/tools/spikes/extmem_probe/extmem_probe.cpp b/tools/spikes/extmem_probe/extmem_probe.cpp index 2e28f0e9c..62fa063c9 100644 --- a/tools/spikes/extmem_probe/extmem_probe.cpp +++ b/tools/spikes/extmem_probe/extmem_probe.cpp @@ -1,18 +1,40 @@ // extmem_probe -- MobileGL disaggregation P0 spike B (plan-B §8.3, §11 P0). // // Question this program answers, per device: -// Can a server-allocated HOST_VISIBLE|HOST_COHERENT VkDeviceMemory be shared -// with another process and mapped there, and by which route? +// Can the memory behind AcquirePersistentMap be shared with another process +// and mapped there -- for BOTH backends -- and by which route? // // T1 server exports its own allocation (VkExportMemoryAllocateInfo + // vkGetMemoryFdKHR, opaque-fd and dma-buf, handed over SCM_RIGHTS; the // importer tries plain mmap() *and* a Vulkan import + vkMapMemory) +// T1-gles the same exported fd imported into GLES (GL_EXT_memory_object +// + GL_EXT_memory_object_fd: glCreateMemoryObjectsEXT + glImportMemoryFdEXT +// + glBufferStorageMemEXT + glMapBufferRange(PERSISTENT|COHERENT)), +// first in-process, then cross-process. DirectGLES ("Espryt") reaches +// AcquirePersistentMap through a GL mapping, not a VkDeviceMemory, so the +// Vulkan-only T1 answer does not decide the tier for it. // T0 server imports a client allocation (AHardwareBuffer BLOB sent over a // unix socket, imported into VkDeviceMemory via // VK_ANDROID_external_memory_android_hardware_buffer and into a GL buffer // via EGL_ANDROID_get_native_client_buffer + glBufferStorageExternalEXT) -// T3 server imports a client host mapping (VK_EXT_external_memory_host over -// a memfd-backed mmap region) +// T3 server imports a client host mapping (VK_EXT_external_memory_host); +// both directions: the process that imports allocates the memfd, and -- +// the direction that actually makes T3 a tier -- the *client* allocates +// the memfd and the *server* imports the client's host pointer. +// +// Every route that can reach OK also takes a real GPU access (vkCmdCopyBuffer +// out of the shared allocation + vkCmdFillBuffer into it, queue-idle, host-read +// barrier), so an OK verdict means the tier survives GPU use and not merely a +// successful map call. +// +// Verdict rule (deliberately strict): a route is OK only when every decisive +// leg round-tripped bytes in both directions, PARTIAL when at least one decisive +// leg did, FAIL otherwise -- and a FAIL always names the failing step and its +// driver error code. +// +// SELINUX CAVEAT: run from `adb shell`, this executes in the `shell` domain, not +// the `untrusted_app` domain MobileGL actually runs in. See README.md; the +// summary repeats it. // // Standalone: depends on nothing from MobileGL. Build with the NDK toolchain // (see CMakeLists.txt / build_android.sh), push to /data/local/tmp and run. @@ -27,10 +49,11 @@ # define VK_USE_PLATFORM_ANDROID_KHR 1 # define PROBE_HAVE_AHB 1 #else -// The probe is an Android deliverable; the host build exists only so the T1/T3 -// harness itself can be validated against a driver that is known to implement -// those routes (lavapipe), which is what makes a device-side FAIL attributable -// to the driver rather than to this program. T0 is Android-only by nature. +// The probe is an Android deliverable; the host build exists only so the +// T1/T1-gles/T3 harness itself can be validated against a driver that is known +// to implement those routes (lavapipe/llvmpipe), which is what makes a +// device-side FAIL attributable to the driver rather than to this program. +// T0 is Android-only by nature. # define PROBE_HAVE_AHB 0 #endif @@ -85,6 +108,7 @@ static void getProp(const char* name, char* out, size_t n) { #endif } +static void pr(const char* fmt, ...) __attribute__((format(printf, 1, 2))); static void pr(const char* fmt, ...) { char buf[4096]; va_list ap; @@ -107,6 +131,7 @@ static void record(const char* route, const char* status, const std::string& det pr("RESULT %-34s %-12s %s", route, status, detail.c_str()); } +static std::string fmt(const char* f, ...) __attribute__((format(printf, 1, 2))); static std::string fmt(const char* f, ...) { char buf[1024]; va_list ap; @@ -116,7 +141,10 @@ static std::string fmt(const char* f, ...) { return std::string(buf); } -static const char* vkStr(VkResult r) { +// Returns a fresh std::string per call: several vkStr() results routinely appear +// in one format call, and a shared static buffer would make all of them show the +// last one. +static std::string vkStr(VkResult r) { switch (r) { case VK_SUCCESS: return "VK_SUCCESS"; case VK_NOT_READY: return "VK_NOT_READY"; @@ -139,27 +167,58 @@ static const char* vkStr(VkResult r) { case VK_ERROR_INVALID_EXTERNAL_HANDLE: return "VK_ERROR_INVALID_EXTERNAL_HANDLE"; case VK_ERROR_FRAGMENTATION: return "VK_ERROR_FRAGMENTATION"; case VK_ERROR_INVALID_OPAQUE_CAPTURE_ADDRESS: return "VK_ERROR_INVALID_OPAQUE_CAPTURE_ADDRESS"; - default: { - static char tmp[32]; - snprintf(tmp, sizeof(tmp), "VkResult(%d)", (int)r); - return tmp; - } + default: return fmt("VkResult(%d)", (int)r); } } +static std::string glErrStr(GLenum e) { + switch (e) { + case GL_NO_ERROR: return "GL_NO_ERROR"; + case GL_INVALID_ENUM: return "GL_INVALID_ENUM"; + case GL_INVALID_VALUE: return "GL_INVALID_VALUE"; + case GL_INVALID_OPERATION: return "GL_INVALID_OPERATION"; + case GL_OUT_OF_MEMORY: return "GL_OUT_OF_MEMORY"; + case GL_INVALID_FRAMEBUFFER_OPERATION: return "GL_INVALID_FRAMEBUFFER_OPERATION"; + default: return fmt("GL(0x%04x)", (unsigned)e); + } +} + +// drains and returns the last error, so one failing call cannot be blamed on the +// previous one +static GLenum glDrain() { + GLenum last = GL_NO_ERROR, e; + while ((e = glGetError()) != GL_NO_ERROR) last = e; + return last; +} + +static std::string readSmallFile(const char* path) { + int fd = open(path, O_RDONLY); + if (fd < 0) return fmt("<%s: errno=%d>", path, errno); + char buf[256]; + ssize_t n = read(fd, buf, sizeof(buf) - 1); + close(fd); + if (n <= 0) return ""; + buf[n] = 0; + while (n > 0 && (buf[n - 1] == '\n' || buf[n - 1] == 0)) buf[--n] = 0; + return std::string(buf); +} + // --------------------------------------------------------------------------- // payload patterns // --------------------------------------------------------------------------- static const uint64_t kRegion = 4096; // bytes per verification region +static const uint64_t kRegionCount = 8; // A..F plus slack static const uint64_t kDefaultSize = 65536; // region indices inside the shared allocation enum { - REG_A = 0, // first writer's payload - REG_B = 1, // importer write through the plain host mapping (mmap / AHB lock) + REG_A = 0, // first writer's payload (CPU, allocating side) + REG_B = 1, // importer write through the plain host mapping (mmap / AHB lock / vk map) REG_C = 2, // importer write through the imported Vulkan mapping - REG_D = 3, // importer write through the imported GL mapping + REG_D = 3, // importer write through the imported GL mapping (cross-process) + REG_E = 4, // GPU write (vkCmdFillBuffer) + REG_F = 5, // in-process GL-import write }; static void fillPattern(void* p, uint64_t bytes, uint32_t seed) { @@ -186,6 +245,63 @@ static int64_t checkRegion(const void* base, int region, uint32_t seed) { return checkPattern((const uint8_t*)base + region * kRegion, kRegion, seed); } +// vkCmdFillBuffer writes a repeating 32-bit word; -1 on match, else the first +// mismatching word index * 4 +static int64_t checkFillWord(const void* base, int region, uint32_t word) { + const uint32_t* w = (const uint32_t*)((const uint8_t*)base + region * kRegion); + for (uint64_t i = 0; i < kRegion / 4; ++i) + if (w[i] != word) return (int64_t)(i * 4); + return -1; +} + +// --------------------------------------------------------------------------- +// verdict: decisive legs must round-trip bytes, in both directions +// --------------------------------------------------------------------------- + +struct Leg { + std::string name; + bool decisive = false; // counted by the verdict; informational legs are not + bool attempted = false; + bool readOk = false; // the allocating side's bytes were visible to the other side + bool writeOk = false; // the other side's bytes came back + std::string fail; // failing step + driver error code +}; + +static const char* legVerdict(const std::vector& legs, std::string* why) { + int decisive = 0, round = 0; + std::string bad; + for (const Leg& l : legs) { + if (!l.decisive) continue; + ++decisive; + if (l.attempted && l.readOk && l.writeOk) { + ++round; + } else { + if (!bad.empty()) bad += "; "; + bad += l.name + "=" + (l.fail.empty() ? std::string("no round trip") : l.fail); + } + } + if (why) *why = bad; + if (decisive == 0) return "SKIP"; + if (round == decisive) return "OK"; + if (round > 0) return "PARTIAL"; + return "FAIL"; +} + +// compact per-leg trace that stays in the summary line +static std::string legTrace(const std::vector& legs) { + std::string s; + for (const Leg& l : legs) { + if (!s.empty()) s += " "; + const char* v = !l.attempted ? "notrun" + : (l.readOk && l.writeOk) ? "rt" + : l.readOk ? "read-only" + : l.writeOk ? "write-only" + : "no"; + s += l.name + "[" + (l.decisive ? "D" : "i") + "]=" + v; + } + return s; +} + // --------------------------------------------------------------------------- // socket message plumbing // --------------------------------------------------------------------------- @@ -199,6 +315,12 @@ enum MsgTag : uint32_t { MSG_T0_RESULT = 6, MSG_T3_OFFER = 7, MSG_T3_RESULT = 8, + MSG_T1GL_OFFER = 9, + MSG_T1GL_RESULT = 10, + MSG_T3C_REQUEST = 11, + MSG_T3C_READY = 12, + MSG_T3C_VERIFY = 13, + MSG_T3C_RESULT = 14, MSG_BYE = 99, }; @@ -360,6 +482,8 @@ struct VkCtx { VkPhysicalDevice phys = VK_NULL_HANDLE; VkDevice device = VK_NULL_HANDLE; uint32_t queueFamily = 0; + VkQueue queue = VK_NULL_HANDLE; + VkCommandPool cmdPool = VK_NULL_HANDLE; VkPhysicalDeviceMemoryProperties memProps{}; VkPhysicalDeviceProperties props{}; uint8_t deviceUUID[VK_UUID_SIZE]{}; @@ -417,7 +541,7 @@ static bool vkCtxInit(VkCtx& c, bool verbose) { VkResult r = vkCreateInstance(&ici, nullptr, &c.instance); if (r != VK_SUCCESS) { - pr("vkCreateInstance failed: %s", vkStr(r)); + pr("vkCreateInstance failed: %s", vkStr(r).c_str()); return false; } @@ -506,10 +630,21 @@ static bool vkCtxInit(VkCtx& c, bool verbose) { r = vkCreateDevice(c.phys, &dci, nullptr, &c.device); if (r != VK_SUCCESS) { - pr("vkCreateDevice failed: %s", vkStr(r)); + pr("vkCreateDevice failed: %s", vkStr(r).c_str()); return false; } + vkGetDeviceQueue(c.device, c.queueFamily, 0, &c.queue); + VkCommandPoolCreateInfo cpi{}; + cpi.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; + cpi.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + cpi.queueFamilyIndex = c.queueFamily; + VkResult pr_ = vkCreateCommandPool(c.device, &cpi, nullptr, &c.cmdPool); + if (pr_ != VK_SUCCESS) { + c.cmdPool = VK_NULL_HANDLE; + pr("vkCreateCommandPool failed: %s (GPU touch will be skipped)", vkStr(pr_).c_str()); + } + c.pGetMemoryFdKHR = (PFN_vkGetMemoryFdKHR)vkGetDeviceProcAddr(c.device, "vkGetMemoryFdKHR"); c.pGetMemoryFdPropertiesKHR = (PFN_vkGetMemoryFdPropertiesKHR)vkGetDeviceProcAddr(c.device, "vkGetMemoryFdPropertiesKHR"); @@ -526,15 +661,17 @@ static bool vkCtxInit(VkCtx& c, bool verbose) { VK_VERSION_PATCH(c.props.apiVersion), c.props.driverVersion, c.props.vendorID); char uuid[64] = {0}; for (uint32_t i = 0; i < VK_UUID_SIZE; ++i) snprintf(uuid + i * 2, 3, "%02x", c.deviceUUID[i]); - pr("deviceUUID=%s minImportedHostPointerAlignment=%llu", uuid, - (unsigned long long)c.minImportedHostPointerAlignment); + pr("deviceUUID=%s minImportedHostPointerAlignment=%llu queueFamily=%u", uuid, + (unsigned long long)c.minImportedHostPointerAlignment, c.queueFamily); } return true; } static void vkCtxDestroy(VkCtx& c) { + if (c.cmdPool) vkDestroyCommandPool(c.device, c.cmdPool, nullptr); if (c.device) vkDestroyDevice(c.device, nullptr); if (c.instance) vkDestroyInstance(c.instance, nullptr); + c.cmdPool = VK_NULL_HANDLE; c.device = VK_NULL_HANDLE; c.instance = VK_NULL_HANDLE; } @@ -553,6 +690,275 @@ static const VkBufferUsageFlags kProbeBufferUsage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT; +// --------------------------------------------------------------------------- +// GPU touch: prove the shared allocation survives a real GPU access +// +// The GPU copies `readRegion` into a private staging buffer (so a mismatch means +// the GPU could not read what the host/peer wrote) and fills `fillRegion` with a +// known word (so the caller can check, through whichever mapping it is testing, +// that a GPU write lands in the shared pages). Without this an OK verdict would +// only prove that a map call returned a pointer. +// --------------------------------------------------------------------------- + +struct GpuTouch { + bool ran = false; + VkResult submitResult = VK_NOT_READY; + int64_t readMismatch = -3; // -1 match, -3 never ran + std::string fail; +}; + +static GpuTouch gpuTouch(VkCtx& c, VkBuffer buf, int readRegion, uint32_t readSeed, int fillRegion, + uint32_t fillWord) { + GpuTouch g; + if (buf == VK_NULL_HANDLE || c.cmdPool == VK_NULL_HANDLE || c.queue == VK_NULL_HANDLE) { + g.fail = "no buffer/queue/command pool for the GPU touch"; + return g; + } + + // private host-visible staging target for the read-back + VkBufferCreateInfo sbi{}; + sbi.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + sbi.size = kRegion; + sbi.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT; + VkBuffer staging = VK_NULL_HANDLE; + VkResult r = vkCreateBuffer(c.device, &sbi, nullptr, &staging); + if (r != VK_SUCCESS) { + g.fail = "staging vkCreateBuffer=" + vkStr(r); + return g; + } + VkMemoryRequirements sreq{}; + vkGetBufferMemoryRequirements(c.device, staging, &sreq); + int sType = pickMemType(c.memProps, sreq.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + if (sType < 0) { + vkDestroyBuffer(c.device, staging, nullptr); + g.fail = fmt("no HOST_VISIBLE|HOST_COHERENT staging type in bits=0x%x", sreq.memoryTypeBits); + return g; + } + VkMemoryAllocateInfo smai{}; + smai.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + smai.allocationSize = sreq.size; + smai.memoryTypeIndex = (uint32_t)sType; + VkDeviceMemory smem = VK_NULL_HANDLE; + r = vkAllocateMemory(c.device, &smai, nullptr, &smem); + if (r != VK_SUCCESS) { + vkDestroyBuffer(c.device, staging, nullptr); + g.fail = "staging vkAllocateMemory=" + vkStr(r); + return g; + } + vkBindBufferMemory(c.device, staging, smem, 0); + + VkCommandBufferAllocateInfo cai{}; + cai.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + cai.commandPool = c.cmdPool; + cai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + cai.commandBufferCount = 1; + VkCommandBuffer cmd = VK_NULL_HANDLE; + r = vkAllocateCommandBuffers(c.device, &cai, &cmd); + if (r != VK_SUCCESS) { + vkFreeMemory(c.device, smem, nullptr); + vkDestroyBuffer(c.device, staging, nullptr); + g.fail = "vkAllocateCommandBuffers=" + vkStr(r); + return g; + } + + VkCommandBufferBeginInfo bi{}; + bi.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + vkBeginCommandBuffer(cmd, &bi); + + // host writes are made visible to the device by the queue submit itself for + // HOST_COHERENT memory, but the shared allocation may be imported and + // non-coherent, so ask for it explicitly + VkMemoryBarrier pre{}; + pre.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER; + pre.srcAccessMask = VK_ACCESS_HOST_WRITE_BIT; + pre.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_HOST_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 1, &pre, 0, nullptr, 0, + nullptr); + + VkBufferCopy copy{}; + copy.srcOffset = (VkDeviceSize)(readRegion * kRegion); + copy.dstOffset = 0; + copy.size = kRegion; + vkCmdCopyBuffer(cmd, buf, staging, 1, ©); + vkCmdFillBuffer(cmd, buf, (VkDeviceSize)(fillRegion * kRegion), (VkDeviceSize)kRegion, fillWord); + + // device writes must be made visible to the host explicitly + VkMemoryBarrier post{}; + post.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER; + post.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + post.dstAccessMask = VK_ACCESS_HOST_READ_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_HOST_BIT, 0, 1, &post, 0, nullptr, 0, + nullptr); + vkEndCommandBuffer(cmd); + + VkSubmitInfo si{}; + si.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + si.commandBufferCount = 1; + si.pCommandBuffers = &cmd; + g.submitResult = vkQueueSubmit(c.queue, 1, &si, VK_NULL_HANDLE); + if (g.submitResult == VK_SUCCESS) { + VkResult wr = vkQueueWaitIdle(c.queue); + if (wr != VK_SUCCESS) { + g.fail = "vkQueueWaitIdle=" + vkStr(wr); + } else { + void* sp = nullptr; + VkResult mr = vkMapMemory(c.device, smem, 0, VK_WHOLE_SIZE, 0, &sp); + if (mr == VK_SUCCESS && sp) { + g.readMismatch = checkPattern(sp, kRegion, readSeed); + vkUnmapMemory(c.device, smem); + g.ran = true; + if (g.readMismatch != -1) + g.fail = fmt("GPU copy out of the shared allocation mismatched at byte %lld", + (long long)g.readMismatch); + } else { + g.fail = "staging vkMapMemory=" + vkStr(mr); + } + } + } else { + g.fail = "vkQueueSubmit=" + vkStr(g.submitResult); + } + + vkFreeCommandBuffers(c.device, c.cmdPool, 1, &cmd); + vkFreeMemory(c.device, smem, nullptr); + vkDestroyBuffer(c.device, staging, nullptr); + return g; +} + +// --------------------------------------------------------------------------- +// exportable HOST_VISIBLE|HOST_COHERENT allocation + fd +// --------------------------------------------------------------------------- + +struct ExportAlloc { + VkBuffer buf = VK_NULL_HANDLE; + VkDeviceMemory mem = VK_NULL_HANDLE; + void* host = nullptr; + uint64_t allocationSize = 0; + uint64_t bufferSize = 0; + uint32_t memoryTypeIndex = 0; + uint32_t memoryTypeBits = 0; + bool dedicated = false; + int fd = -1; + bool advertisedExportable = false; + bool advertisedImportable = false; + VkResult bindResult = VK_SUCCESS; + std::string fail; // empty on success +}; + +// A failure on a handle type the driver advertised as EXPORTABLE is a driver +// bug (FAIL); the same failure on one it never advertised is simply the route +// not being there (UNSUPPORTED). One rule, used at every export failure site. +static const char* exportFailStatus(const ExportAlloc& a) { + return a.advertisedExportable ? "FAIL" : "UNSUPPORTED"; +} + +static bool exportHostVisible(VkCtx& c, VkExternalMemoryHandleTypeFlagBits ht, uint64_t size, const char* tag, + ExportAlloc& a) { + a.bufferSize = size; + + VkPhysicalDeviceExternalBufferInfo ebi{}; + ebi.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_BUFFER_INFO; + ebi.usage = kProbeBufferUsage; + ebi.handleType = ht; + VkExternalBufferProperties ebp{}; + ebp.sType = VK_STRUCTURE_TYPE_EXTERNAL_BUFFER_PROPERTIES; + vkGetPhysicalDeviceExternalBufferProperties(c.phys, &ebi, &ebp); + a.advertisedExportable = + (ebp.externalMemoryProperties.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_EXPORTABLE_BIT) != 0; + a.advertisedImportable = + (ebp.externalMemoryProperties.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_IMPORTABLE_BIT) != 0; + a.dedicated = + (ebp.externalMemoryProperties.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT) != 0; + pr("%s advertisedExportable=%d importable=%d dedicatedOnly=%d", tag, (int)a.advertisedExportable, + (int)a.advertisedImportable, (int)a.dedicated); + + VkExternalMemoryBufferCreateInfo ext{}; + ext.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_BUFFER_CREATE_INFO; + ext.handleTypes = ht; + VkBufferCreateInfo bci{}; + bci.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bci.pNext = &ext; + bci.size = size; + bci.usage = kProbeBufferUsage; + bci.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + + VkResult r = vkCreateBuffer(c.device, &bci, nullptr, &a.buf); + if (r != VK_SUCCESS) { + a.buf = VK_NULL_HANDLE; + a.fail = "vkCreateBuffer(external)=" + vkStr(r); + return false; + } + VkMemoryRequirements req{}; + vkGetBufferMemoryRequirements(c.device, a.buf, &req); + a.allocationSize = req.size; + a.memoryTypeBits = req.memoryTypeBits; + int typeIdx = pickMemType(c.memProps, req.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + if (typeIdx < 0) { + a.fail = fmt("no HOST_VISIBLE|HOST_COHERENT memory type in bits=0x%x", req.memoryTypeBits); + return false; + } + a.memoryTypeIndex = (uint32_t)typeIdx; + pr("%s memReq size=%llu align=%llu typeBits=0x%x -> type %d", tag, (unsigned long long)req.size, + (unsigned long long)req.alignment, req.memoryTypeBits, typeIdx); + + VkExportMemoryAllocateInfo exportInfo{}; + exportInfo.sType = VK_STRUCTURE_TYPE_EXPORT_MEMORY_ALLOCATE_INFO; + exportInfo.handleTypes = ht; + VkMemoryDedicatedAllocateInfo ded{}; + ded.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO; + ded.buffer = a.buf; + if (a.dedicated) exportInfo.pNext = &ded; + + VkMemoryAllocateInfo mai{}; + mai.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + mai.pNext = &exportInfo; + mai.allocationSize = req.size; + mai.memoryTypeIndex = a.memoryTypeIndex; + + r = vkAllocateMemory(c.device, &mai, nullptr, &a.mem); + if (r != VK_SUCCESS) { + a.mem = VK_NULL_HANDLE; + a.fail = fmt("vkAllocateMemory(export)=%s (advertisedExportable=%d)", vkStr(r).c_str(), + (int)a.advertisedExportable); + return false; + } + a.bindResult = vkBindBufferMemory(c.device, a.buf, a.mem, 0); + if (a.bindResult != VK_SUCCESS) pr("%s vkBindBufferMemory=%s (continuing)", tag, vkStr(a.bindResult).c_str()); + + r = vkMapMemory(c.device, a.mem, 0, VK_WHOLE_SIZE, 0, &a.host); + if (r != VK_SUCCESS || !a.host) { + a.host = nullptr; + a.fail = "exporter-side vkMapMemory=" + vkStr(r); + return false; + } + + VkMemoryGetFdInfoKHR gfi{}; + gfi.sType = VK_STRUCTURE_TYPE_MEMORY_GET_FD_INFO_KHR; + gfi.memory = a.mem; + gfi.handleType = ht; + r = c.pGetMemoryFdKHR(c.device, &gfi, &a.fd); + if (r != VK_SUCCESS || a.fd < 0) { + a.fd = -1; + a.fail = fmt("vkGetMemoryFdKHR=%s (advertisedExportable=%d)", vkStr(r).c_str(), (int)a.advertisedExportable); + return false; + } + pr("%s exported fd=%d -> %s", tag, a.fd, describeFd(a.fd).c_str()); + return true; +} + +static void freeExportAlloc(VkCtx& c, ExportAlloc& a) { + if (a.fd >= 0) close(a.fd); + if (a.host) vkUnmapMemory(c.device, a.mem); + if (a.mem) vkFreeMemory(c.device, a.mem, nullptr); + if (a.buf) vkDestroyBuffer(c.device, a.buf, nullptr); + a.fd = -1; + a.host = nullptr; + a.mem = VK_NULL_HANDLE; + a.buf = VK_NULL_HANDLE; +} + // --------------------------------------------------------------------------- // GLES / EGL context // --------------------------------------------------------------------------- @@ -568,6 +974,15 @@ struct GlCtx { PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC pGetNativeClientBuffer = nullptr; PFNGLBUFFERSTORAGEEXTERNALEXTPROC pBufferStorageExternal = nullptr; + // GL_EXT_memory_object / GL_EXT_memory_object_fd + PFNGLCREATEMEMORYOBJECTSEXTPROC pCreateMemoryObjects = nullptr; + PFNGLDELETEMEMORYOBJECTSEXTPROC pDeleteMemoryObjects = nullptr; + PFNGLMEMORYOBJECTPARAMETERIVEXTPROC pMemoryObjectParameteriv = nullptr; + PFNGLBUFFERSTORAGEMEMEXTPROC pBufferStorageMem = nullptr; + PFNGLIMPORTMEMORYFDEXTPROC pImportMemoryFd = nullptr; + PFNGLGETUNSIGNEDBYTEI_VEXTPROC pGetUnsignedBytei_v = nullptr; + void (GL_APIENTRYP pMemoryBarrier)(GLbitfield) = nullptr; + bool hasGl(const char* n) const { for (auto& s : glExts) if (s == n) return true; @@ -578,6 +993,18 @@ struct GlCtx { if (s == n) return true; return false; } + // everything the T1 GLES leg needs + bool canImportFd() const { + return hasGl("GL_EXT_memory_object") && hasGl("GL_EXT_memory_object_fd") && pCreateMemoryObjects && + pImportMemoryFd && pBufferStorageMem; + } + std::string missingForImportFd() const { + return fmt("GL_EXT_memory_object=%d GL_EXT_memory_object_fd=%d GL_EXT_buffer_storage=%d " + "glCreateMemoryObjectsEXT=%d glImportMemoryFdEXT=%d glBufferStorageMemEXT=%d", + (int)hasGl("GL_EXT_memory_object"), (int)hasGl("GL_EXT_memory_object_fd"), + (int)hasGl("GL_EXT_buffer_storage"), (int)(pCreateMemoryObjects != nullptr), + (int)(pImportMemoryFd != nullptr), (int)(pBufferStorageMem != nullptr)); + } }; static void splitExts(const char* s, std::vector& out) { @@ -661,6 +1088,15 @@ static bool glCtxInit(GlCtx& g) { (PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC)eglGetProcAddress("eglGetNativeClientBufferANDROID"); g.pBufferStorageExternal = (PFNGLBUFFERSTORAGEEXTERNALEXTPROC)eglGetProcAddress("glBufferStorageExternalEXT"); + g.pCreateMemoryObjects = (PFNGLCREATEMEMORYOBJECTSEXTPROC)eglGetProcAddress("glCreateMemoryObjectsEXT"); + g.pDeleteMemoryObjects = (PFNGLDELETEMEMORYOBJECTSEXTPROC)eglGetProcAddress("glDeleteMemoryObjectsEXT"); + g.pMemoryObjectParameteriv = + (PFNGLMEMORYOBJECTPARAMETERIVEXTPROC)eglGetProcAddress("glMemoryObjectParameterivEXT"); + g.pBufferStorageMem = (PFNGLBUFFERSTORAGEMEMEXTPROC)eglGetProcAddress("glBufferStorageMemEXT"); + g.pImportMemoryFd = (PFNGLIMPORTMEMORYFDEXTPROC)eglGetProcAddress("glImportMemoryFdEXT"); + g.pGetUnsignedBytei_v = (PFNGLGETUNSIGNEDBYTEI_VEXTPROC)eglGetProcAddress("glGetUnsignedBytei_vEXT"); + // ES 3.1 core, but loaded dynamically so an ES 3.0 context still links + g.pMemoryBarrier = (void(GL_APIENTRYP)(GLbitfield))eglGetProcAddress("glMemoryBarrier"); return true; } @@ -674,6 +1110,191 @@ static void glCtxDestroy(GlCtx& g) { g.dpy = EGL_NO_DISPLAY; } +// GL_DEVICE_UUID_EXT must equal the Vulkan deviceUUID for an fd import to be +// legal; a mismatch is the usual reason glImportMemoryFdEXT declines. +static std::string glDeviceUuidReport(GlCtx& g, const uint8_t* vkUuid, bool* matched) { + if (matched) *matched = false; + if (!g.hasGl("GL_EXT_memory_object") || !g.pGetUnsignedBytei_v) return "unavailable"; + GLint n = 0; + glDrain(); + glGetIntegerv(GL_NUM_DEVICE_UUIDS_EXT, &n); + if (glDrain() != GL_NO_ERROR || n <= 0) return "GL_NUM_DEVICE_UUIDS_EXT unreadable"; + std::string out; + for (GLint i = 0; i < n; ++i) { + GLubyte uuid[GL_UUID_SIZE_EXT] = {0}; + g.pGetUnsignedBytei_v(GL_DEVICE_UUID_EXT, (GLuint)i, uuid); + char hex[2 * GL_UUID_SIZE_EXT + 1] = {0}; + for (int k = 0; k < GL_UUID_SIZE_EXT; ++k) snprintf(hex + k * 2, 3, "%02x", uuid[k]); + if (!out.empty()) out += ","; + out += hex; + if (!memcmp(uuid, vkUuid, GL_UUID_SIZE_EXT) && matched) *matched = true; + } + return out; +} + +// --------------------------------------------------------------------------- +// GL side of T1: import an exported fd as GL buffer storage and map it +// --------------------------------------------------------------------------- + +struct GlImport { + bool memObjOk = false; + bool storageOk = false; + bool mapOk = false; + bool persistentCoherent = false; // the PERSISTENT|COHERENT map is what AcquirePersistentMap needs + GLenum errImport = GL_NO_ERROR; + GLenum errStorage = GL_NO_ERROR; + GLenum errMap = GL_NO_ERROR; // error of the PERSISTENT|COHERENT attempt + GLenum errMap2 = GL_NO_ERROR; // error of the plain MAP_READ|MAP_WRITE fallback + GLuint memObj = 0; + GLuint buf = 0; + void* ptr = nullptr; + uint64_t mappedSize = 0; + std::string variant; // which phrasing the driver accepted + std::string ladder; // every phrasing tried, with its error + std::string fail; +}; + +// Borrows `fd` (dups it per attempt; the caller keeps ownership). +// +// Drivers disagree about how this call has to be phrased -- whether the memory +// object must be flagged dedicated, and whether the buffer may be smaller than +// the imported allocation -- and a probe that tried only one phrasing would +// report a driver preference as a missing capability. So walk the ladder and +// report which rung the driver accepted. +static void glImportFdBuffer(GlCtx& g, int fd, uint64_t allocationSize, uint64_t bufferSize, bool dedicatedHint, + GlImport& o) { + struct Attempt { + bool dedicated; + uint64_t importSize; + uint64_t storageSize; + }; + std::vector attempts; + // some drivers validate the imported size against the fd's own size rather + // than against the exporter's VkMemoryRequirements::size + off_t fdSize = lseek(fd, 0, SEEK_END); + if (fdSize > 0) lseek(fd, 0, SEEK_SET); + std::vector importSizes{allocationSize}; + if (fdSize > 0 && (uint64_t)fdSize != allocationSize) importSizes.push_back((uint64_t)fdSize); + for (uint64_t imp : importSizes) { + for (bool ded : {dedicatedHint, !dedicatedHint}) { + attempts.push_back(Attempt{ded, imp, bufferSize}); + if (imp != bufferSize) attempts.push_back(Attempt{ded, imp, imp}); + } + } + glGenBuffers(1, &o.buf); + + for (const Attempt& at : attempts) { + std::string tag = fmt("[ded=%d imp=%llu store=%llu]", (int)at.dedicated, (unsigned long long)at.importSize, + (unsigned long long)at.storageSize); + glDrain(); + GLuint mo = 0; + g.pCreateMemoryObjects(1, &mo); + if (at.dedicated && g.pMemoryObjectParameteriv) { + GLint yes = GL_TRUE; + g.pMemoryObjectParameteriv(mo, GL_DEDICATED_MEMORY_OBJECT_EXT, &yes); + glDrain(); + } + int dupFd = dup(fd); + g.pImportMemoryFd(mo, (GLuint64)at.importSize, GL_HANDLE_TYPE_OPAQUE_FD_EXT, (GLint)dupFd); + GLenum eImport = glDrain(); + if (eImport != GL_NO_ERROR) { + // Deliberately NOT closed: EXT_memory_object_fd transfers ownership of + // the fd to the implementation and does not say whether that still + // happens when the import fails, and Mesa closes it either way. A + // double close would land on whatever fd the allocator handed out + // next -- the socket, in this program. At most a handful of rungs + // run, so leaking the dup is the cheap, safe side of that trade. + (void)dupFd; + if (g.pDeleteMemoryObjects) g.pDeleteMemoryObjects(1, &mo); + glDrain(); + if (!o.memObjOk) o.errImport = eImport; + o.ladder += tag + "import=" + glErrStr(eImport) + " "; + continue; + } + o.memObjOk = true; + o.errImport = GL_NO_ERROR; + + glBindBuffer(GL_ARRAY_BUFFER, o.buf); + glDrain(); + g.pBufferStorageMem(GL_ARRAY_BUFFER, (GLsizeiptr)at.storageSize, mo, 0); + GLenum eStorage = glDrain(); + o.ladder += tag + "storage=" + glErrStr(eStorage) + " "; + if (eStorage != GL_NO_ERROR) { + o.errStorage = eStorage; + if (g.pDeleteMemoryObjects) g.pDeleteMemoryObjects(1, &mo); + glDrain(); + // storage is immutable once it takes, so a failed attempt needs a + // fresh buffer name before the next rung + glBindBuffer(GL_ARRAY_BUFFER, 0); + glDeleteBuffers(1, &o.buf); + glGenBuffers(1, &o.buf); + continue; + } + o.memObj = mo; + o.errStorage = GL_NO_ERROR; + o.storageOk = true; + o.mappedSize = at.storageSize; + o.variant = tag; + break; + } + + if (!o.memObjOk) { + o.fail = "glImportMemoryFdEXT -> " + glErrStr(o.errImport) + " (ladder: " + o.ladder + ")"; + return; + } + if (!o.storageOk) { + o.fail = "glBufferStorageMemEXT -> " + glErrStr(o.errStorage) + " (ladder: " + o.ladder + ")"; + return; + } + bufferSize = o.mappedSize; + + o.ptr = glMapBufferRange(GL_ARRAY_BUFFER, 0, (GLsizeiptr)bufferSize, + GL_MAP_READ_BIT | GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT_EXT | + GL_MAP_COHERENT_BIT_EXT); + o.errMap = glDrain(); + if (o.ptr) { + o.mapOk = true; + o.persistentCoherent = true; + return; + } + // A driver may back the storage but refuse the persistent/coherent flags -- + // that is exactly the T1/T2 distinction for DirectGLES, so it is reported + // separately rather than folded into one failure. + o.ptr = glMapBufferRange(GL_ARRAY_BUFFER, 0, (GLsizeiptr)bufferSize, GL_MAP_READ_BIT | GL_MAP_WRITE_BIT); + o.errMap2 = glDrain(); + if (o.ptr) { + o.mapOk = true; + o.fail = "PERSISTENT|COHERENT map refused (" + glErrStr(o.errMap) + "), only a scoped map works"; + } else { + o.fail = "glMapBufferRange persistent -> " + glErrStr(o.errMap) + ", plain -> " + glErrStr(o.errMap2); + } +} + +static void glImportPublish(GlCtx& g, GlImport& o) { + if (!o.mapOk) return; + if (o.persistentCoherent) { + if (g.pMemoryBarrier) g.pMemoryBarrier(GL_ALL_BARRIER_BITS); + } else { + glUnmapBuffer(GL_ARRAY_BUFFER); + o.ptr = nullptr; + } + glFinish(); +} + +static void glImportRelease(GlCtx& g, GlImport& o) { + if (o.buf) { + glBindBuffer(GL_ARRAY_BUFFER, o.buf); + if (o.ptr) glUnmapBuffer(GL_ARRAY_BUFFER); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glDeleteBuffers(1, &o.buf); + } + if (o.memObj && g.pDeleteMemoryObjects) g.pDeleteMemoryObjects(1, &o.memObj); + glDrain(); + o.buf = 0; + o.memObj = 0; + o.ptr = nullptr; +} + // --------------------------------------------------------------------------- // child spawn // --------------------------------------------------------------------------- @@ -739,7 +1360,7 @@ static std::string reapChild(pid_t pid) { } // --------------------------------------------------------------------------- -// T1 payloads +// wire payloads // --------------------------------------------------------------------------- struct T1Offer { @@ -751,6 +1372,8 @@ struct T1Offer { uint32_t seedC; // pattern the child must write in REG_C (via imported vkMapMemory) uint32_t memoryTypeIndex; uint32_t memoryTypeBits; + uint32_t gpuWord; // word the GPU filled REG_E with + uint32_t gpuRan; // 0 -> REG_E carries nothing, do not check it }; struct T1Result { @@ -759,6 +1382,7 @@ struct T1Result { int32_t mmapErrno; int64_t mmapMismatch; // -1 == data matched int64_t mmapPatternOffset; // where the exporter's payload really starts in the mapping, -1 = not found + int64_t mmapGpuMismatch; // REG_E through the plain mapping (-2 = not checked) int32_t vkInitOk; int32_t fdPropsResult; // VkResult of vkGetMemoryFdPropertiesKHR uint32_t fdMemoryTypeBits; @@ -766,11 +1390,36 @@ struct T1Result { int32_t bindResult; int32_t mapResult; int64_t vkMismatch; // -1 == data matched + int64_t vkGpuMismatch; // REG_E through the imported mapping (-2 = not checked) int32_t wroteB; int32_t wroteC; char note[384]; }; +struct T1GlOffer { + uint64_t allocationSize; + uint64_t bufferSize; + uint32_t seedA; + uint32_t seedD; // the child writes REG_D through the imported GL mapping + uint32_t gpuWord; + uint32_t gpuRan; + uint32_t dedicated; +}; + +struct T1GlResult { + int32_t glInitOk; + int32_t haveExts; + int32_t memObjOk; + int32_t storageOk; + int32_t mapOk; + int32_t persistentCoherent; + uint32_t errImport, errStorage, errMap, errMap2; + int64_t mismatchA; + int64_t mismatchGpu; + int32_t wroteD; + char note[640]; +}; + struct T0Request { uint64_t size; uint32_t seedA; // pattern the child writes through AHardwareBuffer_lock @@ -785,10 +1434,11 @@ struct T0Alloc { }; struct T0Verify { - uint32_t seedB; // parent wrote REG_B through the imported vkMapMemory - uint32_t seedC; // parent wrote REG_C through the imported GL mapping - uint32_t seedD; // parent wrote REG_D through AHardwareBuffer_lock - uint32_t writtenMask; // bit0=B bit1=C bit2=D + uint32_t seedB; // parent wrote REG_B through the imported vkMapMemory + uint32_t seedC; // parent wrote REG_C through the imported GL mapping + uint32_t seedD; // parent wrote REG_D through AHardwareBuffer_lock + uint32_t gpuWord; // the GPU filled REG_E with this + uint32_t writtenMask; // bit0=B bit1=C bit2=D bit3=E(gpu) }; struct T0Result { @@ -797,6 +1447,7 @@ struct T0Result { int64_t mismatchB; int64_t mismatchC; int64_t mismatchD; + int64_t mismatchE; char note[192]; }; @@ -804,29 +1455,75 @@ struct T3Offer { uint64_t size; uint32_t seedA; uint32_t seedB; + uint32_t gpuWord; + uint32_t gpuRan; }; struct T3Result { int32_t mmapOk; int32_t mmapErrno; int64_t mismatch; + int64_t gpuMismatch; char note[192]; }; +// T3 in the direction that makes it a tier: the CLIENT allocates, the SERVER +// imports the client's host pointer. +struct T3cRequest { + uint64_t size; // must be a multiple of minImportedHostPointerAlignment + uint32_t seedA; // the child writes REG_A +}; + +struct T3cReady { + int32_t ok; + int32_t err; + uint64_t size; + char note[160]; +}; + +struct T3cVerify { + uint32_t seedB; // the parent wrote REG_B through the imported VkDeviceMemory + uint32_t gpuWord; // the parent's GPU filled REG_E + uint32_t mask; // bit0=B bit1=E +}; + +struct T3cResult { + int64_t mismatchB; + int64_t mismatchE; + char note[160]; +}; + // --------------------------------------------------------------------------- // Phase A: enumeration // --------------------------------------------------------------------------- -static const char* memFlagStr(VkMemoryPropertyFlags f) { - static char b[128]; - b[0] = 0; - if (f & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT) strcat(b, "DEVICE_LOCAL "); - if (f & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) strcat(b, "HOST_VISIBLE "); - if (f & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) strcat(b, "HOST_COHERENT "); - if (f & VK_MEMORY_PROPERTY_HOST_CACHED_BIT) strcat(b, "HOST_CACHED "); - if (f & VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT) strcat(b, "LAZY "); - if (f & VK_MEMORY_PROPERTY_PROTECTED_BIT) strcat(b, "PROTECTED "); - return b; +static std::string memFlagStr(VkMemoryPropertyFlags f) { + std::string s; + if (f & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT) s += "DEVICE_LOCAL "; + if (f & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) s += "HOST_VISIBLE "; + if (f & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) s += "HOST_COHERENT "; + if (f & VK_MEMORY_PROPERTY_HOST_CACHED_BIT) s += "HOST_CACHED "; + if (f & VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT) s += "LAZY "; + if (f & VK_MEMORY_PROPERTY_PROTECTED_BIT) s += "PROTECTED "; + return s; +} + +// The whole point of this probe is what the driver does in the process MobileGL +// runs in. `adb shell` is not that process: it is the `shell` SELinux domain, +// which has access to device nodes and ashmem/dmabuf rules that `untrusted_app` +// does not necessarily share. Print the domain we actually got so a later app +// run can be compared against it. +static const char* kDomainCaveat = + "run context is `adb shell` (SELinux domain u:r:shell:s0), NOT the untrusted_app " + "domain MobileGL runs in; per-domain SELinux rules can reject a route that works here"; + +static void printRunContext() { + std::string sec = readSmallFile("/proc/self/attr/current"); + pr("=== run context ==="); + pr("uid=%d gid=%d pid=%d selinux=%s", (int)getuid(), (int)getgid(), (int)getpid(), sec.c_str()); + pr("CAVEAT: %s", kDomainCaveat); + pr(" to answer the question for the real domain, run this binary from the app " + "process (spike A's trace-app hook) rather than from adb shell -- see README.md"); } static void reportExternalBufferCaps(VkCtx& c, VkExternalMemoryHandleTypeFlagBits ht, const char* name) { @@ -838,13 +1535,12 @@ static void reportExternalBufferCaps(VkCtx& c, VkExternalMemoryHandleTypeFlagBit out.sType = VK_STRUCTURE_TYPE_EXTERNAL_BUFFER_PROPERTIES; vkGetPhysicalDeviceExternalBufferProperties(c.phys, &info, &out); const VkExternalMemoryProperties& p = out.externalMemoryProperties; - char feat[96]; - feat[0] = 0; - if (p.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT) strcat(feat, "DEDICATED_ONLY "); - if (p.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_EXPORTABLE_BIT) strcat(feat, "EXPORTABLE "); - if (p.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_IMPORTABLE_BIT) strcat(feat, "IMPORTABLE "); - if (!feat[0]) strcat(feat, ""); - pr(" externalBuffer[%s]: features=%s exportFrom=0x%x compatible=0x%x", name, feat, + std::string feat; + if (p.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT) feat += "DEDICATED_ONLY "; + if (p.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_EXPORTABLE_BIT) feat += "EXPORTABLE "; + if (p.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_IMPORTABLE_BIT) feat += "IMPORTABLE "; + if (feat.empty()) feat = ""; + pr(" externalBuffer[%s]: features=%s exportFrom=0x%x compatible=0x%x", name, feat.c_str(), p.exportFromImportedHandleTypes, p.compatibleHandleTypes); } @@ -879,7 +1575,8 @@ static void phaseEnumerate(VkCtx& c, GlCtx& g, bool glOk) { for (uint32_t i = 0; i < c.memProps.memoryTypeCount; ++i) { const VkMemoryType& mt = c.memProps.memoryTypes[i]; pr(" [%u] heap=%u size=%lluMiB flags=%s", i, mt.heapIndex, - (unsigned long long)(c.memProps.memoryHeaps[mt.heapIndex].size >> 20), memFlagStr(mt.propertyFlags)); + (unsigned long long)(c.memProps.memoryHeaps[mt.heapIndex].size >> 20), + memFlagStr(mt.propertyFlags).c_str()); } // Only query handle types the driver actually claims: a handle type whose @@ -894,7 +1591,7 @@ static void phaseEnumerate(VkCtx& c, GlCtx& g, bool glOk) { if (!glOk) { pr("gles: context unavailable, GL extension probe skipped"); - record("A-gles-context", "FAIL", "no headless EGL context"); + record("A-gles-context", "FAIL", "no headless EGL context; every GLES leg is unanswered"); return; } pr("gles: vendor=%s renderer=%s version=%s", g.vendor.c_str(), g.renderer.c_str(), g.version.c_str()); @@ -909,12 +1606,21 @@ static void phaseEnumerate(VkCtx& c, GlCtx& g, bool glOk) { "EGL_EXT_image_dma_buf_import", "EGL_KHR_gl_texture_2D_image", }; for (const char* n : eglWanted) pr(" EGL ext %-40s %s", n, g.hasEgl(n) ? "YES" : "no"); - pr(" eglGetNativeClientBufferANDROID=%p glBufferStorageExternalEXT=%p", - (void*)g.pGetNativeClientBuffer, (void*)g.pBufferStorageExternal); + pr(" eglGetNativeClientBufferANDROID=%p glBufferStorageExternalEXT=%p", (void*)g.pGetNativeClientBuffer, + (void*)g.pBufferStorageExternal); + pr(" glCreateMemoryObjectsEXT=%p glImportMemoryFdEXT=%p glBufferStorageMemEXT=%p glMemoryObjectParameterivEXT=%p", + (void*)g.pCreateMemoryObjects, (void*)g.pImportMemoryFd, (void*)g.pBufferStorageMem, + (void*)g.pMemoryObjectParameteriv); + + bool uuidMatch = false; + std::string glUuid = glDeviceUuidReport(g, c.deviceUUID, &uuidMatch); + char vkUuid[2 * VK_UUID_SIZE + 1] = {0}; + for (uint32_t i = 0; i < VK_UUID_SIZE; ++i) snprintf(vkUuid + i * 2, 3, "%02x", c.deviceUUID[i]); + pr(" GL_DEVICE_UUID_EXT=%s vkDeviceUUID=%s match=%d", glUuid.c_str(), vkUuid, (int)uuidMatch); } // --------------------------------------------------------------------------- -// T1 parent +// T1 parent: server exports its own allocation // --------------------------------------------------------------------------- static void runT1Parent(VkCtx& c, VkExternalMemoryHandleTypeFlagBits handleType, const char* routeName, @@ -928,177 +1634,160 @@ static void runT1Parent(VkCtx& c, VkExternalMemoryHandleTypeFlagBits handleType, return; } - // exportability report first -- a driver that says "not exportable" here and - // still returns an fd is a driver bug we want on the record. - VkPhysicalDeviceExternalBufferInfo ebi{}; - ebi.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_BUFFER_INFO; - ebi.usage = kProbeBufferUsage; - ebi.handleType = handleType; - VkExternalBufferProperties ebp{}; - ebp.sType = VK_STRUCTURE_TYPE_EXTERNAL_BUFFER_PROPERTIES; - vkGetPhysicalDeviceExternalBufferProperties(c.phys, &ebi, &ebp); - bool advertisedExportable = - (ebp.externalMemoryProperties.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_EXPORTABLE_BIT) != 0; - pr("T1[%s] advertisedExportable=%d importable=%d", routeName, (int)advertisedExportable, - (int)((ebp.externalMemoryProperties.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_IMPORTABLE_BIT) != 0)); - - VkExternalMemoryBufferCreateInfo ext{}; - ext.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_BUFFER_CREATE_INFO; - ext.handleTypes = handleType; - VkBufferCreateInfo bci{}; - bci.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; - bci.pNext = &ext; - bci.size = size; - bci.usage = kProbeBufferUsage; - bci.sharingMode = VK_SHARING_MODE_EXCLUSIVE; - - VkBuffer buf = VK_NULL_HANDLE; - VkResult r = vkCreateBuffer(c.device, &bci, nullptr, &buf); - if (r != VK_SUCCESS) { - record(routeName, "FAIL", fmt("vkCreateBuffer(external)=%s", vkStr(r))); - return; - } - VkMemoryRequirements req{}; - vkGetBufferMemoryRequirements(c.device, buf, &req); - int typeIdx = pickMemType(c.memProps, req.memoryTypeBits, - VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); - if (typeIdx < 0) { - vkDestroyBuffer(c.device, buf, nullptr); - record(routeName, "FAIL", fmt("no HOST_VISIBLE|HOST_COHERENT type in bits=0x%x", req.memoryTypeBits)); - return; - } - pr("T1[%s] memReq size=%llu align=%llu typeBits=0x%x -> type %d", routeName, - (unsigned long long)req.size, (unsigned long long)req.alignment, req.memoryTypeBits, typeIdx); - - VkExportMemoryAllocateInfo exportInfo{}; - exportInfo.sType = VK_STRUCTURE_TYPE_EXPORT_MEMORY_ALLOCATE_INFO; - exportInfo.handleTypes = handleType; - VkMemoryDedicatedAllocateInfo dedicated{}; - dedicated.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO; - dedicated.buffer = buf; - bool needDedicated = - (ebp.externalMemoryProperties.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT) != 0; - if (needDedicated) exportInfo.pNext = &dedicated; - - VkMemoryAllocateInfo mai{}; - mai.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; - mai.pNext = &exportInfo; - mai.allocationSize = req.size; - mai.memoryTypeIndex = (uint32_t)typeIdx; - - VkDeviceMemory mem = VK_NULL_HANDLE; - r = vkAllocateMemory(c.device, &mai, nullptr, &mem); - if (r != VK_SUCCESS) { - vkDestroyBuffer(c.device, buf, nullptr); - record(routeName, advertisedExportable ? "FAIL" : "UNSUPPORTED", - fmt("vkAllocateMemory(export)=%s (advertisedExportable=%d)", vkStr(r), (int)advertisedExportable)); + ExportAlloc a; + std::string tag = fmt("T1[%s]", routeName); + if (!exportHostVisible(c, handleType, size, tag.c_str(), a)) { + // one rule for every export-path failure, advertised or not + record(routeName, exportFailStatus(a), a.fail); + freeExportAlloc(c, a); return; } - r = vkBindBufferMemory(c.device, buf, mem, 0); - if (r != VK_SUCCESS) pr("T1[%s] vkBindBufferMemory=%s (continuing)", routeName, vkStr(r)); - void* host = nullptr; - r = vkMapMemory(c.device, mem, 0, VK_WHOLE_SIZE, 0, &host); - if (r != VK_SUCCESS) { - vkFreeMemory(c.device, mem, nullptr); - vkDestroyBuffer(c.device, buf, nullptr); - record(routeName, "FAIL", fmt("server-side vkMapMemory=%s", vkStr(r))); - return; - } const uint32_t seedA = 0xA5A50001u, seedB = 0xB0B00002u, seedC = 0xC0C00003u; - memset(host, 0, (size_t)size); - writeRegion(host, REG_A, seedA); - - VkMemoryGetFdInfoKHR gfi{}; - gfi.sType = VK_STRUCTURE_TYPE_MEMORY_GET_FD_INFO_KHR; - gfi.memory = mem; - gfi.handleType = handleType; - int fd = -1; - r = c.pGetMemoryFdKHR(c.device, &gfi, &fd); - if (r != VK_SUCCESS || fd < 0) { - vkUnmapMemory(c.device, mem); - vkFreeMemory(c.device, mem, nullptr); - vkDestroyBuffer(c.device, buf, nullptr); - record(routeName, "UNSUPPORTED", fmt("vkGetMemoryFdKHR=%s fd=%d (advertisedExportable=%d)", vkStr(r), fd, - (int)advertisedExportable)); - return; - } - pr("T1[%s] exported fd=%d -> %s", routeName, fd, describeFd(fd).c_str()); + const uint32_t gpuWord = 0x5EED1234u; + memset(a.host, 0, (size_t)size); + writeRegion(a.host, REG_A, seedA); + + // real GPU access on the shared allocation, before the handover: the GPU + // reads REG_A (host-written) and writes REG_E, which the importer then has + // to see through its own mapping. + GpuTouch gt = gpuTouch(c, a.buf, REG_A, seedA, REG_E, gpuWord); + int64_t gpuFillSeenHere = gt.ran ? checkFillWord(a.host, REG_E, gpuWord) : -3; + pr("%s gpuTouch ran=%d submit=%s readMismatch=%lld fillSeenByExporter=%lld %s", tag.c_str(), (int)gt.ran, + vkStr(gt.submitResult).c_str(), (long long)gt.readMismatch, (long long)gpuFillSeenHere, gt.fail.c_str()); int sock = -1; pid_t pid = spawnChild("t1", &sock); if (pid < 0) { - close(fd); - vkUnmapMemory(c.device, mem); - vkFreeMemory(c.device, mem, nullptr); - vkDestroyBuffer(c.device, buf, nullptr); record(routeName, "FAIL", "spawnChild failed"); + freeExportAlloc(c, a); return; } T1Offer offer{}; - offer.allocationSize = req.size; + offer.allocationSize = a.allocationSize; offer.bufferSize = size; offer.handleType = (uint32_t)handleType; offer.seedA = seedA; offer.seedB = seedB; offer.seedC = seedC; - offer.memoryTypeIndex = (uint32_t)typeIdx; - offer.memoryTypeBits = req.memoryTypeBits; + offer.memoryTypeIndex = a.memoryTypeIndex; + offer.memoryTypeBits = a.memoryTypeBits; + offer.gpuWord = gpuWord; + offer.gpuRan = (gt.ran && gpuFillSeenHere == -1) ? 1u : 0u; + + // For OPAQUE_FD the raw mmap leg is informational only: the Vulkan spec + // explicitly forbids interpreting an opaque fd payload outside the driver, + // so a driver that refuses it is conformant and MobileGL would never take + // that route. For DMA_BUF a CPU mapping is the point of the handle type, + // so there it is decisive. + const bool mmapDecisive = (handleType == VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT); std::string detail; const char* status = "FAIL"; - if (!sendMsg(sock, MSG_T1_OFFER, &offer, sizeof(offer), fd)) { + if (!sendMsg(sock, MSG_T1_OFFER, &offer, sizeof(offer), a.fd)) { detail = fmt("sendMsg(offer) errno=%d", errno); - } else { - close(fd); - fd = -1; - uint32_t tag = 0; - T1Result res{}; - size_t got = 0; - if (!recvMsg(sock, &tag, &res, sizeof(res), &got, nullptr) || tag != MSG_T1_RESULT || - got != sizeof(res)) { - detail = fmt("no T1 result from child (errno=%d, %s)", errno, reapChild(pid).c_str()); - pid = -1; - } else { - // The child wrote REG_B (mmap) and REG_C (imported vkMapMemory); check - // that the writes are visible through the *server's* own mapping. - int64_t backB = res.wroteB ? checkRegion(host, REG_B, seedB) : -2; - int64_t backC = res.wroteC ? checkRegion(host, REG_C, seedC) : -2; - - detail = fmt( - "mmap=%s(errno=%d,cmp=%lld,payloadAt=%lld,back=%lld) vkimport=%s(fdProps=%s bits=0x%x bind=%s " - "map=%s cmp=%lld back=%lld) %s", - res.mmapOk ? "ok" : "fail", res.mmapErrno, (long long)res.mmapMismatch, - (long long)res.mmapPatternOffset, (long long)backB, - res.importResult == VK_SUCCESS ? "ok" : vkStr((VkResult)res.importResult), - vkStr((VkResult)res.fdPropsResult), res.fdMemoryTypeBits, vkStr((VkResult)res.bindResult), - vkStr((VkResult)res.mapResult), (long long)res.vkMismatch, (long long)backC, res.note); - - bool mmapPath = res.mmapOk && res.mmapMismatch == -1 && backB == -1; - bool vkPath = res.importResult == VK_SUCCESS && res.mapResult == VK_SUCCESS && res.vkMismatch == -1 && - backC == -1; - if (mmapPath && vkPath) { - status = "OK"; - } else if (mmapPath || vkPath) { - status = "PARTIAL"; - } else if (!res.mmapOk && res.importResult != VK_SUCCESS) { - status = "FAIL"; - } else { - status = "PARTIAL"; - } - } - } - if (pid > 0) { + record(routeName, "FAIL", detail); sendMsg(sock, MSG_BYE, nullptr, 0, -1); - detail += " "; - detail += reapChild(pid); + reapChild(pid); + close(sock); + freeExportAlloc(c, a); + return; + } + close(a.fd); + a.fd = -1; + + uint32_t tag2 = 0; + T1Result res{}; + size_t got = 0; + if (!recvMsg(sock, &tag2, &res, sizeof(res), &got, nullptr) || tag2 != MSG_T1_RESULT || got != sizeof(res)) { + detail = fmt("no T1 result from child (errno=%d, %s)", errno, reapChild(pid).c_str()); + record(routeName, "FAIL", detail); + close(sock); + freeExportAlloc(c, a); + return; + } + + // The child wrote REG_B (mmap) and REG_C (imported vkMapMemory); check that + // the writes are visible through the *server's* own mapping. + int64_t backB = res.wroteB ? checkRegion(a.host, REG_B, seedB) : -2; + int64_t backC = res.wroteC ? checkRegion(a.host, REG_C, seedC) : -2; + + std::vector legs; + { + Leg l; + l.name = "rawmmap"; + l.decisive = mmapDecisive; + l.attempted = res.mmapOk != 0; + l.readOk = res.mmapOk && res.mmapMismatch == -1 && (!offer.gpuRan || res.mmapGpuMismatch == -1); + l.writeOk = res.wroteB && backB == -1; + if (!l.attempted) + l.fail = fmt("mmap failed errno=%d(%s)", res.mmapErrno, strerror(res.mmapErrno)); + else if (!l.readOk) + l.fail = fmt("exporter payload not at offset 0 (cmp=%lld payloadAt=%lld gpuCmp=%lld)", + (long long)res.mmapMismatch, (long long)res.mmapPatternOffset, + (long long)res.mmapGpuMismatch); + else if (!l.writeOk) + l.fail = fmt("importer write not visible to exporter (back=%lld)", (long long)backB); + legs.push_back(l); + } + { + Leg l; + l.name = "vkimport"; + l.decisive = true; + l.attempted = res.importResult == VK_SUCCESS; + l.readOk = res.importResult == VK_SUCCESS && res.mapResult == VK_SUCCESS && res.vkMismatch == -1 && + (!offer.gpuRan || res.vkGpuMismatch == -1); + l.writeOk = res.wroteC && backC == -1; + if (!res.vkInitOk) + l.fail = "child Vulkan init failed"; + else if (res.importResult != VK_SUCCESS) + l.fail = "vkAllocateMemory(import)=" + vkStr((VkResult)res.importResult); + else if (res.mapResult != VK_SUCCESS) + l.fail = "importer vkMapMemory=" + vkStr((VkResult)res.mapResult); + else if (!l.readOk) + l.fail = fmt("payload mismatch cmp=%lld gpuCmp=%lld", (long long)res.vkMismatch, + (long long)res.vkGpuMismatch); + else if (!l.writeOk) + l.fail = fmt("importer write not visible to exporter (back=%lld)", (long long)backC); + legs.push_back(l); } + { + Leg l; + l.name = "gpu"; + l.decisive = true; + l.attempted = gt.ran; + l.readOk = gt.readMismatch == -1; + l.writeOk = gpuFillSeenHere == -1; + if (!gt.ran) + l.fail = "GPU touch did not run: " + gt.fail; + else if (!l.readOk) + l.fail = fmt("GPU read of the shared allocation mismatched at %lld", (long long)gt.readMismatch); + else if (!l.writeOk) + l.fail = fmt("GPU write not visible through the exporter's map (at %lld)", (long long)gpuFillSeenHere); + legs.push_back(l); + } + + std::string why; + status = legVerdict(legs, &why); + detail = fmt( + "%s | mmap=%s(errno=%d,cmp=%lld,payloadAt=%lld,gpu=%lld,back=%lld) vkimport=%s(fdProps=%s bits=0x%x " + "bind=%s map=%s cmp=%lld gpu=%lld back=%lld) gpuTouch(submit=%s read=%lld fill=%lld) %s%s%s", + legTrace(legs).c_str(), res.mmapOk ? (res.mmapOk == 2 ? "ok-buffersize" : "ok") : "fail", res.mmapErrno, + (long long)res.mmapMismatch, (long long)res.mmapPatternOffset, (long long)res.mmapGpuMismatch, + (long long)backB, res.importResult == VK_SUCCESS ? "ok" : vkStr((VkResult)res.importResult).c_str(), + vkStr((VkResult)res.fdPropsResult).c_str(), res.fdMemoryTypeBits, vkStr((VkResult)res.bindResult).c_str(), + vkStr((VkResult)res.mapResult).c_str(), (long long)res.vkMismatch, (long long)res.vkGpuMismatch, + (long long)backC, vkStr(gt.submitResult).c_str(), (long long)gt.readMismatch, (long long)gpuFillSeenHere, + res.note, why.empty() ? "" : " | why: ", why.c_str()); + if (!mmapDecisive) + detail += " | rawmmap informational: an opaque fd is not required to be mmap-able"; + + sendMsg(sock, MSG_BYE, nullptr, 0, -1); + detail += " "; + detail += reapChild(pid); close(sock); - if (fd >= 0) close(fd); - vkUnmapMemory(c.device, mem); - vkFreeMemory(c.device, mem, nullptr); - vkDestroyBuffer(c.device, buf, nullptr); + freeExportAlloc(c, a); record(routeName, status, detail); } @@ -1119,6 +1808,8 @@ static int childT1(int sock) { T1Result res{}; res.mmapMismatch = -3; res.vkMismatch = -3; + res.mmapGpuMismatch = -2; + res.vkGpuMismatch = -2; res.gotFd = fd; if (fd < 0) { snprintf(res.note, sizeof(res.note), "no fd received over SCM_RIGHTS"); @@ -1130,17 +1821,22 @@ static int childT1(int sock) { // (1) plain mmap of the exported fd size_t mappedLen = (size_t)offer.allocationSize; + int firstErrno = 0; void* p = mmap(nullptr, mappedLen, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (p == MAP_FAILED) { + firstErrno = errno; res.mmapOk = 0; - res.mmapErrno = errno; - pr("child: mmap(MAP_SHARED) failed errno=%d (%s)", errno, strerror(errno)); + res.mmapErrno = firstErrno; + pr("child: mmap(MAP_SHARED, allocationSize) failed errno=%d (%s)", firstErrno, strerror(firstErrno)); // second chance: some allocators only allow the buffer size, not the padded size mappedLen = (size_t)offer.bufferSize; p = mmap(nullptr, mappedLen, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (p != MAP_FAILED) { res.mmapOk = 2; - note += " [mmap needed bufferSize not allocationSize]"; + res.mmapErrno = 0; // the mapping succeeded; the first errno is history, not the verdict + note += fmt(" [mmap needed bufferSize not allocationSize; allocationSize errno=%d]", firstErrno); + } else { + res.mmapErrno = errno; } } else { res.mmapOk = 1; @@ -1152,6 +1848,7 @@ static int childT1(int sock) { res.mmapPatternOffset = -1; if (p != MAP_FAILED) { res.mmapMismatch = checkRegion(p, REG_A, offer.seedA); + if (offer.gpuRan) res.mmapGpuMismatch = checkFillWord(p, REG_E, offer.gpuWord); if (res.mmapMismatch != -1) { // Locate the exporter's payload: an fd that maps at a fixed offset from // the driver's base is still usable, but only if that offset is @@ -1168,14 +1865,18 @@ static int childT1(int sock) { } } + // writes through the plain mapping, deferred until every read is done + auto writeThroughMmap = [&]() { + if (p == MAP_FAILED) return; + writeRegion(p, REG_B, offer.seedB); + res.wroteB = 1; + msync(p, mappedLen, MS_SYNC); + }; + // (2) import the same fd into a child-side VkDeviceMemory and map it VkCtx c; if (!vkCtxInit(c, false)) { - if (p != MAP_FAILED) { - writeRegion(p, REG_B, offer.seedB); - res.wroteB = 1; - msync(p, (size_t)mappedLen, MS_SYNC); - } + writeThroughMmap(); snprintf(res.note, sizeof(res.note), "%s | child vulkan init failed", note.c_str()); sendMsg(sock, MSG_T1_RESULT, &res, sizeof(res), -1); return 4; @@ -1209,12 +1910,8 @@ static int childT1(int sock) { VkResult r = vkCreateBuffer(c.device, &bci, nullptr, &buf); if (r != VK_SUCCESS) { res.importResult = (int32_t)r; - if (p != MAP_FAILED) { - writeRegion(p, REG_B, offer.seedB); - res.wroteB = 1; - msync(p, (size_t)mappedLen, MS_SYNC); - } - snprintf(res.note, sizeof(res.note), "%s | child vkCreateBuffer=%s", note.c_str(), vkStr(r)); + writeThroughMmap(); + snprintf(res.note, sizeof(res.note), "%s | child vkCreateBuffer=%s", note.c_str(), vkStr(r).c_str()); sendMsg(sock, MSG_T1_RESULT, &res, sizeof(res), -1); vkCtxDestroy(c); return 5; @@ -1266,12 +1963,8 @@ static int childT1(int sock) { res.importResult = (int32_t)r; } else { close(importFd); - if (p != MAP_FAILED) { - writeRegion(p, REG_B, offer.seedB); - res.wroteB = 1; - msync(p, (size_t)mappedLen, MS_SYNC); - } - snprintf(res.note, sizeof(res.note), "%s | import=%s type=%d bits=0x%x", note.c_str(), vkStr(r), + writeThroughMmap(); + snprintf(res.note, sizeof(res.note), "%s | import=%s type=%d bits=0x%x", note.c_str(), vkStr(r).c_str(), typeIdx, bits); vkDestroyBuffer(c.device, buf, nullptr); sendMsg(sock, MSG_T1_RESULT, &res, sizeof(res), -1); @@ -1285,16 +1978,13 @@ static int childT1(int sock) { res.mapResult = (int32_t)r; if (r == VK_SUCCESS && host) { res.vkMismatch = checkRegion(host, REG_A, offer.seedA); + if (offer.gpuRan) res.vkGpuMismatch = checkFillWord(host, REG_E, offer.gpuWord); writeRegion(host, REG_C, offer.seedC); res.wroteC = 1; vkUnmapMemory(c.device, mem); } // now that both mappings have been read, write through the plain one too - if (p != MAP_FAILED) { - writeRegion(p, REG_B, offer.seedB); - res.wroteB = 1; - msync(p, (size_t)mappedLen, MS_SYNC); - } + writeThroughMmap(); snprintf(res.note, sizeof(res.note), "%s | childType=%d bits=0x%x", note.c_str(), typeIdx, bits); vkFreeMemory(c.device, mem, nullptr); vkDestroyBuffer(c.device, buf, nullptr); @@ -1305,6 +1995,274 @@ static int childT1(int sock) { return 0; } +// --------------------------------------------------------------------------- +// T1 for DirectGLES ("Espryt"): the exported fd imported as GL buffer storage +// +// AcquirePersistentMap on the GLES backend is glBufferStorageEXT + +// glMapBufferRange(PERSISTENT|COHERENT), not a VkDeviceMemory map, so the +// Vulkan T1 answer above does not decide the tier for that backend. The GL +// route to the same question is GL_EXT_memory_object{,_fd}: import the fd as a +// memory object, back a buffer with it, and map that buffer persistently. +// Tried in-process first (isolates "GL can import this fd at all" from +// "the fd survives a process boundary"), then cross-process. +// --------------------------------------------------------------------------- + +static const char* kT1GlSameProc = "T1-gles-memobj-fd-same-proc"; +static const char* kT1GlCrossProc = "T1-gles-memobj-fd-cross-proc"; + +static int childT1Gl(int sock) { + setRecvTimeout(sock, 30); + T1GlOffer offer{}; + uint32_t tag = 0; + size_t got = 0; + int fd = -1; + if (!recvMsg(sock, &tag, &offer, sizeof(offer), &got, &fd) || tag != MSG_T1GL_OFFER) { + pr("child: bad T1GL offer errno=%d", errno); + return 2; + } + T1GlResult res{}; + res.mismatchA = -3; + res.mismatchGpu = -2; + if (fd < 0) { + snprintf(res.note, sizeof(res.note), "no fd over SCM_RIGHTS"); + sendMsg(sock, MSG_T1GL_RESULT, &res, sizeof(res), -1); + return 3; + } + std::string note = describeFd(fd); + + GlCtx g; + if (!glCtxInit(g)) { + snprintf(res.note, sizeof(res.note), "%s | child EGL/GLES init failed", note.c_str()); + sendMsg(sock, MSG_T1GL_RESULT, &res, sizeof(res), -1); + close(fd); + return 4; + } + res.glInitOk = 1; + if (!g.canImportFd()) { + snprintf(res.note, sizeof(res.note), "%s | %s", note.c_str(), g.missingForImportFd().c_str()); + sendMsg(sock, MSG_T1GL_RESULT, &res, sizeof(res), -1); + glCtxDestroy(g); + close(fd); + return 0; + } + res.haveExts = 1; + + GlImport imp; + glImportFdBuffer(g, fd, offer.allocationSize, offer.bufferSize, offer.dedicated != 0, imp); + res.memObjOk = imp.memObjOk; + res.storageOk = imp.storageOk; + res.mapOk = imp.mapOk; + res.persistentCoherent = imp.persistentCoherent; + res.errImport = imp.errImport; + res.errStorage = imp.errStorage; + res.errMap = imp.errMap; + res.errMap2 = imp.errMap2; + if (imp.mapOk && imp.ptr) { + res.mismatchA = checkRegion(imp.ptr, REG_A, offer.seedA); + if (offer.gpuRan) res.mismatchGpu = checkFillWord(imp.ptr, REG_E, offer.gpuWord); + writeRegion(imp.ptr, REG_D, offer.seedD); + res.wroteD = 1; + glImportPublish(g, imp); + } + snprintf(res.note, sizeof(res.note), "%s | accepted=%s | ladder: %s| %s", note.c_str(), + imp.variant.empty() ? "none" : imp.variant.c_str(), imp.ladder.c_str(), imp.fail.c_str()); + glImportRelease(g, imp); + sendMsg(sock, MSG_T1GL_RESULT, &res, sizeof(res), -1); + glCtxDestroy(g); + close(fd); + return 0; +} + +static void runT1GlesParent(VkCtx& c, GlCtx& g, bool glOk, uint64_t size) { + if (!glOk) { + record(kT1GlSameProc, "SKIP", "no headless GLES context"); + record(kT1GlCrossProc, "SKIP", "no headless GLES context"); + return; + } + bool uuidMatch = false; + std::string glUuid = glDeviceUuidReport(g, c.deviceUUID, &uuidMatch); + std::string uuidNote = fmt("glDeviceUUID=%s vkMatch=%d", glUuid.c_str(), (int)uuidMatch); + + if (!g.canImportFd()) { + std::string d = g.missingForImportFd() + " | " + uuidNote; + record(kT1GlSameProc, "UNSUPPORTED", d); + record(kT1GlCrossProc, "UNSUPPORTED", d); + return; + } + if (!c.hasExtMemFd || !c.pGetMemoryFdKHR) { + record(kT1GlSameProc, "UNSUPPORTED", "VK_KHR_external_memory_fd absent, nothing to import"); + record(kT1GlCrossProc, "UNSUPPORTED", "VK_KHR_external_memory_fd absent, nothing to import"); + return; + } + + ExportAlloc a; + if (!exportHostVisible(c, VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT, size, "T1-gles", a)) { + std::string d = a.fail + " | " + uuidNote; + record(kT1GlSameProc, exportFailStatus(a), d); + record(kT1GlCrossProc, exportFailStatus(a), d); + freeExportAlloc(c, a); + return; + } + + const uint32_t seedA = 0x61510001u, seedD = 0x61510004u, seedF = 0x61510006u; + const uint32_t gpuWord = 0x6C651234u; + memset(a.host, 0, (size_t)size); + writeRegion(a.host, REG_A, seedA); + GpuTouch gt = gpuTouch(c, a.buf, REG_A, seedA, REG_E, gpuWord); + int64_t gpuFillSeenHere = gt.ran ? checkFillWord(a.host, REG_E, gpuWord) : -3; + const bool gpuUsable = gt.ran && gt.readMismatch == -1 && gpuFillSeenHere == -1; + pr("T1-gles gpuTouch ran=%d submit=%s read=%lld fill=%lld %s", (int)gt.ran, vkStr(gt.submitResult).c_str(), + (long long)gt.readMismatch, (long long)gpuFillSeenHere, gt.fail.c_str()); + + // ---- (a) same process ---------------------------------------------------- + { + GlImport imp; + glImportFdBuffer(g, a.fd, a.allocationSize, size, a.dedicated, imp); + int64_t cmpA = -3, cmpGpu = -2, backF = -2; + if (imp.mapOk && imp.ptr) { + cmpA = checkRegion(imp.ptr, REG_A, seedA); + if (gpuUsable) cmpGpu = checkFillWord(imp.ptr, REG_E, gpuWord); + writeRegion(imp.ptr, REG_F, seedF); + glImportPublish(g, imp); + backF = checkRegion(a.host, REG_F, seedF); + } + + std::vector legs; + Leg l; + l.name = "gl-import"; + l.decisive = true; + l.attempted = imp.storageOk; + l.readOk = imp.mapOk && cmpA == -1 && (!gpuUsable || cmpGpu == -1); + l.writeOk = imp.mapOk && backF == -1; + if (!imp.memObjOk) + l.fail = "glImportMemoryFdEXT -> " + glErrStr(imp.errImport); + else if (!imp.storageOk) + l.fail = "glBufferStorageMemEXT -> " + glErrStr(imp.errStorage); + else if (!imp.mapOk) + l.fail = "glMapBufferRange persistent -> " + glErrStr(imp.errMap) + ", plain -> " + glErrStr(imp.errMap2); + else if (!l.readOk) + l.fail = fmt("Vulkan-written payload not visible through the GL map (cmp=%lld gpuCmp=%lld)", + (long long)cmpA, (long long)cmpGpu); + else if (!l.writeOk) + l.fail = fmt("GL-map write not visible through the Vulkan map (back=%lld)", (long long)backF); + legs.push_back(l); + // The tier needs a *persistent coherent* mapping, not a scoped one: a + // driver that only grants the scoped map cannot host AcquirePersistentMap. + Leg pc; + pc.name = "persistent-coherent"; + pc.decisive = true; + pc.attempted = imp.mapOk; + pc.readOk = imp.persistentCoherent; + pc.writeOk = imp.persistentCoherent; + if (!imp.mapOk) + pc.fail = "no mapping at all"; + else if (!imp.persistentCoherent) + pc.fail = "PERSISTENT|COHERENT refused (" + glErrStr(imp.errMap) + "), only a scoped map works"; + legs.push_back(pc); + + std::string why; + const char* status = legVerdict(legs, &why); + record(kT1GlSameProc, status, + fmt("%s | memObj=%d(%s) storage=%d(%s) accepted=%s map=%d persistentCoherent=%d(%s/%s) cmpA=%lld " + "cmpGpu=%lld backF=%lld | ladder: %s| %s | %s", + legTrace(legs).c_str(), (int)imp.memObjOk, glErrStr(imp.errImport).c_str(), (int)imp.storageOk, + glErrStr(imp.errStorage).c_str(), imp.variant.empty() ? "none" : imp.variant.c_str(), + (int)imp.mapOk, (int)imp.persistentCoherent, glErrStr(imp.errMap).c_str(), + glErrStr(imp.errMap2).c_str(), (long long)cmpA, (long long)cmpGpu, (long long)backF, + imp.ladder.c_str(), uuidNote.c_str(), why.c_str())); + glImportRelease(g, imp); + } + + // ---- (b) cross process --------------------------------------------------- + { + int sock = -1; + pid_t pid = spawnChild("t1gl", &sock); + if (pid < 0) { + record(kT1GlCrossProc, "FAIL", "spawnChild failed"); + freeExportAlloc(c, a); + return; + } + T1GlOffer offer{}; + offer.allocationSize = a.allocationSize; + offer.bufferSize = size; + offer.seedA = seedA; + offer.seedD = seedD; + offer.gpuWord = gpuWord; + offer.gpuRan = gpuUsable ? 1u : 0u; + offer.dedicated = a.dedicated ? 1u : 0u; + + if (!sendMsg(sock, MSG_T1GL_OFFER, &offer, sizeof(offer), a.fd)) { + record(kT1GlCrossProc, "FAIL", fmt("sendMsg(offer) errno=%d", errno)); + sendMsg(sock, MSG_BYE, nullptr, 0, -1); + reapChild(pid); + close(sock); + freeExportAlloc(c, a); + return; + } + T1GlResult res{}; + uint32_t tag = 0; + size_t got = 0; + if (!recvMsg(sock, &tag, &res, sizeof(res), &got, nullptr) || tag != MSG_T1GL_RESULT || got != sizeof(res)) { + record(kT1GlCrossProc, "FAIL", fmt("no reply errno=%d %s", errno, reapChild(pid).c_str())); + close(sock); + freeExportAlloc(c, a); + return; + } + int64_t backD = res.wroteD ? checkRegion(a.host, REG_D, seedD) : -2; + + std::vector legs; + Leg l; + l.name = "gl-import"; + l.decisive = true; + l.attempted = res.storageOk != 0; + l.readOk = res.mapOk && res.mismatchA == -1 && (!offer.gpuRan || res.mismatchGpu == -1); + l.writeOk = res.wroteD && backD == -1; + if (!res.glInitOk) + l.fail = "child EGL/GLES init failed"; + else if (!res.haveExts) + l.fail = "child lacks GL_EXT_memory_object{,_fd}"; + else if (!res.memObjOk) + l.fail = "glImportMemoryFdEXT -> " + glErrStr(res.errImport); + else if (!res.storageOk) + l.fail = "glBufferStorageMemEXT -> " + glErrStr(res.errStorage); + else if (!res.mapOk) + l.fail = "glMapBufferRange persistent -> " + glErrStr(res.errMap) + ", plain -> " + glErrStr(res.errMap2); + else if (!l.readOk) + l.fail = fmt("exporter payload not visible through the child's GL map (cmp=%lld gpuCmp=%lld)", + (long long)res.mismatchA, (long long)res.mismatchGpu); + else if (!l.writeOk) + l.fail = fmt("child GL-map write not visible to the exporter (back=%lld)", (long long)backD); + legs.push_back(l); + Leg pc; + pc.name = "persistent-coherent"; + pc.decisive = true; + pc.attempted = res.mapOk != 0; + pc.readOk = res.persistentCoherent != 0; + pc.writeOk = res.persistentCoherent != 0; + if (!res.mapOk) + pc.fail = "no mapping at all"; + else if (!res.persistentCoherent) + pc.fail = "PERSISTENT|COHERENT refused (" + glErrStr(res.errMap) + "), only a scoped map works"; + legs.push_back(pc); + + std::string why; + const char* status = legVerdict(legs, &why); + std::string detail = + fmt("%s | glInit=%d exts=%d memObj=%d(%s) storage=%d(%s) map=%d persistentCoherent=%d(%s/%s) " + "cmpA=%lld cmpGpu=%lld backD=%lld | %s | %s [%s] ", + legTrace(legs).c_str(), res.glInitOk, res.haveExts, res.memObjOk, glErrStr(res.errImport).c_str(), + res.storageOk, glErrStr(res.errStorage).c_str(), res.mapOk, res.persistentCoherent, + glErrStr(res.errMap).c_str(), glErrStr(res.errMap2).c_str(), (long long)res.mismatchA, + (long long)res.mismatchGpu, (long long)backD, uuidNote.c_str(), why.c_str(), res.note); + sendMsg(sock, MSG_BYE, nullptr, 0, -1); + detail += reapChild(pid); + close(sock); + record(kT1GlCrossProc, status, detail); + } + + freeExportAlloc(c, a); +} + // --------------------------------------------------------------------------- // T0: child allocates an AHardwareBuffer BLOB, parent imports it // --------------------------------------------------------------------------- @@ -1368,7 +2326,7 @@ static int childT0(int sock) { return 7; } T0Result res{}; - res.mismatchB = res.mismatchC = res.mismatchD = -2; + res.mismatchB = res.mismatchC = res.mismatchD = res.mismatchE = -2; void* q = nullptr; rc = AHardwareBuffer_lock(ahb, AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN, -1, nullptr, &q); res.lockOk = (rc == 0 && q) ? 1 : 0; @@ -1377,6 +2335,7 @@ static int childT0(int sock) { if (ver.writtenMask & 1) res.mismatchB = checkRegion(q, REG_B, ver.seedB); if (ver.writtenMask & 2) res.mismatchC = checkRegion(q, REG_C, ver.seedC); if (ver.writtenMask & 4) res.mismatchD = checkRegion(q, REG_D, ver.seedD); + if (ver.writtenMask & 8) res.mismatchE = checkFillWord(q, REG_E, ver.gpuWord); AHardwareBuffer_unlock(ahb, nullptr); } snprintf(res.note, sizeof(res.note), "mask=0x%x", ver.writtenMask); @@ -1387,18 +2346,27 @@ static int childT0(int sock) { static void runT0Parent(VkCtx& c, GlCtx& g, bool glOk, uint64_t size) { const uint32_t seedA = 0x0A0A0011u, seedB = 0x0B0B0022u, seedC = 0x0C0C0033u, seedD = 0x0D0D0044u; + const uint32_t gpuWord = 0x70701234u; + + // The composite row is recorded at the very end from the full + // import + map + compare + write-back chain; the handoff alone is only a + // diagnostic and gets its own informational row. + auto failAll = [&](const std::string& why) { + record("T0-ahb-handoff", "FAIL", why); + record("T0-ahb-blob-transfer", "FAIL", "handoff failed, nothing to import: " + why); + }; int sock = -1; pid_t pid = spawnChild("t0", &sock); if (pid < 0) { - record("T0-ahb-blob-transfer", "FAIL", "spawnChild failed"); + failAll("spawnChild failed"); return; } T0Request rq{}; rq.size = size; rq.seedA = seedA; if (!sendMsg(sock, MSG_T0_REQUEST, &rq, sizeof(rq), -1)) { - record("T0-ahb-blob-transfer", "FAIL", fmt("sendMsg errno=%d", errno)); + failAll(fmt("sendMsg errno=%d", errno)); close(sock); reapChild(pid); return; @@ -1407,12 +2375,12 @@ static void runT0Parent(VkCtx& c, GlCtx& g, bool glOk, uint64_t size) { uint32_t tag = 0; size_t got = 0; if (!recvMsg(sock, &tag, &alloc, sizeof(alloc), &got, nullptr) || tag != MSG_T0_ALLOC) { - record("T0-ahb-blob-transfer", "FAIL", fmt("no alloc reply errno=%d %s", errno, reapChild(pid).c_str())); + failAll(fmt("no alloc reply errno=%d %s", errno, reapChild(pid).c_str())); close(sock); return; } if (alloc.allocOk != 1) { - record("T0-ahb-blob-transfer", "FAIL", fmt("child alloc failed rc=%d %s", alloc.allocErr, alloc.note)); + failAll(fmt("child alloc failed rc=%d %s", alloc.allocErr, alloc.note)); close(sock); reapChild(pid); return; @@ -1422,7 +2390,7 @@ static void runT0Parent(VkCtx& c, GlCtx& g, bool glOk, uint64_t size) { AHardwareBuffer* ahb = nullptr; int rc = AHardwareBuffer_recvHandleFromUnixSocket(sock, &ahb); if (rc != 0 || !ahb) { - record("T0-ahb-blob-transfer", "FAIL", fmt("recvHandleFromUnixSocket rc=%d errno=%d", rc, errno)); + failAll(fmt("recvHandleFromUnixSocket rc=%d errno=%d", rc, errno)); close(sock); reapChild(pid); return; @@ -1431,37 +2399,46 @@ static void runT0Parent(VkCtx& c, GlCtx& g, bool glOk, uint64_t size) { AHardwareBuffer_describe(ahb, &desc); pr("T0 parent received AHB: w=%u h=%u fmt=0x%x usage=0x%llx stride=%u", desc.width, desc.height, desc.format, (unsigned long long)desc.usage, desc.stride); - record("T0-ahb-blob-transfer", "OK", fmt("socket handoff of a %llu-byte BLOB works (%s)", - (unsigned long long)size, alloc.note)); + record("T0-ahb-handoff", "OK", + fmt("socket handoff of a %llu-byte BLOB works (%s) -- handoff only, see T0-ahb-blob-transfer for the tier", + (unsigned long long)size, alloc.note)); - // (a) CPU path: AHardwareBuffer_lock on the receiving side uint32_t writtenMask = 0; + int64_t cpuCmp = -3, vkCmp = -3, glCmp = -3, vkGpuCmp = -2; + bool vkMapped = false, glMapped = false, glPersistent = false; + GpuTouch gt; + std::string vkFail, glFail, cpuFail, gpuFail; + + // (a) CPU path: AHardwareBuffer_lock on the receiving side { void* p = nullptr; rc = AHardwareBuffer_lock(ahb, AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN | AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN, -1, nullptr, &p); if (rc != 0 || !p) { - record("T0-ahb-cpu-lock", "FAIL", fmt("AHardwareBuffer_lock rc=%d errno=%d", rc, errno)); + cpuFail = fmt("AHardwareBuffer_lock rc=%d errno=%d", rc, errno); + record("T0-ahb-cpu-lock", "FAIL", cpuFail); } else { - int64_t cmp = checkRegion(p, REG_A, seedA); + cpuCmp = checkRegion(p, REG_A, seedA); writeRegion(p, REG_D, seedD); writtenMask |= 4; AHardwareBuffer_unlock(ahb, nullptr); - record("T0-ahb-cpu-lock", cmp == -1 ? "OK" : "FAIL", - fmt("cross-process CPU read of the child's payload, mismatch=%lld", (long long)cmp)); + if (cpuCmp != -1) cpuFail = fmt("payload mismatch at %lld", (long long)cpuCmp); + record("T0-ahb-cpu-lock", cpuCmp == -1 ? "OK" : "FAIL", + fmt("cross-process CPU read of the client's payload, mismatch=%lld", (long long)cpuCmp)); } } - // (b) Vulkan import + // (b) Vulkan import (+ a real GPU access on the imported memory) if (!c.hasAhb || !c.pGetAhbProps) { - record("T0-ahb-vulkan-import", "UNSUPPORTED", - "VK_ANDROID_external_memory_android_hardware_buffer absent"); + vkFail = "VK_ANDROID_external_memory_android_hardware_buffer absent"; + record("T0-ahb-vulkan-import", "UNSUPPORTED", vkFail); } else { VkAndroidHardwareBufferPropertiesANDROID props{}; props.sType = VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_PROPERTIES_ANDROID; VkResult r = c.pGetAhbProps(c.device, ahb, &props); if (r != VK_SUCCESS) { - record("T0-ahb-vulkan-import", "FAIL", fmt("vkGetAndroidHardwareBufferPropertiesANDROID=%s", vkStr(r))); + vkFail = "vkGetAndroidHardwareBufferPropertiesANDROID=" + vkStr(r); + record("T0-ahb-vulkan-import", "FAIL", vkFail); } else { pr("T0 AHB props: allocationSize=%llu memoryTypeBits=0x%x", (unsigned long long)props.allocationSize, props.memoryTypeBits); @@ -1476,7 +2453,8 @@ static void runT0Parent(VkCtx& c, GlCtx& g, bool glOk, uint64_t size) { VkBuffer buf = VK_NULL_HANDLE; r = vkCreateBuffer(c.device, &bci, nullptr, &buf); if (r != VK_SUCCESS) { - record("T0-ahb-vulkan-import", "FAIL", fmt("vkCreateBuffer(AHB external)=%s", vkStr(r))); + vkFail = "vkCreateBuffer(AHB external)=" + vkStr(r); + record("T0-ahb-vulkan-import", "FAIL", vkFail); } else { int typeIdx = pickMemType(c.memProps, props.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); @@ -1497,25 +2475,37 @@ static void runT0Parent(VkCtx& c, GlCtx& g, bool glOk, uint64_t size) { VkDeviceMemory mem = VK_NULL_HANDLE; r = vkAllocateMemory(c.device, &mai, nullptr, &mem); if (r != VK_SUCCESS) { - record("T0-ahb-vulkan-import", "FAIL", - fmt("vkAllocateMemory(import AHB)=%s typeIdx=%d bits=0x%x", vkStr(r), typeIdx, - props.memoryTypeBits)); + vkFail = fmt("vkAllocateMemory(import AHB)=%s typeIdx=%d bits=0x%x", vkStr(r).c_str(), typeIdx, + props.memoryTypeBits); + record("T0-ahb-vulkan-import", "FAIL", vkFail); } else { VkResult br = vkBindBufferMemory(c.device, buf, mem, 0); + // GPU access on the client's allocation -- the part that makes + // T0 a tier rather than a successful mmap + if (br == VK_SUCCESS) { + gt = gpuTouch(c, buf, REG_A, seedA, REG_E, gpuWord); + if (gt.ran) writtenMask |= 8; + gpuFail = gt.fail; + } else { + gpuFail = "vkBindBufferMemory=" + vkStr(br); + } void* host = nullptr; VkResult mr = vkMapMemory(c.device, mem, 0, VK_WHOLE_SIZE, 0, &host); if (mr == VK_SUCCESS && host) { - int64_t cmp = checkRegion(host, REG_A, seedA); + vkMapped = true; + vkCmp = checkRegion(host, REG_A, seedA); + if (gt.ran) vkGpuCmp = checkFillWord(host, REG_E, gpuWord); writeRegion(host, REG_B, seedB); writtenMask |= 1; vkUnmapMemory(c.device, mem); - record("T0-ahb-vulkan-import", cmp == -1 ? "OK" : "PARTIAL", - fmt("imported+mapped (hostVisibleType=%d bind=%s) payload mismatch=%lld", - (int)hostVisible, vkStr(br), (long long)cmp)); + if (vkCmp != -1) vkFail = fmt("payload mismatch at %lld", (long long)vkCmp); + record("T0-ahb-vulkan-import", vkCmp == -1 ? "OK" : "PARTIAL", + fmt("imported+mapped (hostVisibleType=%d bind=%s) payload mismatch=%lld gpuFill=%lld", + (int)hostVisible, vkStr(br).c_str(), (long long)vkCmp, (long long)vkGpuCmp)); } else { - record("T0-ahb-vulkan-import", "PARTIAL", - fmt("import ok, vkMapMemory=%s (bind=%s hostVisibleType=%d bits=0x%x)", vkStr(mr), - vkStr(br), (int)hostVisible, props.memoryTypeBits)); + vkFail = fmt("vkMapMemory=%s (bind=%s hostVisibleType=%d bits=0x%x)", vkStr(mr).c_str(), + vkStr(br).c_str(), (int)hostVisible, props.memoryTypeBits); + record("T0-ahb-vulkan-import", "PARTIAL", "import ok, " + vkFail); } vkFreeMemory(c.device, mem, nullptr); } @@ -1523,48 +2513,69 @@ static void runT0Parent(VkCtx& c, GlCtx& g, bool glOk, uint64_t size) { } } } + record("T0-ahb-gpu-access", gt.ran && gt.readMismatch == -1 ? "OK" : (gt.ran ? "FAIL" : "SKIP"), + fmt("vkCmdCopyBuffer out of the client AHB + vkCmdFillBuffer into it: ran=%d submit=%s read=%lld " + "fillSeenByServerMap=%lld %s", + (int)gt.ran, vkStr(gt.submitResult).c_str(), (long long)gt.readMismatch, (long long)vkGpuCmp, + gpuFail.c_str())); // (c) GL import through EGL_ANDROID_get_native_client_buffer + EXT_external_buffer + // -- this is the DirectGLES ("Espryt") form of T0: the server backs a GL + // buffer with the client's allocation and maps it persistent/coherent. if (!glOk) { - record("T0-ahb-gl-import", "SKIP", "no GL context"); + glFail = "no GL context"; + record("T0-ahb-gl-import", "SKIP", glFail); } else if (!g.hasGl("GL_EXT_external_buffer") || !g.pBufferStorageExternal || !g.pGetNativeClientBuffer) { - record("T0-ahb-gl-import", "UNSUPPORTED", - fmt("GL_EXT_external_buffer=%d GL_EXT_buffer_storage=%d eglGetNativeClientBufferANDROID=%d " - "glBufferStorageExternalEXT=%d", - (int)g.hasGl("GL_EXT_external_buffer"), (int)g.hasGl("GL_EXT_buffer_storage"), - (int)(g.pGetNativeClientBuffer != nullptr), (int)(g.pBufferStorageExternal != nullptr))); + glFail = fmt("GL_EXT_external_buffer=%d GL_EXT_buffer_storage=%d eglGetNativeClientBufferANDROID=%d " + "glBufferStorageExternalEXT=%d", + (int)g.hasGl("GL_EXT_external_buffer"), (int)g.hasGl("GL_EXT_buffer_storage"), + (int)(g.pGetNativeClientBuffer != nullptr), (int)(g.pBufferStorageExternal != nullptr)); + record("T0-ahb-gl-import", "UNSUPPORTED", glFail); } else { EGLClientBuffer cb = g.pGetNativeClientBuffer(ahb); if (!cb) { - record("T0-ahb-gl-import", "FAIL", fmt("eglGetNativeClientBufferANDROID=NULL egl=0x%04x", eglGetError())); + glFail = fmt("eglGetNativeClientBufferANDROID=NULL egl=0x%04x", eglGetError()); + record("T0-ahb-gl-import", "FAIL", glFail); } else { GLuint b = 0; glGenBuffers(1, &b); glBindBuffer(GL_ARRAY_BUFFER, b); - while (glGetError() != GL_NO_ERROR) {} + glDrain(); g.pBufferStorageExternal(GL_ARRAY_BUFFER, 0, (GLsizeiptr)size, cb, GL_MAP_READ_BIT | GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT_EXT | GL_MAP_COHERENT_BIT_EXT | GL_DYNAMIC_STORAGE_BIT_EXT); - GLenum err = glGetError(); - if (err != GL_NO_ERROR) { - record("T0-ahb-gl-import", "FAIL", fmt("glBufferStorageExternalEXT -> GL error 0x%04x", err)); + GLenum errStorage = glDrain(); + if (errStorage != GL_NO_ERROR) { + glFail = "glBufferStorageExternalEXT -> " + glErrStr(errStorage); + record("T0-ahb-gl-import", "FAIL", glFail); } else { void* m = glMapBufferRange(GL_ARRAY_BUFFER, 0, (GLsizeiptr)size, GL_MAP_READ_BIT | GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT_EXT | GL_MAP_COHERENT_BIT_EXT); - GLenum merr = glGetError(); + GLenum errMap = glDrain(); + GLenum errMap2 = GL_NO_ERROR; + glPersistent = m != nullptr; if (!m) { - record("T0-ahb-gl-import", "PARTIAL", - fmt("storage ok, glMapBufferRange returned NULL (GL error 0x%04x)", merr)); + m = glMapBufferRange(GL_ARRAY_BUFFER, 0, (GLsizeiptr)size, GL_MAP_READ_BIT | GL_MAP_WRITE_BIT); + errMap2 = glDrain(); + } + if (!m) { + glFail = "glMapBufferRange persistent -> " + glErrStr(errMap) + ", plain -> " + glErrStr(errMap2); + record("T0-ahb-gl-import", "FAIL", "storage ok, " + glFail); } else { - int64_t cmp = checkRegion(m, REG_A, seedA); + glMapped = true; + glCmp = checkRegion(m, REG_A, seedA); writeRegion(m, REG_C, seedC); writtenMask |= 2; glUnmapBuffer(GL_ARRAY_BUFFER); glFinish(); - record("T0-ahb-gl-import", cmp == -1 ? "OK" : "PARTIAL", - fmt("persistent-coherent GL map of the client AHB, payload mismatch=%lld", - (long long)cmp)); + if (glCmp != -1) glFail = fmt("payload mismatch at %lld", (long long)glCmp); + if (!glPersistent) glFail += " [PERSISTENT|COHERENT refused: " + glErrStr(errMap) + "]"; + record("T0-ahb-gl-import", (glCmp == -1 && glPersistent) ? "OK" : "PARTIAL", + fmt("GL map of the client AHB: persistentCoherent=%d (persistent err=%s, plain err=%s) " + "payload mismatch=%lld", + (int)glPersistent, glErrStr(errMap).c_str(), glErrStr(errMap2).c_str(), + (long long)glCmp)); } } glBindBuffer(GL_ARRAY_BUFFER, 0); @@ -1572,38 +2583,106 @@ static void runT0Parent(VkCtx& c, GlCtx& g, bool glOk, uint64_t size) { } } - // (d) ask the child to verify everything the parent wrote + // (d) ask the client to verify everything the server wrote T0Verify ver{}; ver.seedB = seedB; ver.seedC = seedC; ver.seedD = seedD; + ver.gpuWord = gpuWord; ver.writtenMask = writtenMask; + T0Result res{}; + res.mismatchB = res.mismatchC = res.mismatchD = res.mismatchE = -3; + bool gotVerify = false; std::string wbDetail; const char* wbStatus = "FAIL"; if (!sendMsg(sock, MSG_T0_VERIFY, &ver, sizeof(ver), -1)) { wbDetail = fmt("sendMsg(verify) errno=%d", errno); + } else if (!recvMsg(sock, &tag, &res, sizeof(res), &got, nullptr) || tag != MSG_T0_RESULT) { + wbDetail = fmt("no verify reply errno=%d", errno); } else { - T0Result res{}; - if (!recvMsg(sock, &tag, &res, sizeof(res), &got, nullptr) || tag != MSG_T0_RESULT) { - wbDetail = fmt("no verify reply errno=%d", errno); - } else { - bool anyChecked = false, allOk = true; - auto acc = [&](int64_t v) { - if (v == -2) return; - anyChecked = true; - if (v != -1) allOk = false; - }; - acc(res.mismatchB); - acc(res.mismatchC); - acc(res.mismatchD); - wbStatus = !anyChecked ? "SKIP" : (allOk ? "OK" : "FAIL"); - wbDetail = fmt("mask=0x%x vkWrite=%lld glWrite=%lld cpuWrite=%lld (lock=%d)", writtenMask, - (long long)res.mismatchB, (long long)res.mismatchC, (long long)res.mismatchD, - res.lockOk); - } + gotVerify = true; + bool anyChecked = false, allOk = true; + auto acc = [&](int64_t v) { + if (v == -2 || v == -3) return; + anyChecked = true; + if (v != -1) allOk = false; + }; + acc(res.mismatchB); + acc(res.mismatchC); + acc(res.mismatchD); + acc(res.mismatchE); + wbStatus = !anyChecked ? "SKIP" : (allOk ? "OK" : "FAIL"); + wbDetail = fmt("mask=0x%x vkWrite=%lld glWrite=%lld cpuWrite=%lld gpuFill=%lld (clientLock=%d rc=%d)", + writtenMask, (long long)res.mismatchB, (long long)res.mismatchC, (long long)res.mismatchD, + (long long)res.mismatchE, res.lockOk, res.lockErr); } record("T0-ahb-writeback-to-client", wbStatus, wbDetail); + // composite tier verdict: handoff alone is not the tier + std::vector legs; + { + Leg l; + l.name = "vk-import"; + l.decisive = true; + l.attempted = vkMapped; + l.readOk = vkMapped && vkCmp == -1; + l.writeOk = gotVerify && (writtenMask & 1) && res.mismatchB == -1; + if (!l.attempted) + l.fail = vkFail.empty() ? "not attempted" : vkFail; + else if (!l.readOk) + l.fail = "server could not read the client payload: " + vkFail; + else if (!l.writeOk) + l.fail = fmt("server write not visible to the client (back=%lld)", (long long)res.mismatchB); + legs.push_back(l); + } + { + Leg l; + l.name = "gl-import"; + l.decisive = true; + l.attempted = glMapped; + l.readOk = glMapped && glCmp == -1 && glPersistent; + l.writeOk = gotVerify && (writtenMask & 2) && res.mismatchC == -1; + if (!l.attempted) + l.fail = glFail.empty() ? "not attempted" : glFail; + else if (!l.readOk) + l.fail = "GL side: " + glFail; + else if (!l.writeOk) + l.fail = fmt("server GL write not visible to the client (back=%lld)", (long long)res.mismatchC); + legs.push_back(l); + } + { + Leg l; + l.name = "gpu"; + l.decisive = true; + l.attempted = gt.ran; + l.readOk = gt.readMismatch == -1; + l.writeOk = gotVerify && (writtenMask & 8) && res.mismatchE == -1; + if (!gt.ran) + l.fail = "GPU touch did not run: " + gpuFail; + else if (!l.readOk) + l.fail = fmt("GPU read of the client allocation mismatched at %lld", (long long)gt.readMismatch); + else if (!l.writeOk) + l.fail = fmt("GPU write not visible to the client (back=%lld)", (long long)res.mismatchE); + legs.push_back(l); + } + { + Leg l; + l.name = "cpu-lock"; + l.decisive = false; // informational: proves the handle, not the tier + l.attempted = cpuCmp != -3; + l.readOk = cpuCmp == -1; + l.writeOk = gotVerify && (writtenMask & 4) && res.mismatchD == -1; + l.fail = cpuFail; + legs.push_back(l); + } + std::string why; + const char* status = legVerdict(legs, &why); + record("T0-ahb-blob-transfer", status, + fmt("%s | full chain handoff+import+map+compare+writeback | cpuCmp=%lld vkCmp=%lld glCmp=%lld " + "glPersistentCoherent=%d gpuRan=%d | %s", + legTrace(legs).c_str(), (long long)cpuCmp, (long long)vkCmp, (long long)glCmp, (int)glPersistent, + (int)gt.ran, why.c_str())); + sendMsg(sock, MSG_BYE, nullptr, 0, -1); std::string reap = reapChild(pid); pr("T0 %s", reap.c_str()); @@ -1627,6 +2706,110 @@ static void runT0Parent(VkCtx&, GlCtx&, bool, uint64_t) { // T3: VK_EXT_external_memory_host over a memfd-backed mapping // --------------------------------------------------------------------------- +// Reserves an alignment-corrected window and places `fd` inside it. Returns the +// aligned pointer, or nullptr; `*reserveOut` must be munmap'ed with +// `mapSize + align` bytes. +static void* mapAlignedFd(int fd, uint64_t mapSize, uint64_t align, void** reserveOut, std::string* fail) { + *reserveOut = mmap(nullptr, (size_t)(mapSize + align), PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (*reserveOut == MAP_FAILED) { + *reserveOut = nullptr; + *fail = fmt("reserve mmap errno=%d(%s)", errno, strerror(errno)); + return nullptr; + } + uintptr_t base = ((uintptr_t)*reserveOut + align - 1) & ~(uintptr_t)(align - 1); + void* host = mmap((void*)base, (size_t)mapSize, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_FIXED, fd, 0); + if (host == MAP_FAILED) { + *fail = fmt("mmap(fd, MAP_FIXED) errno=%d(%s)", errno, strerror(errno)); + munmap(*reserveOut, (size_t)(mapSize + align)); + *reserveOut = nullptr; + return nullptr; + } + return host; +} + +// Imports `host` as VkDeviceMemory and binds a buffer to it. +struct HostImport { + VkBuffer buf = VK_NULL_HANDLE; + VkDeviceMemory mem = VK_NULL_HANDLE; + void* mapped = nullptr; + int typeIdx = -1; + VkResult hostPtrProps = VK_NOT_READY; + VkResult createResult = VK_NOT_READY; + VkResult allocResult = VK_NOT_READY; + VkResult bindResult = VK_NOT_READY; + VkResult mapResult = VK_NOT_READY; + uint32_t bits = 0; + std::string fail; +}; + +static bool importHostPointer(VkCtx& c, void* host, uint64_t mapSize, HostImport& o) { + VkMemoryHostPointerPropertiesEXT hp{}; + hp.sType = VK_STRUCTURE_TYPE_MEMORY_HOST_POINTER_PROPERTIES_EXT; + o.hostPtrProps = c.pGetHostPtrProps(c.device, VK_EXTERNAL_MEMORY_HANDLE_TYPE_HOST_ALLOCATION_BIT_EXT, host, &hp); + if (o.hostPtrProps != VK_SUCCESS) { + o.fail = "vkGetMemoryHostPointerPropertiesEXT=" + vkStr(o.hostPtrProps); + return false; + } + VkExternalMemoryBufferCreateInfo ext{}; + ext.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_BUFFER_CREATE_INFO; + ext.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_HOST_ALLOCATION_BIT_EXT; + VkBufferCreateInfo bci{}; + bci.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bci.pNext = &ext; + bci.size = mapSize; + bci.usage = kProbeBufferUsage; + o.createResult = vkCreateBuffer(c.device, &bci, nullptr, &o.buf); + VkMemoryRequirements req{}; + if (o.createResult == VK_SUCCESS) { + vkGetBufferMemoryRequirements(c.device, o.buf, &req); + } else { + o.buf = VK_NULL_HANDLE; + req.memoryTypeBits = 0xFFFFFFFFu; + } + o.bits = hp.memoryTypeBits & req.memoryTypeBits; + o.typeIdx = pickMemType(c.memProps, o.bits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + if (o.typeIdx < 0) o.typeIdx = pickMemType(c.memProps, o.bits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT); + if (o.typeIdx < 0) { + o.fail = fmt("no host-visible memory type in hostPtrBits=0x%x & reqBits=0x%x", hp.memoryTypeBits, + req.memoryTypeBits); + return false; + } + VkImportMemoryHostPointerInfoEXT imp{}; + imp.sType = VK_STRUCTURE_TYPE_IMPORT_MEMORY_HOST_POINTER_INFO_EXT; + imp.handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_HOST_ALLOCATION_BIT_EXT; + imp.pHostPointer = host; + VkMemoryAllocateInfo mai{}; + mai.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + mai.pNext = &imp; + mai.allocationSize = mapSize; + mai.memoryTypeIndex = (uint32_t)o.typeIdx; + o.allocResult = vkAllocateMemory(c.device, &mai, nullptr, &o.mem); + if (o.allocResult != VK_SUCCESS) { + o.mem = VK_NULL_HANDLE; + o.fail = fmt("vkAllocateMemory(import host ptr)=%s type=%d bits=0x%x", vkStr(o.allocResult).c_str(), o.typeIdx, + o.bits); + return false; + } + o.bindResult = (o.buf != VK_NULL_HANDLE) ? vkBindBufferMemory(c.device, o.buf, o.mem, 0) : VK_SUCCESS; + o.mapResult = vkMapMemory(c.device, o.mem, 0, VK_WHOLE_SIZE, 0, &o.mapped); + if (o.mapResult != VK_SUCCESS) { + o.mapped = nullptr; + o.fail = "vkMapMemory=" + vkStr(o.mapResult); + return false; + } + return true; +} + +static void releaseHostImport(VkCtx& c, HostImport& o) { + if (o.mapped) vkUnmapMemory(c.device, o.mem); + if (o.mem) vkFreeMemory(c.device, o.mem, nullptr); + if (o.buf) vkDestroyBuffer(c.device, o.buf, nullptr); + o.mapped = nullptr; + o.mem = VK_NULL_HANDLE; + o.buf = VK_NULL_HANDLE; +} + static int childT3(int sock) { setRecvTimeout(sock, 30); T3Offer offer{}; @@ -1636,6 +2819,7 @@ static int childT3(int sock) { if (!recvMsg(sock, &tag, &offer, sizeof(offer), &got, &fd) || tag != MSG_T3_OFFER) return 2; T3Result res{}; res.mismatch = -3; + res.gpuMismatch = -2; if (fd < 0) { snprintf(res.note, sizeof(res.note), "no fd"); sendMsg(sock, MSG_T3_RESULT, &res, sizeof(res), -1); @@ -1649,7 +2833,9 @@ static int childT3(int sock) { } else { res.mmapOk = 1; res.mismatch = checkRegion(p, REG_A, offer.seedA); + if (offer.gpuRan) res.gpuMismatch = checkFillWord(p, REG_E, offer.gpuWord); writeRegion(p, REG_B, offer.seedB); + msync(p, (size_t)offer.size, MS_SYNC); munmap(p, (size_t)offer.size); } sendMsg(sock, MSG_T3_RESULT, &res, sizeof(res), -1); @@ -1657,9 +2843,203 @@ static int childT3(int sock) { return 0; } +// The direction that makes T3 a tier: the CLIENT allocates the memory and the +// SERVER imports the client's host pointer. The child is the client here. +static int childT3Client(int sock) { + setRecvTimeout(sock, 30); + T3cRequest rq{}; + uint32_t tag = 0; + size_t got = 0; + if (!recvMsg(sock, &tag, &rq, sizeof(rq), &got, nullptr) || tag != MSG_T3C_REQUEST) return 2; + + T3cReady ready{}; + ready.size = rq.size; + int memfd = memfd_create("extmem_probe_client", 0); + if (memfd < 0) { + ready.err = errno; + snprintf(ready.note, sizeof(ready.note), "memfd_create errno=%d(%s)", errno, strerror(errno)); + sendMsg(sock, MSG_T3C_READY, &ready, sizeof(ready), -1); + return 3; + } + if (ftruncate(memfd, (off_t)rq.size) != 0) { + ready.err = errno; + snprintf(ready.note, sizeof(ready.note), "ftruncate errno=%d(%s)", errno, strerror(errno)); + sendMsg(sock, MSG_T3C_READY, &ready, sizeof(ready), -1); + close(memfd); + return 4; + } + void* p = mmap(nullptr, (size_t)rq.size, PROT_READ | PROT_WRITE, MAP_SHARED, memfd, 0); + if (p == MAP_FAILED) { + ready.err = errno; + snprintf(ready.note, sizeof(ready.note), "mmap errno=%d(%s)", errno, strerror(errno)); + sendMsg(sock, MSG_T3C_READY, &ready, sizeof(ready), -1); + close(memfd); + return 5; + } + memset(p, 0, (size_t)rq.size); + writeRegion(p, REG_A, rq.seedA); + ready.ok = 1; + snprintf(ready.note, sizeof(ready.note), "client memfd %s", describeFd(memfd).c_str()); + if (!sendMsg(sock, MSG_T3C_READY, &ready, sizeof(ready), memfd)) { + munmap(p, (size_t)rq.size); + close(memfd); + return 6; + } + + T3cVerify ver{}; + T3cResult res{}; + res.mismatchB = res.mismatchE = -2; + if (!recvMsg(sock, &tag, &ver, sizeof(ver), &got, nullptr) || tag != MSG_T3C_VERIFY) { + munmap(p, (size_t)rq.size); + close(memfd); + return 7; + } + if (ver.mask & 1) res.mismatchB = checkRegion(p, REG_B, ver.seedB); + if (ver.mask & 2) res.mismatchE = checkFillWord(p, REG_E, ver.gpuWord); + snprintf(res.note, sizeof(res.note), "mask=0x%x", ver.mask); + sendMsg(sock, MSG_T3C_RESULT, &res, sizeof(res), -1); + munmap(p, (size_t)rq.size); + close(memfd); + return 0; +} + +static void runT3ClientAllocParent(VkCtx& c, uint64_t size) { + const char* route = "T3-client-memfd-server-import"; + if (!c.hasExtMemHost || !c.pGetHostPtrProps) { + record(route, "UNSUPPORTED", "VK_EXT_external_memory_host absent"); + return; + } + uint64_t align = c.minImportedHostPointerAlignment ? c.minImportedHostPointerAlignment : 4096; + uint64_t mapSize = (size + align - 1) & ~(align - 1); + const uint32_t seedA = 0x3C3C0001u, seedB = 0x3C3C0002u, gpuWord = 0x3C3C1234u; + + int sock = -1; + pid_t pid = spawnChild("t3c", &sock); + if (pid < 0) { + record(route, "FAIL", "spawnChild failed"); + return; + } + T3cRequest rq{}; + rq.size = mapSize; + rq.seedA = seedA; + if (!sendMsg(sock, MSG_T3C_REQUEST, &rq, sizeof(rq), -1)) { + record(route, "FAIL", fmt("sendMsg(request) errno=%d", errno)); + close(sock); + reapChild(pid); + return; + } + T3cReady ready{}; + uint32_t tag = 0; + size_t got = 0; + int fd = -1; + if (!recvMsg(sock, &tag, &ready, sizeof(ready), &got, &fd) || tag != MSG_T3C_READY) { + record(route, "FAIL", fmt("no ready reply errno=%d %s", errno, reapChild(pid).c_str())); + close(sock); + return; + } + if (!ready.ok || fd < 0) { + record(route, "FAIL", fmt("client could not allocate: %s (fd=%d)", ready.note, fd)); + if (fd >= 0) close(fd); + sendMsg(sock, MSG_BYE, nullptr, 0, -1); + reapChild(pid); + close(sock); + return; + } + pr("T3c server received the client's memfd: %s", describeFd(fd).c_str()); + + void* reserve = nullptr; + std::string mapFail; + void* host = mapAlignedFd(fd, mapSize, align, &reserve, &mapFail); + if (!host) { + record(route, "FAIL", "server could not map the client's memfd: " + mapFail); + close(fd); + sendMsg(sock, MSG_BYE, nullptr, 0, -1); + reapChild(pid); + close(sock); + return; + } + + HostImport hi; + bool imported = importHostPointer(c, host, mapSize, hi); + int64_t cmpA = -3, gpuFillSeen = -3; + GpuTouch gt; + if (imported) { + cmpA = checkRegion(hi.mapped, REG_A, seedA); // server reads what the client wrote + writeRegion(hi.mapped, REG_B, seedB); // server writes back + gt = gpuTouch(c, hi.buf, REG_A, seedA, REG_E, gpuWord); + gpuFillSeen = gt.ran ? checkFillWord(hi.mapped, REG_E, gpuWord) : -3; + } + + T3cVerify ver{}; + ver.seedB = seedB; + ver.gpuWord = gpuWord; + ver.mask = (imported ? 1u : 0u) | ((gt.ran && gpuFillSeen == -1) ? 2u : 0u); + T3cResult res{}; + res.mismatchB = res.mismatchE = -3; + bool gotVerify = false; + if (sendMsg(sock, MSG_T3C_VERIFY, &ver, sizeof(ver), -1) && + recvMsg(sock, &tag, &res, sizeof(res), &got, nullptr) && tag == MSG_T3C_RESULT) { + gotVerify = true; + } + + std::vector legs; + { + Leg l; + l.name = "server-import"; + l.decisive = true; + l.attempted = imported; + l.readOk = imported && cmpA == -1; + l.writeOk = gotVerify && (ver.mask & 1) && res.mismatchB == -1; + if (!imported) + l.fail = hi.fail; + else if (!l.readOk) + l.fail = fmt("server could not read the client's payload (cmp=%lld)", (long long)cmpA); + else if (!l.writeOk) + l.fail = fmt("server write not visible to the client (back=%lld)", (long long)res.mismatchB); + legs.push_back(l); + } + { + Leg l; + l.name = "gpu"; + l.decisive = true; + l.attempted = gt.ran; + l.readOk = gt.readMismatch == -1; + l.writeOk = gotVerify && (ver.mask & 2) && res.mismatchE == -1; + if (!gt.ran) + l.fail = "GPU touch did not run: " + gt.fail; + else if (!l.readOk) + l.fail = fmt("GPU read of the client's memory mismatched at %lld", (long long)gt.readMismatch); + else if (!l.writeOk) + l.fail = fmt("GPU write not visible to the client (serverMap=%lld clientMap=%lld)", + (long long)gpuFillSeen, (long long)res.mismatchE); + legs.push_back(l); + } + std::string why; + const char* status = legVerdict(legs, &why); + std::string detail = + fmt("%s | client allocates, server imports: align=%llu size=%llu hostPtrProps=%s create=%s alloc=%s bind=%s " + "map=%s type=%d bits=0x%x | serverReadOfClient=%lld clientReadOfServer=%lld gpuRead=%lld " + "gpuFill(server=%lld,client=%lld) | %s [%s] ", + legTrace(legs).c_str(), (unsigned long long)align, (unsigned long long)mapSize, + vkStr(hi.hostPtrProps).c_str(), vkStr(hi.createResult).c_str(), vkStr(hi.allocResult).c_str(), + vkStr(hi.bindResult).c_str(), vkStr(hi.mapResult).c_str(), hi.typeIdx, hi.bits, (long long)cmpA, + (long long)res.mismatchB, (long long)gt.readMismatch, (long long)gpuFillSeen, (long long)res.mismatchE, + why.c_str(), ready.note); + + releaseHostImport(c, hi); + munmap(host, (size_t)mapSize); + if (reserve) munmap(reserve, (size_t)(mapSize + align)); + close(fd); + sendMsg(sock, MSG_BYE, nullptr, 0, -1); + detail += reapChild(pid); + close(sock); + record(route, status, detail); +} + static void runT3Parent(VkCtx& c, uint64_t size) { if (!c.hasExtMemHost || !c.pGetHostPtrProps) { record("T3-external-memory-host", "UNSUPPORTED", "VK_EXT_external_memory_host absent"); + record("T3-memfd-cross-process", "UNSUPPORTED", "VK_EXT_external_memory_host absent"); return; } uint64_t align = c.minImportedHostPointerAlignment ? c.minImportedHostPointerAlignment : 4096; @@ -1675,82 +3055,60 @@ static void runT3Parent(VkCtx& c, uint64_t size) { close(memfd); return; } - // reserve an aligned window, then place the memfd inside it - void* reserve = mmap(nullptr, (size_t)(mapSize + align), PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (reserve == MAP_FAILED) { - record("T3-external-memory-host", "FAIL", fmt("reserve mmap errno=%d", errno)); + void* reserve = nullptr; + std::string mapFail; + void* host = mapAlignedFd(memfd, mapSize, align, &reserve, &mapFail); + if (!host) { + record("T3-external-memory-host", "FAIL", mapFail); close(memfd); return; } - uintptr_t base = ((uintptr_t)reserve + align - 1) & ~(uintptr_t)(align - 1); - void* host = mmap((void*)base, (size_t)mapSize, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_FIXED, memfd, 0); - if (host == MAP_FAILED) { - record("T3-external-memory-host", "FAIL", fmt("mmap(memfd, MAP_FIXED) errno=%d", errno)); - munmap(reserve, (size_t)(mapSize + align)); - close(memfd); - return; - } - const uint32_t seedA = 0x33330001u, seedB = 0x33330002u; + const uint32_t seedA = 0x33330001u, seedB = 0x33330002u, gpuWord = 0x33331234u; memset(host, 0, (size_t)mapSize); writeRegion(host, REG_A, seedA); - VkMemoryHostPointerPropertiesEXT hp{}; - hp.sType = VK_STRUCTURE_TYPE_MEMORY_HOST_POINTER_PROPERTIES_EXT; - VkResult r = c.pGetHostPtrProps(c.device, VK_EXTERNAL_MEMORY_HANDLE_TYPE_HOST_ALLOCATION_BIT_EXT, host, &hp); - if (r != VK_SUCCESS) { - record("T3-external-memory-host", "FAIL", fmt("vkGetMemoryHostPointerPropertiesEXT=%s align=%llu", vkStr(r), - (unsigned long long)align)); - } else { - VkExternalMemoryBufferCreateInfo ext{}; - ext.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_BUFFER_CREATE_INFO; - ext.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_HOST_ALLOCATION_BIT_EXT; - VkBufferCreateInfo bci{}; - bci.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; - bci.pNext = &ext; - bci.size = mapSize; - bci.usage = kProbeBufferUsage; - VkBuffer buf = VK_NULL_HANDLE; - VkResult cr = vkCreateBuffer(c.device, &bci, nullptr, &buf); - VkMemoryRequirements req{}; - if (cr == VK_SUCCESS) vkGetBufferMemoryRequirements(c.device, buf, &req); - uint32_t bits = hp.memoryTypeBits & (cr == VK_SUCCESS ? req.memoryTypeBits : 0xFFFFFFFFu); - int typeIdx = pickMemType(c.memProps, bits, - VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); - if (typeIdx < 0) typeIdx = pickMemType(c.memProps, bits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT); - if (typeIdx < 0) { - record("T3-external-memory-host", "FAIL", - fmt("no host-visible type in hostPtrBits=0x%x & reqBits=0x%x", hp.memoryTypeBits, - req.memoryTypeBits)); - } else { - VkImportMemoryHostPointerInfoEXT imp{}; - imp.sType = VK_STRUCTURE_TYPE_IMPORT_MEMORY_HOST_POINTER_INFO_EXT; - imp.handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_HOST_ALLOCATION_BIT_EXT; - imp.pHostPointer = host; - VkMemoryAllocateInfo mai{}; - mai.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; - mai.pNext = &imp; - mai.allocationSize = mapSize; - mai.memoryTypeIndex = (uint32_t)typeIdx; - VkDeviceMemory mem = VK_NULL_HANDLE; - VkResult ar = vkAllocateMemory(c.device, &mai, nullptr, &mem); - if (ar != VK_SUCCESS) { - record("T3-external-memory-host", "FAIL", - fmt("vkAllocateMemory(import host ptr)=%s type=%d bits=0x%x align=%llu", vkStr(ar), typeIdx, - bits, (unsigned long long)align)); - } else { - VkResult br = (cr == VK_SUCCESS) ? vkBindBufferMemory(c.device, buf, mem, 0) : VK_SUCCESS; - void* mapped = nullptr; - VkResult mr = vkMapMemory(c.device, mem, 0, VK_WHOLE_SIZE, 0, &mapped); - int64_t cmp = -3; - if (mr == VK_SUCCESS && mapped) cmp = checkRegion(mapped, REG_A, seedA); - if (mr == VK_SUCCESS) vkUnmapMemory(c.device, mem); - record("T3-external-memory-host", (mr == VK_SUCCESS && cmp == -1) ? "OK" : "PARTIAL", - fmt("import ok (align=%llu type=%d bind=%s) vkMapMemory=%s mismatch=%lld", - (unsigned long long)align, typeIdx, vkStr(br), vkStr(mr), (long long)cmp)); - vkFreeMemory(c.device, mem, nullptr); - } - } - if (cr == VK_SUCCESS) vkDestroyBuffer(c.device, buf, nullptr); + HostImport hi; + bool imported = importHostPointer(c, host, mapSize, hi); + int64_t cmpA = -3, gpuFillSeen = -3; + GpuTouch gt; + if (imported) { + cmpA = checkRegion(hi.mapped, REG_A, seedA); + gt = gpuTouch(c, hi.buf, REG_A, seedA, REG_E, gpuWord); + gpuFillSeen = gt.ran ? checkFillWord(host, REG_E, gpuWord) : -3; + } + { + std::vector legs; + Leg l; + l.name = "import-map"; + l.decisive = true; + l.attempted = imported; + // one process on both ends here, so the "write back" direction is the + // imported mapping seeing the original mmap's bytes + l.readOk = imported && cmpA == -1; + l.writeOk = imported && cmpA == -1; + l.fail = imported ? (cmpA == -1 ? "" : fmt("payload mismatch at %lld", (long long)cmpA)) : hi.fail; + legs.push_back(l); + Leg gl; + gl.name = "gpu"; + gl.decisive = true; + gl.attempted = gt.ran; + gl.readOk = gt.readMismatch == -1; + gl.writeOk = gpuFillSeen == -1; + if (!gt.ran) + gl.fail = "GPU touch did not run: " + gt.fail; + else if (!gl.readOk) + gl.fail = fmt("GPU read mismatched at %lld", (long long)gt.readMismatch); + else if (!gl.writeOk) + gl.fail = fmt("GPU write not visible through the host mapping (at %lld)", (long long)gpuFillSeen); + legs.push_back(gl); + std::string why; + record("T3-external-memory-host", legVerdict(legs, &why), + fmt("%s | align=%llu type=%d bits=0x%x hostPtrProps=%s alloc=%s bind=%s map=%s mismatch=%lld " + "gpuRead=%lld gpuFill=%lld %s", + legTrace(legs).c_str(), (unsigned long long)align, hi.typeIdx, hi.bits, + vkStr(hi.hostPtrProps).c_str(), vkStr(hi.allocResult).c_str(), vkStr(hi.bindResult).c_str(), + vkStr(hi.mapResult).c_str(), (long long)cmpA, (long long)gt.readMismatch, + (long long)gpuFillSeen, why.c_str())); } // the same memfd handed to another process @@ -1763,6 +3121,8 @@ static void runT3Parent(VkCtx& c, uint64_t size) { off.size = mapSize; off.seedA = seedA; off.seedB = seedB; + off.gpuWord = gpuWord; + off.gpuRan = (gt.ran && gpuFillSeen == -1) ? 1u : 0u; if (!sendMsg(sock, MSG_T3_OFFER, &off, sizeof(off), memfd)) { record("T3-memfd-cross-process", "FAIL", fmt("sendMsg errno=%d", errno)); } else { @@ -1773,9 +3133,26 @@ static void runT3Parent(VkCtx& c, uint64_t size) { record("T3-memfd-cross-process", "FAIL", fmt("no reply errno=%d", errno)); } else { int64_t back = res.mmapOk ? checkRegion(host, REG_B, seedB) : -3; - record("T3-memfd-cross-process", (res.mmapOk && res.mismatch == -1 && back == -1) ? "OK" : "FAIL", - fmt("child mmap=%d errno=%d cmp=%lld writeback=%lld [%s]", res.mmapOk, res.mmapErrno, - (long long)res.mismatch, (long long)back, res.note)); + std::vector legs; + Leg l; + l.name = "peer-mmap"; + l.decisive = true; + l.attempted = res.mmapOk != 0; + l.readOk = res.mmapOk && res.mismatch == -1 && (!off.gpuRan || res.gpuMismatch == -1); + l.writeOk = res.mmapOk && back == -1; + if (!l.attempted) + l.fail = fmt("peer mmap failed errno=%d(%s)", res.mmapErrno, strerror(res.mmapErrno)); + else if (!l.readOk) + l.fail = fmt("peer could not read (cmp=%lld gpuCmp=%lld)", (long long)res.mismatch, + (long long)res.gpuMismatch); + else if (!l.writeOk) + l.fail = fmt("peer write not visible here (back=%lld)", (long long)back); + legs.push_back(l); + std::string why; + record("T3-memfd-cross-process", legVerdict(legs, &why), + fmt("%s | child mmap=%d errno=%d cmp=%lld gpuCmp=%lld writeback=%lld %s [%s]", + legTrace(legs).c_str(), res.mmapOk, res.mmapErrno, (long long)res.mismatch, + (long long)res.gpuMismatch, (long long)back, why.c_str(), res.note)); } } sendMsg(sock, MSG_BYE, nullptr, 0, -1); @@ -1783,8 +3160,9 @@ static void runT3Parent(VkCtx& c, uint64_t size) { close(sock); } + releaseHostImport(c, hi); munmap(host, (size_t)mapSize); - munmap(reserve, (size_t)(mapSize + align)); + if (reserve) munmap(reserve, (size_t)(mapSize + align)); close(memfd); } @@ -1795,7 +3173,9 @@ static void runT3Parent(VkCtx& c, uint64_t size) { static void printSummary() { char model[PROP_VALUE_MAX] = {0}; getProp("ro.product.model", model, sizeof(model)); - printf("\n=== extmem_probe summary (model=%s) ===\n", model); + std::string sec = readSmallFile("/proc/self/attr/current"); + printf("\n=== extmem_probe summary (model=%s selinux=%s) ===\n", model, sec.c_str()); + printf("NOTE: %s\n", kDomainCaveat); printf("%-34s %-12s %s\n", "ROUTE", "STATUS", "DETAIL"); for (const RouteResult& r : gResults) printf("%-34s %-12s %s\n", r.route.c_str(), r.status.c_str(), r.detail.c_str()); @@ -1806,7 +3186,7 @@ static void printSummary() { int main(int argc, char** argv) { uint64_t size = kDefaultSize; const char* childRoute = nullptr; - bool doT1 = true, doT0 = true, doT3 = true; + bool doT1 = true, doT0 = true, doT3 = true, doGles = true; for (int i = 1; i < argc; ++i) { if (!strncmp(argv[i], "--child=", 8)) { childRoute = argv[i] + 8; @@ -1815,15 +3195,19 @@ int main(int argc, char** argv) { } else if (!strcmp(argv[i], "--only-t1")) { doT0 = doT3 = false; } else if (!strcmp(argv[i], "--only-t0")) { - doT1 = doT3 = false; + doT1 = doT3 = doGles = false; } else if (!strcmp(argv[i], "--only-t3")) { - doT1 = doT0 = false; + doT1 = doT0 = doGles = false; + } else if (!strcmp(argv[i], "--only-gles")) { + doT1 = doT0 = doT3 = false; + } else if (!strcmp(argv[i], "--no-gles")) { + doGles = false; } else if (!strcmp(argv[i], "--help")) { - printf("usage: extmem_probe [--size=BYTES] [--only-t0|--only-t1|--only-t3]\n"); + printf("usage: extmem_probe [--size=BYTES] [--only-t0|--only-t1|--only-t3|--only-gles] [--no-gles]\n"); return 0; } } - if (size < 4 * kRegion) size = 4 * kRegion; + if (size < kRegionCount * kRegion) size = kRegionCount * kRegion; // A peer that has already exited must not take this process down with it. signal(SIGPIPE, SIG_IGN); @@ -1834,13 +3218,16 @@ int main(int argc, char** argv) { gRole = roleBuf; int sock = 3; if (!strcmp(childRoute, "t1")) return childT1(sock); + if (!strcmp(childRoute, "t1gl")) return childT1Gl(sock); if (!strcmp(childRoute, "t0")) return childT0(sock); if (!strcmp(childRoute, "t3")) return childT3(sock); + if (!strcmp(childRoute, "t3c")) return childT3Client(sock); pr("unknown child route %s", childRoute); return 1; } pr("extmem_probe: MobileGL disaggregation spike B, size=%llu bytes", (unsigned long long)size); + printRunContext(); VkCtx c; bool vkOk = vkCtxInit(c, true); @@ -1859,10 +3246,15 @@ int main(int argc, char** argv) { runT1Parent(c, VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT, "T1-opaque-fd", size); runT1Parent(c, VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT, "T1-dma-buf", size); } + pr("=== phase T1-gles: the same export imported as GL buffer storage ==="); + if (doGles) runT1GlesParent(c, g, glOk, size); pr("=== phase T0: client-allocated AHardwareBuffer BLOB ==="); if (doT0) runT0Parent(c, g, glOk, size); pr("=== phase T3: VK_EXT_external_memory_host ==="); - if (doT3) runT3Parent(c, size); + if (doT3) { + runT3Parent(c, size); + runT3ClientAllocParent(c, size); + } glCtxDestroy(g); vkCtxDestroy(c); From 1154f9a00d238f132d434ded748a43f8382466a6 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 20:54:13 -0400 Subject: [PATCH 025/529] [Fix] (MG_Remote, Transport): give the inproc doorbell a death state so Shutdown can join a parked waiter, and bound a ring record at half the capacity so a refusal can never look like backpressure - T-1: InProcessChannel::Close rang each CondVarDoorbell once and claimed that unparks a peer mid-frame. It does not. Doorbell::Wait consumes the one ring, re-tests a condition nothing published, finds the bell alive (CondVarDoorbell never overrode Dead(); Doorbell.cpp had no death state at all) and with kWaitForever parks again for good - so Shutdown could never join a server thread sitting in the design's own steady state (spun, set consumerParked, blocked; plan section 8.1 inheriting the earlier plan's 6.2a). CondVarDoorbell now carries an atomic death latch: Kill() sets it under the mutex and notify_all's, Dead() reports it, Park returns false at once on a dead bell (and the wait predicate includes it, so a Kill cannot slip between the test and the wait), and Close kills both bells instead of ringing them. Same shape as SocketDoorbell's EOF latch; Notify stays the ordinary wakeup. - T-2: RingProducer::Reserve refused only total > capacity, but a record with capacity/2 < total <= capacity is unplaceable at every head offset where neither the space to the wrap boundary nor the space before it holds it - even in an EMPTY ring, because a wrap pad costs spaceToEnd bytes on top of the record. Concretely: head offset 16 of an empty 256-byte ring, a 248-byte record; FreeBytes() says 256, Reserve says nullptr, forever, and a producer waiting for FreeBytes() >= 248 stalls with nothing logged. The bound is now capacity/2, which is exact rather than conservative (worst case 2*total-8 <= capacity-8), exposed as MaxRecordBytes() for the emitter to chunk against; the minimum ring is two headers so the smallest record still fits the bound. Ring.h states Capacity()/2 as the chunking bound and the G3 header comment in gen_pipe.py now states the chunking rule plan section 8.2 asks G3 to define (PipeWire.inc regenerated). - Tests, each shown red with only the fix site reverted and green with it: InProcessTransportTest.ShutdownUnparksAWaiterWithNoDeadline (bounded join through a shared_ptr-owned waiter: 5 s red instead of a hung job; reverted it hangs and fails at 5051 ms), RingTest.RecordLargerThanHalfTheRingIsRefused (reverted, the 248-byte record is accepted), RingTest.RecordPlaceabilityDoesNotDependOnTheHeadOffset (the offset-0 vs offset-16 negative control), RingTest.HalfCapacityRecordFitsAtEveryHeadOffset (the positive half: the maximal record at all 32 head offsets of a 256-byte ring) and RingTest.RejectsARingTooSmallForTheSmallestRecord. --- MobileGL/MG_Pipe/generated/PipeWire.inc | 10 +++ MobileGL/MG_Remote/Transport/Doorbell.cpp | 29 +++++- MobileGL/MG_Remote/Transport/Doorbell.h | 28 ++++-- .../Transport/InProcessTransport.cpp | 13 ++- MobileGL/MG_Remote/Transport/Ring.cpp | 38 +++++--- MobileGL/MG_Remote/Transport/Ring.h | 33 +++++-- .../MG_Test/Wire/InProcessTransportTest.cpp | 66 ++++++++++++++ MobileGL/MG_Test/Wire/RingTest.cpp | 88 +++++++++++++++++++ scripts/gen_pipe.py | 10 +++ 9 files changed, 285 insertions(+), 30 deletions(-) diff --git a/MobileGL/MG_Pipe/generated/PipeWire.inc b/MobileGL/MG_Pipe/generated/PipeWire.inc index 426d3e14e..249fa6340 100644 --- a/MobileGL/MG_Pipe/generated/PipeWire.inc +++ b/MobileGL/MG_Pipe/generated/PipeWire.inc @@ -21,6 +21,16 @@ // a record that is shorter than its own type, longer than what is left in the buffer, or // not a multiple of 8 is protocol corruption and is fatal. There is no recovery path - // silently applying a truncated record is how a corrupt stream becomes a wrong picture. +// +// OVERSIZED PAYLOADS ARE CHUNKED, NEVER EMITTED WHOLE (plan section 8.2: G3 has to define +// the path for a record larger than the segment). The bound is the ring's, +// RingProducer::MaxRecordBytes() == Capacity()/2, and it is exact rather than +// conservative: a record has to be placeable at every head offset of an empty ring, the +// wrap pad in front of it costs up to total-8 bytes, and only a record of at most half the +// ring survives that at every offset. An emitter holding more than Capacity()/2 bytes of +// record (a large resource_subdata, a create_shader_state archive) splits it into several +// records of at most that size; the transport refuses a bigger one outright - nullptr plus +// an MGLOG_E - rather than let the producer wait on free bytes that can never suffice. struct MGPWireRecHeader { Uint16 Op; // MGPWireOp diff --git a/MobileGL/MG_Remote/Transport/Doorbell.cpp b/MobileGL/MG_Remote/Transport/Doorbell.cpp index 8c5301954..7ec1f2154 100644 --- a/MobileGL/MG_Remote/Transport/Doorbell.cpp +++ b/MobileGL/MG_Remote/Transport/Doorbell.cpp @@ -48,6 +48,12 @@ namespace MobileGL::MG_Remote::Transport { bool CondVarDoorbell::Park(std::uint32_t timeoutMs) { std::unique_lock lock(m_impl->mutex); + // The death latch is tested under the same mutex Kill sets it under, so + // a Kill cannot slip between this test and the wait below: it either + // returns here or wakes the predicate. + if (m_dead.load(std::memory_order_relaxed)) { + return false; + } if (m_impl->signals != 0) { --m_impl->signals; return true; @@ -55,16 +61,33 @@ namespace MobileGL::MG_Remote::Transport { if (timeoutMs == 0) { return false; } + const auto woken = [this] { + return m_impl->signals != 0 || m_dead.load(std::memory_order_relaxed); + }; if (timeoutMs == kWaitForever) { - m_impl->cv.wait(lock, [this] { return m_impl->signals != 0; }); - } else if (!m_impl->cv.wait_for(lock, std::chrono::milliseconds(timeoutMs), - [this] { return m_impl->signals != 0; })) { + m_impl->cv.wait(lock, woken); + } else if (!m_impl->cv.wait_for(lock, std::chrono::milliseconds(timeoutMs), woken)) { + return false; + } + if (m_dead.load(std::memory_order_relaxed)) { + // Woken by Kill, not by an event. The caller re-tests its condition + // regardless (Doorbell::Wait always does) and then sees Dead(). return false; } --m_impl->signals; return true; } + void CondVarDoorbell::Kill() { + { + std::lock_guard lock(m_impl->mutex); + m_dead.store(true, std::memory_order_release); + } + // notify_all, not notify_one: both a raw Park and a Doorbell::Wait may + // be parked here, and after this nobody will ring again. + m_impl->cv.notify_all(); + } + void CondVarDoorbell::Reset() { std::lock_guard lock(m_impl->mutex); m_impl->signals = 0; diff --git a/MobileGL/MG_Remote/Transport/Doorbell.h b/MobileGL/MG_Remote/Transport/Doorbell.h index ec37678cc..641bc2c83 100644 --- a/MobileGL/MG_Remote/Transport/Doorbell.h +++ b/MobileGL/MG_Remote/Transport/Doorbell.h @@ -104,12 +104,15 @@ namespace MobileGL::MG_Remote::Transport { // does not make the next Park return spuriously forever. virtual void Reset() = 0; - // True once the wakeup channel is permanently unusable, e.g. the peer - // closed its end of the socket. A dead doorbell can never deliver - // another wakeup AND its descriptor is permanently poll-ready, so Wait - // must stop re-parking on it: otherwise a waiter with no deadline - // burns a big core at full clock, which is the exact pathology the - // bidirectional doorbell exists to prevent. + // True once the wakeup channel is permanently unusable: the peer closed + // its end of the socket, or the inproc channel was shut down. A dead + // doorbell can never deliver another wakeup, and Wait must stop + // re-parking on it - for the socket because its descriptor is + // permanently poll-ready and a waiter with no deadline would burn a + // big core at full clock, for the condvar because Park would otherwise + // block forever and Shutdown could never join the waiter. Every + // implementation has a death state; the base default is only for a + // bell that cannot die. virtual bool Dead() const { return false; } // Spin `spinUs`, then park until `ready()` or the deadline. @@ -204,10 +207,23 @@ namespace MobileGL::MG_Remote::Transport { void Notify() override; bool Park(std::uint32_t timeoutMs) override; void Reset() override; + bool Dead() const override { return m_dead.load(std::memory_order_acquire); } + + // Hangs the bell up for good: every parked waiter returns false now and + // every later Park returns false at once. The inproc twin of the socket + // peer closing its end (SocketDoorbell latches m_dead on EOF), and what + // InProcessChannel::Close rings instead of Notify. A Notify is consumed + // by ONE Park; Doorbell::Wait then re-tests its condition, finds + // nothing published, finds the bell alive, and with kWaitForever parks + // again - so a Shutdown that only rang could never join a server thread + // sitting in the design's own steady state (spun, set consumerParked, + // blocked). Irreversible by design, like the socket's. + void Kill(); private: struct Impl; Impl* m_impl; + std::atomic m_dead{false}; }; #if !defined(_WIN32) diff --git a/MobileGL/MG_Remote/Transport/InProcessTransport.cpp b/MobileGL/MG_Remote/Transport/InProcessTransport.cpp index da5989ab8..e3253913d 100644 --- a/MobileGL/MG_Remote/Transport/InProcessTransport.cpp +++ b/MobileGL/MG_Remote/Transport/InProcessTransport.cpp @@ -81,9 +81,13 @@ namespace MobileGL::MG_Remote::Transport { dir.fdCv.notify_all(); } // Anything parked on a ring doorbell has to come back too, or a - // shutdown mid-frame hangs the peer forever. + // shutdown mid-frame hangs the peer forever. Kill, not Notify: a + // ring is consumed by one Park, after which Doorbell::Wait re-tests + // a condition nothing published and - the bell still reporting + // alive - parks again, with no deadline forever. Only Dead() ends + // that loop. for (CondVarDoorbell& bell : m_bells) { - bell.Notify(); + bell.Kill(); } } @@ -277,8 +281,9 @@ namespace MobileGL::MG_Remote::Transport { } // Whole-connection teardown, as ITransport::Shutdown documents: both - // directions are half-closed and both ring doorbells are rung, because a - // peer parked on a ring doorbell mid-frame would otherwise never come back. + // directions are half-closed and both ring doorbells are KILLED, because a + // peer parked on a ring doorbell mid-frame would otherwise never come back + // (a mere ring is consumed once and the waiter parks again). void InProcessTransport::Shutdown() { m_channel->Close(); } Doorbell& InProcessTransport::PeerDoorbell() { return m_channel->Bell(1 - m_endpoint); } diff --git a/MobileGL/MG_Remote/Transport/Ring.cpp b/MobileGL/MG_Remote/Transport/Ring.cpp index 90fb95c66..defee42d0 100644 --- a/MobileGL/MG_Remote/Transport/Ring.cpp +++ b/MobileGL/MG_Remote/Transport/Ring.cpp @@ -87,11 +87,13 @@ namespace MobileGL::MG_Remote::Transport { : m_control(control), m_base(static_cast(base)), m_capacity(capacityBytes), m_mask(capacityBytes - 1), m_cursors(cursors) { if (control == nullptr || base == nullptr || !IsPowerOfTwo(capacityBytes) || - capacityBytes < sizeof(RingRecordHeader) || capacityBytes > kMaxRingCapacity) { + capacityBytes < kMinRingCapacity || capacityBytes > kMaxRingCapacity) { MGLOG_E("MG_Remote ring: producer rejected, capacity %llu must be a power of two " - "between %zu and %llu bytes over a non-null mapping (the record header's size " - "field is 32-bit, so a bigger ring would truncate it)", - static_cast(capacityBytes), sizeof(RingRecordHeader), + "between %llu and %llu bytes over a non-null mapping (a record may be at most " + "half the ring, and the record header's size field is 32-bit, so a bigger ring " + "would truncate it)", + static_cast(capacityBytes), + static_cast(kMinRingCapacity), static_cast(kMaxRingCapacity)); m_control = nullptr; m_base = nullptr; @@ -123,11 +125,23 @@ namespace MobileGL::MG_Remote::Transport { return nullptr; } const std::uint64_t total = Align8(sizeof(RingRecordHeader) + payloadBytes); - if (total > m_capacity) { - // A single record larger than the whole ring is a caller bug: the + if (total > MaxRecordBytes()) { + // A single record larger than HALF the ring is a caller bug: the // record catalogue has to chunk oversized payloads (large subdata // becomes several records) rather than emit one giant record. - MGLOG_E("MG_Remote ring: record kind %u of %llu bytes does not fit a %llu byte ring; " + // + // Half, not the whole ring, because a record has to be placeable at + // EVERY head offset of an empty ring. Straddling the wrap boundary + // costs a pad of spaceToEnd bytes on top of the record, and with + // spaceToEnd < total that is at most 2*total-8, which stays within + // the capacity exactly up to capacity/2. Above it the record is + // placeable at some offsets and not at others: at head offset 16 of + // an empty 256-byte ring a 248-byte record needs 240+248 bytes while + // FreeBytes() reports 256, so a producer that waits for FreeBytes() + // >= total stalls forever, and nothing is ever logged. Refusing here + // makes that impossible - a nullptr with FreeBytes() >= total can no + // longer mean "wait". + MGLOG_E("MG_Remote ring: record kind %u of %llu bytes exceeds half of a %llu byte ring; " "the emitter must chunk it", static_cast(kind), static_cast(total), static_cast(m_capacity)); @@ -184,11 +198,13 @@ namespace MobileGL::MG_Remote::Transport { : m_control(control), m_base(static_cast(base)), m_capacity(capacityBytes), m_mask(capacityBytes - 1), m_cursors(cursors) { if (control == nullptr || base == nullptr || !IsPowerOfTwo(capacityBytes) || - capacityBytes < sizeof(RingRecordHeader) || capacityBytes > kMaxRingCapacity) { + capacityBytes < kMinRingCapacity || capacityBytes > kMaxRingCapacity) { MGLOG_E("MG_Remote ring: consumer rejected, capacity %llu must be a power of two " - "between %zu and %llu bytes over a non-null mapping (the record header's size " - "field is 32-bit, so a bigger ring would truncate it)", - static_cast(capacityBytes), sizeof(RingRecordHeader), + "between %llu and %llu bytes over a non-null mapping (a record may be at most " + "half the ring, and the record header's size field is 32-bit, so a bigger ring " + "would truncate it)", + static_cast(capacityBytes), + static_cast(kMinRingCapacity), static_cast(kMaxRingCapacity)); m_control = nullptr; m_base = nullptr; diff --git a/MobileGL/MG_Remote/Transport/Ring.h b/MobileGL/MG_Remote/Transport/Ring.h index 115a99312..fcb216aba 100644 --- a/MobileGL/MG_Remote/Transport/Ring.h +++ b/MobileGL/MG_Remote/Transport/Ring.h @@ -120,6 +120,11 @@ namespace MobileGL::MG_Remote::Transport { // class of construction-time guard as the power-of-two check beside it. inline constexpr std::uint64_t kMaxRingCapacity = 0xFFFFFFFFull; + // Smallest ring: two record headers. A record may be at most HALF the ring + // (see RingProducer::Reserve), so a ring of one header could carry nothing + // at all - not even the smallest record, a bare header. + inline constexpr std::uint64_t kMinRingCapacity = 2 * sizeof(RingRecordHeader); + // Which cursor triple a producer/consumer pair drives. enum class RingCursorSet : std::uint32_t { Cmd = 0, @@ -156,8 +161,8 @@ namespace MobileGL::MG_Remote::Transport { public: RingProducer() = default; // `base` is the ring's byte area (NOT the control page) and - // `capacityBytes` must be a power of two of at least one record header - // and at most kMaxRingCapacity. Anything else leaves Valid() false. + // `capacityBytes` must be a power of two between kMinRingCapacity and + // kMaxRingCapacity. Anything else leaves Valid() false. RingProducer(RingControl* control, void* base, std::uint64_t capacityBytes, RingCursorSet cursors); @@ -167,12 +172,28 @@ namespace MobileGL::MG_Remote::Transport { std::uint64_t FreeBytes() const; // Reserves room for one record and returns a pointer to its payload, - // or nullptr when the ring is full (or the record cannot fit at all). - // The payload is uninitialized; alignment padding at its tail is NOT - // zeroed. Emits a pad record automatically when the record would - // straddle the wrap boundary, so every record is contiguous. + // or nullptr when the ring is full. The payload is uninitialized; + // alignment padding at its tail is NOT zeroed. Emits a pad record + // automatically when the record would straddle the wrap boundary, so + // every record is contiguous. + // + // A record whose total (header + payload, rounded up to 8) exceeds + // MaxRecordBytes() == Capacity()/2 is refused outright, with an error + // log and however empty the ring is: chunking it is the emitter's job + // (plan section 8.2, the G3 chunking rule). Half is exact, not + // conservative - it is the largest record EVERY head offset can place, + // because a wrap pad costs at most total-8 bytes on top of the record + // and 2*total-8 <= capacity-8 holds exactly up to capacity/2. Above it + // a record is placeable at some offsets and not at others, and a + // producer waiting for FreeBytes() >= total stalls forever on an empty + // ring. So: nullptr with FreeBytes() >= total never means "wait"; it + // can only mean "too big, chunk". void* Reserve(std::uint16_t kind, std::uint16_t flags, std::uint64_t payloadBytes); + // The largest header+payload total Reserve accepts: Capacity()/2. This + // is the number the emitter chunks against. + std::uint64_t MaxRecordBytes() const { return m_capacity / 2; } + // Makes every reserved record visible to the consumer (release store on // the head cursor). Cheap: publishing per record is fine, batching 8-16 // only amortizes the doorbell store. diff --git a/MobileGL/MG_Test/Wire/InProcessTransportTest.cpp b/MobileGL/MG_Test/Wire/InProcessTransportTest.cpp index 9808c3a6a..41510cc68 100644 --- a/MobileGL/MG_Test/Wire/InProcessTransportTest.cpp +++ b/MobileGL/MG_Test/Wire/InProcessTransportTest.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -330,3 +331,68 @@ TEST(InProcessTransportTest, DoorbellTimesOutWhenNothingHappens) { 20); EXPECT_EQ(parked.load(), 0u); } + +// The design's own steady state: the consumer spun, set consumerParked and blocked +// with NO deadline. Shutdown has to bring that thread back, and a single Notify +// cannot - Doorbell::Wait consumes it, re-tests a condition that is still false, +// and with kWaitForever parks again. Only a bell that reports Dead() ends the +// loop, which is what InProcessChannel::Close rings now. +// +// A regression here is a HANG, so the join is bounded: the waiter owns its state +// through a shared_ptr and is detached on timeout, and the test fails red after +// five seconds instead of wedging the CI job. +TEST(InProcessTransportTest, ShutdownUnparksAWaiterWithNoDeadline) { + struct Shared { + std::unique_ptr client; + std::unique_ptr server; + std::atomic parked{0}; + std::atomic returned{false}; + std::atomic woke{true}; + }; + auto shared = std::make_shared(); + InProcessTransport::CreatePair(shared->client, shared->server); + + std::thread waiter([shared] { + shared->woke.store(shared->server->SelfDoorbell().Wait( + shared->parked, [] { return false; }, kDefaultSpinUs, kWaitForever)); + shared->returned.store(true, std::memory_order_release); + }); + // Past the spin and announced as parked; a little longer and it is inside + // Park. (A Kill that lands before the Park is handled too - Park returns at + // once on a dead bell - but the case under test is the parked one.) + while (shared->parked.load() == 0) { + std::this_thread::yield(); + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + ASSERT_FALSE(shared->returned.load()); + + shared->client->Shutdown(); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!shared->returned.load(std::memory_order_acquire) && + std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + if (!shared->returned.load(std::memory_order_acquire)) { + waiter.detach(); + FAIL() << "Shutdown did not unpark a waiter with no deadline within 5 s: the inproc doorbell " + "has no death state, so the waiter consumed the ring and parked again"; + } + waiter.join(); + + // No wakeup was consumed - the bell died - and the park flag is clear. + EXPECT_FALSE(shared->woke.load()); + EXPECT_TRUE(shared->server->SelfDoorbell().Dead()); + EXPECT_TRUE(shared->client->SelfDoorbell().Dead()); + EXPECT_EQ(shared->parked.load(), 0u); + + // Sticky: a wait with no deadline that ARRIVES after the Shutdown returns at + // once rather than parking, so a late thread cannot hang either. + const auto start = std::chrono::steady_clock::now(); + EXPECT_FALSE(shared->server->SelfDoorbell().Wait( + shared->parked, [] { return false; }, 0, kWaitForever)); + EXPECT_LT(std::chrono::duration_cast(std::chrono::steady_clock::now() - start) + .count(), + 1000); + EXPECT_EQ(shared->parked.load(), 0u); +} diff --git a/MobileGL/MG_Test/Wire/RingTest.cpp b/MobileGL/MG_Test/Wire/RingTest.cpp index 3656f72a4..f38ea98ab 100644 --- a/MobileGL/MG_Test/Wire/RingTest.cpp +++ b/MobileGL/MG_Test/Wire/RingTest.cpp @@ -231,6 +231,94 @@ TEST(RingTest, RecordLargerThanTheRingIsRefused) { EXPECT_TRUE(ring.Invariants()); } +TEST(RingTest, RecordLargerThanHalfTheRingIsRefused) { + // 256-byte ring: the bound is 128 bytes of header + payload. + RingFixture ring(256); + EXPECT_EQ(ring.Producer().MaxRecordBytes(), 128u); + // 8 + 240 = 248: fits the whole ring, does not fit half of it. + EXPECT_EQ(ring.Producer().Reserve(1, kRecNone, 240), nullptr); + // 8 + 128 = 136: one step over the bound, refused the same way... + EXPECT_EQ(ring.Producer().Reserve(1, kRecNone, 128), nullptr); + // ...and 8 + 120 = 128, exactly the bound, is accepted. + EXPECT_NE(ring.Producer().Reserve(1, kRecNone, 120), nullptr); + EXPECT_TRUE(ring.Invariants()); +} + +// The scenario that motivated the bound, as the negative control. Whether a record +// can be placed must not depend on where the head happens to be. With "total <= +// capacity" as the only rule, a 248-byte record is accepted at head offset 0 of an +// empty 256-byte ring and refused forever at head offset 16 of the same empty +// ring - it would need a 240-byte wrap pad plus itself, 488 bytes - while +// FreeBytes() reports 256 the whole time, so a producer waiting for FreeBytes() +// >= 248 spins on nullptr with nothing logged. Both answers have to be the same +// refusal, and it has to be the loud one. +TEST(RingTest, RecordPlaceabilityDoesNotDependOnTheHeadOffset) { + RingFixture atOffsetZero(256); + void* atZero = atOffsetZero.Producer().Reserve(1, kRecNone, 240); + + RingFixture atOffsetSixteen(256); + ASSERT_TRUE(atOffsetSixteen.WriteRecord(1, 8, 0x01)); // 8 + 8 = 16 bytes + RingRecordView view{}; + ASSERT_TRUE(atOffsetSixteen.Consumer().Pop(view)); + atOffsetSixteen.Consumer().PublishRetired(); + ASSERT_EQ(atOffsetSixteen.Producer().LocalHead(), 16u); + ASSERT_EQ(atOffsetSixteen.Producer().FreeBytes(), 256u); + void* atSixteen = atOffsetSixteen.Producer().Reserve(1, kRecNone, 240); + + EXPECT_EQ(atSixteen, nullptr); + EXPECT_EQ(atZero, nullptr) + << "a 248-byte record was accepted at head offset 0 but is unplaceable at head offset 16 of " + "the same empty ring: the emitter cannot tell a refusal it must chunk from a full ring it " + "must wait on"; + EXPECT_TRUE(atOffsetZero.Invariants()); + EXPECT_TRUE(atOffsetSixteen.Invariants()); +} + +// The positive half of the same argument: a record of exactly half the capacity is +// placeable at EVERY head offset of an empty ring, because the wrap pad in front of +// it costs at most total-8 bytes. Walk the head to each 8-byte offset with bare +// header records and reserve the maximal record there. +TEST(RingTest, HalfCapacityRecordFitsAtEveryHeadOffset) { + RingFixture ring(256); + const std::uint64_t mask = ring.Capacity() - 1; + const std::uint64_t maximal = ring.Producer().MaxRecordBytes() - sizeof(RingRecordHeader); // 120 + for (std::uint64_t target = 0; target < ring.Capacity(); target += 8) { + // A bare header never straddles the boundary, so no pad appears on the way. + while ((ring.Producer().LocalHead() & mask) != target) { + ASSERT_TRUE(ring.WriteRecord(1, 0, 0)); + RingRecordView filler{}; + ASSERT_TRUE(ring.Consumer().Pop(filler)); + ring.Consumer().PublishRetired(); + } + ASSERT_EQ(ring.Producer().FreeBytes(), ring.Capacity()) << "head offset " << target; + void* payload = ring.Producer().Reserve(2, kRecNone, maximal); + ASSERT_NE(payload, nullptr) << "head offset " << target; + ring.Producer().Publish(); + RingRecordView view{}; + bool corrupt = false; + ASSERT_TRUE(ring.Consumer().Pop(view, &corrupt)) << "head offset " << target; + ASSERT_FALSE(corrupt); + EXPECT_EQ(view.kind, 2u); + EXPECT_EQ(view.payloadSize, maximal); + ring.Consumer().PublishRetired(); + ASSERT_TRUE(ring.Invariants()) << "head offset " << target; + } +} + +TEST(RingTest, RejectsARingTooSmallForTheSmallestRecord) { + alignas(4096) RingControl control{}; + InitRingControl(control); + std::uint8_t bytes[16] = {}; + // One header's worth of ring can carry nothing once a record may be at most + // half the ring; two headers' worth carries a bare header. + RingProducer tooSmall(&control, bytes, sizeof(RingRecordHeader), RingCursorSet::Cmd); + EXPECT_FALSE(tooSmall.Valid()); + RingProducer smallest(&control, bytes, kMinRingCapacity, RingCursorSet::Cmd); + ASSERT_TRUE(smallest.Valid()); + EXPECT_EQ(smallest.MaxRecordBytes(), sizeof(RingRecordHeader)); + EXPECT_NE(smallest.Reserve(1, kRecNone, 0), nullptr); +} + TEST(RingTest, HardDrainBumpsTheGenerationOnlyWhenQuiesced) { RingFixture ring(256); ASSERT_TRUE(ring.WriteRecord(1, 32, 0x01)); diff --git a/scripts/gen_pipe.py b/scripts/gen_pipe.py index 21788eb6b..e6599abb7 100644 --- a/scripts/gen_pipe.py +++ b/scripts/gen_pipe.py @@ -272,6 +272,16 @@ def gen_wire(calls): // a record that is shorter than its own type, longer than what is left in the buffer, or // not a multiple of 8 is protocol corruption and is fatal. There is no recovery path - // silently applying a truncated record is how a corrupt stream becomes a wrong picture. +// +// OVERSIZED PAYLOADS ARE CHUNKED, NEVER EMITTED WHOLE (plan section 8.2: G3 has to define +// the path for a record larger than the segment). The bound is the ring's, +// RingProducer::MaxRecordBytes() == Capacity()/2, and it is exact rather than +// conservative: a record has to be placeable at every head offset of an empty ring, the +// wrap pad in front of it costs up to total-8 bytes, and only a record of at most half the +// ring survives that at every offset. An emitter holding more than Capacity()/2 bytes of +// record (a large resource_subdata, a create_shader_state archive) splits it into several +// records of at most that size; the transport refuses a bigger one outright - nullptr plus +// an MGLOG_E - rather than let the producer wait on free bytes that can never suffice. struct MGPWireRecHeader { Uint16 Op; // MGPWireOp From e8ee7b1a88a7de1d36c890e67416714033809597 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 21:03:22 -0400 Subject: [PATCH 026/529] [Feat] (Backend, MGPipe): carry the six per-axis compute limits in DynamicBackendParameters so MGPCaps has every backend-owned indexed answer, and pin them against glGetIntegeri_v on both backends - P-1: MGPCaps is DynamicBackendParameters by inclusion (plan B section 4.4.1), but that struct carried MaxComputeWorkGroupInvocations and no per-axis GL_MAX_COMPUTE_WORK_GROUP_COUNT / GL_MAX_COMPUTE_WORK_GROUP_SIZE - the six numbers that ARE the backend-owned indexed answers surviving the getter retirement (GL_Getter.cpp and CompileEnv.cpp ask GLFunctionsTable::GetIntegeri_v for exactly these, DirectVulkan answers them from VkPhysicalDeviceLimits), so the interface had a hole where its only genuine indexed carrier should be. DynamicBackendParameters now has MaxComputeWorkGroupCount[3] / MaxComputeWorkGroupSize[3] with the GL 4.3 minimums as the no-backend defaults; DirectGLES fills them from glGetIntegeri_v inside the loader's bracketed probe run (GLESCapabilities carries them, logged with the other limits) and DirectVulkan from maxComputeWorkGroupCount / maxComputeWorkGroupSize through the loader's SaturateToInt like every other limit. Raw driver answers, as the invocations limit is: the frontend floors them at the shared MIN_COMPUTE_WORK_GROUP_* minimums itself. - The GetIntegeri_v table path is untouched, as is GL_Getter and CompileEnv behaviour: retiring the getter in favour of the caps is P0.5, and this only makes sure the caps have what P0.5 needs. - PipeCalls.def's footer no longer claims that "only GL_COMPUTE_WORK_GROUP_SIZE is a real backend answer and it lives in MGPCaps": the six limits live in MGPCaps, and GL_COMPUTE_WORK_GROUP_SIZE is a frontend link artifact (ProgramObject::GetComputeLocalSize, what GL_Program.cpp answers from), which AdvertisedLimitsScenario.ComputeLocalSizeComesFromTheLinkedProgram already pins. The MGPCaps size assertion is a composition of sizeof(DynamicBackendParameters) and follows the struct. - AdvertisedLimitsScenario.ComputeWorkGroupLimitsAreTheCapsBlocksAnswer pins, on both lanes: answerability, the GL 4.3 floors, vector/indexed agreement, INVALID_VALUE past axis 2, and - through the new Harness/BackendCapsPeek translation unit, which is the one place the module looks past the GL API - that max(caps, minimum) equals the live glGetIntegeri_v answer axis by axis. Shown live by halving each backend's caps copy: both lanes fail with "MGPCaps carries 512 but glGetIntegeri_v answers 1024". On Android the module links the shipping .so (hidden visibility), so the peek returns false there and only the GL-visible half runs. ComputeWorkGroupCapabilities.TakesEveryAxisFromTheIndexedQuery in BackendLoaderTest pins the DirectGLES loader half against the fake driver, per axis and above the initialisers. - Verified: AdvertisedLimitsScenario 20/20 on DirectGLES and DirectVulkan (llvmpipe / lavapipe), BackendLoaderTest green. --- MobileGL/MG_Backend/BackendObject.h | 13 ++++ .../DirectGLES/BackendObject_DirectGLES.cpp | 7 +++ .../BackendObject_DirectVulkan.cpp | 9 +++ .../MG_Backend/DirectVulkan/DirectVulkan.cpp | 5 +- MobileGL/MG_IntegrationTest/CMakeLists.txt | 1 + .../Harness/BackendCapsPeek.cpp | 42 +++++++++++++ .../Harness/BackendCapsPeek.h | 29 +++++++++ .../Scenarios/AdvertisedLimitsScenario.cpp | 61 +++++++++++++++++++ MobileGL/MG_Pipe/MGPipeTypes.h | 5 +- MobileGL/MG_Pipe/PipeCalls.def | 21 +++++-- .../BackendLoader/BackendLoaderTest.cpp | 41 ++++++++++++- .../MG_Util/BackendLoaders/OpenGL/Loader.cpp | 21 +++++++ .../MG_Util/BackendLoaders/OpenGL/Loader.h | 5 ++ .../MG_Util/BackendLoaders/Vulkan/Loader.cpp | 8 +++ .../MG_Util/BackendLoaders/Vulkan/Loader.h | 5 ++ 15 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.cpp create mode 100644 MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.h diff --git a/MobileGL/MG_Backend/BackendObject.h b/MobileGL/MG_Backend/BackendObject.h index 6b2c02318..70cd3c785 100644 --- a/MobileGL/MG_Backend/BackendObject.h +++ b/MobileGL/MG_Backend/BackendObject.h @@ -378,6 +378,19 @@ namespace MobileGL { Int MaxFragmentShaderStorageBlocks = 8; Int MaxComputeUniformBlocks = 12; Int MaxComputeWorkGroupInvocations = 128; + // GL_MAX_COMPUTE_WORK_GROUP_COUNT / GL_MAX_COMPUTE_WORK_GROUP_SIZE, one value per + // axis. These six, with the invocations limit above, are the only indexed limits a + // backend genuinely OWNS - the device answers them (glGetIntegeri_v on DirectGLES, + // VkPhysicalDeviceLimits::maxComputeWorkGroupCount/Size on DirectVulkan) - and so + // the only ones that survive the retirement of the GetIntegeri_v table entry: they + // cross the MGPipe boundary inside MGPCaps, by inclusion of this struct (plan B + // section 4.4.1). Every other indexed pname names frontend state. RAW driver + // answers, like the invocations limit: GL_Getter and the compile environment floor + // them at the shared MIN_COMPUTE_WORK_GROUP_* minimums themselves. The defaults are + // the GL 4.3 core minimums (table 23.60) and describe the no-backend case, as + // MaxClipDistances' does. + Int MaxComputeWorkGroupCount[3] = {65535, 65535, 65535}; + Int MaxComputeWorkGroupSize[3] = {1024, 1024, 64}; Int MaxShaderStorageBufferBindings = 8; Int MaxTextureBufferSize = 65536; // GL_TEXTURE_BUFFER_OFFSET_ALIGNMENT; 1 means the offset is unconstrained. diff --git a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp index c8d7c5a15..b36af187e 100644 --- a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp @@ -1415,6 +1415,13 @@ namespace MobileGL::MG_Backend::DirectGLES { clampStageStorageBlocks(m_GLESCapabilities.MaxFragmentShaderStorageBlocks); m_dynamicParameters.MaxComputeUniformBlocks = m_GLESCapabilities.MaxComputeUniformBlocks; m_dynamicParameters.MaxComputeWorkGroupInvocations = m_GLESCapabilities.MaxComputeWorkGroupInvocations; + // The six per-axis compute limits: the driver's raw glGetIntegeri_v answers, the same + // numbers GLFunctionsTable::GetIntegeri_v forwards live. Carried here so that MGPCaps has + // them once the table entry retires (plan B section 4.4.1); GL_Getter floors them. + for (SizeT axis = 0; axis < 3; ++axis) { + m_dynamicParameters.MaxComputeWorkGroupCount[axis] = m_GLESCapabilities.MaxComputeWorkGroupCount[axis]; + m_dynamicParameters.MaxComputeWorkGroupSize[axis] = m_GLESCapabilities.MaxComputeWorkGroupSize[axis]; + } // (MaxShaderStorageBufferBindings is assigned above, before the per-stage clamp reads it.) // This is the number glGetIntegerv(GL_MAX_TEXTURE_BUFFER_SIZE) hands the application, and // on a host without buffer textures it is knowingly a floor MobileGL cannot honour rather diff --git a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp index e113d81af..3a3bc0d14 100644 --- a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp @@ -937,6 +937,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { clampLimit("GL_MAX_COMPUTE_UNIFORM_BLOCKS", m_vulkanCaps.MaxComputeUniformBlocks, kMaxAdvertisedBufferBlocks); m_dynamicParameters.MaxComputeWorkGroupInvocations = m_vulkanCaps.MaxComputeWorkGroupInvocations; + // The six per-axis compute limits, from the same VkPhysicalDeviceLimits fields + // GLFunctionsTable::GetIntegeri_v (DirectVulkan.cpp) reads live. Carried here so that + // MGPCaps has them once the table entry retires (plan B section 4.4.1); GL_Getter floors + // them. Not clamped: unlike the block counts these are not amounts an application + // allocates, and the frontend already raises them to the GL minimum. + for (SizeT axis = 0; axis < 3; ++axis) { + m_dynamicParameters.MaxComputeWorkGroupCount[axis] = m_vulkanCaps.MaxComputeWorkGroupCount[axis]; + m_dynamicParameters.MaxComputeWorkGroupSize[axis] = m_vulkanCaps.MaxComputeWorkGroupSize[axis]; + } m_dynamicParameters.MaxShaderStorageBufferBindings = clampLimit("GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS", m_vulkanCaps.MaxShaderStorageBufferBindings, kMaxAdvertisedBufferBlocks); diff --git a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp index 33789e613..bb778cc2d 100644 --- a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp @@ -674,7 +674,10 @@ namespace MobileGL::MG_Backend::DirectVulkan { // The two compute limits are the only indexed pnames a backend genuinely owns: they come // from the physical device, and MG_Impl/GLImpl/Getter/GL_Getter.cpp asks for them here so it - // can raise the answer to the GL required minimum. Every other indexed pname names FRONTEND + // can raise the answer to the GL required minimum. The same six numbers are carried in + // DynamicBackendParameters::MaxComputeWorkGroupCount/Size (filled at capability init from + // the same limits), which is their MGPCaps carrier once this entry retires - the + // AdvertisedLimitsScenario pins the two against each other. Every other indexed pname names FRONTEND // state (the indexed buffer bindings, the per-unit texture/sampler bindings, the image-unit // bindings, the viewport rectangles, the indexed capabilities) and is answered there before // the table is consulted, so the arms this function used to carry for diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 7dad1f1dc..83e93597f 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -51,6 +51,7 @@ endif() add_executable(MobileGLIntegrationTest Main.cpp Harness/HeadlessGL.cpp + Harness/BackendCapsPeek.cpp Scenarios/OrientationScenario.cpp Scenarios/CrossFrameBufferScenario.cpp Scenarios/ResidentIndexScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.cpp b/MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.cpp new file mode 100644 index 000000000..03e3e95e3 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.cpp @@ -0,0 +1,42 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#include "BackendCapsPeek.h" + +#if !defined(__ANDROID__) +#include + +namespace MobileGL::MG_Backend { + // Declared in MG_Backend/BackendObjects.h, which also pulls in both backends' headers + // and, through them, their loaders; the reference alone is all that is needed here. + extern UniquePtr& pActiveBackendObject; +} // namespace MobileGL::MG_Backend +#endif + +namespace MGITest { + + bool PeekComputeWorkGroupCaps(int outCount[3], int outSize[3]) { +#if defined(__ANDROID__) + (void)outCount; + (void)outSize; + return false; +#else + const auto& backend = MobileGL::MG_Backend::pActiveBackendObject; + if (!backend) { + return false; + } + const MobileGL::MG_Backend::DynamicBackendParameters& caps = backend->GetDynamicParameters(); + for (int axis = 0; axis < 3; ++axis) { + outCount[axis] = caps.MaxComputeWorkGroupCount[axis]; + outSize[axis] = caps.MaxComputeWorkGroupSize[axis]; + } + return true; +#endif + } + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.h b/MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.h new file mode 100644 index 000000000..773b5dba4 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.h @@ -0,0 +1,29 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// The one place this module looks past the GL API into the active backend's caps block. +// +// It exists for exactly one assertion: that the six per-axis compute limits the MGPipe +// caps block carries (DynamicBackendParameters::MaxComputeWorkGroupCount/Size, plan B +// section 4.4.1) are the same numbers glGetIntegeri_v answers today, since P0.5 retires +// the getter in favour of the caps. A separate translation unit, because the scenario +// sources include the GL headers with prototypes and MobileGL's umbrella header is not +// meant to meet them in one file. + +#pragma once + +namespace MGITest { + + // Copies the active backend's MaxComputeWorkGroupCount / MaxComputeWorkGroupSize into the + // two arrays and returns true. Returns false, touching nothing, where the caps block is + // out of reach: on Android this module links the SHIPPING libMobileGL.so, built + // -fvisibility=hidden, so no internal symbol resolves; on desktop it links MobileGL_s and + // the read is direct. + bool PeekComputeWorkGroupCaps(int outCount[3], int outSize[3]); + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp index 0e9296189..479683a34 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp @@ -26,9 +26,11 @@ // quantities, so an entry that only fails on DirectVulkan is a translation bug and one that // fails on both is a table bug. +#include #include #include +#include "../Harness/BackendCapsPeek.h" #include "../Harness/HeadlessGL.h" #include "../Harness/ScenarioFixture.h" @@ -564,5 +566,64 @@ void main() { g_data[gl_LocalInvocationIndex] = 1u; } (void)FirstGLError(); } + // THE SIX COMPUTE LIMITS THAT OUTLIVE THE GETTER. GL_MAX_COMPUTE_WORK_GROUP_COUNT and + // GL_MAX_COMPUTE_WORK_GROUP_SIZE, three axes each, are the only indexed pnames the + // DEVICE answers rather than the frontend (glGetIntegeri_v on Espryt, VkPhysicalDevice- + // Limits on Magma), and therefore the only ones that have to cross the MGPipe boundary + // once GetIntegeri_v is retired (plan B section 4.4.6 / P0.5). They ride in MGPCaps by + // inclusion, as DynamicBackendParameters::MaxComputeWorkGroupCount/Size, filled by both + // backends at capability init. This case pins that the caps copy and the live getter + // answer are one number - the getter floors the backend's raw answer at the GL 4.3 + // minimum, so the comparison is against the floored caps value - and pins the + // GL-visible half on every lane: answerability, the floors, vector/indexed agreement + // and the index bound. On a lane where the caps block is out of reach (Android links + // the shipping .so) only the GL-visible half runs. + TEST_F(AdvertisedLimitsScenario, ComputeWorkGroupLimitsAreTheCapsBlocksAnswer) { + struct Axis { + GLenum pname; + const char* name; + GLint minimum[3]; // GL 4.3 core table 23.60 + }; + const Axis axes[] = { + {GL_MAX_COMPUTE_WORK_GROUP_COUNT, "GL_MAX_COMPUTE_WORK_GROUP_COUNT", {65535, 65535, 65535}}, + {GL_MAX_COMPUTE_WORK_GROUP_SIZE, "GL_MAX_COMPUTE_WORK_GROUP_SIZE", {1024, 1024, 64}}, + }; + int capsCount[3] = {0, 0, 0}; + int capsSize[3] = {0, 0, 0}; + const bool capsVisible = PeekComputeWorkGroupCaps(capsCount, capsSize); + + for (const Axis& axis : axes) { + GLint indexed[3] = {-1, -1, -1}; + for (GLuint i = 0; i < 3; ++i) { + glGetIntegeri_v(axis.pname, i, &indexed[i]); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << axis.name << "[" << i << "]"; + EXPECT_GE(indexed[i], axis.minimum[i]) + << axis.name << "[" << i << "] = " << indexed[i] + << " is below the GL 4.3 core table 23.60 minimum " << axis.minimum[i]; + } + GLint vector[3] = {-1, -1, -1}; + glGetIntegerv(axis.pname, vector); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << axis.name; + for (int i = 0; i < 3; ++i) { + EXPECT_EQ(vector[i], indexed[i]) + << axis.name << "[" << i << "]: the vector query and the indexed query disagree"; + } + GLint outOfRange = -424242; + glGetIntegeri_v(axis.pname, 3, &outOfRange); + EXPECT_EQ(FirstGLError(), GLenum(GL_INVALID_VALUE)) + << axis.name << "[3]: an index past the three axes is INVALID_VALUE (GL 4.6 core 22.1)"; + + if (!capsVisible) continue; + const int* capsAxis = axis.pname == GL_MAX_COMPUTE_WORK_GROUP_COUNT ? capsCount : capsSize; + for (int i = 0; i < 3; ++i) { + EXPECT_EQ(std::max(capsAxis[i], axis.minimum[i]), indexed[i]) + << axis.name << "[" << i << "]: MGPCaps carries " << capsAxis[i] + << " but glGetIntegeri_v answers " << indexed[i] + << " - the caps block and the getter path must be one number, because P0.5 retires " + "the getter in favour of the caps"; + } + } + } + } // namespace } // namespace MGITest diff --git a/MobileGL/MG_Pipe/MGPipeTypes.h b/MobileGL/MG_Pipe/MGPipeTypes.h index 227338d35..133fff8f4 100644 --- a/MobileGL/MG_Pipe/MGPipeTypes.h +++ b/MobileGL/MG_Pipe/MGPipeTypes.h @@ -122,7 +122,10 @@ namespace MobileGL::MG_Pipe { struct MGPCaps { // The ~90 flat scalars the backends already publish, by inclusion rather than by - // restatement: a caps field added there must not need a second edit here. + // restatement: a caps field added there must not need a second edit here. This is + // also where the six per-axis compute limits (MaxComputeWorkGroupCount/Size) ride - + // the only indexed answers the device owns, and therefore the only ones that outlive + // the GetIntegeri_v table entry (see the PipeCalls.def footer). DynamicBackendParameters Dynamic; Uint64 CallMask; // MGPCapBit // The two halves that are not flat PODs travel as blobs: the format capability diff --git a/MobileGL/MG_Pipe/PipeCalls.def b/MobileGL/MG_Pipe/PipeCalls.def index 799fdea65..05db1f188 100644 --- a/MobileGL/MG_Pipe/PipeCalls.def +++ b/MobileGL/MG_Pipe/PipeCalls.def @@ -140,9 +140,18 @@ X(SetSwapInterval, MGPSwapInterval, kCtxVerb, kOptional) // clang-format on -// Explicitly NOT migrated (plan 4.4.6 / appendix A "explicit deletions"): GetIntegeri_v, -// GetInteger64i_v, GetProgramiv (only GL_COMPUTE_WORK_GROUP_SIZE is a real backend answer -// and it lives in MGPCaps), ShaderStorageBlockBinding (folded into MGPProgramDesc's -// reflection archive), set_pixel_unpack_state (no such state crosses the line - plan 4.6 -// D5), a compressed-format concept, pipe_transfer, and the stage dimension of -// set_sampler_views (MobileGL's texture unit space is merged, not per stage - plan 4.4.3). +// Explicitly NOT migrated (plan 4.4.6 / appendix A "explicit deletions"): +// - GetIntegeri_v / GetInteger64i_v. The six backend-owned answers they carry - +// GL_MAX_COMPUTE_WORK_GROUP_COUNT and GL_MAX_COMPUTE_WORK_GROUP_SIZE, three axes each, +// the only indexed pnames the device rather than the frontend answers - live in MGPCaps +// as DynamicBackendParameters::MaxComputeWorkGroupCount / MaxComputeWorkGroupSize, filled +// by both backends at capability init (DirectGLES from glGetIntegeri_v, DirectVulkan from +// VkPhysicalDeviceLimits) and floored by the frontend. Every other indexed pname names +// frontend state and is answered before any table is consulted. +// - GetProgramiv. GL_COMPUTE_WORK_GROUP_SIZE is a FRONTEND link artifact +// (ProgramObject::GetComputeLocalSize, what GL_Program.cpp has always answered from), not +// a backend answer at all; nothing a backend knows about a program crosses this way. +// - ShaderStorageBlockBinding (folded into MGPProgramDesc's reflection archive), +// set_pixel_unpack_state (no such state crosses the line - plan 4.6 D5), a +// compressed-format concept, pipe_transfer, and the stage dimension of set_sampler_views +// (MobileGL's texture unit space is merged, not per stage - plan 4.4.3). diff --git a/MobileGL/MG_Test/BackendLoader/BackendLoaderTest.cpp b/MobileGL/MG_Test/BackendLoader/BackendLoaderTest.cpp index 48ac87975..ed10ea493 100644 --- a/MobileGL/MG_Test/BackendLoader/BackendLoaderTest.cpp +++ b/MobileGL/MG_Test/BackendLoader/BackendLoaderTest.cpp @@ -37,6 +37,11 @@ namespace { std::size_t ioBlockDraws = 0; // Behavior knobs, configured per test before running the probe. GLint maxVertexSsboBlocks = 4; + // GL_MAX_COMPUTE_WORK_GROUP_COUNT / _SIZE per axis, answered through glGetIntegeri_v. + // Above the GL minimums and distinct per axis, so a loader that left an initialiser in + // place or copied one axis into another is caught. + GLint maxComputeWorkGroupCount[3] = {70001, 70002, 70003}; + GLint maxComputeWorkGroupSize[3] = {1500, 1501, 100}; GLint glesMajorVersion = 3; GLint glesMinorVersion = 1; GLint maxVertexImageUniforms = 2; @@ -460,8 +465,20 @@ namespace { if (data == nullptr) return; for (int i = 0; i < 4; ++i) data[i] = GL_TRUE; }; - funcs.glGetIntegeri_v = [](GLenum, GLuint, GLint* data) { - if (data != nullptr) *data = 0; + funcs.glGetIntegeri_v = [](GLenum pname, GLuint index, GLint* data) { + if (data == nullptr) return; + *data = 0; + if (index >= 3) return; + switch (pname) { + case GL_MAX_COMPUTE_WORK_GROUP_COUNT: + *data = g_fake.maxComputeWorkGroupCount[index]; + break; + case GL_MAX_COMPUTE_WORK_GROUP_SIZE: + *data = g_fake.maxComputeWorkGroupSize[index]; + break; + default: + break; + } }; funcs.glGetProgramInfoLog = [](GLuint, GLsizei bufSize, GLsizei* length, GLchar* infoLog) { if (infoLog != nullptr && bufSize > 0) infoLog[0] = '\0'; @@ -1551,3 +1568,23 @@ TEST(LocatedIoBlockProbe, ReportsTheDefectOnlyWhenTheUnlocatedControlCarriesTheP EXPECT_FALSE(ProbeLocatedIoBlocksLosePayload(crippled).detected); EXPECT_EQ(g_fake.ioBlockDraws, 0u) << "an entry-point-gated probe must not draw at all"; } + +// The six per-axis compute limits are the backend-owned answers that cross the MGPipe boundary +// inside MGPCaps (DynamicBackendParameters::MaxComputeWorkGroupCount/Size), so the loader has +// to take EACH axis from glGetIntegeri_v rather than leave an initialiser - or one axis's +// answer - in the other slots. The integration side (AdvertisedLimitsScenario) pins the copy +// against the live getter on both backends; this pins the driver-to-caps step on its own. +TEST(ComputeWorkGroupCapabilities, TakesEveryAxisFromTheIndexedQuery) { + const auto funcs = MakeFakeGLESFunctions(); + ResetFakeDriver(); + MobileGL::MG_External::GLESCapabilities caps; + ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs)); + for (int axis = 0; axis < 3; ++axis) { + EXPECT_EQ(caps.MaxComputeWorkGroupCount[axis], g_fake.maxComputeWorkGroupCount[axis]) << "axis " << axis; + EXPECT_EQ(caps.MaxComputeWorkGroupSize[axis], g_fake.maxComputeWorkGroupSize[axis]) << "axis " << axis; + } + // The initialisers are the GL 4.3 minimums and every fake answer is above them, so a + // value equal to its initialiser here would mean the query never ran. + EXPECT_GT(caps.MaxComputeWorkGroupCount[0], 65535); + EXPECT_GT(caps.MaxComputeWorkGroupSize[2], 64); +} diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp index eb143d0d1..4584572e6 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp @@ -1145,6 +1145,8 @@ namespace MobileGL::MG_Util::BackendLoader { GLint maxFragmentShaderStorageBlocks = 4; GLint maxComputeUniformBlocks = 12; GLint maxComputeWorkGroupInvocations = 128; + GLint maxComputeWorkGroupCount[3] = {65535, 65535, 65535}; + GLint maxComputeWorkGroupSize[3] = {1024, 1024, 64}; GLint maxShaderStorageBufferBindings = 8; GLint maxTextureBufferSize = 65536; GLint maxUniformBufferBindings = 24; @@ -1276,6 +1278,17 @@ namespace MobileGL::MG_Util::BackendLoader { glesFuncs.glGetIntegerv(GL_MAX_COMBINED_SHADER_STORAGE_BLOCKS, &maxCombinedShaderStorageBlocks); glesFuncs.glGetIntegerv(GL_MAX_COMPUTE_UNIFORM_BLOCKS, &maxComputeUniformBlocks); glesFuncs.glGetIntegerv(GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS, &maxComputeWorkGroupInvocations); + // The per-axis pair beside it, through the indexed query. ES 3.1 core like the + // invocations limit, so it sits inside the same bracketed run: a 3.0 context rejects + // it, the drain below swallows the error and the locals keep the GL 4.3 minimums. + // These are the six backend-owned indexed answers that cross the MGPipe boundary in + // MGPCaps (DynamicBackendParameters::MaxComputeWorkGroupCount/Size). + if (glesFuncs.glGetIntegeri_v) { + for (GLuint axis = 0; axis < 3; ++axis) { + glesFuncs.glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_COUNT, axis, &maxComputeWorkGroupCount[axis]); + glesFuncs.glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_SIZE, axis, &maxComputeWorkGroupSize[axis]); + } + } glesFuncs.glGetIntegerv(GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS, &maxShaderStorageBufferBindings); // GL_MAX_TEXTURE_BUFFER_SIZE is deliberately NOT batched here: like // GL_TEXTURE_BUFFER_OFFSET_ALIGNMENT below, the pname only exists once buffer textures do, @@ -1584,6 +1597,10 @@ namespace MobileGL::MG_Util::BackendLoader { caps.MaxFragmentShaderStorageBlocks = maxFragmentShaderStorageBlocks; caps.MaxComputeUniformBlocks = maxComputeUniformBlocks; caps.MaxComputeWorkGroupInvocations = maxComputeWorkGroupInvocations; + for (SizeT axis = 0; axis < 3; ++axis) { + caps.MaxComputeWorkGroupCount[axis] = maxComputeWorkGroupCount[axis]; + caps.MaxComputeWorkGroupSize[axis] = maxComputeWorkGroupSize[axis]; + } caps.MaxShaderStorageBufferBindings = maxShaderStorageBufferBindings; caps.MaxTextureBufferSize = maxTextureBufferSize; // Through glesFuncs, like every other capability query here: a bare glGetIntegerv resolves @@ -1681,6 +1698,10 @@ namespace MobileGL::MG_Util::BackendLoader { MGLOG_I(" GL_MAX_FRAGMENT_SHADER_STORAGE_BLOCKS: %d", caps.MaxFragmentShaderStorageBlocks); MGLOG_I(" GL_MAX_COMPUTE_UNIFORM_BLOCKS: %d", caps.MaxComputeUniformBlocks); MGLOG_I(" GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS: %d", caps.MaxComputeWorkGroupInvocations); + MGLOG_I(" GL_MAX_COMPUTE_WORK_GROUP_COUNT: %d %d %d", caps.MaxComputeWorkGroupCount[0], + caps.MaxComputeWorkGroupCount[1], caps.MaxComputeWorkGroupCount[2]); + MGLOG_I(" GL_MAX_COMPUTE_WORK_GROUP_SIZE: %d %d %d", caps.MaxComputeWorkGroupSize[0], + caps.MaxComputeWorkGroupSize[1], caps.MaxComputeWorkGroupSize[2]); MGLOG_I(" GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS: %d", caps.MaxShaderStorageBufferBindings); // Three distinct states, and the suffix must not conflate them: a driver answer, a floor // kept because there are no buffer textures to ask about, and a floor kept because the diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h index d8b3b02fe..e0d41fd3d 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h @@ -1309,6 +1309,11 @@ namespace MobileGL { Int MaxFragmentShaderStorageBlocks = 4; Int MaxComputeUniformBlocks = 12; Int MaxComputeWorkGroupInvocations = 128; + // GL_MAX_COMPUTE_WORK_GROUP_COUNT / _SIZE per axis, as the driver answers + // glGetIntegeri_v. Raw: the frontend floors them at the GL minimums itself. The + // initialisers are those minimums, for a context that rejects the query. + Int MaxComputeWorkGroupCount[3] = {65535, 65535, 65535}; + Int MaxComputeWorkGroupSize[3] = {1024, 1024, 64}; Int MaxShaderStorageBufferBindings = 8; Int MaxTextureBufferSize = 65536; // GL_TEXTURE_BUFFER_OFFSET_ALIGNMENT; 1 means the offset is unconstrained. diff --git a/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp b/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp index e2d9a3641..2a7126c25 100644 --- a/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp +++ b/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp @@ -196,6 +196,10 @@ namespace MobileGL::MG_Util::BackendLoader { caps.MaxCombinedShaderStorageBlocks = SaturateToInt(p.limits.maxDescriptorSetStorageBuffers); caps.MaxComputeUniformBlocks = SaturateToInt(p.limits.maxPerStageDescriptorUniformBuffers); caps.MaxComputeWorkGroupInvocations = SaturateToInt(p.limits.maxComputeWorkGroupInvocations); + for (SizeT axis = 0; axis < 3; ++axis) { + caps.MaxComputeWorkGroupCount[axis] = SaturateToInt(p.limits.maxComputeWorkGroupCount[axis]); + caps.MaxComputeWorkGroupSize[axis] = SaturateToInt(p.limits.maxComputeWorkGroupSize[axis]); + } caps.MaxShaderStorageBufferBindings = SaturateToInt(p.limits.maxDescriptorSetStorageBuffers); caps.MaxTextureBufferSize = SaturateToInt(p.limits.maxTexelBufferElements); caps.TextureBufferOffsetAlignment = @@ -333,6 +337,10 @@ namespace MobileGL::MG_Util::BackendLoader { caps.MaxCombinedShaderStorageBlocks = SaturateToInt(properties.limits.maxDescriptorSetStorageBuffers); caps.MaxComputeUniformBlocks = SaturateToInt(properties.limits.maxPerStageDescriptorUniformBuffers); caps.MaxComputeWorkGroupInvocations = SaturateToInt(properties.limits.maxComputeWorkGroupInvocations); + for (SizeT axis = 0; axis < 3; ++axis) { + caps.MaxComputeWorkGroupCount[axis] = SaturateToInt(properties.limits.maxComputeWorkGroupCount[axis]); + caps.MaxComputeWorkGroupSize[axis] = SaturateToInt(properties.limits.maxComputeWorkGroupSize[axis]); + } caps.MaxShaderStorageBufferBindings = SaturateToInt(properties.limits.maxDescriptorSetStorageBuffers); caps.MaxTextureBufferSize = SaturateToInt(properties.limits.maxTexelBufferElements); caps.TextureBufferOffsetAlignment = diff --git a/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.h b/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.h index 33907086b..2e370cf87 100644 --- a/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.h +++ b/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.h @@ -56,6 +56,11 @@ namespace MobileGL { Int MaxCombinedShaderStorageBlocks = 32; Int MaxComputeUniformBlocks = 12; Int MaxComputeWorkGroupInvocations = 128; + // VkPhysicalDeviceLimits::maxComputeWorkGroupCount / maxComputeWorkGroupSize per + // axis, saturated to Int like every other limit here. Raw: the frontend floors them + // at the GL minimums itself. + Int MaxComputeWorkGroupCount[3] = {65535, 65535, 65535}; + Int MaxComputeWorkGroupSize[3] = {1024, 1024, 64}; Int MaxShaderStorageBufferBindings = 8; Int MaxTextureBufferSize = 65536; // GL_TEXTURE_BUFFER_OFFSET_ALIGNMENT; 1 means the offset is unconstrained. From 901d48a678083cbed73e044d5de5037e5ac0c91b Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 21:06:26 -0400 Subject: [PATCH 027/529] [Fix] (MGPipe, Metrics, Config, CI): exchange the per-frame stats instead of racing a store, carry the three uncarried table entries, drop the inline host span from a buffer range, spell the buffer subdata range, and close the small gate holes - S-1 PipeStats::OnPresent read each frame accumulator and then store(0)'d it; a Bump from a staging thread landing in between was lost from the Tracy plot and from every frame. Each accumulator is now exchange(0, relaxed) and the exchanged value is what is plotted, so every add lands in exactly one frame. - T-3 FdPassing without MSG_CMSG_CLOEXEC (macOS, BSD) handed back descriptors that survived exec; every received fd now gets FD_CLOEXEC by hand under !MSG_CMSG_CLOEXEC. MSG_NOSIGNAL is defined to 0 where the platform lacks it (FdPassing.cpp, Doorbell.cpp) and SO_NOSIGPIPE is set on the socketpair and on a SocketDoorbell's descriptor where it exists, so a write to a hung-up peer is EPIPE rather than a fatal signal. - T-4 the missing-flatbuffers fallback wrote OFF into the cache with FORCE, so a plain re-configure after `git submodule update` stayed OFF silently. It is a normal-variable set now, shadowing the cache for that configure only; verified by hiding flatbuffers.h, configuring with ON (warning, transport off, cache still ON) and re-configuring plainly with the header back (transport ON). - P-2 three LIVE GLFunctionsTable entries had no carrier: GetGpuTimestampNs (glGetInteger64v(GL_TIMESTAMP), a synchronous server answer), QueryCounterTimestamp (glQueryCounter, a one-shot stamp, not a begin/end pair) and WaitSync (the GPU-side wait FenceWait's client wait does not express). QueryTimestamp (MGPTimestampRequest, kCtxQuery, kReplySlot), QueryCounter (MGPQueryDesc with Kind = GL_TIMESTAMP, kCtxQuery) and FenceWaitServer (MGPFenceWait, kScreen) are APPENDED at the end of PipeCalls.def because the opcode is the position: SetSwapInterval stays 68, the three take 69-71, and PipeCatalogue.LateArrivalsAreAppendedWithoutRenumbering pins that. Header counts 71 (screen 11, query 8); the seven generators regenerated. - P-3 MGPBufferRange inlined a 32-byte MGHostSpan into every range of every class - dead space on every SSBO, atomic-counter and XFB range, and D-B8 says not to freeze the named-UBO payload before the stage-ubo-named numbers exist. The range is 24 bytes now; the host spans are an optional second var-tail behind the ranges, announced by MGPShaderBuffers::HostSpanCount (0 or Count), with set_shader_buffers keeping its kVarTail|kHostSpan flags. PipeCatalogue.BufferRangeCarriesNoInlineHostSpan pins the sizes, the flags and the comparator's view of the count. - P-4 QueryEnvUint64 parsed with base 0 (a leading zero meant octal: MOBILEGL_PIPE_PUSH=010 read as 8) and accepted -1 as every bit set; it is decimal or explicit 0x now and a '-' anywhere is refused with the warning (smoke through the integration binary: -1 and 12abc warn, 010 and 0x10 parse). The CI stdio gate's alternation now also catches fprintf(stdout, puts( and std::cout/cerr; it is green over MG_Backend and MG_State. MGPSubData states how the buffer half expresses [offset, size): UnionBox.X / UnionBox.W with Target == Buffer, Y = Z = 0, H = D = 1, one record bounded at a 2^31-1 offset and 2^32-1 size beyond which the emitter splits (the same rule the ring's half-capacity bound already imposes); MGPipeSetSubDataBufferRange / MGPipeSubDataBufferOffset / Size are the only spelling and PipeCatalogue.SubDataBufferRangeRidesInTheUnionBox pins the encoding and its bounds. gen_pipe.py now refuses, in both modes, a call payload named in PipeCalls.def with no field list in PipeFields.def (the four memcmp-fallback member types are the documented exception); shown by dropping P(MGPSwapInterval), which exits 1 naming the payload. - The MGPPixelPackState size assertion compared sizeof against itself; it asserts the literal 28 PixelStoreParameters measures. - Verified: ctest -L unit green in both the default and the split configuration, gen_pipe.py --check clean with the generated files committed, nm --defined-only of the default libMobileGL.so has no MG_Remote symbol, and the full integration-gpu suite passes (the *IsActuallyArmedWhenTheEnvironmentPinsItOn family trips under -j 8 as documented and passes serially). --- .github/workflows/test.yml | 8 ++- CMakeLists.txt | 11 +++- MobileGL/ConfigLoader.cpp | 22 +++++-- MobileGL/MG_Pipe/MGPipeTypes.h | 65 +++++++++++++++--- MobileGL/MG_Pipe/PipeCalls.def | 38 ++++++++--- MobileGL/MG_Pipe/PipeFields.def | 9 ++- MobileGL/MG_Pipe/generated/PipeTables.inc | 13 ++-- MobileGL/MG_Pipe/generated/PipeThunks.inc | 12 ++++ MobileGL/MG_Pipe/generated/PipeVerify.inc | 10 ++- MobileGL/MG_Pipe/generated/PipeWire.inc | 38 ++++++++++- MobileGL/MG_Remote/Transport/Doorbell.cpp | 17 ++++- MobileGL/MG_Remote/Transport/FdPassing.cpp | 27 ++++++++ MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp | 73 ++++++++++++++++++++- MobileGL/MG_Util/Metrics/PipeStats.cpp | 39 ++++++----- scripts/gen_pipe.py | 26 ++++++++ 15 files changed, 351 insertions(+), 57 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e29f42ae6..8d5a454c6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -830,15 +830,17 @@ jobs: # once inside a mutex critical section. Nothing under these two trees prints to a # stdio stream today - MGLOG_D compiles out in INFO builds and is the only channel # they are allowed to use - so this gate starts with no exceptions, and any addition - # to it needs a reason in the pull request rather than a quiet whitelist entry. + # to it needs a reason in the pull request rather than a quiet whitelist entry. The + # alternation names every stdio spelling, not just the two that were committed: + # fprintf to either stream, printf, puts, and the iostream pair. - name: No stdio instrumentation in MG_Backend or MG_State run: | - if grep -rnE 'fprintf[[:space:]]*\(stderr|(^|[^[:alnum:]_>.])printf[[:space:]]*\(' \ + if grep -rnE 'fprintf[[:space:]]*\((stderr|stdout)|(^|[^[:alnum:]_>.])printf[[:space:]]*\(|(^|[^[:alnum:]_>.:])puts[[:space:]]*\(|std::(cout|cerr)' \ MobileGL/MG_Backend MobileGL/MG_State; then echo "::error::stdio instrumentation found; use MGLOG_D (compiled out in INFO builds)" exit 1 fi - echo "no fprintf(stderr / printf( under MobileGL/MG_Backend or MobileGL/MG_State" + echo "no fprintf(stderr/stdout / printf( / puts( / std::cout|cerr under MobileGL/MG_Backend or MobileGL/MG_State" # Informational: the frontend mutation surface an MGPipe aggregate generation has to # cover. It becomes a gate in P1, when the mapping file exists to diff against. diff --git a/CMakeLists.txt b/CMakeLists.txt index 354cac153..8224159b3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -441,9 +441,14 @@ if (MOBILEGL_BUILD_DISAGGREGATED AND NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/3rdparty/flatbuffers/include/flatbuffers/flatbuffers.h") message(WARNING "MOBILEGL_BUILD_DISAGGREGATED=ON but 3rdparty/flatbuffers/include is missing. " - "Run `git submodule update --init 3rdparty/flatbuffers`. Forcing the option OFF.") - set(MOBILEGL_BUILD_DISAGGREGATED OFF CACHE BOOL - "Build the MG_Remote transport layer (two-process shape)" FORCE) + "Run `git submodule update --init 3rdparty/flatbuffers`. Building without the " + "disaggregated shape for this configure; the cached ON takes effect once the " + "submodule is present.") + # A NORMAL variable, deliberately not `CACHE BOOL ... FORCE`: forcing OFF into the cache + # made the plain re-configure after `git submodule update` stay OFF with no message at + # all. Shadowing the cache entry for this configure only keeps the operator's ON where it + # was, so the next configure - with the submodule there - honours it. + set(MOBILEGL_BUILD_DISAGGREGATED OFF) endif() if (MOBILEGL_BUILD_DISAGGREGATED) diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index 94558a222..a329008ee 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -159,8 +159,11 @@ namespace MobileGL::MG_ConfigLoader { return static_cast(parsedValue); } - // Same contract as QueryEnvUint32, over 64 bits and accepting a 0x prefix: the one - // consumer is a subsystem BITMASK, and a bitmask written in decimal is unreadable. + // Same contract as QueryEnvUint32, over 64 bits and accepting an explicit 0x prefix: the + // one consumer is a subsystem BITMASK, and a bitmask written in decimal is unreadable. + // Decimal otherwise - never strtoull's base 0, whose "leading zero means octal" rule + // silently read MOBILEGL_PIPE_PUSH=010 as 8 - and a '-' anywhere is rejected rather than + // wrapped, which strtoull would otherwise do without complaint (-1 -> every bit set). inline Uint64 QueryEnvUint64(const String& key, Uint64 defaultValue) { auto it = acceptedEnvVariablesMap->find(key); if (it == acceptedEnvVariablesMap->end()) { @@ -168,12 +171,19 @@ namespace MobileGL::MG_ConfigLoader { } const String& value = it->second; + const char* text = value.c_str(); + int base = 10; + if (value.size() > 2 && text[0] == '0' && (text[1] == 'x' || text[1] == 'X')) { + text += 2; + base = 16; + } char* parseEnd = nullptr; errno = 0; - const unsigned long long parsedValue = std::strtoull(value.c_str(), &parseEnd, 0); - if (parseEnd == value.c_str() || *parseEnd != '\0' || errno == ERANGE) { - MGLOG_W("Config: Ignoring invalid env variable %s='%s'; expected an integer (decimal or " - "0x-prefixed), using default %llu", + const bool negative = value.find('-') != String::npos; + const unsigned long long parsedValue = negative ? 0 : std::strtoull(text, &parseEnd, base); + if (negative || parseEnd == text || *parseEnd != '\0' || errno == ERANGE) { + MGLOG_W("Config: Ignoring invalid env variable %s='%s'; expected a non-negative integer " + "(decimal, or 0x-prefixed hexadecimal), using default %llu", key.c_str(), value.c_str(), static_cast(defaultValue)); return defaultValue; } diff --git a/MobileGL/MG_Pipe/MGPipeTypes.h b/MobileGL/MG_Pipe/MGPipeTypes.h index 133fff8f4..7d8f73d4e 100644 --- a/MobileGL/MG_Pipe/MGPipeTypes.h +++ b/MobileGL/MG_Pipe/MGPipeTypes.h @@ -14,7 +14,9 @@ // Every MGPipe payload (plan B section 4.5). Each one is a flat POD with explicit padding, // carries a static_assert on trivial copyability and one on its exact size, and never -// contains a pointer other than the single MGHostSpan the design isolates on purpose. +// contains a pointer: MGHostSpan, the one shape that changes with the transport, only ever +// rides in a variable tail (draw_vbo's user indices, set_shader_buffers' named-UBO bytes), +// never inline in a fixed payload. // // Sizes are asserted rather than merely documented because the wire records generated from // these structs (generated/PipeWire.inc) are memcpy'd; a field silently changing width is a @@ -194,6 +196,15 @@ namespace MobileGL::MG_Pipe { }; MGP_ASSERT_POD(MGPQueryResultRequest, 16); + // query_timestamp: glGetInteger64v(GL_TIMESTAMP), the synchronous "what time is it on the + // GPU" GLFunctionsTable::GetGpuTimestampNs answers today. The request names nothing; the + // Int64 nanosecond stamp comes back through the reply slot. + struct MGPTimestampRequest { + Uint32 Reserved; + Uint32 Pad0; + }; + MGP_ASSERT_POD(MGPTimestampRequest, 8); + // --------------------------------------------------------------------------------- // CSOs // --------------------------------------------------------------------------------- @@ -409,25 +420,32 @@ namespace MobileGL::MG_Pipe { }; MGP_ASSERT_POD(MGPShaderImages, 16); - // One bound buffer range. Payload is populated for the Uniform class only, and only - // while kCapNeedsHostUboBytes is set (D-B8). + // One bound buffer range: 24 bytes, no inline host span. The named-UBO host bytes a + // backend needs under kCapNeedsHostUboBytes (D-B8) travel as an OPTIONAL second var-tail, + // MGHostSpan[HostSpanCount] behind the ranges, announced by MGPShaderBuffers below. An + // inline span would have cost every SSBO, atomic-counter and XFB range 32 dead bytes, and + // D-B8 says not to freeze that payload's shape before the stage-ubo-named counter has + // produced numbers. struct MGPBufferRange { MGPipeHandle Res; Uint64 Offset; Uint64 Size; - MGHostSpan Payload; }; - MGP_ASSERT_POD(MGPBufferRange, 56); + MGP_ASSERT_POD(MGPBufferRange, 24); - // Var-tail header: MGPBufferRange[Count] follows. + // Var-tail header: MGPBufferRange[Count], then MGHostSpan[HostSpanCount]. HostSpanCount is + // 0, or Count for the Uniform class under kCapNeedsHostUboBytes (a range with nothing to + // ship carries an empty span, so the two arrays stay index-aligned). struct MGPShaderBuffers { Uint32 Class; // Uniform | ShaderStorage | AtomicCounter Uint32 Start; Uint32 Count; Uint32 WritableMask; + Uint32 HostSpanCount; // 0, or Count when the kHostSpan tail is present (D-B8) + Uint32 Pad0; Uint64 ContentHash; }; - MGP_ASSERT_POD(MGPShaderBuffers, 24); + MGP_ASSERT_POD(MGPShaderBuffers, 32); // Var-tail header: MGPBufferRange[Count] then Uint32 offsets[Count]. struct MGPStreamOutputTargets { @@ -470,7 +488,11 @@ namespace MobileGL::MG_Pipe { PixelStoreParameters Pack; }; static_assert(std::is_trivially_copyable_v); - static_assert(sizeof(MGPPixelPackState) == sizeof(PixelStoreParameters)); + // 28 is what PixelStoreParameters measures: two Bools, two bytes of padding, six Ints. + // Asserting against sizeof(PixelStoreParameters) itself was a tautology that could not + // notice the value struct changing width under the wire format. + static_assert(sizeof(MGPPixelPackState) == 28, + "MGPPixelPackState changed size; update the wire format and this assertion"); // Also a shader-variant input: both backends bake these into the synthesized // pass-through control stage. @@ -542,6 +564,15 @@ namespace MobileGL::MG_Pipe { // Carries the union box AND the region list so the SERVER picks the upload shape - the // decision belongs on the side that pays the GPU cost. Mali prices texture upload by // JOB COUNT: ~100 sprite rects against one union box measured +6 ms/frame. + // + // THE BUFFER HALF. With Target == Buffer there is no level and no box, so the destination + // byte range rides in the box's first coordinate and first extent: UnionBox.X is the byte + // offset, UnionBox.W the byte size, Y = Z = 0, H = D = 1, Level = 0, RegionCount = 0, and + // Blob holds exactly Size source bytes. That caps ONE record at a 2^31-1 offset and a + // 2^32-1 size; a range beyond either is split by the emitter - the same rule, and at + // SEG_STAGE's 32 MiB the far tighter one, that the ring's half-capacity bound already + // imposes on it. MGPipeSetSubDataBufferRange / MGPipeSubDataBufferOffset / Size below are + // the only spelling of this convention; nothing else reads the box for a buffer. struct MGPSubData { MGPipeHandle Res; Uint16 Target, Level; @@ -556,6 +587,24 @@ namespace MobileGL::MG_Pipe { }; MGP_ASSERT_POD(MGPSubData, 72); + // Encodes a buffer byte range into the record's box. False, with the record untouched, + // when the range does not fit one record: the emitter has to split it. + inline Bool MGPipeSetSubDataBufferRange(MGPSubData& record, Uint64 offset, Uint64 size) { + if (offset > 0x7FFFFFFFull || size > 0xFFFFFFFFull) { + return false; + } + record.UnionBox = MGPBox{static_cast(offset), 0, 0, static_cast(size), 1, 1}; + record.Level = 0; + record.RegionCount = 0; + return true; + } + inline Uint64 MGPipeSubDataBufferOffset(const MGPSubData& record) { + // A negative X is a corrupt record (the encoder never writes one); read as unsigned + // it lands above the encodable bound, which the applier's bounds gate refuses. + return static_cast(static_cast(record.UnionBox.X)); + } + inline Uint64 MGPipeSubDataBufferSize(const MGPSubData& record) { return record.UnionBox.W; } + // The forward terminator for a server-initiated texture pull (section 7.1). May carry // zero regions - that is how a pull that needs nothing is answered. struct MGPSubDataComplete { diff --git a/MobileGL/MG_Pipe/PipeCalls.def b/MobileGL/MG_Pipe/PipeCalls.def index 05db1f188..469cf3bf5 100644 --- a/MobileGL/MG_Pipe/PipeCalls.def +++ b/MobileGL/MG_Pipe/PipeCalls.def @@ -27,22 +27,23 @@ // that the expansion, the two generated tables and this number agree. // // class entries group (as the plan tabulates it) -// kScreen 10 screen: caps 1 + resource 3 + persistent map 2 + fence 4 -// kCtxQuery 6 query object namespace +// kScreen 11 screen: caps 1 + resource 3 + persistent map 2 + fence 4, plus the +// appended server-side fence wait 1 +// kCtxQuery 8 query object namespace 6, plus the appended timestamp pair 2 // kCtxCso 13 CSO create/bind/delete // kCtxState 17 16 of the 17 set_* calls + the temporary set_residual_value_state // kCtxObject 9 set_texture_params (the 17th set_*) + 8 object-scoped transfers // kCtxVerb 13 3 context-reading transfer calls + the 10 commands -// total 68 +// total 71 // // Reconciliation with the plan's headline numbers (section 4.4 / appendix A), because they // do not add up to a set of UNIQUE records and this file has to hold unique records: // - "screen 14" tabulates the fence and query families together with the screen block. // Section 4.3 assigns the query NAMESPACE to the context ("VAO / FBO / XFB object / // query namespaces, the command stream, present"), so the six query calls carry -// kCtxQuery and live in MGPipeContext. Screen keeps 10. The eight EGL lifecycle entry -// points stay virtual functions on pActiveBackendObject and are deliberately NOT calls -// here (section 4.4.1, last row). +// kCtxQuery and live in MGPipeContext. Screen keeps 10 of the plan's (11 with the appended +// FenceWaitServer, below). The eight EGL lifecycle entry points stay virtual functions on +// pActiveBackendObject and are deliberately NOT calls here (section 4.4.1, last row). // - "CSO 15" is create/bind/delete x 5 kinds. Two of those binds are ALSO named in the // set_* catalogue as their array forms - bind_sampler_states and set_sampler_views // (section 4.4.3) - and a call may only exist once, so they are emitted under @@ -54,10 +55,18 @@ // resource_subdata_complete). Eleven is what is emitted; the twelfth is not named // anywhere in the plan. // - "about 74 items" in section 4.1 is the sum of those headline numbers, so it inherits -// the same double counting. 68 unique records is the honest total. +// the same double counting. 68 unique records was the honest total of the plan's own +// catalogue. +// - Three LIVE GLFunctionsTable entries had no carrier in it at all: GetGpuTimestampNs +// (glGetInteger64v(GL_TIMESTAMP), a synchronous server answer), QueryCounterTimestamp +// (glQueryCounter, a one-shot stamp rather than a begin/end pair) and WaitSync (the +// GPU-side wait, which FenceWait's client-side wait does not express). They are +// QueryTimestamp, QueryCounter and FenceWaitServer, APPENDED at the end of the list - +// not slotted into their groups - because the wire opcode is the position, so a record +// that arrives late goes last. 71 unique records. // --------------------------------------------------------------------------------------- -#define MGP_CALL_LIST_DOCUMENTED_COUNT 68 +#define MGP_CALL_LIST_DOCUMENTED_COUNT 71 // clang-format off #define MGP_CALL_LIST(X) \ @@ -137,7 +146,18 @@ X(ResumeStreamOutput, MGPStreamOutputControl, kCtxVerb, kNone) \ X(Flush, MGPFlush, kCtxVerb, kNone) \ X(Present, MGPPresent, kCtxVerb, kNone) \ - X(SetSwapInterval, MGPSwapInterval, kCtxVerb, kOptional) + X(SetSwapInterval, MGPSwapInterval, kCtxVerb, kOptional) \ + /* ---- APPENDED. Opcodes are positional, so a late arrival goes at the END, never into ---- */ \ + /* ---- its group: three live GLFunctionsTable entries the catalogue had no carrier for. ---- */ \ + /* glGetInteger64v(GL_TIMESTAMP) - GetGpuTimestampNs, a synchronous server answer, which */ \ + /* the reply slot carries. The query namespace is the context's (plan 4.3). */ \ + X(QueryTimestamp, MGPTimestampRequest, kCtxQuery, kReplySlot) \ + /* glQueryCounter(GL_TIMESTAMP) - QueryCounterTimestamp, a one-shot stamp into a query */ \ + /* object, NOT a begin/end pair. Kind carries GL_TIMESTAMP. */ \ + X(QueryCounter, MGPQueryDesc, kCtxQuery, kNone) \ + /* glWaitSync - WaitSync, the GPU-side wait, distinct from FenceWait's client-side one. */ \ + /* TimeoutNs is GL_TIMEOUT_IGNORED by contract. */ \ + X(FenceWaitServer, MGPFenceWait, kScreen, kNone) // clang-format on // Explicitly NOT migrated (plan 4.4.6 / appendix A "explicit deletions"): diff --git a/MobileGL/MG_Pipe/PipeFields.def b/MobileGL/MG_Pipe/PipeFields.def index 0aaddd1d4..d142ac804 100644 --- a/MobileGL/MG_Pipe/PipeFields.def +++ b/MobileGL/MG_Pipe/PipeFields.def @@ -54,6 +54,9 @@ #define MGP_FIELDS_MGPQueryResultRequest(F) \ F(Query) F(Wait) +#define MGP_FIELDS_MGPTimestampRequest(F) \ + F(Reserved) + #define MGP_FIELDS_MGPRenderStateDesc(F) \ F(Cso) F(BaseCso) F(ChunkMask) F(Blob) @@ -116,10 +119,10 @@ F(Start) F(Count) F(ContentHash) #define MGP_FIELDS_MGPBufferRange(F) \ - F(Res) F(Offset) F(Size) F(Payload) + F(Res) F(Offset) F(Size) #define MGP_FIELDS_MGPShaderBuffers(F) \ - F(Class) F(Start) F(Count) F(WritableMask) F(ContentHash) + F(Class) F(Start) F(Count) F(WritableMask) F(HostSpanCount) F(ContentHash) #define MGP_FIELDS_MGPStreamOutputTargets(F) \ F(Count) F(Generation) F(ContentHash) @@ -219,7 +222,7 @@ // macros; gen_pipe.py reads THIS list to know what to emit. #define MGP_VERIFY_PAYLOAD_LIST(P) \ P(MGPBlobRef) P(MGPRange) P(MGPBox) P(MGPReplySlot) P(MGPStateChunk) P(MGPHandleOnly) P(MGPCaps) \ - P(MGPResourceDesc) P(MGPFenceWait) P(MGPQueryDesc) P(MGPQueryResultRequest) P(MGPRenderStateDesc) \ + P(MGPResourceDesc) P(MGPFenceWait) P(MGPQueryDesc) P(MGPQueryResultRequest) P(MGPTimestampRequest) P(MGPRenderStateDesc) \ P(MGPBindRenderState) P(MGPDynamicState) P(MGPVertexElements) P(MGPSamplerDesc) P(MGPSamplerView) \ P(MGPTextureParams) P(MGPProgramDesc) P(MGPSurface) P(MGPFramebufferState) P(MGPVertexBuffer) \ P(MGPVertexBuffers) P(MGPIndexBuffer) P(MGPIndirectBuffers) P(MGPBoundView) P(MGPSamplerViews) \ diff --git a/MobileGL/MG_Pipe/generated/PipeTables.inc b/MobileGL/MG_Pipe/generated/PipeTables.inc index cb8a602de..072635c41 100644 --- a/MobileGL/MG_Pipe/generated/PipeTables.inc +++ b/MobileGL/MG_Pipe/generated/PipeTables.inc @@ -12,7 +12,7 @@ // Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result. // This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe. -// share group: 10 calls. A null entry means the backend does not implement this +// share group: 11 calls. A null entry means the backend does not implement this // call and the frontend keeps its own path (plan B section 4.1). struct MGPipeScreen { void (*GetCaps)(const MGPCaps* payload, MGPReplySlot* reply); @@ -25,9 +25,10 @@ struct MGPipeScreen { void (*FenceStatus)(const MGPHandleOnly* payload, MGPReplySlot* reply); void (*FenceWait)(const MGPFenceWait* payload, MGPReplySlot* reply); void (*FenceDestroy)(const MGPHandleOnly* payload); + void (*FenceWaitServer)(const MGPFenceWait* payload); }; -// context: 58 calls. A null entry means the backend does not implement this +// context: 60 calls. A null entry means the backend does not implement this // call and the frontend keeps its own path (plan B section 4.1). struct MGPipeContext { void (*QueryCreate)(const MGPQueryDesc* payload); @@ -88,11 +89,13 @@ struct MGPipeContext { void (*Flush)(const MGPFlush* payload); void (*Present)(const MGPPresent* payload); void (*SetSwapInterval)(const MGPSwapInterval* payload); + void (*QueryTimestamp)(const MGPTimestampRequest* payload, MGPReplySlot* reply); + void (*QueryCounter)(const MGPQueryDesc* payload); }; -inline constexpr SizeT kMGPipeScreenCallCount = 10; -inline constexpr SizeT kMGPipeContextCallCount = 58; -inline constexpr SizeT kMGPipeCallCount = 68; +inline constexpr SizeT kMGPipeScreenCallCount = 11; +inline constexpr SizeT kMGPipeContextCallCount = 60; +inline constexpr SizeT kMGPipeCallCount = 71; // A table that is not exactly its call count of function pointers has grown a // member that no generator knows about. diff --git a/MobileGL/MG_Pipe/generated/PipeThunks.inc b/MobileGL/MG_Pipe/generated/PipeThunks.inc index 70e999314..6a898679b 100644 --- a/MobileGL/MG_Pipe/generated/PipeThunks.inc +++ b/MobileGL/MG_Pipe/generated/PipeThunks.inc @@ -288,3 +288,15 @@ inline void MGP_Present(const MGPPresent* payload) { inline void MGP_SetSwapInterval(const MGPSwapInterval* payload) { gMGPipeContext.SetSwapInterval(payload); } + +inline void MGP_QueryTimestamp(const MGPTimestampRequest* payload, MGPReplySlot* reply) { + gMGPipeContext.QueryTimestamp(payload, reply); +} + +inline void MGP_QueryCounter(const MGPQueryDesc* payload) { + gMGPipeContext.QueryCounter(payload); +} + +inline void MGP_FenceWaitServer(const MGPFenceWait* payload) { + gMGPipeScreen.FenceWaitServer(payload); +} diff --git a/MobileGL/MG_Pipe/generated/PipeVerify.inc b/MobileGL/MG_Pipe/generated/PipeVerify.inc index 4b14a829b..db652e6e9 100644 --- a/MobileGL/MG_Pipe/generated/PipeVerify.inc +++ b/MobileGL/MG_Pipe/generated/PipeVerify.inc @@ -38,6 +38,7 @@ inline Bool MGPipeVerify(const MGPResourceDesc& a, const MGPResourceDesc& b, con inline Bool MGPipeVerify(const MGPFenceWait& a, const MGPFenceWait& b, const char** outField); inline Bool MGPipeVerify(const MGPQueryDesc& a, const MGPQueryDesc& b, const char** outField); inline Bool MGPipeVerify(const MGPQueryResultRequest& a, const MGPQueryResultRequest& b, const char** outField); +inline Bool MGPipeVerify(const MGPTimestampRequest& a, const MGPTimestampRequest& b, const char** outField); inline Bool MGPipeVerify(const MGPRenderStateDesc& a, const MGPRenderStateDesc& b, const char** outField); inline Bool MGPipeVerify(const MGPBindRenderState& a, const MGPBindRenderState& b, const char** outField); inline Bool MGPipeVerify(const MGPDynamicState& a, const MGPDynamicState& b, const char** outField); @@ -113,6 +114,8 @@ struct MGPipeHasFieldVerifier : std::true_type {}; template <> struct MGPipeHasFieldVerifier : std::true_type {}; template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> struct MGPipeHasFieldVerifier : std::true_type {}; template <> struct MGPipeHasFieldVerifier : std::true_type {}; @@ -305,6 +308,11 @@ inline Bool MGPipeVerify(const MGPQueryResultRequest& a, const MGPQueryResultReq return true; } +inline Bool MGPipeVerify(const MGPTimestampRequest& a, const MGPTimestampRequest& b, const char** outField) { + MGP_FIELDS_MGPTimestampRequest(MGP_VERIFY_FIELD) + return true; +} + inline Bool MGPipeVerify(const MGPRenderStateDesc& a, const MGPRenderStateDesc& b, const char** outField) { MGP_FIELDS_MGPRenderStateDesc(MGP_VERIFY_FIELD) return true; @@ -562,4 +570,4 @@ inline Bool MGPipeVerify(const MGPSurfaceInfo& a, const MGPSurfaceInfo& b, const #undef MGP_VERIFY_FIELD -inline constexpr SizeT kMGPipeVerifiedPayloadCount = 62; +inline constexpr SizeT kMGPipeVerifiedPayloadCount = 63; diff --git a/MobileGL/MG_Pipe/generated/PipeWire.inc b/MobileGL/MG_Pipe/generated/PipeWire.inc index 249fa6340..fca186f1b 100644 --- a/MobileGL/MG_Pipe/generated/PipeWire.inc +++ b/MobileGL/MG_Pipe/generated/PipeWire.inc @@ -112,7 +112,10 @@ enum class MGPWireOp : Uint16 { Flush = 66, Present = 67, SetSwapInterval = 68, - kOpCount = 69, + QueryTimestamp = 69, + QueryCounter = 70, + FenceWaitServer = 71, + kOpCount = 72, }; struct alignas(8) MGPWireRec_GetCaps { @@ -659,6 +662,30 @@ static_assert(sizeof(MGPWireRec_SetSwapInterval) == ((sizeof(MGPWireRecHeader) + sizeof(MGPSwapInterval) + 7u) & ~SizeT(7u)), "MGPWireRec_SetSwapInterval gained padding; the wire format moved"); +struct alignas(8) MGPWireRec_QueryTimestamp { + MGPWireRecHeader Header; + MGPTimestampRequest Payload; +}; +static_assert(sizeof(MGPWireRec_QueryTimestamp) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPTimestampRequest) + 7u) & ~SizeT(7u)), + "MGPWireRec_QueryTimestamp gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_QueryCounter { + MGPWireRecHeader Header; + MGPQueryDesc Payload; +}; +static_assert(sizeof(MGPWireRec_QueryCounter) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPQueryDesc) + 7u) & ~SizeT(7u)), + "MGPWireRec_QueryCounter gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_FenceWaitServer { + MGPWireRecHeader Header; + MGPFenceWait Payload; +}; +static_assert(sizeof(MGPWireRec_FenceWaitServer) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPFenceWait) + 7u) & ~SizeT(7u)), + "MGPWireRec_FenceWaitServer gained padding; the wire format moved"); + [[noreturn]] inline void MGPipeWireProtocolFatal(const char* call, Uint64 size, Uint64 remaining) { MGLOG_F("MGPipe: protocol corruption applying %s: size=%llu remaining=%llu", call, static_cast(size), static_cast(remaining)); @@ -885,6 +912,15 @@ inline Bool MGPipeApplyWireRecord(MGPWireOp op, const void* record, Uint64 size, case MGPWireOp::SetSwapInterval: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetSwapInterval, "SetSwapInterval"); return false; + case MGPWireOp::QueryTimestamp: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryTimestamp, "QueryTimestamp"); + return false; + case MGPWireOp::QueryCounter: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryCounter, "QueryCounter"); + return false; + case MGPWireOp::FenceWaitServer: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_FenceWaitServer, "FenceWaitServer"); + return false; case MGPWireOp::kInvalid: case MGPWireOp::kOpCount: default: diff --git a/MobileGL/MG_Remote/Transport/Doorbell.cpp b/MobileGL/MG_Remote/Transport/Doorbell.cpp index 7ec1f2154..1851f38b5 100644 --- a/MobileGL/MG_Remote/Transport/Doorbell.cpp +++ b/MobileGL/MG_Remote/Transport/Doorbell.cpp @@ -20,6 +20,12 @@ #include #endif +// Same fallback as FdPassing.cpp: on macOS / BSD the protection is SO_NOSIGPIPE on the +// socket, set in SocketDoorbell's constructor, not a per-send flag. +#if !defined(_WIN32) && !defined(MSG_NOSIGNAL) +#define MSG_NOSIGNAL 0 +#endif + namespace MobileGL::MG_Remote::Transport { // ----------------------------------------------------------------------- @@ -100,7 +106,16 @@ namespace MobileGL::MG_Remote::Transport { // ----------------------------------------------------------------------- SocketDoorbell::SocketDoorbell(int fd, std::uint8_t code, bool ownsFd) - : m_fd(fd), m_code(code), m_ownsFd(ownsFd) {} + : m_fd(fd), m_code(code), m_ownsFd(ownsFd) { +#if defined(SO_NOSIGPIPE) + // The per-socket form of MSG_NOSIGNAL, on the platforms that lack the per-call one: + // a Notify to a hung-up peer must come back as EPIPE, not as a fatal signal. + if (m_fd >= 0) { + const int one = 1; + (void)::setsockopt(m_fd, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one)); + } +#endif + } SocketDoorbell::~SocketDoorbell() { if (m_ownsFd && m_fd >= 0) { diff --git a/MobileGL/MG_Remote/Transport/FdPassing.cpp b/MobileGL/MG_Remote/Transport/FdPassing.cpp index 229911776..a2dff4c42 100644 --- a/MobileGL/MG_Remote/Transport/FdPassing.cpp +++ b/MobileGL/MG_Remote/Transport/FdPassing.cpp @@ -15,12 +15,21 @@ #if !defined(_WIN32) #include +#include #include #include #include #include #endif +// MSG_NOSIGNAL is Linux (and Android). macOS and the BSDs spell the same protection as the +// SO_NOSIGPIPE socket option, set once per socket at creation (CreateSocketPair below, and +// SocketDoorbell's constructor). With neither, a write to a hung-up peer raises SIGPIPE and +// kills the process instead of returning EPIPE. +#if !defined(_WIN32) && !defined(MSG_NOSIGNAL) +#define MSG_NOSIGNAL 0 +#endif + namespace MobileGL::MG_Remote::Transport::FdPassing { #if defined(_WIN32) @@ -84,6 +93,13 @@ namespace MobileGL::MG_Remote::Transport::FdPassing { MGLOG_E("MG_Remote fd passing: socketpair failed (errno=%d)", errno); return MOBILEGL_ERR_TRANSPORT_CLOSED; } +#if defined(SO_NOSIGPIPE) + // The per-socket form of MSG_NOSIGNAL, on the platforms that lack the per-call one. + for (int fd : fds) { + const int one = 1; + (void)::setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one)); + } +#endif outFds[0] = fds[0]; outFds[1] = fds[1]; return MOBILEGL_OK; @@ -241,6 +257,17 @@ namespace MobileGL::MG_Remote::Transport::FdPassing { received[receivedCount++] = fd; } } +#if !defined(MSG_CMSG_CLOEXEC) + // No atomic close-on-exec on receive here (macOS, the BSDs): set it by hand on every + // descriptor that arrived, before anything else can fork. The window between the + // recvmsg and this loop is the platform's, not ours; leaving the flag off altogether + // would hand every shared segment to every child the process ever spawns. + for (int i = 0; i < receivedCount; ++i) { + if (received[i] >= 0) { + (void)::fcntl(received[i], F_SETFD, FD_CLOEXEC); + } + } +#endif const auto closeAll = [&](int keepIndex) { for (int i = 0; i < receivedCount; ++i) { if (i != keepIndex && received[i] >= 0) { diff --git a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp index 3d63eb71a..ab9907dd0 100644 --- a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp +++ b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp @@ -12,6 +12,8 @@ #include +#include + #include "Includes.h" #include @@ -81,8 +83,8 @@ TEST(PipeCatalogue, GeneratedTablesHoldTheWholeCatalogue) { EXPECT_EQ(kMGPipeContextCallCount, kMGPipeCallCount - ClassCount()); // The per-class counts PipeCalls.def documents in its header. - EXPECT_EQ(ClassCount(), 10u); - EXPECT_EQ(ClassCount(), 6u); + EXPECT_EQ(ClassCount(), 11u); + EXPECT_EQ(ClassCount(), 8u); EXPECT_EQ(ClassCount(), 13u); EXPECT_EQ(ClassCount(), 17u); EXPECT_EQ(ClassCount(), 9u); @@ -123,6 +125,17 @@ TEST(PipeCatalogue, WireOpcodesAreThePositionsInTheCatalogue) { EXPECT_EQ(sizeof(MGPWireRec_SetResidualValueState) % 8, 0u); } +// Records are append-only. The three carriers added after the first cut - for the live +// GLFunctionsTable entries GetGpuTimestampNs, QueryCounterTimestamp and WaitSync - sit at +// the END of the list, after SetSwapInterval, so no opcode the first cut assigned has moved. +TEST(PipeCatalogue, LateArrivalsAreAppendedWithoutRenumbering) { + EXPECT_EQ(static_cast(MGPWireOp::SetSwapInterval), 68); + EXPECT_EQ(static_cast(MGPWireOp::QueryTimestamp), 69); + EXPECT_EQ(static_cast(MGPWireOp::QueryCounter), 70); + EXPECT_EQ(static_cast(MGPWireOp::FenceWaitServer), 71); + EXPECT_EQ(static_cast(MGPWireOp::kOpCount), 72); +} + // A well-formed record passes the applier's bounds gate. P0 has no applier, so "accepted" // is reported as "not applied" rather than "fatal". TEST(PipeCatalogue, ApplierAcceptsAWellFormedRecord) { @@ -218,3 +231,59 @@ TEST(PipeCatalogue, HostSpanResolvesTheMonolithPointer) { EXPECT_EQ(gMGPipeSegmentResolver, nullptr); EXPECT_EQ(MGPipeHostBytes(staged), nullptr); } + +// D-B8: a bound buffer range carries no inline host span. The named-UBO bytes are an +// optional second var-tail announced by HostSpanCount, so the SSBO, atomic-counter and XFB +// ranges - the majority - pay nothing for a payload whose shape is not frozen yet. +TEST(PipeCatalogue, BufferRangeCarriesNoInlineHostSpan) { + static_assert(sizeof(MGPBufferRange) == 24); + static_assert(sizeof(MGPShaderBuffers) == 32); + EXPECT_LT(sizeof(MGPBufferRange), sizeof(MGHostSpan)); + + // The call still declares the span it may carry, so the transport lays the tail out. + Uint32 flags = 0; +#define MGP_FLAGS_OF_SET_SHADER_BUFFERS(Name, Payload, Class, Flags) \ + if (std::strcmp(#Name, "SetShaderBuffers") == 0) flags = static_cast(Flags); + MGP_CALL_LIST(MGP_FLAGS_OF_SET_SHADER_BUFFERS) +#undef MGP_FLAGS_OF_SET_SHADER_BUFFERS + EXPECT_EQ(flags & (kVarTail | kHostSpan), static_cast(kVarTail | kHostSpan)); + + // And the comparator sees the count that announces the tail. + MGPShaderBuffers a{}; + MGPShaderBuffers b{}; + const char* field = nullptr; + EXPECT_TRUE(MGPipeVerify(a, b, &field)); + b.HostSpanCount = 4; + EXPECT_FALSE(MGPipeVerify(a, b, &field)); + EXPECT_STREQ(field, "HostSpanCount"); +} + +// The buffer half of resource_subdata has no level and no box of its own: [offset, size) +// rides in UnionBox.X / UnionBox.W, and only through the two helpers, which also say where +// one record stops and the emitter has to split. +TEST(PipeCatalogue, SubDataBufferRangeRidesInTheUnionBox) { + MGPSubData record{}; + record.Level = 3; + record.RegionCount = 2; + ASSERT_TRUE(MGPipeSetSubDataBufferRange(record, 4096, 65536)); + EXPECT_EQ(record.UnionBox.X, 4096); + EXPECT_EQ(record.UnionBox.W, 65536u); + EXPECT_EQ(record.UnionBox.Y, 0); + EXPECT_EQ(record.UnionBox.Z, 0); + EXPECT_EQ(record.UnionBox.H, 1u); + EXPECT_EQ(record.UnionBox.D, 1u); + EXPECT_EQ(record.Level, 0); + EXPECT_EQ(record.RegionCount, 0u); + EXPECT_EQ(MGPipeSubDataBufferOffset(record), 4096u); + EXPECT_EQ(MGPipeSubDataBufferSize(record), 65536u); + + // The largest range one record expresses... + ASSERT_TRUE(MGPipeSetSubDataBufferRange(record, 0x7FFFFFFFull, 0xFFFFFFFFull)); + EXPECT_EQ(MGPipeSubDataBufferOffset(record), 0x7FFFFFFFull); + EXPECT_EQ(MGPipeSubDataBufferSize(record), 0xFFFFFFFFull); + // ...and beyond it the emitter splits: refused, record untouched. + EXPECT_FALSE(MGPipeSetSubDataBufferRange(record, 0x80000000ull, 1)); + EXPECT_FALSE(MGPipeSetSubDataBufferRange(record, 0, 0x100000000ull)); + EXPECT_EQ(MGPipeSubDataBufferOffset(record), 0x7FFFFFFFull); + EXPECT_EQ(MGPipeSubDataBufferSize(record), 0xFFFFFFFFull); +} diff --git a/MobileGL/MG_Util/Metrics/PipeStats.cpp b/MobileGL/MG_Util/Metrics/PipeStats.cpp index e12123150..165e436c9 100644 --- a/MobileGL/MG_Util/Metrics/PipeStats.cpp +++ b/MobileGL/MG_Util/Metrics/PipeStats.cpp @@ -296,7 +296,12 @@ namespace MobileGL::MG_Util::PipeStats { void RecordDrawPayloadBytes(Uint64 bytes) { Bump(g_totalPayloadBuckets[PayloadBucketOf(bytes)], 1); } void OnPresent() { -#ifdef TRACY_ENABLE + // Every frame accumulator is EXCHANGED for zero, and the exchanged value is what gets + // plotted. A read followed by a store(0) would lose any Bump that lands in between - + // buffer and texture staging reach these counters from more than one thread - from + // the plot AND from every frame; an exchange hands every add to exactly one frame. + // Without Tracy the value is taken and dropped: the clear is still the point. + // // One plot per counter, the frame's value. Tracy keeps the series by name, and the // names are the static literals above, which is what TracyPlot requires. A gate is // two series - hits and misses - because the ratio is the deliverable and a miss @@ -305,26 +310,30 @@ namespace MobileGL::MG_Util::PipeStats { // The payload histogram is deliberately NOT plotted: it is a run-total distribution // over draws (section 4.5.7), not a per-frame scalar, and Tracy has no histogram // series. It reaches the operator through the JSON dump. + const auto take = [](Counter& counter) { return counter.exchange(0, std::memory_order_relaxed); }; for (Uint32 i = 0; i < kByteClassCount; ++i) { - TracyPlot(kByteClassNames[i], static_cast(Read(g_frameBytes[i]))); - } - for (Uint32 i = 0; i < kCallClassCount; ++i) { - TracyPlot(kCallClassNames[i], static_cast(Read(g_frameCalls[i]))); - } - for (Uint32 i = 0; i < kGateCount; ++i) { - TracyPlot(kGateHitPlotNames[i], static_cast(Read(g_frameGateHit[i]))); - TracyPlot(kGateMissPlotNames[i], static_cast(Read(g_frameGateMiss[i]))); - } + const Uint64 value = take(g_frameBytes[i]); + (void)value; +#ifdef TRACY_ENABLE + TracyPlot(kByteClassNames[i], static_cast(value)); #endif - for (Uint32 i = 0; i < kByteClassCount; ++i) { - g_frameBytes[i].store(0, std::memory_order_relaxed); } for (Uint32 i = 0; i < kCallClassCount; ++i) { - g_frameCalls[i].store(0, std::memory_order_relaxed); + const Uint64 value = take(g_frameCalls[i]); + (void)value; +#ifdef TRACY_ENABLE + TracyPlot(kCallClassNames[i], static_cast(value)); +#endif } for (Uint32 i = 0; i < kGateCount; ++i) { - g_frameGateHit[i].store(0, std::memory_order_relaxed); - g_frameGateMiss[i].store(0, std::memory_order_relaxed); + const Uint64 hits = take(g_frameGateHit[i]); + const Uint64 misses = take(g_frameGateMiss[i]); + (void)hits; + (void)misses; +#ifdef TRACY_ENABLE + TracyPlot(kGateHitPlotNames[i], static_cast(hits)); + TracyPlot(kGateMissPlotNames[i], static_cast(misses)); +#endif } const Uint64 frames = g_frameCount.fetch_add(1, std::memory_order_relaxed) + 1; if (frames % kSummaryFramePeriod == 0) { diff --git a/scripts/gen_pipe.py b/scripts/gen_pipe.py index e6599abb7..8b128798a 100644 --- a/scripts/gen_pipe.py +++ b/scripts/gen_pipe.py @@ -24,6 +24,9 @@ python3 scripts/gen_pipe.py # write the generated files, print the summary python3 scripts/gen_pipe.py --check # fail if regenerating would change anything + +Both modes refuse a catalogue whose call payload has no field list in PipeFields.def: a +payload the G4 comparator cannot see is a payload MOBILEGL_PIPE_VERIFY is blind to. """ import argparse @@ -157,6 +160,17 @@ def parse_calls(): return calls +# The member types the G4 comparator falls back to memcmp for (see gen_verify): the +# MG_State / MG_Backend value structs and MGHostSpan. They are not call payloads and get +# field lists of their own in P0.5. Nothing else may be missing from PipeFields.def. +MEMCMP_FALLBACK_TYPES = { + "RenderStateParameters", + "PixelStoreParameters", + "DynamicBackendParameters", + "MGHostSpan", +} + + def parse_verify_payloads(): text = read(os.path.join(PIPE_DIR, "PipeFields.def")) match = re.search(r"#define MGP_VERIFY_PAYLOAD_LIST\(P\)(.*?)\n\n", text, re.S) @@ -169,6 +183,17 @@ def parse_verify_payloads(): return payloads +def check_call_payloads_have_field_lists(calls, payloads): + """Every payload PipeCalls.def names must have a G4 field list, or the verify comparator + is silently blind to that call. Runs in both modes, --check included.""" + known = set(payloads) + missing = sorted({c.Payload for c in calls + if c.Payload not in known and c.Payload not in MEMCMP_FALLBACK_TYPES}) + if missing: + sys.exit("PipeFields.def: call payload(s) with no field list, so MOBILEGL_PIPE_VERIFY " + "would be blind to them: %s" % ", ".join(missing)) + + def parse_coverage(): text = read(os.path.join(PIPE_DIR, "Coverage.def")) accessors = [] @@ -608,6 +633,7 @@ def main(): calls = parse_calls() payloads = parse_verify_payloads() + check_call_payloads_have_field_lists(calls, payloads) accessors, deltas = parse_coverage() rows = parse_inventory() From 458ccde176628e97cb6fa37894e9acbf4642de26 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 21:21:29 -0400 Subject: [PATCH 028/529] [Feat] (Metrics, Config): make the boundary-counter summary cadence a knob, because the device harness never reaches the teardown dump - MOBILEGL_PIPE_STATS_PERIOD (default 120, clamped to [1, 1000000]) sets the frames per summary line; Init() latches it and a zero falls back to the default - the trace APK's replay never tears MobileGL down, so MOBILEGL_PIPE_STATS_FILE never fires on device and a fixture shorter than the period (create-indirect) reported nothing - PipeStatsTest pins the latch and the zero fallback --- MobileGL/Config.h | 5 +++++ MobileGL/ConfigLoader.cpp | 1 + MobileGL/MG_Test/Util/PipeStatsTest.cpp | 22 +++++++++++++++++++--- MobileGL/MG_Util/Metrics/PipeStats.cpp | 11 +++++++++-- MobileGL/MG_Util/Metrics/PipeStats.h | 5 ++++- 5 files changed, 38 insertions(+), 6 deletions(-) diff --git a/MobileGL/Config.h b/MobileGL/Config.h index 3ceb3c9df..0c59110ee 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -347,6 +347,11 @@ namespace MobileGL::MG_Config { // the server without shipping index bytes per draw. Over budget it degrades to // per-draw staging, counted separately in the stats. Uint32 PipeIndexMirrorMb = 64; + // MOBILEGL_PIPE_STATS_PERIOD: frames per boundary-counter summary line. 120 is the + // steady-state cadence; the device retrace harness never reaches the teardown dump + // and a trimmed fixture (create-indirect) is shorter than 120 frames, so a run that + // needs its numbers at all sets this low enough to land at least one window. + Uint32 PipeStatsPeriod = 120; // MOBILEGL_PIPE_STATS_FILE: where the boundary counters' teardown JSON dump goes. // Empty (the default) means no dump; the per-120-frame summary line still goes to // the log whenever PipeStats is on, so a device run needs no writable path. diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index a329008ee..f4087c563 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -252,6 +252,7 @@ namespace MobileGL::MG_ConfigLoader { QueryEnvQuirkOverride("MOBILEGL_PIPE_LEGACY_MEMOS") != MG_Config::QuirkOverride::ForceOff; features.PipeTexelRetainMb = QueryEnvUint32("MOBILEGL_PIPE_TEXEL_RETAIN_MB", 0, 0, 4096); features.PipeIndexMirrorMb = QueryEnvUint32("MOBILEGL_PIPE_INDEX_MIRROR_MB", 64, 0, 4096); + features.PipeStatsPeriod = QueryEnvUint32("MOBILEGL_PIPE_STATS_PERIOD", 120, 1, 1000000); QueryEnvVariable("MOBILEGL_PIPE_STATS_FILE", features.PipeStatsFile, ""); } diff --git a/MobileGL/MG_Test/Util/PipeStatsTest.cpp b/MobileGL/MG_Test/Util/PipeStatsTest.cpp index 4d329ed7b..9ccd4e45c 100644 --- a/MobileGL/MG_Test/Util/PipeStatsTest.cpp +++ b/MobileGL/MG_Test/Util/PipeStatsTest.cpp @@ -12,6 +12,7 @@ #include +#include #include #include @@ -77,6 +78,21 @@ namespace { EXPECT_EQ(PS::FrameCount(), 1u); } + TEST_F(PipeStatsTest, InitLatchesTheSummaryPeriodFromTheConfigAndNeverKeepsZero) { + // The device retrace harness never reaches the teardown dump, so the summary + // cadence is the only way a short fixture yields numbers at all: it must follow + // MOBILEGL_PIPE_STATS_PERIOD, and a zero must fall back rather than divide. + const Uint32 saved = MobileGL::MG_Config::Features.PipeStatsPeriod; + MobileGL::MG_Config::Features.PipeStatsPeriod = 7; + PS::Init(); + EXPECT_EQ(PS::SummaryFramePeriod(), 7u); + MobileGL::MG_Config::Features.PipeStatsPeriod = 0; + PS::Init(); + EXPECT_EQ(PS::SummaryFramePeriod(), PS::kDefaultSummaryFramePeriod); + MobileGL::MG_Config::Features.PipeStatsPeriod = saved; + PS::Init(); + } + TEST_F(PipeStatsTest, GateHitsAndMissesAreSeparateCounters) { for (Uint32 i = 0; i < 5; ++i) { PS::CountGate(PS::Gate::MagmaPipelineMemo, /*hit=*/true); @@ -262,10 +278,10 @@ namespace { // A summary is emitted every kSummaryFramePeriod presents. The period is a constant the // smoke check depends on, so a change to it has to break a test. TEST_F(PipeStatsTest, SummaryPeriodIsOneHundredAndTwentyFrames) { - EXPECT_EQ(PS::kSummaryFramePeriod, 120u); - for (Uint64 i = 0; i < PS::kSummaryFramePeriod; ++i) { + EXPECT_EQ(PS::SummaryFramePeriod(), 120u); + for (Uint64 i = 0; i < PS::SummaryFramePeriod(); ++i) { PS::OnPresent(); } - EXPECT_EQ(PS::FrameCount(), PS::kSummaryFramePeriod); + EXPECT_EQ(PS::FrameCount(), PS::SummaryFramePeriod()); } } // namespace diff --git a/MobileGL/MG_Util/Metrics/PipeStats.cpp b/MobileGL/MG_Util/Metrics/PipeStats.cpp index 165e436c9..8813b915d 100644 --- a/MobileGL/MG_Util/Metrics/PipeStats.cpp +++ b/MobileGL/MG_Util/Metrics/PipeStats.cpp @@ -130,6 +130,8 @@ namespace MobileGL::MG_Util::PipeStats { Uint64 g_windowBaseFrames = 0; Bool g_shutdownDone = false; + // Frames per summary line, latched by Init() from MOBILEGL_PIPE_STATS_PERIOD. + Uint64 g_summaryPeriod = kDefaultSummaryFramePeriod; inline void Bump(Counter& counter, Uint64 amount) { counter.fetch_add(amount, std::memory_order_relaxed); } @@ -253,14 +255,19 @@ namespace MobileGL::MG_Util::PipeStats { ResetCounters(); g_shutdownDone = false; g_pipeStatsEnabled = MG_Config::Features.PipeStats; + g_summaryPeriod = MG_Config::Features.PipeStatsPeriod == 0 + ? kDefaultSummaryFramePeriod + : static_cast(MG_Config::Features.PipeStatsPeriod); if (g_pipeStatsEnabled) { MGLOG_I("MGPipe stats: counters ON (MOBILEGL_PIPE_STATS), summary every %llu frames%s%s", - static_cast(kSummaryFramePeriod), + static_cast(g_summaryPeriod), MG_Config::Features.PipeStatsFile.empty() ? "" : ", JSON dump to ", MG_Config::Features.PipeStatsFile.c_str()); } } + Uint64 SummaryFramePeriod() { return g_summaryPeriod; } + void Shutdown() { if (!g_pipeStatsEnabled || g_shutdownDone) { return; @@ -336,7 +343,7 @@ namespace MobileGL::MG_Util::PipeStats { #endif } const Uint64 frames = g_frameCount.fetch_add(1, std::memory_order_relaxed) + 1; - if (frames % kSummaryFramePeriod == 0) { + if (frames % g_summaryPeriod == 0) { EmitSummaryLine(); } } diff --git a/MobileGL/MG_Util/Metrics/PipeStats.h b/MobileGL/MG_Util/Metrics/PipeStats.h index 367a323e6..0e8e74e42 100644 --- a/MobileGL/MG_Util/Metrics/PipeStats.h +++ b/MobileGL/MG_Util/Metrics/PipeStats.h @@ -129,7 +129,10 @@ namespace MobileGL::MG_Util::PipeStats { inline constexpr Uint32 kPayloadHistogramBuckets = 24; // Frames between two summary lines when MOBILEGL_PIPE_STATS=1. - inline constexpr Uint64 kSummaryFramePeriod = 120; + inline constexpr Uint64 kDefaultSummaryFramePeriod = 120; + // The period Init() latched from MOBILEGL_PIPE_STATS_PERIOD (kDefaultSummaryFramePeriod + // when unset); never 0. + Uint64 SummaryFramePeriod(); // The latch. Read directly by Enabled() so the off path is a global load and a // predicted branch - do not turn this into a function call. From 8952b1402405f7debeb4f047d423f0a61a543f4a Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 21:56:31 -0400 Subject: [PATCH 029/529] [Docs] (Disaggregated): rewrite the MGPipe plan into a design and architecture set - README, ARCHITECTURE, ROADMAP, MEASUREMENTS - and retire PLAN.md and REVIEW.md to git history - README.md: what MGPipe is, the one-paragraph architecture, the P0 status line, the file and code map, and the commit range where the design competition and the three adversarial review rounds live - ARCHITECTURE.md: the design as decided, one reason per decision - handles and generations, the 71-call catalogue by class with flags and cap bits, record and payload conventions, the tracker, texture subdata and dirty ownership, shader state and the P0.5 header extraction, the reverse channel, the backend strangler, the server side and the index host mirror, the transport as landed in MG_Remote, the persistent-map tiers as measured, roundtrips, present and threads, process and platform delivery, build shapes and purity gates, the five-part verification gate, and the knob tables marked landed versus planned - ROADMAP.md: P0..P13 as one table of what lands, the gate and the dependency, the two tracks and milestones, the day-43 GO/NO-GO checklist with both exits, the re-baseline checkpoints, and the questions still open after P0 - MEASUREMENTS.md: spike A on both devices, the spike B tier matrix, the four-trace boundary-counter baselines on both backends, the desktop and corpus facts, and the harness traps with the exact commands - every file:line kept is verified at 458ccde1 and the citation lint is clean; everything else cites a symbol plus a file - dropped on purpose: the v1/v2 revision archaeology, rejected alternatives, per-subsystem day estimates, the Feat/CS-Delta-IPC reuse audit and the reviewer back-and-forth --- docs/Disaggregated/ARCHITECTURE.md | 601 +++++++ docs/Disaggregated/MEASUREMENTS.md | 96 ++ docs/Disaggregated/PLAN.md | 2470 ---------------------------- docs/Disaggregated/README.md | 64 + docs/Disaggregated/REVIEW.md | 302 ---- docs/Disaggregated/ROADMAP.md | 90 + 6 files changed, 851 insertions(+), 2772 deletions(-) create mode 100644 docs/Disaggregated/ARCHITECTURE.md create mode 100644 docs/Disaggregated/MEASUREMENTS.md delete mode 100644 docs/Disaggregated/PLAN.md create mode 100644 docs/Disaggregated/README.md delete mode 100644 docs/Disaggregated/REVIEW.md create mode 100644 docs/Disaggregated/ROADMAP.md diff --git a/docs/Disaggregated/ARCHITECTURE.md b/docs/Disaggregated/ARCHITECTURE.md new file mode 100644 index 000000000..d939e6134 --- /dev/null +++ b/docs/Disaggregated/ARCHITECTURE.md @@ -0,0 +1,601 @@ +# MGPipe 设计与架构 + +> 本文描述**已决定**的设计。每条决定附一行理由;数字凡有实测的取实测(见 `MEASUREMENTS.md`)。落地状态以 `feat/disaggregated@458ccde1` 为准:标注"P0 已落地"的是树里的代码,其余是后续阶段要实现的形状(阶段号见 `ROADMAP.md`)。 + +## 1. 边界 + +### 1.1 一句话 + +`MG_Backend` 已经是一台贴着目标 API 的状态机(Espryt 有逐字节的渲染状态镜像、6 个 twin registry、三条 persistent ring;Magma 有 `SetupDrawSnapshot`、pipeline memo、5 个 `Vk*Manager`)。它缺的不是状态,而是一份"我被告知了什么"的显式声明。MGPipe 就是那份声明:前端在每条 verb 之前把变化**推**过去,后端不再拉 `MG_State::pGLContext`。server 进程因此只装 `MG_Backend` + MGPipe 对象表,不链接 `MG_State`、`MG_Impl`、glslang。 + +接口不是从 gallium 自顶向下设计的,而是从两个后端自己维护的关键结构反推出来的:`SetupDrawSnapshot` 的字段并集 → `set_*` 组;`DrawTextureSyncKeys` → `set_sampler_views`+`create_sampler_view`+`set_texture_params`;`ResolvedDrawBuffers`/`ResolvedVertexBindings` → vertex elements 三件;`g_syncedRenderStateParameters` → render-state CSO;`UnpackStagingBlock` → `MGPSubData` 的 region 形状;`BufferBackendOps`(7 个 hook,注释自称 `pipe_context` 类比)→ `resource_*` 全族。gallium 是目的地(词汇可读、可迁移),不是推导前提;与 gallium 的十条偏离见 §3.5。 + +### 1.2 两张函数指针表 + +`MGPipeScreen`(share-group 作用域:caps、resource、persistent map、fence)与 `MGPipeContext`(其余全部:query 命名空间、CSO、`set_*`、对象操作、verb),由 `PipeCalls.def` 经 G1 生成(`MG_Pipe/generated/PipeTables.inc`)。**P0 已落地。** + +- 函数指针 struct 而非虚基类:边界今天就是函数指针 struct(`gBackendFunctionsTable`);**null 项已经表示"未实现,前端回退"**,正好就是"这个子系统还没迁移,继续拉取";`MG_Test` 已用替换整张表的方式 mock 后端。 +- 两张表从第一天分开:事后拆分意味着给记录重新编号。v1 只有一个 screen、一个 context、一条 flow(`pGLContext` 是进程全局,share group 全库无人读取)。 +- EGL 生命周期 8 项与 caps 面留在 `pActiveBackendObject` 的虚函数上(罕见路径)。 + +### 1.3 三种形态,一份后端 + +| 形态 | 表里装的是什么 | 用途 | +|---|---|---| +| `monolith`(默认) | backend 自己的函数;`MGPipeCallbacks` 是对 `MG_State` 的直调;`MGHostSpan.Ptr` 指向 client shadow(零新增拷贝) | 出货 | +| `inproc` | 发射器 → 同进程第二个线程上的 applier | CI 形态;同时就是 monolith 的**渲染线程**(把 `PrepareForDraw` 与驱动调用搬离 GL 线程,是本项目手上最大的单一 CPU 杠杆) | +| `spawn` | 发射器 → SPSC shm ring → 另一个进程的 applier → 同一批 backend 函数 | 两进程出货形态 | + +唯一 hook 点是 `MG_Backend::Init()`(`MG_Backend/Init.cpp`)里一个 `#if MOBILEGL_BUILD_DISAGGREGATED` 分支:`MG_Config::Transport != Monolith` 时装 `MG_Remote::BackendObject_Remote`,否则走今天的 `switch`。下游 `MG_Impl` 的边界调用点零 `#ifdef`。(分支在 P5 落地;P0 的 `Init.cpp` 尚未含它。) + +## 2. 对象模型 + +### 2.1 句柄 = `{slot, gen}`(P0 已落地,`MG_Pipe/MGPipeHandles.h`) + +- 8 字节 POD,按值走寄存器对;**client 铸造,server 永不返回句柄** → 整份目录零创建 round trip(对 gallium 的偏离 D1)。 +- slot 稠密、**按 kind 分配**(free list + 高水位),server 对象表是数组而非哈希表。与 `IndexGenerator` 无关——后者的 LIFO 名字复用正是句柄要关掉的问题。 +- `gen` 只在 slot 复用时 ++,不在 respecify 时 ++;同一 slot 复用 2³² 次才回绕(1000 fps 逐帧复用约 50 天),debug 分配器断言回绕。 +- kind:`Buffer, Texture, Renderbuffer, Framebuffer, Xfb, RenderStateCso, VertexElementsCso, SamplerCso, SamplerViewCso, ShaderCso, Fence, Query, Context`。 +- 保留句柄:`{0,0}` = null;`{0,1}` of `Framebuffer` = 默认帧缓冲(退役 Espryt 四处 `pDefaultFramebufferInfo->defaultFBO` 身份比较);`ShaderCso` slot 空间的高 1/16 保留给 program pipeline 合成体(`MobileGL/MG_Pipe/MGPipeHandles.h:88-90`)。 +- GL name 只以 `GlNameForDiag` 出现在 `MGPResourceDesc` 里,永不做身份、永不进 memo 键或 content hash;`GetLifetimeId()` 留在 client 作 tracker 自己的身份,client 维护 `lifetimeId → slot`。 + +### 2.2 两种世代,严格分开 + +| | 拥有者 | 回答 | 过线 | +|---|---|---|---| +| `MGPipeHandle::Gen` | client | "还是同一个 GL 对象吗?" | 是 | +| `MGGen`(`g_bufferMutationEpoch`、`m_textureImageEpoch`、`m_cacheStructureEpoch` 等 12 个后端纪元) | server | "我自己是否重铸了驱动对象?" | **永不**;server→client 只以纹理拉取请求出现(§8.4) | + +规范:任何 MGPipe 调用不得要求 client 提供或知晓 `MGGen`;反过来,client 的回绕 `Uint16` 版本计数器永远不是新鲜度的唯一证明——过线时要么加宽、要么与 `{slot, gen}` 同行。 + +### 2.3 CSO 与可变对象 + +| 类别 | 形态 | 对应后端已有缓存 | +|---|---|---| +| `VertexElementsCso` | create/bind/delete | `VertexInputStateFactory::m_cache` | +| `SamplerCso` | create/delete + `bind_sampler_states` | `VkSamplerManager::m_samplers`、`BackendSamplerObject` | +| `SamplerViewCso` | create/delete + `set_sampler_views` | `TextureResource::{perMipViews,…}`、`SyncTextureViewToBackend` | +| `ShaderCso` | create/bind/delete + server 侧惰性特化 | `ProgramFactory::m_cache`、`BackendProgramObjectImpl` | +| `RenderStateCso` | create/bind/delete,身份 = pipeline 子集 | Espryt 值镜像;Magma `ComputePipelineStateHash` | +| Buffer / Texture / Renderbuffer | create / respecify / subdata / destroy | 各自 twin | +| Framebuffer / Xfb | per-context 身份 + `set_*` payload | `BackendFramebufferObject`、`m_xfbCounterSlotByObject` | + +CSO 在 client 侧内容寻址(Mesa `cso_cache` 先例):每类一张 `ska::flat_hash_map`,容量上限 render-state 64 / vertex-elements 1024 / sampler 256 / sampler-view 4096 / shader 跟随 `ProgramObject` 生命周期,LRU 淘汰时发 `delete_*`。两个不同 program 设置了相同状态时 server 零状态转换。 + +## 3. 调用目录(P0 已落地) + +### 3.1 单一真相源 + +`MobileGL/MG_Pipe/PipeCalls.def`:一行一个调用 `X(Name, PayloadStruct, Class, Flags)`。**线上 opcode 就是行在文件里的位置**(1-based),所以目录必须是唯一记录的集合,新调用只能**追加**到文件末尾、退役的调用保留槽位。`MGP_CALL_LIST_DOCUMENTED_COUNT = 71`(`MobileGL/MG_Pipe/PipeCalls.def:69`)由 `MG_Test/Pipe/PipeCatalogueTest.cpp` 钉住。 + +七个生成器(`scripts/gen_pipe.py`,产物提交进树,CI `pipe-gates` 重生成并 `git diff --exit-code`): + +| | 产物 | 内容 | +|---|---|---| +| G1 | `PipeTables.inc` | 两张函数指针表 | +| G2 | `PipeThunks.inc` | monolith 直调 thunk `MGP_()`,`MG_Impl` 的约 93 个 `gBackendFunctionsTable.GL.*` 站点逐名改到它上面 | +| G3 | `PipeWire.inc` | wire 记录 + 每种一条尺寸 `static_assert` + applier 分发前的运行期边界检查 → `Fatal{ProtocolCorruption}` | +| G4 | `PipeVerify.inc` | `MOBILEGL_PIPE_VERIFY` 的逐字段比对器(字段表来自 `PipeFields.def`;浮点按位比较,NaN patch level 不会误报) | +| G5 | `PipeFilled.inc` | `PipeInputs` 字段 id(61 个)与逐 verb 世代 poison | +| G6 | `PipeCoverage.inc` | 477 行后端读点清单 → MGPipe 调用的映射(`Coverage.def` 手工维护一半):299 → 调用、5 client 自答、6 反向通道、167 结构性句柄、**0 UNMAPPED** | +| G7 | `PipeSpanTable.inc` | render-state pipeline 子集的成员名表(24 个,取自 `ComputePipelineStateHash` 今天哈希的字段,`scripts/gen_pipe.py:67-92`);带 `offsetof` 的 chunk 表与 setter 一致性测试在 P2 | + +### 3.2 分组与计数 + +| Class | 条 | 内容 | +|---|---|---| +| `kScreen` | 11 | `GetCaps`(R)、`ResourceCreate/Respecify/Destroy`、`MapPersistent`(R,O)/`UnmapPersistent`(O)、`FenceCreate/Status(R)/Wait(R)/Destroy`、追加的 `FenceWaitServer`(`glWaitSync`,GPU 侧等待) | +| `kCtxQuery` | 8 | `QueryCreate/Begin/End/Available(R)/Result(R)/Destroy`、追加的 `QueryTimestamp`(R)(`glGetInteger64v(GL_TIMESTAMP)`)与 `QueryCounter`(`glQueryCounter`) | +| `kCtxCso` | 13 | create/delete × {render state, vertex elements, sampler, sampler view, shader} + bind × {render state, vertex elements, shader};sampler 与 sampler view 的绑定是下一组的批量调用 | +| `kCtxState` | 17 | `SetDynamicState`(B)、`SetFramebufferState`、`SetVertexBuffers`(V)、`SetIndexBuffer`、`SetIndirectBuffers`、`SetSamplerViews`(V)、`BindSamplerStates`(V)、`SetShaderImages`(V)、`SetShaderBuffers`(V,H)、`SetStreamOutputTargets`(V)、`SetGlobalConstants`(B)、`SetVertexAttribDefaults`(V)、`SetPixelPackState`、`SetPatchState`、`SetDrawProgram`、`SetDispatchProgram`、迁移期临时的 `SetResidualValueState`(B) | +| `kCtxObject` | 9 | 按资源寻址:`SetTextureParams`、`ResourceSubData`(B,V)、`BufferSubDataResident`(B,O)、`ResourceSubDataComplete`、`ResourceFlushRange`、`ResourceReadback`(R)、`ResourceCopyRegion`、`GenerateMipmap`、`GetTextureImage`(R) | +| `kCtxVerb` | 13 | 按上下文寻址:`Blit`、`Clear`、`ReadPixels`(R)、`DrawVbo`(H,V)、`LaunchGrid`、`MemoryBarrier`、`Begin/End/Pause/ResumeStreamOutput`、`Flush`、`Present`、`SetSwapInterval`(O) | + +Flags:`kNeedsAck`(调用方等 server 确认;目录里目前无条目携带,见 §8.3)、`kHasBlob`(B)、`kVarTail`(V)、`kHostSpan`(H)、`kReplySlot`(R,答进 `MGPReplySlot`,永不阻塞)、`kOptional`(O,后端表里可为 null:Magma 故意不注册 `BufferSubDataResident` 与 `SetSwapInterval`)。 + +- 今天 20 个 draw 入口塌成 `DrawVbo` 一条,`MGPDrawRange[]` 就是 `MultiDraw*` 族今天的形状;`Clear` 一条判别式合并 `glClear` + 4 个 `glClearBuffer*` + 4 个 `glClearNamedFramebuffer*`。 +- `SetSamplerViews` / `BindSamplerStates` **没有 stage 维度**:MobileGL 的纹理单元空间是合并的(`TextureState::m_textureUnits` 是 192 个单元的一个数组,每 stage 32 只是广告数字),同一单元可被两个 stage 采样;stage 只在目标 API 需要时由 server 从反射归档推导。 +- `SetTextureParams` 按资源寻址、与 sampler view 分开(D10):只作 FBO attachment / image 单元 / `glCopyImageSubData` 端点的纹理没有 sampler view,但 Espryt 对 attachment 也同步纹理参数,且 `RequireImageBindableStorage` 需要在前端参数版本不动时强制重同步。 +- `SetIndexBuffer` 独立于 VAO 配置版本(D5):索引 slot 重绑不移动 VAO config version。 +- `SetGlobalConstants` 只覆盖默认 uniform block(D6):`globalUboScratch` 是 link phase B 的 CPU 数组,没有 GL name、没有 `BufferObject`。 + +**显式不移植**:`GetIntegeri_v`/`GetInteger64i_v`/`GetProgramiv`(后两项 P0 已从 `GLFunctionsTable` 删除,`50815a23`;唯一属于后端的带下标答案 `GL_MAX_COMPUTE_WORK_GROUP_COUNT/SIZE` 进 `MGPCaps`,`e8ee7b1a`;`GL_COMPUTE_WORK_GROUP_SIZE` 是前端反射查询)、`ShaderStorageBlockBinding`(折进反射归档)、`set_pixel_unpack_state`(不存在:前端已在 `glTexImage` 时解析压缩格式、强制默认 unpack)、压缩格式概念、`pipe_transfer`。 + +### 3.3 能力位(`MGPCapBit`) + +`kCapViewportArray`、`kCapFloat64VertexAttrib`、`kCapResidentSubData`、`kCapCpuXfbPrimitiveAccounting`、`kCapTimerQuery`、`kCapOcclusionQuery`、`kCapXfbPrimitivesQuery`、`kCapNeedsHostIndexBytes`(server 做 restart 重写 / multi-draw 展平,split 下开启索引宿主镜像,§10.3)、`kCapNeedsHostUboBytes`(server 把具名 UBO 打进自己的 ring,需要 `SetShaderBuffers` 的 host payload)。`CallMask` 取代"槽位是否为 null"这个隐式能力探测。 + +不存在 `kCapPrimitiveRestart` / `kCapMultiDraw*` 一类"归属开关"(D-B7):`ResolveTierForBatch` 逐 batch 用 `programReadsDrawID`(转译后 ESSL 的性质,只存在于 server)选档,两个后端都做 restart 重写,所以这类归属不可用 cap 表达。规则一句话:**multi-draw 分档与 restart 重写永远由 server 拥有;client 在 caps 说需要时提供索引字节。** + +一个待转显式能力位的现有陷阱:`GL_Drawing.cpp` 把 `EndTransformFeedback` 槽位的非空当作"后端按 GL 顶点序捕获"来跳过 `FixupGsStripCaptureOrder`。MGPipe 下改为显式 `kCapDriverOrderedXfbCapture` 一类的位(P8/P9)。 + +`MGPCaps` = `DynamicBackendParameters`(整块包含,~90 个标量含六个 compute 限制)+ `CallMask` + 两个 blob(format 能力表、renderer 字符串),握手后一次快照,取代 40 个 `pActiveBackendObject->` 站点与 89 个 caps 读点。 + +## 4. 记录与 payload 约定(P0 已落地,`MG_Pipe/MGPipeTypes.h`) + +- 每个 payload 是平坦 POD、显式 padding、`static_assert` 平凡可复制与**精确尺寸**;**永不含指针**。 +- `MGPBlobRef{Offset, Size, Seg}`(24 B)指向 blob 区:monolith 下 `Seg == kMGHostSpanSegNone`、Offset 是调用方 staging arena 内地址;split 下 Seg 命名传输段。 +- `MGHostSpan`(32 B,`MG_Pipe/MGPipeHostSpan.h`)是整份接口里**唯一形状随传输而变**的东西:monolith 下 `Ptr` 指向 shadow 或应用内存;split 下 `Ptr == nullptr`、字节在 `Seg/Offset` 命名的 `SEG_STAGE`,或 `Seg == kMGHostSpanSegFromServerIndexMirror`(`MobileGL/MG_Pipe/MGPipeHostSpan.h:26`)表示"字节已在你那边的索引镜像里"。`MGPipeHostBytes()` 是一次可预测分支;split 解析器 `gMGPipeSegmentResolver` 由 `MG_Remote` 安装。它只进变长尾(`DrawVbo` 的用户索引、`SetShaderBuffers` 的具名 UBO 字节),永不内联进定长 payload——VBO 路径(MC/Sodium 的全部 draw)不为它付字节。 +- 变长记录(`kVarTail`)= 定长前缀 + 自描述长度的内联尾巴;`kHasBlob` 记录额外校验 `BlobRef` 落在其声明的段内。运行期边界纪律:`SEG_CMD` 是对端并发写入的区域,`static_assert` 管不到运行期损坏,违反一律 `Fatal{ProtocolCorruption}`。 +- wire 记录头 `MGPWireRecHeader{Op:u16, Flags:u16, Size:u32}`(8 B),Size 含头、8 字节倍数;**没有逐记录序号字段**——seq 就是记录序数(producer `m_emitSeq++` / consumer `m_applySeq++`)。 +- **分块上界 = ring 容量的一半**(`RingProducer::MaxRecordBytes()`):这是每个 head 偏移都能放下的最大记录(wrap pad 最多花 total−8 字节),超过它的 payload(大 `ResourceSubData`、`CreateShaderState` 归档)由发射器切成多条;ring 对更大的记录直接拒绝(nullptr + `MGLOG_E`)而不是让 producer 等一个永远不够的空闲量。 +- `MGPSubData` 的 buffer 半边:`Target == Buffer` 时没有 level 与 box,目的字节范围搭在 `UnionBox.X`(offset)与 `UnionBox.W`(size)上,`MGPipeSetSubDataBufferRange()` 是唯一拼写;单条记录上限 offset 2³¹−1 / size 2³²−1,越界由发射器拆分。 + +### 4.1 关键 payload + +| payload | 尺寸 | 要点 | +|---|---|---| +| `MGPResourceDesc` | 88 | buffer / 全部纹理 target / renderbuffer 一个判别式 create/respecify 形状;`BindMask` 的 `ELEMENT_ARRAY` 位是索引镜像的开关;`ImageBindableHint` 预防性分配 image-bindable 存储;`ViewOf` 是纹理视图的存储属主(server 侧 keep-alive);`BufferForTexBuffer/BufOffset/BufSize` 实时解析(`kMGPipeWholeBuffer = ~0`)。Renderbuffer 保持独立类(自己的 format-capability target、`ComponentSizes`、twin) | +| `MGPRenderStateDesc` / `MGPBindRenderState` / `MGPDynamicState` | 48 / **12** / 32 | §5.3 | +| `MGPVertexElements` | 40 | blob 同时带解析后的 `VertexAttribute[]` **和** `VertexBufferBindingPoint[]`,缺一不可(pointer 调用的 stride 0 = element size,binding 模型的 stride 0 = 每顶点读同一 element);`IsLong` 与 `Type == Float64` 分开携带;仅供查询的 `LegacyStride/LegacyPointer` 留在 client | +| `MGPSamplerDesc` | 32 | `SamplerParameters` 逐字节过线**含 `borderColorForm`**(三种 border color 表示永远都被数值填满,没有它后端无法在 `Iiv`/`fv` 或 `VkBorderColor` 家族间选择) | +| `MGPSamplerView` / `MGPTextureParams` | 36 / 32 | view 只带视图限制(min/num level、min/num layer、别名格式);纹理参数(base/max level、swizzle、depth-stencil mode、LOD 钳、`ForceResync`)挂在纹理对象上 | +| `MGPProgramDesc` | 192 | 逐 stage SPIR-V blob ×6 + 反射归档 blob + `StageMask`/`GlobalUboSize`/`ReservedNumSamplesOffset` + 四个状态字节,§7 | +| `MGPFramebufferState` | 304 | 8 color + depth + stencil + **client 解析后的 `ReadSurface`**(按结构消灭 read-buffer-shared-FBO 缺陷类);`MGPSurface::InternalFormat` 内联(四个跨对象 mask 推送时零查表);`ContentHash` 既是 server 的 render-pass memo 键也是 client 的发射抑制器 | +| `MGPSubData` / `MGPSubRegion` | 72 / 40 | §6 | +| `MGPDrawInfo` / `MGPDrawRange` / `MGPDrawIndirect` | **56** / 12 / 40 | `Flags` 门控 `MinIndex/MaxIndex`(只在 client-memory 数组路径算)与 `XfbCpuCapturedVertices`(只在 XFB scatter 路径读)——不是每 draw 都算;`NumDraws` 个 `MGPDrawRange` 在变长尾;用户索引的 `MGHostSpan` 只在 `kDrawHasUserIndices` 时进变长尾;indirect 的 `DrawCount` 由 client 解析,server 永不读 indirect 命令块来数 draw | +| `MGPShaderBuffers` / `MGPBufferRange` | 32 / 24 | range 不内联 host span;`kCapNeedsHostUboBytes` 下 Uniform 类带第二个变长尾 `MGHostSpan[HostSpanCount]`,与 range 数组下标对齐 | +| `MGPPixelPackState` | 28 | 只有 PACK 方向(D5) | +| `MGPPatchState` | 40 | 同时是 shader variant 输入 | +| `MGPClear` | 48 | Whole / Color / Depth / Stencil / DepthStencil 判别式 | +| `MGPGlobalConstants` | 40 | `(ShaderCso, Version)` 键控,每 program 每帧至多一次 | +| `MGPSubDataComplete` | 24 | 纹理拉取的正向终止符,可携带零个 region | +| `ResidualValueBlock` | **1248** | 迁移期 Track V 载体,§9.4 | + +每条 `kVarTail` 的 `set_*`(`SetVertexBuffers`、`SetSamplerViews`、`BindSamplerStates`、`SetShaderImages`、`SetShaderBuffers`、`SetStreamOutputTargets`)都带 `ContentHash`——与 `MGPFramebufferState` 同一模式,hash 未变就不发(§5.4)。 + +## 5. 前端 state tracker(`MG_Impl/Pipe/Tracker`,P2 起) + +### 5.1 推送发生在 verb 之前的 validate 时刻,不在 GL setter 里 + +Blaze3D 每个 batch 用 `glEnable/glDisable(GL_BLEND)` 包住(Espryt 代码自己标它为最热路径),per-setter 推送会把每次冗余开关变成一次接口调用加一次 server 侧 CSO 查表,严格慢于今天。正确形态是 gallium `st_validate_state`。 + +八个 validate 入口,由 `PipeCalls.def` 的 `kCtxVerb`/`kCtxObject` 条目生成:`ValidateForDraw`(20 个 draw 入口)、`ValidateForDispatch`、`ValidateForClear`、`ValidateForBlitOrCopy`、`ValidateForTextureOp`(GenerateMipmap / CopyTex* / BindImageTexture)、`ValidateForReadback`、`ValidateForXfbSpan`、`ValidateForQuery`。八个而不是四个,因为 `MG_Impl` 用到的 70 个表项里只有约 22 个是 draw/dispatch,其余 ~48 个(clear、blit、copy、回读、barrier、XFB 跨度、query/sync)很多自己就读 `pGLContext`。 + +**只有今天就在 GL 调用时刻分发的资源 op 在 GL 调用时刻推送**——即 `BufferBackendOps` 的七个 hook。纹理 subdata 不在此列(§6)。 + +### 5.2 dirty 位:值类零新增记账,对象类新增 5 个聚合世代 + +| dirty 位 | 类 | 快门来源 | +|---|---|---| +| `NEW_RENDER_STATE` / `NEW_PIPELINE_STATE` | 值 | `m_version` / `m_pipelineStateVersion` | +| `NEW_PIXEL_PACK`、`NEW_PATCH_STATE`(`BitwiseEqual`,NaN 合法)、`NEW_VERTEX_ATTRIB_DEFAULTS`、`NEW_VERTEX_ELEMENTS`(VAO config version) | 值 | 既有计数器 | +| `NEW_SHADER`、`NEW_SHADER_BINDINGS`、`NEW_GLOBAL_CONSTANTS` | 值 | link/image-unit/backend-state/block-binding/uniform-write-set/UBO-content 版本 | +| `NEW_VERTEX_BUFFERS` | 对象 | **`VertexArrayState::m_anyVaoAttributeGeneration`**(新增)→ 命中后走 32 属性前缀 | +| `NEW_INDEX_BUFFER` | 对象 | 索引 slot 版本 + 绑定对象 `{slot,gen}` | +| `NEW_FRAMEBUFFER` | 对象 | **`FramebufferState::m_anyAttachmentGeneration`**(新增)+ 对象/slot 版本 → 重算 `ContentHash` | +| `NEW_SAMPLER_VIEWS`、`NEW_SAMPLERS`、`NEW_SHADER_IMAGES` | 对象 | **`TextureState::m_anyTextureContentGeneration` + `m_anyTextureParamsGeneration`**(新增)+ bind/sampling-resolution generation → 走 `GetMaxTouchedUnit()` 前缀、重算集合 hash | +| `NEW_CONST_BUFFERS` / `NEW_SHADER_BUFFERS` / `NEW_SO_TARGETS` | 对象 | **`BufferState::m_anyBufferChangeGeneration`**(新增)→ 走 `GetTouchedBindPointCount()` 前缀 | + +五个聚合世代全部落在既有 bump 点上(约 20 行),把对象类组的快门从"每 validate 走查 192 单元 / 84×4 绑定点 / 32 属性 / 40 attachment"降成一次 `Uint64` 比较;对象类不能靠轮询逐对象版本(没有聚合能回答"有没有哪张已绑定纹理动了",这正是 Magma 不得不用有损 `sampledContentSum` 的原因)。 + +完整性由 `scripts/gen_pipe_dirty_surface.py` 保证:枚举 `MG_Impl/GLImpl` 里每个 mutator → 必须 bump 的聚合世代,CI 重生成 + `git diff --exit-code`,未映射即失败(P1 起成为门)。**实测规模**:926 次 mutator 调用落在 73 个不同 mutator 上,其中 92 次(7 个 mutator,绝大多数 `RecordError`)位于同函数内也会到达后端的"即时发布点",其余 834 次由紧随其后的 verb 发布——映射表是 73 条目的问题。 + +三个回绕 `Uint16` 在 tracker 边界加宽(`m_lastPushed[]` 是 tracker 自己的字段,不改 `MG_State`);回绕在 tracker 本地无害(多一次重推,永不漏推),且被集合 hash 抑制器吞掉。 + +### 5.3 渲染状态:整块 blob 过线,身份只取 pipeline 子集,动态状态单独走(D-B1) + +``` +create_render_state(cso, MGPBlobRef pipelineSubsetChunks) // 只带 pipeline 子集 +bind_render_state(cso, Uint16 version, Uint16 pipelineVersion) // 稳态 12 B +set_dynamic_state(MGPBlobRef dynamicChunks, Uint16 version) // 只带动态子集的变化 chunk +``` + +- 整块的理由:`RenderStateParameters` 是平凡可复制 POD,Espryt 自己 `static_assert` 并做 head/blend/tail 三段 memcmp,**字段顺序承重**(`ScissorBoxWrittenMask`、`ClipDistanceEnabledMask` 故意放在 tail 段);拆成 blend/depth-stencil/rasterizer 三个 CSO 要手工维护 ~150 字段划分表且无绊线。 +- 子集身份的理由:整块内容寻址会让 `glViewport`/`glScissor`/`glBlendColor`/`glClearColor` 每次铸造新 CSO、冲掉 server 的 pipeline memo——`RenderState.h` 记录的那次回归。`RenderState.cpp` 里 viewport/scissor/line-width 族只 `++m_version`,`SET_CAPABILITY` 与 pipeline 相关 setter 才 `BumpVersions()`。 +- 动态子集:viewport、scissor、depth range、blend color、line width、polygon offset、stencil ref/write mask、clear 值、sample coverage、hints、point-size 族。 +- 划分只写在一处:`MG_Pipe/MGPipeRenderStateSpans.{h,cpp}`(P2)的 chunk 表 + `MGPipeComputePipelineSubsetHash()`,从 Magma 的 `ComputePipelineStateHash` 搬来,client 与两个后端共用;G7 的 `MG_Test` 遍历每个 `RenderState` public setter,断言 `pipelineSubsetHash 变 ⟺ m_pipelineStateVersion 变`。 +- server 侧:每 context 一份 working `RenderStateParameters`(1168 B),`bind` 与 `set_dynamic_state` 各把自己的 chunk 散射进去。**Espryt 的 `SyncRenderState`(693 行)拿到的仍是 `const RenderStateParameters&`,单 `Uint16` 早退、三段 memcmp 一行不动**;Magma 的 pipeline memo 键是 `cso.slot`,动态尾巴仍走 `ApplyDynamicDrawStateTail`。Espryt 的 head/blend/tail 划分(驱动侧增量)与 pipeline/dynamic 划分(线上与身份)是两回事,并存、各有绊线。 +- client 取值顺序:`m_pipelineStateVersion` 未变 → 复用上一个 CSO handle,零哈希;变了 → 对 pipeline 子集算 xxHash(~25-30 字,Magma 今天就在算)→ CSO map 探测 → 命中发 12 B bind,未命中发变化 chunk 的 create 再 bind;`m_version` 变而子集未变 → 只发 `set_dynamic_state`(~200 B)。 +- `FramebufferSrgb` 与 `DepthClamp` 今天**没有存储**(`glEnable` 被静默吞掉且不报错,六个后端读点恒为 false);chunk 表冻结前要补真存储并把 `FramebufferSrgb` 划进 pipeline 半边(它改变 attachment/blend 的解释)——待拍板,见 `ROADMAP.md`。 + +### 5.4 验证不变式、合并与抑制器 + +规范(D-B3):**一条 verb 的全部 `set_*`/`bind_*` 必须在该 verb 之前完成;server 在 verb 处、从它此刻持有的全部已推送状态惰性特化 shader 与 pipeline。除"资源 create 先于对它的 bind"外,`set_*` 之间没有顺序要求。** 推荐实现顺序(framebuffer → program → 纹理/sampler/image/buffer/global constants → render state/dynamic → vertex elements/buffers/index/attrib defaults → patch/XFB → verb)只是代码组织,不是契约。退役 Espryt 的 fragColor 重推导 workaround、`g_broadcastMemo*` 与 `ImageUnitFormatsStillMatch` 的机制是惰性特化,不是调用顺序。 + +`create_shader_state` 从编译池的终止 continuation 发出(不是从 draw),SPIR-V 在首个用到它的 draw 之前到达 server——monolith 拿不到的异步收益。 + +四条合并规则:整块结构优于逐字段;高水位标记(`GetTouchedBindPointCount`、`GetMaxTouchedUnit`)留在 tracker 走查里,直接就是 `count` 实参;只发 program 解析过的集合(`uniformSamplerOrImageUnitIndex`);**集合 hash 抑制器**——每条 `kVarTail` `set_*` 在 client 算已解析集合的 xxHash,未变不发。最后一条是从后端搬到 client 的 ~175 行去抖(`UnitBindingsSnapshot`/`PairingsIntact`/`g_fboTextureSyncList` 族)的载体:`GetTextureBindGeneration()` 在冗余重绑时也 bump(MC 26.2 每次纹理单元切换都重绑同一个 sampler),没有抑制器每个 batch 都会重发一条几百字节的变长记录并冲掉 server 的两个 memo。 + +索引绑定范围在 validate 时刻实时解析(`glBindBufferBase` 之后再 `glBufferData` 是普通应用代码)。 + +### 5.5 sampler view 在 client 侧解析 + +GL 是每 unit 每 target 各一个绑定;shader 看见哪一个取决于 sampler uniform 类型、mipmap 完备性(`IsMipmapCompleteForFilter`、`SamplesAsIncompleteTexture`)与 `IsUndefinedDefaultTexture`。gallium 的"每槽一个 view"就是解析后的形态,解析留在 client 并带自己的 memo(~40 行搬迁)。两处后端特定后处理留在 server、作用于已解析集合:Espryt 的 raw-depth-fetch sampler 替换、Magma 的 feedback-loop 检测。 + +### 5.6 生命周期、共享组、composite program + +- `resource_create` 在前端对象构造时发,存储由 `resource_respecify` 惰性定义;`resource_destroy` 在析构时发。三条顺序约束由 payload 表达:view 先于存储属主销毁(`ViewOf` + server keep-alive)、FBO attachment 钉住纹理(surface handle 隐含 keep-alive)、buffer texture 钉住 buffer(`BufferForTexBuffer`,范围实时解析)。 +- 共享组:v1 一个 screen、一个 context、一条 flow;`eglMakeCurrent` 是 flow 所有权转移,在既有 `EGLOperationMutex` 下发射(顺手让 `ReleaseThread` 与 `SwapInterval` 也取该锁)。 +- program pipeline 合成体:`GLContext::GetProgramForDraw()` 今天就完全在前端合成(join、签名查 cache、`Link(true)`)。tracker 拿到 `SharedPtr` 推**一个** handle,slot 从 `ShaderCso` 保留高位段分配,pipeline cache 淘汰时释放 slot、`gen++`、发 `delete_shader_state`。合成体从不过线,server 不需要任何"解析后的 draw program"钩子;副带收益是阻塞的 `JoinLinkAndSpirv()` 离开 server 的 draw path。 + +### 5.7 emulation 的归属 + +规则:**驱动表达不了的变换在 tracker 里 lowering,硬件/驱动强加的变换在 driver 里 lowering。** 只有三个"读前端字节的纯 CPU 变换"下放到 client。 + +| emulation | 归属 | 过线的是什么 | +|---|---|---| +| client 顶点数组(`(first+count-1)*stride+elementSize`) | client | 字节(`MGHostSpan`),永不是指针 | +| 最大索引扫描(`TryComputeMaxIndexFromHostBytes`,唯一无界的应用指针读,只有 client 同时持有两个数组) | client | `MGPDrawInfo::MinIndex/MaxIndex`(flag 门控,`~0` = 未知) | +| client 索引数组 | client | 变长尾里的 `MGHostSpan` | +| `*IndirectCount` 计数解析(从 parameter buffer 的 shadow 读实际 draw 数) | client | 解析后的 `MGPDrawRange[]`(几十字节) | +| primitive-restart 重写(整 EBO 重写,`kMaxRestartRewriteBytes` = 64 MiB) | **server** | 零线上流量:从索引宿主镜像读(§10.3) | +| multi-draw 五档分档 + 展平(`ResolveTierForBatch`,CPU 展平是回退) | **server** | 同上 | +| viewport-array N 遍回放 | server | 无新增:16 组 viewport/scissor/depth-range 已在渲染状态里 | +| fp64 顶点窄化 | server | 原始字节;`IsLong` 与 `Type` 分开过线 | +| image-bindable 存储加宽/拆分 | server | 正向 `ImageBindableHint`;反向纹理拉取 + 终止符 | +| 生成 mipmap 的前端存储 | 拆开:client 分配 level 存储,server 生成 | `MGPMipPlan`;`OnMipLevelsGenerated` 只带形状不带字节 | +| CopyImage shadow 镜像 | client | 只回"拷贝成功",删掉一整条 server→client 字节通道 | +| XFB CPU 图元计数 | client | `XfbCpuCapturedVertices`(flag 门控)+ `EndStreamOutput` 的 `MGPXfbAccounting` | +| XFB scatter 的 read-modify-write | client | §8.5 | +| 压缩纹理 / pixel unpack 规整 | client | 无 | + +**陈旧索引纪律是逐站点表,不是一条笼统规则**(client 侧扫描/解析之前要做的 reconcile 必须逐字复现 monolith 的集合): + +| client 侧动作 | 必须做的 reconcile | +|---|---| +| client 顶点数组范围计算 + 暂存 | 无(应用内存,无 GPU 写者) | +| 最大索引扫描(EBO 源) | `SyncPersistentMappedRange()` **+** `SyncGpuWrites()` | +| 最大索引扫描(client 指针源) | 无 | +| `*IndirectCount` 计数解析 | **只** `SyncPersistentMappedRange()`,不加 `SyncGpuWrites()`——monolith 今天就只做这一个,加了会给 Create/Flywheel 的每 batch 平白加一次 publish-and-wait | +| server 侧 restart 重写 / multi-draw 展平 | server 从镜像读;GPU 写者可见性由 `OnGpuWritten` 收窄集在 server 本地判定 | + +前两条 client reconcile 的形态:publish → 等 `appliedSeq` → 排空事件 → 再碰 shadow。门:`ClientArrayAfterComputeWriteScenario`(去掉等待必须看到几何缺失);`create-indirect` fixture 上 `roundtrips-per-frame` 必须读零(P8)。 + +## 6. 纹理 subdata 与 dirty 归属 + +- `glTexSubImage*` 根本不调后端表:全部纹理上传由 Espryt 在 sync 时刻按**累积**区域做,那里跑 `MipmapStorage` 的 96-rect 级联合并与 `summedArea*4 >= unionArea*3` 的 union-box 回退,并在 unpack ring 可用时刻意塌成一个 box——Mali 按**作业数**给上传计价,~100 个精灵 rect 对一个 union box 实测 +6 ms/frame。逐 `glTexSubImage` 发一条记录会精确复现那个形状。 +- 因此:client 在自己的 `MipmapStorage` rect 模型里累积,在**下一个 validate / flush 点**把合并后的形状作为**一条** `ResourceSubData` 发出。`MOBILEGL_PIPE_STATS` 单列逐帧发射次数与上传作业数(`TextureUploadEmissions/Box/Rect/Jobs`)。 +- **同时携带 union box 与 region 列表,由 server 选上传形状**:决策留在付 GPU 代价的那一侧。实测(`MEASUREMENTS.md`):vanilla 世界同样 185 次发射,Espryt 的整 box 路径每帧 635 KB 纹素、Magma 的 rect 路径 40 KB,16×。 +- `MGPSubRegion` 显式携带 `SrcRowStride/SrcSliceStride`,`MGPSubData::SourceIsVerbatimLevelShadow` 显式携带原来由 `uploadData == mipData` 指针比较回答的问题:"这批字节是未经转换的 level shadow 吗"。split 下 client 既不发整 level 也不在 server 留整 level 镜像,指针比较不成立;Espryt 的上传路径改为从描述符取步长,`UNPACK_ROW_LENGTH` 从 `SrcRowStride/bpp` 设。形状照抄已存在的 `UnpackStagingBlock`(ring 路径本来就紧密重打包、不发 `glPixelStorei`)。 +- **dirty 归属反转**:client 保留 rect 模型、维护一份发射游标、发射后清自己的标志,server 从不碰 client 的标志。安全,因为 `MG_Impl` 里没有任何 `IsStorageDirty/GetStorageDirtyRects/GetStorageDirtyRegion` 调用点(前端从不读自己的 dirty 状态)。逐 level "server 权威位"与纹理 ack 协议因此不必存在。 +- 发射游标按**存储属主**键控 `(storageOwnerHandle, ownerUploadTarget, ownerLevel)`:`TextureObjectView` 把 dirty 查询/清除全部转发给属主并做索引重映射,view 与属主共用同一份 dirty 状态。门:通过 view 上传、经属主采样(及反向),跨 draw 边界各一次。 +- 后端真正在 shadow 里写字节的两处——CPU 回退生成 mip(RGB16F/RGB32F)与 `glCopyImageSubData` 目的地镜像——分别由 `OnTextureWriteback` 与"CopyImage 镜像搬到 client"处理。 +- Unpack PBO 完全在 client 解析;压缩纹理永不到达后端;`glCopyTexSubImage*` 与 `glClearTexImage` 整体留在 client(今天就是纯前端操作:借一次 `ReadPixels` 进 CPU scratch 再写 shadow),拆分后恰好是一次阻塞 ReadPixels round trip,脏区按普通 subdata 下发。 + +## 7. Shader state = SPIR-V + 反射归档 + +- `CreateShaderState` 的 payload 是逐 stage SPIR-V + 反射归档(`LinkArtifacts` + `SpirvArtifacts` 全结构体),**不是源码**。"server 从源码重新 link"这条路显式关闭:链接真 `ProgramObject` 就链接 glslang。glslang 全在 client,SPIRV-Cross(`TranspileSpirvToEssl`)全在 server,文件级切割。没有 `MOBILEGL_IPC_PROGRAM` 开关、没有 server 侧 compile pool。 +- 归档机制:`Visit()` + `sizeof` 绊线(`static_assert(sizeof(LinkArtifacts) == MGL_LINKARTIFACTS_SIZE)`),一份字段表服务序列化两个方向。必须覆盖四个 `ResourceReflection`(各带 `TypeFacts`)、`uniformSamplerOrImageUnitIndex`、`uniformBlockBinding`、`shaderStorageBlockBinding`(按名字)、`explicitOpaqueUniformBindings`、`xfbVaryings/xfbStrides/xfbPackedStride/xfbNeedsScatteredCapture`、`computeLocalSize`、GS/TCS/TES 事实、`usesReservedNumSamples`、`uniformOffsets`。`XfbVarying` 带两套拼写(GL 名字 + block 实例/成员/元素)。 +- **P0.5 硬前置**:反射类型今天声明在 `ProgramObject.h` 里,而它 include `ShaderObject.h`(→ glslang)与 `SpvcSession.h`(→ spirv_reflect)。P0.5 把 `TypeFacts`、`ResourceReflection`、`XfbVarying`、`LinkArtifacts`、`SpirvArtifacts` 抽到 `MG_State/GLState/ProgramState/ProgramArtifacts.h`(只 include `` 与容器),更新 7 个 includer,加 CI `-H` 闭包断言。同批抽取 `MG_Pipe/MGPipeValueTypes.h`(`MAX_DRAW_BUFFERS`、`PerBufferBlendState`、`StencilFaceState`、`PixelStoreParameters`、`RenderStateParameters`、`SamplerParameters`、`BorderColorForm`、`VertexAttribute`、`VertexBufferBindingPoint`),它不 include `MG_State/GLState` 任何东西;`MGPipeTypes.h` 今天为此临时 include 了 `BackendObject.h` 与 `RenderState.h`(文件头注明为 P0.5 债务)。没有这一步,P7 的 `nm -D | grep glslang` 判据不可达。 +- server 侧惰性特化(D-B2):后端 program 还依赖 8 个额外输入(draw FBO 的 snorm/unorm clamp mask、fragColor 广播数、storage-block 绑定签名、atomic counter 集、活的 image 格式、patch 参数;Magma 另加 FragCoord-Y-flip 的 default-FB 高度与 XFB 布局),`create_shader_state` 发布**制品**,server 在 verb 时刻从已推送状态特化——正是两个后端今天的做法,也是 gallium `st_variant` 的做法。 +- 后端 link/compile 失败不需要同步返回:今天只是一行 `MGLOG_E` 加 bind program 0 的空 draw,`GL_LINK_STATUS` 永不撤回,同步查询由 client 从 `ProgramObject` 回答。`OnLog` 逐字复现——由此要求日志按严重级分级(§8.3)。 +- Magma 的两个内部 shader(blit、depth-mipmap)烘焙成签进树的 SPIR-V + uniform location + UBO 布局,用一个 `MG_Test` 重跑树内 glslang 逐字节比对守新鲜度(`MOBILEGL_BAKED_INTERNAL_SHADERS`,P7);顺带把一次 glslang 编译从 monolith 启动路径上删掉。 + +## 8. 反向通道 + +### 8.1 `MGPipeCallbacks`(P0 已落地,`MobileGL/MG_Pipe/MGPipeCallbacks.h:27-51`) + +十个具名回调 + 一个正向终止符(`ResourceSubDataComplete`),取代今天 95 个调用点 / 17 个方法直接 poke 前端对象。gallium 没有 shadow writeback、GPU-write 通知、纹理重发请求、default-FB 几何这些词汇(Mesa 里两者共享地址空间),具名化是有意偏离(D8)。monolith 下直调,split 下是 `SEG_EVENT` 上的记录。 + +| 回调 | 取代 | +|---|---| +| `OnGlError(code)` | 6 处 `RecordError`;**必须对命令流有序**,否则 `glGetError` 答错(`glGetError` 本身永远本地) | +| `OnGpuWritten(res, ranges[])` | 6 处 `MarkGpuWritten`:client 在每个 draw/dispatch 发射点**保守自建** pending 集,这是**收窄**通道 | +| `OnBufferWriteback(res, offset, bytes)` | PBO 回读、XFB 捕获;**按操作级批处理**(今天两处逐行循环绝不能变成每扫描线一次 IPC);必须与 epoch bump 有序 | +| `OnTextureWriteback(res, box, bytes)` | CPU 回退生成 mip 的纹素(唯一生产者) | +| `OnTexturePullRequest(res, target, firstLevel, levelCount, pullSerial)` | §8.4 | +| `OnMipLevelsGenerated(res, base, count)` | 只带形状:monolith 的 `EnsureGenerateMipmapStorageAllocated` 也只 `AllocateStorage` + `MarkStorageDirty(false)` 不填内容,split 行为一致 | +| `OnSurfaceChanged(info)` | `SwapchainObject` 写 `pDefaultFramebufferInfo` 的分层倒置;client 自己合成 default-FB 对象 | +| `OnCapsInvalidated()` | 2 处 `InvalidateCompileEnv` | +| `OnLog(level, text)` | ≤WARN 有损,≥ERROR 无损 + 速率限制 | +| `OnXfbScatterReady(scratch, packedStride, vertices)` | §8.5 | + +95 个写回点的其余归属:`MarkStorageDirty` 大多是 server 本地记账(零消息);后端凭空造的前端对象(Magma 占位纹理、swapchain default-FB 占位)→ server 原生;`SetBackendResource` 删除(server 拥有资源表);`SetBackendStateMemo`(前端 VAO 里存后端堆裸指针)直接删除;`SetBackendHashMemo/AuxMemo` → server 侧 per-slot 字段。20 处 `SyncPersistentMappedRange` + 6 处 `SyncGpuWrites` 按 §5.7 逐站点归属,其中至少一处消费者搬不走:Magma 的 `ResolveUniformBufferPayload` 把具名 UBO 打进自己的 UBO ring → `SetShaderBuffers` 的 host payload(D-B8)。 + +### 8.2 有序性是正确性要求 + +每一次 `WritebackFromBackend` 后面都紧跟 `BumpBufferMutationEpoch()`,否则 server 的 draw-clean memo 会在 epoch 背后变陈旧——split 里这变成反向通道上的排序规则:写回的 epoch bump 必须在任何后续读该 handle 的命令之前被 server 应用。**反向通道需要与正向通道相同的有序保证。** + +### 8.3 错误、ack 与日志 + +- 纹理分配的 OOM 在 monolith 里就已推迟到 sync 时刻(`glTexImage*`/`glTexStorage*` 只 `MarkStorageDirty`,Espryt 惰性分配;连 `glRenderbufferStorage*` 也在 `SyncToBackend` 里惰性做),拆分不改变可观察行为,这批不同步 ack。 +- **唯一允许同步 ack 的入口是 `glBufferStorage`(真同步分配)**。`glRenderbufferStorage*` 不 ack:41 个 trace fixture 里 OOM 探测惯用法出现 0 次(9 次调用散在 5 个 fixture,无一在 3 个调用内跟 `glGetError`;语料里的成功性检查是 `glCheckFramebufferStatus`,client 本地作答)。目录里目前没有条目携带 `kNeedsAck`(`ResourceRespecify` 是 `kNone`),标记随 P3a 的 buffer 路径落地。 +- 其余错误一律晚到,走有序的 `OnGlError`。 +- `OnLog` 分级:≤WARN 有损(覆盖最旧 + `eventDropped` 计数);≥ERROR 无损,加入触发 `eventRingFull` + 停止 apply 的语义事件集;每秒 ERROR 速率限制器,超限发一条 "N errors suppressed";`MGLOG_E_ONCE` 的 latch 变 per-server。理由:后端 link 失败只以一行 ERROR 呈现,统一有损会让最有诊断价值的那一行在日志压力下消失。 + +### 8.4 唯一的新停顿类:server 发起的纹理重铸拉取(D-B6) + +server 不保留纹素,三个原因会要求重发已发过的 level:`RequireImageBindableStorage` 的 re-dirty、整格式再生、view 源重铸。四条缓解同时上: + +1. **预防主因**:client 给纹理打 `everImageBound`,`ResourceCreate/Respecify` 一直携带 `ImageBindableHint`,image-bindable 存储前期分配好。 +2. **拉取异步**:server 发 `OnTexturePullRequest` 并把 twin 标 not-ready,client 下次 publish 时重发;阻塞的是 `mgl-srv-apply` 线程不是应用线程。 +3. **有上限的保留,默认关**:`MOBILEGL_PIPE_TEXEL_RETAIN_MB` 默认 0——`MipmapStorage` 保有每 level 完整 CPU 影子,拉取总能被服务,缓存买的是延迟不是正确性。只有实测拉取率非平凡才开。 +4. **显式终止符**:拉取是 request/response 对,由 `ResourceSubDataComplete(res, target, firstLevel, levelCount, pullSerial)` 终止,**可携带零个 region**——内容只来自渲染、被 `CanMirrorCopyImageShadow` 拒绝的 copy、或 GPU 侧 mip 生成的 level,client 根本没有字节;收到零 region 时 server 带着"已分配但为空"的存储继续(正是 monolith 的行为)并记 `MGLOG_W`。没有终止符 apply 线程会永久 park。 + +门:`TextureRemintPullScenario`(含无解用例,且在终止符落地前必须是红的);拉取次数逐 trace 用例发布。本设计从不声称"零 round trip",它测量并公布。 + +### 8.5 XFB scatter 搬到 client + +Espryt 的 `ScatterCapturedRecords` 是对 client shadow 的 read-modify-write:从应用已有的字节起步,只把捕获到的 varying 补进去(`gl_SkipComponents` 的空洞保留应用原本的内容,`KHR-GL46.transform_feedback.capture_special_interleaved_test` 走到它)。server 没有 `MappedData()`,所以:server 把紧密打包的 scratch 通过 `OnBufferWriteback` 推给 client,用 `OnXfbScatterReady` 告知布局;client 拥有目的 shadow 与反射归档里的 varying/stride,原样跑补丁循环;补好的范围作为普通 `ResourceSubData` 重发并 bump change serial。不新增停顿类。 + +## 9. 后端状态机改造 + +### 9.1 原样不动的东西 + +Espryt:三条 persistent-mapped ring 与 `PersistentRing` 算法、buffer pool、7 条 fallback-repack 路径、`m_backendColorSlots` 置换表、三个 scratch FBO 及驱动侧影子、`PackState`、全部驱动绑定影子、Adreno 禁用属性 SIGSEGV workaround、Mali XFB 捕获丢失 workaround、`ScopedDefaultUnpackState`、SPIRV-Cross 会话与 post-emission ESSL 重写、驱动 POST 自检族、restart 重写与 multi-draw 五档。 +Magma:`VulkanRenderer` 全部 memo 与 scratch、`PipelineFactory`、`ProgramFactory`、`UniformManager` 的 ring 与描述符集、五个 `Vk*Manager`、`FrameContext`、`SwapchainObject`、`DynamicStateShadow`、`VertexInputStateFactory` 的 cache 本体、**D18 的节点式容器纪律**(`m_renderbufferResources`/`m_textureResources` 故意用 `std::unordered_map`,调用方跨查表缓存 `Resource*`;postmortem 注释逐字进 review checklist)。 + +从"不动"里移出的一项:Espryt 的 sub-rect 上传判定与跨步计算(§6,从描述符取步长)。 + +唯一两处必须真改的 `MG_State` 类型内部用法(都在 Magma):占位纹理(构造真的 `TextureObject2D*` 只为复用 `SyncTextureAndGetDescriptor(ITextureObject&)` 签名,~120 行木偶戏 → ~60 行原生 `VkImage`+view+descriptor,34 个 `MOBILEGL_ASSERT(pGLContext)` 里的 9 个随之消失);两个内部 shader 烘焙(§7)。Espryt 的小号同类:`g_rawDepthFetchSamplerState` → 后端原生 sampler。 + +### 9.2 strangler 脚手架:`PipeInputs` + 逐 verb 填充 + poison 世代(P1) + +```cpp +// MG_Backend/MGPipe/PipeInputs.h —— 按 memo 键组织,不按读点组织(~20 KB,字段集全迁移期稳定) +struct PipeInputs { + const RenderStateParameters& GetRenderStateParameters() const; // 阶段 A:类型与后端今天读到的完全一致 + // … 每个后端真正用到的 GLContext 方法一个访问器(Espryt 32 / Magma 55) +#if MOBILEGL_DEBUG || MOBILEGL_BUILD_DISAGGREGATED + Uint64 m_filledGen[kFieldCount]; // 逐字段"上次填充的 verb 序号" + Uint64 m_currentVerbSerial; +#endif +}; +#if MOBILEGL_PIPE_PUSH +# define MGB_CTX (&::MobileGL::MG_Pipe::gPipeInputs) +#else +# define MGB_CTX (::MG_State::pGLContext) +#endif +``` + +| 阶段 | 改什么 | 证明 | +|---|---|---| +| A 别名 | 机械 `sed`:`MG_State::pGLContext->` → `MGB_CTX->`(293 处)+ 手工转换 58 行非箭头用法(~34 处 `MOBILEGL_ASSERT` 删除、7 处空守卫、3 处三元、`.get()` 裸指针捕获与 `decltype` 别名、14 处 `!= nullptr`、1 处注释);逐 verb 类填充点填 `gPipeInputs` | `nm --defined-only` 不变;`.text` 差异可逐行归因(空守卫/三元的重写推迟到 P2) | +| B 推送 | tracker 填 `gPipeInputs`,填充器按 `MOBILEGL_PIPE_PUSH` 位图逐字段让位 | `MOBILEGL_PIPE_VERIFY=1`:tracker 再填一份快照版,G4 比对器逐字段每 draw 比一次 | +| C 句柄化 | `SharedPtr<前端对象>` 字段 → `MGPipeHandle` + POD 描述符;memo 重键;写回变回调 | 全套门(§13) | + +- 填充点逐 verb 类,不只 `PrepareForDraw`/`SetupDraw` 两处:G5 从 `PipeCalls.def` 生成"每个 `kCtxVerb`/`kCtxObject` 调用可能读哪些字段"的表,在 `MG_Impl` 的 ~93 个边界站点生成 validate/fill 调用。 +- poison 是**逐 verb 世代**不是位图:每次 verb 递增 `m_currentVerbSerial`,字段被填时记下序号,读取时断言相等(跨 verb 有效的字段显式标 sticky)。位图看不见"上一个 draw 填过、紧随的 `glTexSubImage` 读到陈旧值"。debug 与 disaggregated 构建里读一个当前 verb 未填的字段是 `Fatal{UnmigratedPipeInput, "GetStencilState@DrawVbo"}`。纯度门 grep 的是 `pGLContext` 不是 `pGLContext->`。 + +### 9.3 Track V / Track H + +- Track V(值类型:`GetRenderStateParameters`、`GetPixelStoreParameters`、capability 位、stencil/colormask/depthmask/scissor/patch/attrib 默认值、Magma ~22 个标量 getter……约 B 类读点的 55%):机械。 +- Track H(对象类型:167 个 `SharedPtr` 点):真活。 +- 读点分类实测(静态):A 探测变化 ~35(12%)、B 翻译输入 ~216(74%)、C 瞬时参数 ~4、D 身份/缓存键 ~48(与 B 重叠)、E 数据字节 3、写 8。74% 是 B 类——"bump 一个版本让 server 自己拉"行不通,值本身必须过去。 + +### 9.4 残余值块 + +Track V 的 55% 不需要逐字段接口条目就能跑起来,所以 P2 发一个**显式临时**调用 `SetResidualValueState(MGPBlobRef)`,payload `ResidualValueBlock{RenderStateParameters, PixelStoreParameters, CapabilityBits, patch 三字段}`。三条纪律:退役是编译错误(`MGL_RESIDUAL_BLOCK_SIZE` 只降不升,`MobileGL/MG_Pipe/MGPipeTypes.h:535`,P13 变成 `static_assert(sizeof == 0)`);布局逐成员 `offsetof` 断言且 split 下逐字段序列化(异质 POD 并集的 padding 差异 monolith verify 看不见);只在 P2..P13 存在,`MOBILEGL_PIPE_STATS` 单独计一类字节(`ResidualValueBlock`,P0 已占位)。 + +### 9.5 21 条身份 memo 的重键 + +统一事实:每个进入 memo 键的版本计数器要么是回绕 `Uint16`,要么根本不会被它害怕的那个 mutation bump;身份比较是堵回绕洞的补丁。`{slot, gen}` + 显式 destroy 让 **11 条直接删除**(registry 的同址 `weak_ptr` + GC ×6、`TwinLookupMemo` ×3 + `OwnerEquals`、`UnitSamplerLookupMemo` 的 `WeakPtr` 测试、`SetBackendStateMemo`、`VkTextureManager::TextureIdentity` 存活探测、`ConvertedVertexStreamKey` 的 `sourcePin`……),**2 条** server 删除但去抖搬到 client(§5.4),**7 条重键**成更便宜的比较(`StampSyncedFBO` 四元组 → `ContentHash` + server 私有 `attachmentRemintEpoch`;`ResolvedTextureBindingMemo` 9 键 → `(shaderCso.slot, viewSetSerial)`;`SetupDrawSnapshot` 的 ~14 探测字段与两个有损求和 → 三个 handle + 两个 server 纪元 + dirty mask;`VertexInputStateFactory::ComputeHash` 里的 lifetimeId → `gen` **混进** server 侧每个 content hash),**1 条**(D18)原样不动。两个顺带修掉的潜伏 bug 已先独立落地:`m_xfbCounterSlotByObject` 用裸 GL name 做键(`bd2b4158`)、`RenderbufferObject` 缺 `GetLifetimeId()`(`9c7339b2`)。 + +### 9.6 A/B 与口径收窄 + +`MOBILEGL_PIPE_PUSH` 子系统位图(含一位关闭 CSO 内容寻址,负面对照)在阶段 B 是真正的旧-vs-新 A/B;阶段 C 之后不是——位清零时 `SnapshotFromGLContext()` 仍要合成句柄,后端仍跑重键后的 memo 代码,一个重键 bug 两臂都在。对策:**编译期** `MOBILEGL_PIPE_LEGACY_MEMOS`(默认 ON)在 P3a/P4a 期间保留 registry / `TwinLookupMemo` 实现活在同一个 `PipeInputs` 接口之下,随 pull 路径在 P13 退役(各阶段 +1 天维护)。 + +P13:删 `SnapshotFromGLContext()` 的非 verify 分支、`MGB_CTX`、`MOBILEGL_PIPE_PUSH`、`MOBILEGL_PIPE_LEGACY_MEMOS`;**保留 `MOBILEGL_PIPE_VERIFY` 连同它需要的 `SnapshotFromGLContext()` 与 `MG_State` include**(D-B5,verify 构建永不出货);三道纯度门在非 verify 构建上转绿。 + +## 10. server 侧 + +### 10.1 对象表与 applier + +- `MG_Remote/Server/PipeObjectTables`:按 kind 的 slot 数组,不是对象图;server 不持有任何 buffer 的完整副本、不持有纹素、不持有前端对象图。 +- `PipeApplier`:解码 → 更新对象表与 `PipeInputs` → 调后端函数指针。debug 断言:任何传输下都不得有 `SharedPtr` 或裸前端指针跨过 applier 边界。`InProcessTransport` 走与 spawn **完全相同**的 G3 编解码路径,只在门铃/拷贝机制上不同。 +- 每 context 一份 working `RenderStateParameters`(§5.3)。 + +### 10.2 monolith 侧的净收益 + +即使 IPC 永不上线:复用地址 ABA 一整类不可表达;FBO → program 排序 hazard 消失;`SwapchainObject` 写 `MG_Impl` 的分层倒置消失;两个潜伏 bug 已修;一次 glslang 编译离开启动路径;`inproc` = 渲染线程;`MG_Test` 的 mock 后端变成 MGPipe recorder(§13.3)。monolith 净代码量是**增加**的(约 +6,650 手写 + 4,000 生成,对 ~372 行真删除),所以 monolith 论据是逐线程 CPU 数字(§13.2-④),不是删除行数。 + +### 10.3 索引宿主镜像(`MG_Remote/Server/IndexHostMirror`,P8) + +- 覆盖:`BindMask & ELEMENT_ARRAY` 的资源,且仅当 `kCapNeedsHostIndexBytes`(split 且 server 需要索引字节做 restart 重写 / multi-draw 展平)。 +- 由 server 本来就要收的 `ResourceCreate/Respecify/SubData` 流增量维护:零额外线上流量、零 round trip。GPU 写者对镜像的影响由 `OnGpuWritten` 收窄集在 server 本地判定。 +- 预算 `MOBILEGL_PIPE_INDEX_MIRROR_MB`(默认 64),逐帧发布 `index-mirror-bytes`;超预算时该 buffer 退化为逐 draw 经 `MGHostSpan` 传送(`Seg` 指向 `SEG_STAGE`),计入 `index-bytes-shipped`。 +- 必须是它:`kMaxRestartRewriteBytes` = 64 MiB 是默认 `SEG_STAGE` 的两倍,`kMaxFlattenedIndices` = 1<<24 同量级,逐 draw 塞进 32 MiB 的段既不可行也无必要。它是本设计里唯一的"数据副本"。 + +## 11. 传输与数据面(骨架 P0 已落地,`MobileGL/MG_Remote/`) + +### 11.1 段 + +| 段 | 拥有者 | 默认 | 内容 | +|---|---|---|---| +| `SEG_CMD` | client(server 只读) | 8 MiB,2 的幂 | `RingControl`(4 KiB 页)+ POD 记录 + ≤4 KiB 内联负载 | +| `SEG_STAGE` | client | 32 MiB,上限实测定 | bulk 字节:buffer sub-data、纹理紧密重打包区域、UBO scratch、client 顶点/索引/indirect 数组、multi-draw 参数块、具名 UBO host payload、persistent-map 脏块 | +| `SEG_REPLY` | server(client 只读) | 8 MiB,4 KiB slot | readback 像素、buffer writeback | +| `SEG_EVENT` | server | 256 KiB SPSC ring | 十个回调的事件 + `EvQueryResult/EvFenceSignaled/EvReadbackDone` | +| `SEG_SHADOW[n]` | client | 每对象,≥256 KiB shadow(Phase 2) | 零拷贝 buffer/texture shadow | +| `SEG_ADOPT[n]` | server(client RW) | 每 buffer,≥16 MiB adopted store(P11) | 应用直写 GPU 内存 | + +创建(`ShmSegment`):Android `ASharedMemory_create`(API 26;libc 的 `memfd_create` wrapper 是 API 30);桌面 Linux `syscall(SYS_memfd_create)`;其他 POSIX `shm_open`+`shm_unlink`;Windows `CreateFileMappingW`(`Local\`)。传递:POSIX `SCM_RIGHTS`(`FdPassing`,专用 `AF_UNIX SOCK_DGRAM` socketpair——消息边界保住 ancillary data 与 payload 不被拆开,sideband ≤256 B);Windows 段名走 `SegmentRef`。fd 传递在第一个 transport commit 里实现——没有它数据面在唯一重要的平台上一字节过不去。 + +不进 `SEG_STAGE` 的:restart 重写的整 EBO 与 multi-draw 展平的索引流(走索引镜像)。`SEG_SHADOW` 块的退休规则:释放的块进 pending 链表,`appliedSeq`(借入 GPU 时间线的 slot 用 `retiredSeq`)越过最后一条引用它的记录后才归还 arena。 + +### 11.2 `RingControl`(`Ring.h`) + +一页 4 KiB,每个争用组各占一条 cache line:`SEG_CMD` 游标三元组 `cmdHead / cmdAppliedTail / cmdRetiredTail`;`SEG_STAGE` 独立三元组(`stageHead / stageAppliedTail / stageRetiredTail`——"`SEG_STAGE` 余量 < 1/4"是 publish 触发器,占用率不能从另一个 ring 算出,且 stage slot 的退休条件不同);三个严格区分的水位 `appliedSeq`(释放 `*AppliedTail`)/ `submittedSeq`(释放 staging)/ `retiredSeq` + `completedFrameSerial`(释放 `*RetiredTail` 与 `SEG_ADOPT`)+ `presentAckSerial`;`serverEpoch`(context 丢失 / server 重启 ++)、`ringGeneration`(硬 drain 后 ++,作废缓存 offset)、`consumerParked`/`producerParked`、`eventRingFull`、`eventDropped`。两个 tail 是必须的:P11 之后 server 会**借用** ring slot 而不是再拷一次,那种 slot 只能在 `completedFrameSerial` 之后回收。游标是单调字节计数、2 的幂掩码、永不重置。 + +记录头 `RingRecordHeader{kind, flags, size}`,kind 0 保留给 wrap 填充;`RingProducer::Reserve` 在记录会跨 wrap 边界时自动发 pad 记录,保证每条记录连续;`MaxRecordBytes() == Capacity()/2`;`RingConsumer::Pop` 拒绝不可能的头(非 8 对齐、小于头、大于已发布)并置 corrupt → `Fatal{ProtocolCorruption}`;`HardDrainRing` 只在两侧静默且 ring 全空时 bump generation。 + +### 11.3 双向 doorbell(`Doorbell.h`) + +- client → server:consumer 自旋 → 置 `consumerParked=1` → 阻塞;producer release-store `cmdHead` 之后仅当 `consumerParked` 时敲(字节码 `0x01`)。 +- server → client:client 在**任何**等待(present credit、`kNeedsAck`、ring/stage 满)先自旋 `MOBILEGL_IPC_SPIN_US`(默认 50 µs)→ 置 `producerParked=1` → 阻塞;server 在 release-store 任何 watermark 之后仅当 `producerParked` 时敲(`0x02`)。没有第二个方向,每处 client 等待都退化成跨进程自旋一条 cache line——手机上一颗大核满频空转一整帧,而全库没有亲和性控制。 +- 两个实现,零 futex/eventfd/named-event 平台代码:`CondVarDoorbell`(`inproc`,带 `Kill()` 死亡态让 `Shutdown` 能 join 一个 parked 的等待者)与 `SocketDoorbell`(`spawn`,一字节;`SOCK_STREAM` 端在对端关闭时报 `POLLIN|POLLHUP` + `recv()==0`,这是死亡检测)。 +- 丢失唤醒窗口由**两个 `seq_cst` fence** 关闭(等待者置标志 → fence → 再测条件;通知者发布 watermark → fence → 读标志),标志本身的访问是 relaxed。`NotifyIfParked` 的前置条件:watermark 已发布。死亡的 doorbell 让 `Wait` 停止重新 park。 + +### 11.4 控制面(`protocol.fbs`、`Framing.h`、`ITransport.h`) + +- 一份 schema,两种用法:热路径 → FlatBuffers `struct`(定长、无 vtable、只需边界检查)直接进 ring——即 G3 生成的记录,与 `MGPipeTypes.h` 的 POD 逐条 `static_assert` 尺寸/`offsetof` 对齐;罕见/变长/需演进 → `table` 走 CTRL socket。今天 `protocol.fbs` 只含控制面(`MobileGL/MG_Remote/Protocol/protocol.fbs:218-228` 的 `CtrlMsg`:`Hello`、`Welcome`(四个段的 `SegmentRef`)、`CapsSnapshot`、`SurfaceOp/SurfaceReply`、`ResyncRequest/Done`、`AuxRequest`(外来线程的 fence wait / query result / scalar get)、`Fatal`(`ProtocolCorruption/RingOverrun/SegmentMismatch/DeviceLost/ServerCrashed/AbiMismatch`)、`LogLine`),`file_identifier "MGLC"`;union tag 是 wire 值,只追加。 +- `protocol_generated.h` 提交进树,`scripts/gen_protocol.py` 再生成(只用 `MOBILEGL_FLATC_EXECUTABLE` 或从 pinned submodule 在仓库外构建一次的 flatc,不用 PATH 上的),CI `flatc-check`(`.github/workflows/test.yml:304`)重生成并 diff。**codegen 绝不进默认构建图**;运行时 header-only。 +- 封帧 `[u32 'MGLF'][u32 len][payload]`,64 MiB 上限,**读时校验**:坏 magic / 超长长度立即 latch 失败并报 `MOBILEGL_ERR_PROTOCOL_MISMATCH`(不是静默永久挂起);接收缓冲不足**返回所需大小并保留消息**(`MOBILEGL_ERR_BUFFER_TOO_SMALL`)。 +- `ITransport`:`SendFrame / ReceiveFrame / PeekFrameSize / ShareFd / ReceiveFd / Shutdown / Role`;热路径完全绕过它。`Shutdown` 拆掉整个连接(两端都不能再发,等待者全部解锁,已排队消息仍可读完)。`WireLog.h` 是唯一的日志入口,让 `Transport/` 的头不 include 前端 umbrella(纯度门 A 断言 `-H` 输出)。 +- `mg_protocol_base.h`:纯 C、无依赖的结果码 / span / `ShmRegion` / id 词汇,structSize-first 版本纪律(追加 = minor,改动 = major,major 不符是结构化失败)。 + +### 11.5 WAR 危害、拷贝账与背压 + +- Phase 1(P5–P8):GL 调用时刻把字节拷进 ring slot,slot 到 `stageAppliedTail` 越过它为止不可变,危害按构造消除;代价一次 memcpy,`Ops_ResidentSubData` 与 `StageBlocksIntoUnpackRing` 在 monolith 里已经在付。 +- Phase 2(shadow-in-shm,零拷贝):≥256 KiB 的 shadow 分配在 `SEG_SHADOW`(`PipeResource::MapAlignedAllocator` 增加 shm arena,保留 64 B 对齐契约;`MipmapStorage` 的 level vector 同理),`ResourceSubData` 只带 `{seg, offset, size}`。WAR 用 per-shadow 64 KiB 块发送水位:应用写某块而该块上次发送尚未被 `appliedSeq` 覆盖 → 这次写走 `SEG_STAGE`。必须整段 `#if MOBILEGL_BUILD_DISAGGREGATED` 包裹(改容器 allocator 就改了类型,option OFF 时逐字折叠回今天的 allocator)。 + +| 路径 | monolith | 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 推送(§12) | 0 | 1/发射点 | 1/发射点(精确块) | +| `glTexSubImage` | 2 | 2 | 2 | +| 全局 UBO / draw | 1 | 2 | 1 | +| adopted ≥16 MiB(P11 T1/T0) | 0 | 0 | 0 | + +server 没有第二份 `BufferObject`,所以不存在"staging → server 侧 shadow"这次中间拷贝。字节计数器装在 wire 两侧,验收看总量。 + +- 分配与背压:逐字移植 `PersistentRing`(单调 head/tail、2 的幂掩码、frame mark)。分配失败升级:扩容(翻倍)→ 对最老未 retire 批次有界等待(默认 50 ms,走 `producerParked` doorbell)→ 硬 `Drain` + `ringGeneration` bump。硬 drain 后恢复便宜:正向流是自洽的推送流,tracker 把全部 dirty 位置为"必须重推",下一个 verb 重发完整 `set_*` 集合,纹理侧由发射游标负责,没有"重发未 apply 对象状态"的特殊协议。`SEG_CMD` 与 `SEG_STAGE` 各自独立跑这套升级。 + +### 11.6 publish、序号与 credit + +- 不设"records ≥ 64 KiB"一类阈值(那是一整帧的流水线气泡,且否掉 `inproc` 的全部意义)。规则:每条记录(或每 8–16 条摊销)release-store `cmdHead`,仅当 `consumerParked` 时敲门铃。 +- 显式门铃点:`present`、任何 `kNeedsAck` 请求、`eglMakeCurrent`、`glFlush`(刷出不等待)、`SEG_STAGE` 余量 < 1/4、**轮询类入口**(`glClientWaitSync` 任意 timeout、`glGetSynciv(GL_SYNC_STATUS)`、`glGetQueryObject*(AVAILABLE|NO_WAIT)`——否则 `while (glClientWaitSync(s, FLUSH_COMMANDS_BIT, 0) == TIMEOUT_EXPIRED) {}` 永久自旋);带 `GL_SYNC_FLUSH_COMMANDS_BIT` 无条件 publish。 +- 饥饿升级:同一 handle 连续 N 次(`MOBILEGL_IPC_POLL_ESCALATE`,默认 64)本地回答"未就绪"而 watermark 毫无移动 → 升级为一次阻塞 round trip。 +- `glFinish`/`glFlush` 保持纯 no-op。 +- seq = 记录序数;两个互相独立的窗口:字节 credit(两个 ring 各自占用)与 present credit(`presentsSent - presentAckSerial >= MOBILEGL_IPC_PRESENT_CREDIT` 时 `eglSwapBuffers` 阻塞)。server 不发 credit 消息:对 `RingControl` release store,consumer 每 64 条记录更新一次 `appliedSeq`,`producerParked` 时敲反向门铃。 + +### 11.7 事件回传与溢出 + +`SEG_EVENT` 承载十个回调加回读完成通知。client 排空点:`glGetError`、`glGetQueryObject*`、`glClientWaitSync`、`glGetSynciv`、`eglSwapBuffers`、`glMapBuffer*`/`glGetBufferSubData`/`glCopyBufferSubData`,以及**每一次等待循环的每一轮**。溢出策略(修一个双向死锁:client 卡在 present credit、server apply 线程卡在生产事件):`EvLogLine` ≤WARN 有损;语义承载事件(`EvGpuWritten`、`EvReadbackDone`、`EvFenceSignaled`、writeback、pull request、mip、scatter、`EvGlError`、surface、caps、`EvLogLine ≥ERROR`)无损——ring 满时 server 置 `eventRingFull=1`、**在记录边界停止 apply**、敲反向门铃,client 排空后清标志并敲正向门铃;ERROR 速率限制器。故障注入:client 被 credit 阻塞时灌满 `SEG_EVENT`;日志洪泛下注入一次 link 失败,那行 ERROR 必须出现且两侧恢复。server 侧 `MGLOG` 按流顺序 replay 进 client 日志流(复用 `DeferredLogLine` 机制)。 + +### 11.8 fence 与无 present 负载 + +- fence 完成度必须来自**真的逐 fence 退休**,不是 present 水位:DirectGLES 的 `g_completedFrameSerial` 只在 `Present()` 与 `WaitForFrameSerialCompleted` 里前进,帧中 fence 会退化成帧计数推断——`DirectVulkan.cpp` 写明这是被修掉的 bug(MC 1.21.5 的 fence-paced ring 曾因此 native-heap OOM)。规则:`FenceCreate` 转成真实的后端 `FenceSync()`,server 用自己已有的逐 fence 轮询在非 present 时刻也推进并发 `EvFenceSignaled`。 +- 无 present 循环(CTS、回读循环、从不 swap 的集成场景)下 `retiredTail` 会饿死、`SEG_STAGE` 填满、每个用例都跑到硬 drain。规则:DirectGLES 的 server 加**非 present fence tick**——距上次 `Present` 超过 8 ms 或每 4096 条已 apply 记录插一个 `glFenceSync` 并轮询 fence ring;ring 占用率与升级次数进计数器;P8 加一个无 present 的 split 用例。 + +## 12. persistent map 与 ≥16 MiB 采纳 + +`AcquirePersistentMap` 是永久的地址空间捐赠(返回 host-visible coherent 指针,成为该 buffer 的唯一真相源;≥16 MiB 可变 store 由 `TryAdoptLargeStorage` 自动走到,实测 MC 26.3 p99 163→21 ms、40→115 fps、省 ~400 MB)。**整个 monolith 改造期一动不动**(D-B4),只有 IPC 那一步会打破它。 + +三档,由运行时 POST 探针选择(本项目"后端限制一律探针判定、不硬编码驱动名"的既定规则),**spike B 已在两台设备上给出答案**(`MEASUREMENTS.md` §2): + +| 档 | 形态 | 实测 | +|---|---|---| +| **T0 — server 导入 client 分配**(P11 主攻) | client 分配 `AHardwareBuffer` BLOB,socket 交接;server 以 `VK_ANDROID_external_memory_android_hardware_buffer`(Magma)或 `EGL_ANDROID_get_native_client_buffer` + `glBufferStorageExternalEXT`(Espryt)导入,两侧 persistent+coherent 映射 | **Adreno 830 与 Mali 都是完整读写往返**,含 GPU 访问与两侧字节校验——唯一在两台设备、两个后端上都成立的档 | +| T1 — server 导出自己的映射 | `VK_KHR_external_memory_fd` opaque fd,client `mmap` + 导入 | 只有 Adreno 的 Vulkan 路径可用;Adreno 的 GLES 导入 `glMapBufferRange` 全部 `GL_INVALID_OPERATION`;Mali 不可导出。**每次存储定义一次 round trip**(不是每 store 一次),`StorageBufferRegrowScenario` 发布 `map-persistent-roundtrips` | +| T3 — host pointer 导入(`VK_EXT_external_memory_host`) | | Adreno 无扩展;Mali 只读(GPU 写对宿主映射不可见) | +| T2 — 拒绝(永久正确回退) | `AcquirePersistentMap` 返回 `nullptr`,前端已在三处容忍 | 此档下 client 侧推送强制 | + +`MOBILEGL_IPC_ADOPT_TIER`(`auto`/0/1/2)做负面对照;与 `MOBILEGL_IPC_RESPAWN` 互斥(被采纳的 store 是 server 拥有的内存)。 + +**client 侧 persistent map 推送三件套**(T2 档强制,P5): + +1. 不做 map/unmap 命令对:server 唯一需要知道的是"这个资源现在有没有活的宿主写入者"(`IsBufferDrawClean` 那一行要表达的东西),所以 `ResourceRespecify/SubData` 的 payload 带一个 `hasLiveHostWrites` 位,零新增记录种类。 +2. 块粒度脏块推送:tracker 维护 `m_livePersistentMaps`(persistent+write+非 FlushExplicit+非 GpuResident),在每个 validate 点对本次操作可达的每个这类 buffer(VAO/index/indirect/UBO/SSBO/atomic/XFB target——即后端 20 个 `SyncPersistentMappedRange` 站点的并集)按 `MOBILEGL_IPC_PERSISTENT_BLOCK_KB`(默认 64)切块发送。Phase 1 保守版(整个 mapped span 当脏,按块拆);Phase 2 精确版(shadow-in-shm 的 64 KiB 块脏位,`memcmp` 先行)。P5 验收记录 `persistent-map-push` 字节量;若保守版在 Create/Flywheel fixture 上不可接受,精确版提前——计划里唯一允许因测量改变阶段顺序的地方。 +3. 门从第一天就有:`PersistentCoherentMapScenario`(map PERSISTENT|WRITE|COHERENT、写、不做任何其它 GL 调用、draw、readback 校验)。 + +`MOBILEGL_COHERENT_AS_FLUSH` 在拆分模式下照常生效:两个带 `coherent_as_flush: true` 的 Create fixture 在 split 与 monolith 下走同一条 buffer 路径,逐名对比才有意义。 + +## 13. 回读、roundtrip 清单与验证 + +### 13.1 稳态零 roundtrip 与不可避免的阻塞点 + +零 round trip:全部 draw/clear/blit/copy/dispatch/barrier/XFB 跨度/bind/CSO/`set_*`/上传/`present`(单向记录);全部 caps 站点(握手快照);`glGetError`/`glFinish`/`glFlush`(本地 / no-op);fence 与 query 的创建及非阻塞轮询(client 铸造 handle,未命中合法地答"未就绪");`glGetTexImage`(DirectGLES,含 GPU 生成的 mip);`glReadPixels` → pack PBO(fire-and-forget + client 侧 `MarkGpuWritten`,严格优于 monolith 的无条件停等);`glEndTransformFeedback`(取消无限 fence 等待,对 capture target 置 `MarkGpuWritten`);`eglSwapBuffers`(只查 credit);`*IndirectCount`;restart/multi-draw。 + +不可避免(全部罕见):握手一次;surface 生命周期与首次 `MakeCurrent`+`InitCapabilities` 每 surface 至多一次;`glReadPixels` → 客户内存(像素进 `SEG_REPLY`,逐行写回循环留在 server 内按操作级批成一段);`glGetTexImage`(DirectVulkan,对"无 GPU 背书"的 level 回答"请用你自己的 shadow");GPU-write pending 的 buffer 首次 CPU 读(monolith 本来就 `glFinish()`;由 `writableMask` 与 `OnGpuWritten` 收窄);`glClientWaitSync(timeout>0)`、`GL_QUERY_RESULT` 未完成、`glBeginConditionalRender`(谓词只解析一次,之后每个条件 draw 在 client 丢弃,server 永远不需要那个 query);`glBufferStorage` 的 ack;`MapPersistent`(仅 T1,每次存储定义一次);纹理拉取(§8.4);client 侧索引扫描当源 EBO 在 pending 集里;ring/stage 耗尽与 present credit(节奏,非语义)。 + +验收措辞:在全部 40 个 trace 用例上发布逐用例的 roundtrip 计数器、纹理拉取计数器、索引镜像字节数与 `index-bytes-shipped`;零 timeout 轮询循环必须在有界时间内退出。 + +### 13.2 五部分验证门(取代 monolith 的字节一致门) + +"改前改后 `nm --defined-only` 与 `.text` size 完全相等"的门在本方案里按构造死亡(不存在能让旧字节回来的配置);替换是: + +1. **接口纯度三道门**(只跑非 verify 构建):**A 门 include 图**——disaggregated 配置编译 `MG_Backend` 时把 `MG_State/GLState` 从 include 搜索路径移除(`nm --undefined-only` 对"只 include 不调用"是瞎的,而 `RenderState.h → FramebufferObject.h → TextureObject.h` 正是这种耦合),依赖 P0.5;**B 门符号**——`nm --undefined-only libMobileGLServer.so | grep -E 'MG_State::GLState::|glslang'` 为空;**C 门未声明**——`grep -c 'pGLContext' MG_Backend/` == 0。外加 debug 断言"每个后端 memo 键都是 `{slot, gen}`,永不是裸前端指针",由 `HandleRecycleScenario` 支撑(重键前必须在至少一个后端上是红的)。 +2. **语义影子比对 `MOBILEGL_PIPE_VERIFY=1`**——决定性的一条:两套状态模型活在同一地址空间,tracker 再用 `SnapshotFromGLContext()` 填一份 `PipeInputs`,G4 比对器逐字段、每 draw 比对,打印第一个分歧字段与 draw 序号。抓 tracker 忘推的字段、**dirty 位触发得太少**(危险方向)、两条路径变换不一致的值。第三种 CI 模式,40 个 trace + 全部集成测试,~5–10× 慢,永不出货。逐字段而非 `memcmp`(padding 会 false-DIFFER)。**保留模式**:消费即清的组(纹理 dirty rect)发射后无法重算,verify 时 tracker 保留清除前的集合并比对发射出去的 `(UnionBox, RegionCount, Regions[])`。**活过 P13**。 +3. **行为 A/B**:40 个 trace 在 `{monolith-pull, monolith-push, split}` 下 SSIM ≥ 0.99(默认阈值);`ctest -L integration-gpu` 在 `DirectGLES.` 与 `DirectGLES.Pipe.`/`DirectGLES.Split.`(DirectVulkan 同)之间逐名相同;单元测试全绿;CTS 逐后端 conformance 在 0.5 pp 内(行 = GL 版本/扩展,列 = 状态计数,rate = Pass/(Pass+Fail),NS 不进分母)。`TextureUploadShapeScenario` 把逐纹理逐帧的上传形状(box vs N region、作业数)录金标比对——+6 ms 悬崖由形状相等把关,SSIM 对它完全不敏感。逐名功能基线是"P1 出口的重构后 monolith"(P1 出口先用 verify 证明等价于 `81b17c0b`);`81b17c0b` 只作性能锚点。 +4. **monolith 性能不回归**:两台设备 reboot-clean、同热窗口、配对 A/B,`tools/bench.sh` + trace replay `--benchmark` 逐帧 JSON;**指标是逐线程 CPU 时间**,p50 与 p99;**绝对阈值**——tracker 每 draw 的 ns 公布并设上限(真实拉取基线只有每 draw 6.5–9.3 次 accessor,相对噪声阈值会平凡通过);Blaze3D blend-toggle 微基准单列;关掉 CSO 内容寻址的负面对照。 +5. **覆盖 + poison + 句柄纪律**:G6 重生成 0 UNMAPPED;`gen_pipe_dirty_surface.py` 重生成 0 未映射 mutator;逐 verb 世代 poison;G7 setter 一致性测试;`ResidualValueBlock` 的 `offsetof` 断言与 P13 的 `sizeof == 0`。 + +两条幸存的字节级等式:`MOBILEGL_BUILD_DISAGGREGATED=OFF` 时 `nm --defined-only libMobileGL.so | grep MG_Remote` 为空且链接行不增加库;`nm -D libMobileGL.so | grep mobilegl_server_main` 在 RelWithDebInfo 里命中。符号与 `.text` 漂移每阶段作为信息性指标发布。 + +### 13.3 长期语义门:MGPipe recorder + +P13 把 `MG_Test` 的 mock 后端变成 MGPipe recorder:在一组 fixture 上录下每 draw 的已推送状态,后续构建对比录像。它不依赖 `MG_State`,是 P13 之后不靠 verify 构建的语义门,也给 `tools/trace_replay` 一种记录**已解析**状态的、比 apitrace 精确得多的录制格式。它只覆盖推送内容,不覆盖后端对它的解释(split-only 的渲染 bug 仍无 server 侧第二意见)。 + +## 14. Present、线程与帧节奏 + +- `eglSwapBuffers` → `present{frameSerial}`(swap interval 搭在同一条记录上)→ publish + 敲门铃 → 返回,除非超出 credit。**`present` 与 `eglSwapBuffers` 严格 1:1**:两个后端的帧边界排空(Magma 四次 `OnFrameBoundary` 老化、`TryDrainFrameTransients`、`BeginFrame`;Espryt 三个 ring 与 `TrimBufferPool` 的 retire)只在 `Present` 内发生,批量会饿死它们。 +- **`MOBILEGL_IPC_PRESENT_CREDIT` 默认 1**(可配 1–4):延迟叠加,`端到端 ≈ client credit + server 帧数 + 驱动深度`;server 的 `Present` 末尾已在 `vkWaitForFences` 上等 2–3 帧,credit 2 就是端到端 4–5 帧(60 Hz 下 66–83 ms)。P10/P12 用 `GetGpuTimestampNs` 与 `--benchmark` 逐帧 JSON 构建输入延迟直方图,只有实测吞吐收益能抵掉延迟代价才调高。 +- Magma 从不注册 `SetSwapInterval` 且偏好 `MAILBOX`/`IMMEDIATE`,IPC credit 是它唯一的显式限帧器;若需要 FIFO 作为独立 `dev` 变更。 +- 线程——client:**v1 不加线程**,编码在 GL 线程上直接写 ring(前端本就是 per-context 单线程契约);外来线程的 sync/query 读全部从 `RingControl` 无锁回答,必须发射的少数取 `ctrlMutex` 走 CTRL socket 的 `AuxRequest`(SPSC ring 不允许第二个 producer);`ShaderCompilePool` 原样在 client;可选 `mgl-client-tx` 凭测量决定。server:`mgl-srv-io`(asio、封帧、`SCM_RIGHTS`、doorbell、CTRL RPC)、`mgl-srv-apply`(**终身持有原生 context**:`g_backendContextOwnerThread` 只写一次,`MakeCurrent` 的缓存失效风暴变启动期一次性,每帧 EGL 复核恒真,off-thread 降级消失)、可选 `mgl-srv-dec`。 +- **核心放置**:拆分的全部性能主张押在两半落在两个都快的核上。全库无亲和性控制,server 是独立进程不继承 launcher 的亲和性。规则:报总 CPU 工作量差(client tracker + encode + decode + server apply vs monolith `PrepareForDraw`);复用 `ShaderCompilePool` 的大核探测把 `mgl-srv-apply` 绑到大核(`MOBILEGL_IPC_SERVER_AFFINITY`,默认 auto,解析出的 mask 打进日志);每阶段报逐线程 CPU 时间。 +- 拆机顺序:publish + server 排空并 ack → 停 apply 线程 → 关 transport → client 排空 compile pool(先于 `glslang::FinalizeProcess()` 与 `pGLContext` 析构)→ `MobileGL::Destroy()` → 释放 sync/query handle。 + +## 15. 进程、EGL 与平台 + +### 15.1 启动与握手 + +- server 定位:`MOBILEGL_IPC_SERVER_PATH`(主要)→ `dladdr(&MobileGL::Initialize)` 同目录的 `libMobileGLServer.so`(兜底;不能当主要机制,因为集成测试静态链接 `MobileGL_s`、trace replay 的可执行文件不在库目录)。配套:`MobileGLServer` 的 `RUNTIME_OUTPUT_DIRECTORY` 设为 `$`,每条新 ctest `ENVIRONMENT` 与 `add_trace_replay_test` 的 `SPLIT` 分支带 `MOBILEGL_IPC_SERVER_PATH`。 +- 启动:`socketpair(AF_UNIX, SOCK_STREAM)` + `fork`/`execve`,fd 3 = socket。无文件系统 socket 路径、无 abstract namespace、Android 上无 SELinux 争议。 +- **子进程强制 monolith**(修无界 fork 链——server stub `dlopen(libMobileGL.so)` 后必然走 `MG_Backend::Init()`,继承的 `MOBILEGL_TRANSPORT=spawn` 会再 spawn):spawn 时构造显式 envp 剔除 `MOBILEGL_TRANSPORT` 与全部 `MOBILEGL_IPC_*`;`mobilegl_server_main` 在到达 `Init()` 之前把 `MG_Config::Transport` 硬置为 `Monolith`。两条都做。`MG_Test/Wire` 测试:spawn 一个 server,进程树只多出恰好一个子进程。 +- `Hello{abi, backendType, buildFingerprint, configBlob}` → `Welcome{四个段}`。`configBlob` 转发 client 解析好的 `MG_Config::Features`,两半不可能对 quirk 开关有分歧;`buildFingerprint`(git hash + `PipeCalls.def` hash)不匹配 → 握手期 `Fatal{AbiMismatch}`。 +- `mobilegl_server_main` 声明为 `extern "C" __attribute__((visibility("default")))`:非 Debug 构建设了 hidden visibility,而 FCL/plugin 出货的是 RelWithDebInfo,否则 `dlsym` 在设备上静默失败。 + +### 15.2 Android(spike A 已证) + +- 交付链:APK 唯一可 exec 的位置是 `lib//`,打包器只收 `lib*.so`,所以 server 以 `add_executable` + `PREFIX "lib"/SUFFIX ".so"` 构建(真 PIE),并把 `RUNTIME_OUTPUT_DIRECTORY` 指到 AGP 收集原生产物的 `CMAKE_LIBRARY_OUTPUT_DIRECTORY`(`CMakeLists.txt:784-808`,`MOBILEGL_BUILD_SERVER_SPIKE`)。**两台设备上都已证明**:从 `TraceReplayActivity` 自身的 `untrusted_app` 进程 `fork`+`execve` `/libMobileGLServer.so`,子进程落在同一域、同一 MLS category,exit 0,零 avc denial(`MEASUREMENTS.md` §1)。 +- `fork`+`execve` 而非 `posix_spawn`:bionic 从 API 28 才声明后者,minSdk 26(`android-plugin/app/src/trace/cpp/spawn_spike.cpp:63-68`)。fork 与 execve 之间只做 async-signal-safe 的 open/dup2/execve/write/_exit(父进程是多线程 JVM)。 +- 应用进程的 stdout/stderr 是 `/dev/null`:子进程用 **marker 文件** 证明自己活过,exec 被拒的 errno 经 close-on-exec pipe 回传(EACCES 与 ENOEXEC 是完全不同的判决)。 +- 生产 server 主体是 ~30 行 stub:`dlopen(libMobileGL.so)` → `dlsym("mobilegl_server_main")`。一份共享库、两个角色、版本必然匹配(Android 上那份库仍含 glslang/SPIRV-Cross,因为它同时服务 client;B 门检的是 server 侧代码有没有引用它们)。 +- minSdk 26 没有公开 NDK API 能扁平化 `ANativeWindow`(`libbinder_ndk`、`ASurfaceControl` 都是 API 29)。**P5–P11 验证路径无窗口**:pbuffer 或 `AImageReader` 的 `ANativeWindow`,trace replay 默认 pbuffer。**P12 生产路径**:Java `Surface`(Parcelable)→ Messenger/AIDL → `MobileGLServerService`(`android:process=":mgl"`)→ JNI `ANativeWindow_fromSurface`(FCLauncher 今天在 `egl_bridge.c` 做的那一次调用);仓内先例是 `android:process=":bench"` 的 `BenchService`。代价:server 进程多一个 ART(~15–25 MB)。FCL 把游戏 JVM 跑在主进程,第二个进程必须新建。 +- `HeadlessGL` 的 fork 预检会 fork 一个子进程跑完整 EGL bring-up 然后 `_exit`——拆分模式下那个子进程会 spawn 一个孤儿 server。规则:server 的 EOF 检测**即时且无条件退出**(亚秒级);client 的 socket fd 设成 `_exit` 会确定性关闭的形态;就绪握手有界重试。列为 P6 验收。 +- 通用 env 透传 `--env K=V`(`run_android_retrace_local.py` → intent extra `mobilegl_env` → `trace_replay_core.cpp` 在加载 `libMobileGL.so` 前 `setenv`)已接进 retrace 通道,取代逐 knob 加 `--es/--ez`。 + +### 15.3 Linux / Windows / 崩溃 + +- Linux/X11:`Window` 是 XID,`nativeToken:u64` 直接送,backend 自己 `XOpenDisplay(getenv("DISPLAY"))`;Wayland 维持不支持。WSL/CI 永不开窗:`EGL_PLATFORM=surfaceless` + `EnsureHeadlessPlatform()`。 +- Windows:`HWND` 进 `nativeToken`,Vulkan 可行,WGL/ANGLE-DXGI 对外进程 HWND 不受支持 → headless only。transport 默认 named pipe:asio `windows::stream_handle` 要求 overlapped 句柄,所以用 GUID 命名的 `CreateNamedPipeW(FILE_FLAG_OVERLAPPED)` + `CreateFileW(FILE_FLAG_OVERLAPPED)` 造句柄对再继承给 `CreateProcess`;AF_UNIX-everywhere 是可选简化。Windows 机器不是正确性门。macOS 不拆分(`CAMetalLayer` 无跨进程表示)。 +- server 死:client 读到 EOF/EPIPE → device-lost 闩锁(GL 调用 no-op、`eglSwapBuffers` 返回 `EGL_FALSE`+`EGL_CONTEXT_LOST`、`glGetGraphicsResetStatus` 返回 `GL_UNKNOWN_CONTEXT_RESET`);`MOBILEGL_IPC_RESPAWN=1` 时重启并全量重推(默认关,静默重启会掩盖 bug)。client 死:server 读到 EOF → 立即销毁原生 context 并退出;`MOBILEGL_IPC_IDLE_EXIT_S`(默认 30)只作最后保险。 + +## 16. 构建布局 + +``` +MobileGL/MG_Pipe/ 永远进构建(monolith 的架构,不在任何 option 之后) [P0] +MobileGL/MG_Impl/Pipe/ Tracker、SlotAllocator、CsoCache、HostResolve、CompositeResolver [P2+] +MobileGL/MG_Backend/MGPipe/ PipeInputs.h + MGPipeImpl_DirectGLES/DirectVulkan.cpp [P1+] +MobileGL/MG_Remote/ 仅 MOBILEGL_BUILD_DISAGGREGATED + Protocol/ protocol.fbs generated/protocol_generated.h mg_protocol_base.h [P0] + Transport/ ITransport InProcessTransport Framing Ring ShmSegment(+Posix/Win32) FdPassing Doorbell WireLog [P0] + SocketTransport [P6] + Client/ PipeEmitter EmitTables BackendObject_Remote CapsMirror ShadowArena PersistentMapTracker GpuWritePending Surface/{X11,Win32,Android,Headless} [P5+] + Server/ PipeApplier PipeObjectTables IndexHostMirror ServerLoop ReplyPool EventRing ServerMain [P5+] + ServerJni.cpp [P12] +``` + +- CMake option(`CMakeLists.txt:23`):`MOBILEGL_BUILD_DISAGGREGATED`(默认 OFF)追加 `MG_Remote/**` 进 `SOURCE_FILES`(`CMakeLists.txt:454-469`)并定义 `-DMOBILEGL_BUILD_DISAGGREGATED=1`;OFF 时 `MG_Config::Transport` 是 `constexpr Monolith`,`Init.cpp` 的分支编译期消失。`3rdparty/flatbuffers/include` 缺失时把 option 强制回 OFF 并 `message(WARNING)`(`CMakeLists.txt:440-451`)。`MobileGL` 与 `MobileGL_s` 都拿到同一份源。`MG_Test/Wire` 只在该 option 下注册(`MobileGL/MG_Test/CMakeLists.txt:93-95`)。 +- `MOBILEGL_BUILD_DISAGGREGATED_INPROC`(尚不存在):CI/调试形态,隐含开启前者,额外加角色隔离 shim。MGPipe 让需要角色分身的进程全局从四个(`pGLContext`、`gBackendFunctionsTable`、`pActiveBackendObject`、`pDefaultFramebufferInfo`)降到**两个**(pipe 表与 `pActiveBackendObject`):server 角色不再读 `pGLContext`(三道纯度门就是这个断言),`pDefaultFramebufferInfo` 由保留句柄 `{0,1}` + `OnSurfaceChanged` 取代。两个 shim 都不在 GL 热路径的每次访问上——这是 `inproc` 从"成本可疑的实验"变成"可交付形态"的直接原因(Android 上 dlopen 的库无法可靠用 initial-exec TLS,`pGLContext->` 在 `MG_Impl` 有 1494 处)。 +- `MobileGLServer`:桌面 `add_executable` 链接 `MobileGL_s`;Android `add_executable` 改名 `lib*.so` 链接共享 `MobileGL`,由 AGP 打进 `jniLibs`。 +- `MOBILEGL_TRANSPORT = monolith | inproc | spawn | unix: | pipe:`(P5 起在 `ConfigLoader.cpp` 解析),免费换来 ctest `ENVIRONMENT` 变体、trace-replay 的 `setenv` 块、FCL 用户可编辑 env、plugin APK 的 V2 开关表、`/data/local/tmp` CTS 路径。 +- 测试接线陷阱:ctest `ENVIRONMENT` 是替换而非追加、`;` 必须转义、property 覆盖 job env,必须用 `mgl_itest_join_environment(... ${MGL_ITEST_COMMON_ENV})` 构造;`add_trace_replay_test` 加 `SPLIT` 后缀(否则与同 case+backend 重名)并加 `-DTRACE_TRANSPORT=` 给 `run_trace_case.cmake` 消费。 +- CI(`.github/workflows/test.yml:809` `pipe-gates`,P0 已落地):`gen_pipe.py` 重生成 + diff;`MG_Backend`/`MG_State` 下禁止 stdio 插桩的 grep 门;`gen_pipe_dirty_surface.py --summary`(信息性,P1 成门);`check_doc_citations.py`(警告级,文档定稿后 `--strict`)。独立 job `flatc-check`。后续:`include-graph-check`(P0.5)、`monolith-symbol-report`。 + +## 附 A:开关 + +CMake: + +| 选项 | 默认 | 状态 | +|---|---|---| +| `MOBILEGL_BUILD_DISAGGREGATED` | OFF | 已落地 | +| `MOBILEGL_BUILD_SERVER_SPIKE` | OFF(仅 Android) | 已落地(spike A,非出货) | +| `MOBILEGL_BUILD_DISAGGREGATED_INPROC` | OFF | 计划(P5) | +| `MOBILEGL_PIPE_VERIFY` | OFF | 计划(P1;构建期开关,编译进 `SnapshotFromGLContext()` 与 G4 比对器,P13 后保留) | +| `MOBILEGL_PIPE_LEGACY_MEMOS` | ON(P2..P13) | 计划(编译期臂) | +| `MOBILEGL_FLATC_EXECUTABLE` | 空 | 已落地(只服务 `flatc-check`) | +| `MOBILEGL_BAKED_INTERNAL_SHADERS` | ON(P7+) | 计划 | + +运行时,MGPipe(`MobileGL/Config.h:319-358`,`MobileGL/ConfigLoader.cpp:245-256`,P0 已落地): + +| 变量 | 默认 | 说明 | +|---|---|---| +| `MOBILEGL_PIPE_PUSH` | 0 | 子系统位图(0 = 全 pull),含一位关闭 CSO 内容寻址;十进制或 `0x` | +| `MOBILEGL_PIPE_VERIFY` | 0 | 逐 draw 逐字段影子比对 | +| `MOBILEGL_PIPE_STATS` | 0 | 边界计数器(§附 B) | +| `MOBILEGL_PIPE_LEGACY_MEMOS` | ON | 三态读取,只有显式 falsy 才关 | +| `MOBILEGL_PIPE_TEXEL_RETAIN_MB` | 0(0–4096) | 纹理拉取保留 LRU | +| `MOBILEGL_PIPE_INDEX_MIRROR_MB` | 64(0–4096) | 索引宿主镜像预算 | +| `MOBILEGL_PIPE_STATS_PERIOD` | 120(1–10⁶) | 每多少帧一条汇总行 | +| `MOBILEGL_PIPE_STATS_FILE` | 空 | teardown 时的 JSON 转储路径 | + +运行时,传输与 IPC(计划,P5+):`MOBILEGL_TRANSPORT`(monolith)、`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,Phase 2 起)、`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` 校验器)。既有负面对照开关(`MOBILEGL_ESPRYT_DISABLE_{UBO,UNPACK,UPLOAD}_RING`、`_INVALIDATE_FLUSH`、`MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION`、`MOBILEGL_COHERENT_AS_FLUSH`)全部保留。 + +## 附 B:边界计数器(`MobileGL/MG_Util/Metrics/PipeStats.h:46-122`,P0 已落地) + +关闭时每站点一次全局 load + 一条永不命中的分支。字节类:`stage-buffer`、`stage-texture`、`stage-ubo-global`、`stage-ubo-named`(只有 Magma 贡献,D-B8 的不对称)、`stage-vertex-client`、`stage-index-client`、`stage-indirect-cmd`(Espryt 独有)、`persistent-map-push`(P0 未接线:monolith 期不存在推送)、`residual-value-block`(占位)。调用类:`draws`、`accessor-calls`(实际执行的 GLContext accessor 次数,在约 10 个热入口做静态计数,是**下界**)、`texture-upload-emissions/box/rect/jobs`。六个 memo 门(`SyncRenderState` 早退、`SyncNeccessaryTextures` 键比较、`CurrentUnitBindingsEpoch` 快门、`TrySetupDrawFastPath`、pipeline memo、`ApplyDynamicDrawStateTail`)各计 hit/miss。每 draw payload 直方图(24 桶)已实现,等第一个发射器接入。每 `MOBILEGL_PIPE_STATS_PERIOD` 帧一条 `MGPipe stats:` 汇总行(`MGLOG_I`),`TRACY_ENABLE` 下逐帧 `TracyPlot`,teardown 时可选 JSON。站点清单——哪些路径**没有**接线——写在 `MobileGL/MG_Util/Metrics/PipeStats.cpp:16-100`,那份清单是契约。 diff --git a/docs/Disaggregated/MEASUREMENTS.md b/docs/Disaggregated/MEASUREMENTS.md new file mode 100644 index 000000000..5551e1cba --- /dev/null +++ b/docs/Disaggregated/MEASUREMENTS.md @@ -0,0 +1,96 @@ +# P0 实测 + +> 每张表都写明设备、提交与命令,以便复现。设备:`35d0befa` = Xiaomi 24129PN74C,Adreno 830,Android 16;`3B159D009VZ00000` = Oppo PLG110,Mali,Android 16(ColorOS)。设备运行日期 2026-09-05。设备锁协议照旧。 + +## 1. Spike A — 从应用自身进程 exec 第二个原生可执行文件 + +问题:Android 上能否把 server 以 `lib*.so` 打进 APK,并从应用自己的 `untrusted_app` 域 `fork`+`execve` 它(`adb run-as` 跑在别的域,证明不了)。 + +| 设备 | 结果 | +|---|---| +| Adreno 830 | **OK**。父进程 `u:r:untrusted_app:s0:c173,c257,c512,c768` `fork`+`execve` `/libMobileGLServer.so` → 子进程 pid 31348,exit 0;子进程 SELinux `u:r:untrusted_app:s0:c173,c257,c512,c768`(同域同 category);marker 文件、stdout 捕获、报告全在;`execErrno=0`;窗口内**零 avc denial** | +| Mali | **OK**,同形:子进程 pid 28433,exit 0,`execErrno=0`,`u:r:untrusted_app:s0:c94,c257,c512,c768`,零 avc denial | + +主机侧已证的三条(随 `8a239177`):AGP 会把改名成 `lib*.so` 的 `add_executable` 打进 `lib/arm64-v8a/`,前提是把 `RUNTIME_OUTPUT_DIRECTORY` 重定向到 `CMAKE_LIBRARY_OUTPUT_DIRECTORY`;`posix_spawn` 在 minSdk 26 不可用(bionic API 28 起),出货臂是 `fork`+`execve`;应用进程 stdout/stderr 是 `/dev/null`,子进程用 marker 文件证明自己活过。 + +- 代码:`tools/spikes/server_stub/main.cpp`(stub:打印并写 marker 自己的 pid/uid/SELinux 上下文)、`android-plugin/app/src/trace/cpp/spawn_spike.cpp`(`RunSpawnSpike`)、`CMakeLists.txt:784-808`(`MOBILEGL_BUILD_SERVER_SPIKE`)。 +- APK:`p0-spike-a-android/trace-debug-spike-on.apk`(在 `30d7595b` 构建,与 `7ef7c7e5` 源码相同)。 +- ColorOS 陷阱:首次 `adb install` 一个未安装的包会卡在 `com.oplus.appdetail InstallGuideActivity` 确认页,直到点"继续安装"(1272×2772 面板上 `input tap 353 2349`);同签名重装静默通过。另一台设备上一个外来签名的 trace APK(versionCode 26080769)会让 `install -r` 报 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`,需先卸载。 +- 42-device.sh 的 env 透传 A/B 腿在该 ROM 上跑不了(`run-as sh -c 'cat > files/…'` 被拒);透传由下面的 stats 基线端到端证明(`--env MOBILEGL_PIPE_STATS=1` 必须在 `mobilegl.log` 里产生 `MGPipe stats` 行)。 + +## 2. Spike B — 跨进程外部内存分档 + +问题:`AcquirePersistentMap` 背后的内存能否共享给另一个进程并在那里映射,两个后端各走哪条路。探针 `tools/spikes/extmem_probe/`(`39f982e6` 源码,arm64,`adb shell` = `u:r:shell:s0` 域),4 MiB payload,64 KiB 同判决。每一行都取一次真 GPU 访问(`vkCmdCopyBuffer` + `vkCmdFillBuffer` + host-read barrier)并两侧字节校验才算 OK。 + +| 路线 | Adreno 830 | Mali | +|---|---|---| +| T1-opaque-fd(server 导出 `VkDeviceMemory` fd,client 裸 `mmap` + 导入) | **OK** 完整往返含 GPU 访问(`/dmabuf:system`,dedicatedOnly=1) | UNSUPPORTED(`vkCreateBuffer(external)=VK_ERROR_INVALID_EXTERNAL_HANDLE`,advertisedExportable=0) | +| T1-dma-buf | UNSUPPORTED(`VK_EXT_external_memory_dma_buf` 缺) | UNSUPPORTED | +| T1-gles-memobj-fd(`GL_EXT_memory_object_fd` 导入导出的 fd) | **FAIL**:导入 + `glBufferStorageMemEXT` 接受(`GL_NO_ERROR`)但每次 `glMapBufferRange` → `GL_INVALID_OPERATION`(persistent 与 plain 都是);`GL_DEVICE_UUID` 不可读 | UNSUPPORTED(扩展字符串缺,入口点可解析) | +| **T0-ahb-blob-transfer**(client 分配 `AHardwareBuffer` BLOB → socket 交接 → server Vulkan 导入 + GL 导入) | **OK** 全链:cpu-lock、vk-import+map、GPU copy/fill、GL map persistent+coherent、写回 client 全部字节校验 | **OK** 全链,判决相同(glPersistentCoherent=1,gpuRan=1) | +| T3-external-memory-host(`VK_EXT_external_memory_host`) | UNSUPPORTED(扩展缺) | PARTIAL:导入 + map 往返,但 **GPU 写对宿主映射不可见**(只读档) | +| T3-memfd-cross-process / client-memfd-server-import | UNSUPPORTED | OK / PARTIAL(同样的 GPU 只读 caveat) | + +**P11 的分档决定**:唯一在两台设备、两个后端上都是完整读写的档是 **T0**——client 分配 `AHardwareBuffer` BLOB,server 以 `VK_ANDROID_external_memory_android_hardware_buffer`(Magma)或 `EGL_ANDROID_get_native_client_buffer` + `glBufferStorageExternalEXT`(Espryt)导入,两侧 persistent+coherent 映射。Adreno 另有 T1(Vulkan 路径);Mali 无任何 server 导出路线,host-pointer 导入只读。Caveat:运行域是 `shell` 不是 `untrusted_app`;AHB 的 socket 交接是每个与 SurfaceFlinger 共享 buffer 的应用都在走的路径,域风险在 memfd/opaque-fd 腿上。 + +复现: + +```sh +ANDROID_NDK=$HOME/android-sdk/ndk/27.3.13750724 tools/spikes/extmem_probe/build_android.sh /tmp/extmem-build +S=; adb -s $S push /tmp/extmem-build/extmem_probe /data/local/tmp/extmem_probe \ + && adb -s $S shell "chmod 755 /data/local/tmp/extmem_probe && /data/local/tmp/extmem_probe; echo EXIT=\$?" | tee out-$S.txt +``` + +判决语义(OK / PARTIAL / FAIL / UNSUPPORTED)与逐腿 trace 格式见 `tools/spikes/extmem_probe/README.md`。主机构建(lavapipe)用来证明探针本身报得对:T1/T3 在 lavapipe 上全 OK;T1-gles 在 llvmpipe 上 `GL_OUT_OF_MEMORY` 是 Mesa interop 缺口,不是探针缺陷。 + +## 3. 边界计数器基线(双设备、双后端、四条 trace) + +`MOBILEGL_PIPE_STATS=1` 经 retrace 通道的 `--env` 透传;trace APK 从 `7ef7c7e5` 构建,spike OFF。取每次运行的**最后一个完整 120 帧窗口**。accessor/draw 与 memo 门数字是软件确定的(同一 trace 在两台设备上完全相同:它们数的是代码路径不是硬件),只有墙钟/CPU 时间随设备变。 + +| trace(窗口内帧数) | 后端 | draws/f | **acc/draw** | buf B/f | tex B/f(发射 box/rect) | ubo-global B/f | **ubo-named B/f** | memo 门(hit/miss) | +|---|---|---|---|---|---|---|---|---| +| `minecraft-1.21.4-in-world`(360) | Espryt | 91.6 | **9.28** | 13.5 K | **635 K**(185 box / 0 rect) | 16.7 K | 0 | ers 9257/2577,etl 10538/1296,eub 10720/1114 | +| `minecraft-1.21.4-in-world`(360) | Magma | 91.6 | **8.56** | 13.5 K | 39.9 K(97 box / 89 rect) | 16.7 K | 0 | mfp 0/10994,mpm 9240/1754,mdt 9120/1874 | +| `minecraft-1.21.4-fabric-iris-bsl-in-world`(120,memo 冷) | Espryt | 23.2 | 21.04 | 32.6 K | 8.8 K | 1.8 K | 0 | ers 1958/1843,etl 722/3079 | +| `minecraft-1.21.4-fabric-iris-bsl-in-world`(120,memo 冷) | Magma | 23.2 | 11.26 | 313 K | 256 K | 1.8 K | 0(vtxc 1.7 K) | mpm 1890/895,mdt 1573/1212 | +| `improved-transparency-minecraft-26.3`(1200) | Espryt | 1320 | **8.44** | 333 K | 0 | 0 | 0 | ers 156925/2791,etl 148606/11110,eub 148246/11470 | +| `improved-transparency-minecraft-26.3`(1200) | Magma | 1320 | **6.53** | 173 K | 0 | 0 | **331 K** | mfp 21360/137036,mpm 134421/2615,mdt 156611/1785 | +| `minecraft-1.21.1-neoforge-create-indirect-in-world` | 两者 | — | — | — | — | — | — | 两台设备都失败(§5),且不足 120 帧 | + +门缩写:ers = `EsprytRenderState`,etl = `EsprytTextureSyncList`,eub = `EsprytUnitBindingsEpoch`,mfp = `MagmaDrawFastPath`,mpm = `MagmaPipelineMemo`,mdt = `MagmaDynamicTail`(`MobileGL/MG_Util/Metrics/PipeStats.h:46-122`)。`accessor-calls` 是约 10 个热入口的静态计数,是每 draw accessor 数的**下界**(站点清单 `MobileGL/MG_Util/Metrics/PipeStats.cpp:16-100`)。 + +对设计的读法: + +- **真机稳态动态 accessor 成本是每 draw 6.5–9.3 次**(预测区间 10–25 的下沿;llvmpipe 的 15.5/20.7 是 memo 冷的)。推送要打败的是 ~8 次 accessor + memo 探测,不是 124/169 的静态调用点数。GO/NO-GO 的 tracker 绝对 ns 上限从这里定。 +- **`stage-ubo-named`(D-B8)**:Magma 在 26.3 世界每帧重打包 **331 KB** 具名 UBO 字节,Espryt 直接绑定为 0——host payload 决定的第一个真数字。 +- **union box vs region list**:vanilla 世界同样 185 次发射,Espryt 的整 box 路径移动 **635 K** 纹素字节/帧,Magma 的 rect 路径 **40 K**,16×——"server 选上传形状"这一条的量化依据(Mali 侧的 +6 ms/frame 作业数悬崖在另一个方向)。 + +复现(一台设备一次;两台必须**串行**,见 §4): + +```sh +ANDROID_SERIAL= MSYS_NO_PATHCONV=1 \ +python3 tools/trace_replay/run_android_retrace_local.py \ + --case minecraft-1.21.4-in-world --backend DirectGLES \ + --env MOBILEGL_PIPE_STATS=1 --env MOBILEGL_PIPE_STATS_PERIOD=120 +# 数字在结果目录的 mobilegl.log 里,grep 'MGPipe stats:',取最后一个完整窗口 +``` + +## 4. 桌面数据点与语料事实 + +- **llvmpipe / lavapipe 动态 accessor**(`GuiBatchScenario`,14 帧 / 26 draw,memo 冷):Espryt 20.65 / Magma 15.54 次/draw——落在预测区间内,且因场景太短偏高;真机稳态数字见 §3。 +- **dirty-surface 面**(`python3 scripts/gen_pipe_dirty_surface.py --summary`,本树):`MG_Impl/GLImpl` 41 个文件,926 次 mutator 调用,73 个不同 mutator;92 次(36 个即时发布点、7 个 mutator,836 次里绝大多数是 `RecordError`)位于同函数内也到达后端的入口,其余 834 次由紧随的 verb 发布。映射表是 73 条目的问题。 +- **读点覆盖**(`python3 scripts/gen_pipe.py`):71 条调用(11 screen / 60 context)、63 个 verify payload、61 个 `PipeInputs` 字段;477 行后端读点清单 → 299 调用、5 client 自答、6 反向通道、167 结构性句柄、**0 UNMAPPED**。 +- **OOM 探测惯用法**:41 个 trace fixture 中 0 例——全部语料只有 9 次 `glRenderbufferStorage` 调用散在 5 个 fixture,无一在其后 3 个调用内跟 `glGetError`;语料里真实的成功性检查是 `glCheckFramebufferStatus`。→ `glRenderbufferStorage*` 不 ack。 +- **`FramebufferSrgb` / `DepthClamp`**:`FramebufferSrgb` 的六个后端读点全部消费一个编译期常量 `false`,`DepthClamp` 零读点;两者的 `glEnable` 落到 `RenderState.cpp` 的 `default:` 分支既不存储也不报 `GL_INVALID_ENUM`;41 个 fixture 无一开启任一项(补真存储不会改动任何既有 fixture 的输出)。 +- **`GetIntegeri_v` 族**:Espryt 实现里是 `GetIntegeri_v` 的 9 个分支 + `GetInteger64i_v` 的 2 个(不是"15 个 case");`GL_COMPUTE_WORK_GROUP_SIZE` 由 `GL_Program.cpp` 用 `ProgramObject::GetComputeLocalSize` 纯前端回答。 +- **payload 尺寸**(`MG_Pipe/MGPipeTypes.h` 的 `static_assert`,arm64 与 x86-64 一致):`MGPDrawInfo` **56**、`MGHostSpan` 32、`MGPBindRenderState` **12**、`MGPResourceDesc` 88、`MGPFramebufferState` 304、`MGPProgramDesc` 192、`MGPSubData` 72、`MGPPixelPackState` 28、`ResidualValueBlock` **1248**(其中 `RenderStateParameters` 1168)。`SEG_CMD` 按 56 B 固定头定尺:MC 帧 1000–4000 draw 时每帧 56–224 KiB 头字节。 +- **persistent map 采纳的既有基线**(`dev`,MC 26.3,Adreno):≥16 MiB 可变 store 定义时采纳为 coherent persistent map 后 p99 163→21 ms、稳态 40→115 fps、省 ~400 MB。P11 的回归上限对着它。 +- **Mali 上传作业数悬崖**(Espryt 代码注释记录的既有实测):~100 个精灵 rect 对一个 union box 是 +6 ms/frame。 + +## 5. Harness 事实与陷阱 + +1. trace app 从不到达 `MobileGL::DestroyImpl`,所以 `MOBILEGL_PIPE_STATS_FILE` 的 JSON 转储在设备上永远不会写——只有 `mobilegl.log` 里的周期汇总行;短于一个周期的 trace 什么都不产出。`MOBILEGL_PIPE_STATS_PERIOD`(`458ccde1`)为此而加:需要数字的运行把它设到足够小。 +2. `run_android_retrace_local.py` 每棵树共用一个 `.trace-work/android-retrace-result` 根并在每次调用时 `rmtree`,所以两台设备必须从一棵树**串行**跑。 +3. `--env` 值里嵌入的 `/data/...` 会被 runner 的 bash.exe 做 MSYS 路径转换(`MSYS2_ARG_CONV_EXCL="/data/*"` 只覆盖开头匹配)——用 `MSYS_NO_PATHCONV=1` 跑。 +4. `coherent_as_flush` 管线完好:`--ez coherent_as_flush true` → `trace_replay_core.cpp` 的 `setenv`,独立于 `--env` 透传。 +5. **`minecraft-1.21.1-neoforge-create-indirect-in-world` 在两台设备上都失败**(Adreno 830:Espryt ~4.5 分钟后黑帧,Magma 纹理上传提交时 `VK_ERROR_DEVICE_LOST`;Mali:SSIM 0.85 / 0.45)。Adreno 830 上用 `dev@81b17c0b` 基线 APK 复现,**是基线就有的问题,不是本分支造成**;它是 P3a/P8 验收清单里的用例,需先在 `dev` 修。 diff --git a/docs/Disaggregated/PLAN.md b/docs/Disaggregated/PLAN.md deleted file mode 100644 index 39b8c2881..000000000 --- a/docs/Disaggregated/PLAN.md +++ /dev/null @@ -1,2470 +0,0 @@ -# MobileGL 前后端进程拆分实施计划(MGPipe) - -> 状态:设计定稿 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.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.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.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(默认,延迟叠加分析见 §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.1 今天的边界有七个面(数字按工作树复核) - -**(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` 完全不碰 GL)、`GetProgramiv`(`:197`)。**(P0 实测修正)"15 个 case"是错数**:`:7264-7386` 是 `GetIntegeri_v` 的 9 个分支加 `GetInteger64i_v` 的 2 个,共 11 个。**`GetInteger64i_v` 与 `GetProgramiv` 两个表项已在 P0 从 `GLFunctionsTable` 连同两个 backend 的实现一起删除**(提交 "retire the two frontend queries that were never asked"),本节的表项计数是删除前的基线数。 - -**这 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。 - -**(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`)。**在 MGPipe 里这个面的 replay 义务不存在**(server 没有第二份前端状态可 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 直接照抄它的形状(§3.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 的状态机。** 上面**没有一样东西需要删除或重写**——需要改的只是它们**怎么知道**这些事实,以及它们的 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 类)**,全部在 §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 门控: - -| 路径 | 稳态实际做的事 | -|---|---| -| `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 的对照表必须按动态口径重写(已改)。 - -**(P0 实测修正)第一个实测数据点:预测成立。** P0 的动态 accessor 计数器在 lavapipe / llvmpipe 上跑 `GuiBatchScenario`(14 帧 / 26 draw),得到**每 draw 动态 accessor 调用数:Espryt 20.65、Magma 15.54**——两者都落在本节预测的 10-25 区间内,且都远低于 124/169 的静态调用点数。**告诫两条**:(a) llvmpipe 上 pipeline memo 是**冷的**(场景太短,未进入真正的稳态命中率),所以这两个数偏**高**而不是偏低,真机稳态只会更靠近区间下沿;(b) **两台设备的数字仍然欠着**(设备锁),第 43 天的 GO/NO-GO 绝对 ns 阈值必须等真机基线,不能拿这组桌面数字定。 - -**推论**: -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 因此有了一个可证伪的预测而不只是定性主张。 - ---- - -## 3. 接口设计:MGPipe - -### 3.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 新增:无依赖的共享值类型(见 §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 必须解析到存在的行 -``` - -`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,kNone) /*P0:非 ack*/\ - /* … 共 68 项(P0 实测,非"约 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 -}; -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 完整调用目录 - -**(P0 实测修正)落地的 `PipeCalls.def` 是 68 条**唯一调用,不是"约 74"。按 `.def` 的 Class 列分组:**screen 10、ctx-query 6、CSO 13、`set_*`(`kCtxState`)17、object(`kCtxObject`)9、verb(`kCtxVerb`)13**。旧数虚高有三个来源,本节各小标题下逐条标出:(1) `bind_sampler_states` 与 `set_sampler_views` 在 CSO 组与 `set_*` 组**各记了一次**;(2) query 族被并进 screen 一起统计,而 §3.3 已经把 query 命名空间**给了 context**;(3) transfer 标 12,正文与速查表实际只列出 11 条。 - -**为什么这个算术是承重的**:`PipeCalls.def` 是**唯一真相源**,而**线上 opcode 就是一行在文件里的位置**——所以这份目录必须是**唯一记录的集合**,同一个调用在两个组里各出现一次会让 opcode 编号与目录永久错位(且 G3 的 `static_assert` 抓不到,它只校验单条记录的尺寸)。 - -**`kCtxState` 为什么是 17**:16 个 `set_*` 加上迁移期临时的 `set_residual_value_state`。**`set_texture_params` 不在其中**——它按资源寻址,Class 是 `kCtxObject`。 - -#### 3.4.1 `MGPipeScreen`(14 项 → **P0 实测 10 项**) - -| 调用 | 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` | 原样保留为虚函数(罕见) | - -**(P0 实测修正)本表的 query 族 6 项不属于 screen。** §3.3 已把 query 的命名空间划给 context,落地的 `.def` 因此给它们 `kCtxQuery`,独立成组。screen 组是余下的 10 项:`get_caps`、`resource_create`/`_respecify`/`_destroy`、`map_persistent`/`unmap_persistent`、`fence_create`/`_status`/`_wait`/`_destroy`。EGL 生命周期 8 项留在虚函数上,本来就不在 `.def` 里。 - -**`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 项 → **P0 实测 13 项**) - -`create/bind/delete` × { `render_state`, `vertex_elements`, `sampler`, `sampler_view`, `shader` }。payload 见 §3.5.2-3.5.5。 - -**(P0 实测修正)13 而不是 15**:`create`/`delete` × 5 = 10,`bind` 只有 3(`render_state`、`vertex_elements`、`shader`)。sampler 与 sampler view 的绑定**就是**下一节的 `bind_sampler_states` 与 `set_sampler_views`(它们是带 start/count 的批量绑定,不是单条 CSO bind),在两组各记一次是"约 74"里最大的一处重复计数。 - -#### 3.4.3 `MGPipeContext` — `set_*`(17 项,v2 从 14 增至 17;**P0 实测 `kCtxState` 亦为 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。 - -**(P0 实测修正)`kCtxState` 的 17 项这样凑出来**:本表 17 行里 `set_texture_params` 被划成 `kCtxObject`(它按资源寻址,见 §3.4.3 上一段"为什么纹理参数不能只挂在 sampler view 上"——它的载体是 `res`,不是 context),剩 16 个 `set_*`,再加迁移期临时的 `set_residual_value_state` = 17。**巧合的是它与本节旧标题同为 17,但成分不同**,改动这张表时别把两者当同一个数。 - -#### 3.4.4 `MGPipeContext` — transfer(12 项 → **P0 实测正文只有 11 条**) - -`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 项。 - -**(P0 实测修正)"transfer"在 `.def` 里不是一个 Class。** 标题的 12 是虚数——附 A 的速查表实际列出 11 条。落地的 `.def` 按**寻址方式**给它们分类:按资源寻址的(`resource_subdata`、`renderbuffer_storage`、`set_texture_params` 等)进 `kCtxObject`(该组共 9 项),按上下文寻址的动词(`blit`、`clear`、`read_pixels` 等)进 `kCtxVerb`(该组共 13 项)。**统计时按 Class 数,不要按本节的功能分组数**,否则又会重复计数。 - -#### 3.4.5 `MGPipeContext` — 命令(10 项;在 `.def` 里与 transfer 的动词合成 `kCtxVerb` 13 项) - -```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`)。**(P0 实测修正)`GL_COMPUTE_WORK_GROUP_SIZE` 不是后端答案,别把它放进 `MGPCaps`**:`MG_Impl/GLImpl/Program/GL_Program.cpp:928-946` 用 `ProgramObject::GetComputeLocalSize` 自己回答它,没有链接 compute stage 时抛 `INVALID_OPERATION`——它是一个**程序反射查询**,纯 client。真正属于后端、且确实带下标的只有 **`GL_MAX_COMPUTE_WORK_GROUP_COUNT` / `GL_MAX_COMPUTE_WORK_GROUP_SIZE`**(读点 `GL_Getter.cpp:1160` 与 `MG_Util/ShaderTranspiler/CompileEnv.cpp:134-138`),它们以 **compute 限制**的身份进 `MGPCaps`,与 `DynamicBackendParameters` 的其余标量同列。 -- **(P0 实测修正)`GetInteger64i_v` 与 `GetProgramiv` 的退役已在 P0 落地**(提交 "retire the two frontend queries that were never asked"):两个 `GLFunctionsTable` 表项与两个 backend 的实现均已删除。本文其余处(§8.6-3、§14 P0)把它写成待办的地方,读作**已完成**。 -- `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`)。替换成一个 **56 B**(**P0 实测,不是 ~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` 的定尺依据)。 - -**(P0 实测修正)定尺用的实测布局**(P0 骨架编译产物的 `sizeof`,64 位 arm64/x86-64 一致;本表取代此前散落各处的估数): - -| 类型 | 实测字节 | 用途 | -|---|---|---| -| `MGPDrawInfo`(固定头) | **56**(此前写 ~48) | 每 draw;`MGHostSpan` 的 **32 B 只在 `kHasUserIndices` 时**进变长尾 | -| `MGHostSpan` | 32 | 见上;不进固定头 | -| `MGPBindRenderState` | **12** | 每次 CSO 绑定 | -| `RenderStateParameters` | **1168**(此前写 ~1.2KB) | server 侧每 context 一份 working 副本;**不整块过线** | -| `ResidualValueBlock` | **1248** | 迁移期 `set_residual_value_state` 的 payload 上界,`static_assert` 逐阶段下调至 0(§5.3、P13) | -| `DynamicBackendParameters` | **328** | `MGPCaps` 的主体 | -| `MGPCaps` | **384** | 握手后一次 | -| `MGPipeScreen` | **80** | 函数指针表(进程内,不过线) | -| `MGPipeContext` | **464** | 同上 | -| `PixelStoreParameters` | **28** | `set_pixel_pack_state` 的整块 payload | - -**两条直接后果**:(a) `SEG_CMD` 的定尺按 **56 B 头**算,不是 48——MC 帧 1000-4000 draw 时这是每帧 8-32 KiB 的差额;(b) `ResidualValueBlock` 的 1248 B 是**迁移期每 draw 最坏情况**的额外 payload(`RenderStateParameters` 1168 占了绝大部分),这解释了为什么它的退役绊线要按阶段下调而不是一次性删除。 - -**`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: - // 每一类 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) -}; -``` - -**这八个入口不是随手列的**:`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` 的全绿验收因此不可达。 - -#### 4.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 模型里累积(§6.3 的发射游标),在**下一个 validate / flush 点**把合并后的形状作为**一条** `resource_subdata`(带 union box + region 列表)发出。`MOBILEGL_PIPE_STATS` 必须把逐帧 `resource_subdata` 发射次数单列一类,并在 MC 动画图集 fixture 上设上限。 - -**稳态成本**:见 §13.2(v2 已按动态口径重写)。 - -### 4.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 直接失败**。这是 B-R6 的第四层。 - -**(P0 实测修正)这个面到底有多大——已用 `gen_pipe_dirty_surface.py` 量过。** `MG_Impl/GLImpl` 下共 **926 次 `pGLContext` mutator 调用**,但它们只落在 **73 个不同的 mutator** 上。其中 **92 次(7 个不同 mutator,绝大多数是 `RecordError`)位于同时会走到 backend 的函数里**——只有这批需要"在同一个 GL 入口内既改状态又已经发过消息"的顺序推敲;**其余 834 次由紧随其后的 verb 发布**,不需要各自的即时推送。 -**结论:P1/P2 的 dirty-surface 映射是一个 73 条目的问题,不是 926 条目的问题**,映射表的规模因此可控(每条目一行"mutator → 必须 bump 的聚合世代"),而 CI 门的成本也是按 73 条计。**调用点数仍要监控**(新增调用点若落在未映射的 mutator 上必须失败),但它不是工作量口径。 - -**三个回绕的 `Uint16` 在 tracker 边界加宽。** `m_lastPushed[]` 是 tracker 自己的字段,加宽到 `Uint32`/`Uint64` **不需要改 `MG_State` 一行**;同时 handle 与它同行过线。**回绕在 tracker 本地是无害的**(一次回绕造成一次多余的重推,永不漏推),何况集合 hash 抑制器会把多余重推吞掉。 - -### 4.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 拿不到的异步收益。 - -### 4.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` 是普通应用代码。 - -### 4.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 维度**(§3.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 集合判定。 - -### 4.6 对象生命周期、共享组与 composite pipeline program - -#### 4.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}`。 - -#### 4.6.2 共享组 - -v1:一个 screen、一个 context、一个扁平 handle 空间、一条 flow。`eglMakeCurrent` 是 flow 所有权转移,在既有 `EGLOperationMutex`(`EGLImpl.cpp:241`)下发射——**顺手修今天不取该锁的两个入口**:`ReleaseThread`(`:341-350`)与 `SwapInterval`(`:435-450`)。 - -#### 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`。 - -tracker 调它,拿到 `SharedPtr`,推**一个 handle**。合成体没有 GL name,但**有 lifetimeId**,slot 从 `ShaderCso` 的保留高位段分配。生命周期:pipeline cache 淘汰该条目时释放 slot、`gen++`、发 `delete_shader_state`——`CompositeResolver.cpp` 里三行。 - -**合成体从不过线、从不被重新实现,server 侧不需要任何"解析后的 draw program"钩子。** 副带收益:阻塞的 `JoinLinkAndSpirv()` 彻底离开 server 的 draw path。 - -### 4.7 program artifacts 与全局 UBO scratch - -**`create_shader_state` 的 payload 是 SPIR-V + 全结构体反射归档**(§3.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 每帧至多一次"。它小、每次 `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` 问题,拆分不得借机"顺手修"**——那会改变基线并让逐名对比失去意义。列入开放问题。 - ---- - -## 5. 后端状态机改造 - -### 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)。 - -**(P0 实测修正)一个活的陷阱:`EndTransformFeedback` 槽位的 null 被当成能力位在用。** -`MG_Impl/GLImpl/Drawing/GL_Drawing.cpp:1253-1256` 读的不是这个 hook 的**功能**,而是它的**空与非空**:槽位非空即被解释为"该 backend 按 GL 的顶点序捕获,因此跳过 `FixupGsStripCaptureOrder`"。也就是说**任何**出于别的理由注册了 `EndTransformFeedback` 的 backend,会**静默**丢掉几何阶段的 strip 重排——没有编译错误、没有日志、只有错序的捕获结果。这是 §3.1 那条"null 项表示未实现、前端回退"的惯例被**反向**使用了一次:它在这里表达的是一个正向能力断言。 -**MGPipe 下必须转成显式能力位**(例如 `kCapDriverOrderedXfbCapture`,与 §3.4.1 的 `callMask` 同列),由 backend 主动声明,`GL_Drawing.cpp:1253-1256` 改读该位而不是测空。**列为 P8/P9 项**——P8 触到 XFB 动词、P9 触到反向通道与 `on_xfb_scatter_ready`,两处都会重排这段代码;在此之前它是 monolith 上一个真实存在、只是暂时没人踩到的地雷。 - -### 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`,并把这条事实写进文档(避免后人以为是遗漏)。 -- **(P0 实测修正)`kNeedsAck` 只标一项**:`glBufferStorage`(真同步)。**`glRenderbufferStorage*` 不标**,保持惰性/异步分配。 - **证据**:41 个 trace fixture 里 OOM 探测惯用法出现 **0 次**——全部语料只有 **9 次 `glRenderbufferStorage` 调用、分布在 5 个 fixture**,且**没有一次**在其后 3 个调用之内跟 `glGetError`;语料里真实的成功性检查是 `glCheckFramebufferStatus`,而它本来就在 client 侧作答。因此整条 ack 路径连同它的往返一起省掉,`RenderbufferStorage` 在 `PipeCalls.def` 里的 flags 是 `kNone`。 -- 其余错误一律晚到,走有序的 `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`/`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`),结果它的数据面在唯一重要的平台上**一个字节都过不去**。**这条是 P0 的第一优先级。** - -**`SEG_SHADOW` 块的退休规则**:§7.4 的 64KiB 块发送水位只解决"覆盖一个**活着的** shadow";它没说怎么**释放**一个 shadow。`glDeleteBuffers` 或 `glBufferData` 重定义会释放/重分配 `SEG_SHADOW` 的 arena 块,而携带 `{segId, offset, size}` 指向该块的记录可能还没被 apply——server 于是读到另一个对象的字节。规则:释放的块进入 pending 链表,只有当 `appliedSeq`(对被借入 GPU 时间线的 slot 是 `retiredSeq`)越过最后一条引用它的记录之后才归还 arena,而不是在对象析构时立即归还。 - -#### 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 -struct alignas(4096) RingControl { - // ---- SEG_CMD 游标 ---- - alignas(64) std::atomic cmdHead; // producer:累计写入字节 - alignas(64) std::atomic cmdAppliedTail; // consumer:已解码并拷出的字节 - std::atomic cmdRetiredTail; // consumer:被借入 GPU 时间线的 slot 已释放 - // ---- SEG_STAGE 游标(独立三元组)---- - alignas(64) std::atomic stageHead; - alignas(64) std::atomic stageAppliedTail; - std::atomic stageRetiredTail; - // ---- 序号 / 帧水位 ---- - alignas(64) std::atomic appliedSeq; // 已 apply 的记录序号 - std::atomic submittedSeq; // 已提交给驱动 - std::atomic retiredSeq; // GPU 已完成 - std::atomic completedFrameSerial; - std::atomic presentAckSerial; - // ---- doorbell / 代 ---- - alignas(64) std::atomic serverEpoch; // context 丢失 / server 重启时 ++ - std::atomic ringGeneration; // 硬 drain 后 ++,作废缓存 offset - std::atomic consumerParked; // server 睡了,producer 要敲门 - std::atomic producerParked; // client 睡了,server 要敲门 - std::atomic eventRingFull; // SEG_EVENT 满,server 已停止 apply - std::atomic eventDropped; // 被丢弃的有损日志行计数 -}; -``` - -**三个 seq 水位严格区分**(混为一谈是经典错误):`appliedSeq` 释放 `cmdAppliedTail`/`stageAppliedTail`;`submittedSeq` 释放 staging;`retiredSeq`/`completedFrameSerial` 释放 `*RetiredTail` 与 `SEG_ADOPT` 复用。 - -**两个 tail 是必须的**:`Ops_ResidentSubData` 把字节拷进 `pendingResidentWrites`(`Managers.cpp:1158-1166`),P11 之后 server 会**借用** ring slot 而不是再拷一次——那种 slot 只能在 `completedFrameSerial` 之后回收。单 tail 会在那一天变成保守回收。 - -**`SEG_STAGE` 必须有自己的游标三元组**:§8.2 把"`SEG_STAGE` 余量 < 1/4"列为 Publish 触发器,而第二个 ring 的占用率无法从第一个 ring 的游标算出;且 stage slot 的退休条件(`retiredSeq`)与 cmd 记录(`appliedSeq`)不同。 - -#### 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'`)。 - -没有这一条,每一处 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`)。 - -### 7.3 记录格式 - -```cpp -// MobileGL/MG_Remote/Protocol/RecordKinds.h -struct RecHeader { Uint16 kind; Uint16 flags; Uint32 size; }; // 8 B,size 含 header,8 字节倍数 -enum RecFlags : Uint16 { kNone=0, kNeedsAck=1<<0, kHasBlob=1<<1, kPad=1<<2, kBorrowSlot=1<<3, kVarTail=1<<4 }; -struct BlobRef { Uint32 seg; Uint32 pad; Uint64 offset; Uint64 size; }; // 24 B -``` - -**没有 per-record 序号字段**:seq 就是记录序数(producer `m_emitSeq++`,consumer `m_applySeq++`),省 8B/记录并消除一整类失步。 - -**单一真相源是 `PipeCalls.def`,生成器是 G3**(§3.1)。它对**每一个** MGPipe 调用生成三样东西: - -```cpp -// 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` 管不到运行期损坏。`kVarTail` 记录额外校验 `定长前缀 + 尾巴自描述长度 == h.size`;`kHasBlob` 记录额外校验 `BlobRef` 落在它声明的段内。违反一律 `Fatal{ProtocolCorruption}`,绝不进入未定义行为。 - -变长记录(`set_sampler_views` 的 view 数组、`resource_subdata` 的 rect 列表、`draw_vbo` 的 `MGPDrawRange[]` 与 `MGHostSpan`、`set_shader_buffers` 的 range 数组):`kVarTail` + 定长前缀 + 自描述长度的内联尾巴。 - -### 7.4 WAR 危害与字节稳定性 - -**Phase 1 规则(P5-P8):GL 调用时刻把字节拷进 ring slot。** slot 从写入到 `stageAppliedTail` 越过它为止不可变,client 拿不回它 → **危害按构造消除**。代价是一次 memcpy,而 `Ops_ResidentSubData`(`Managers.cpp:1165`)和 `StageBlocksIntoUnpackRing` 在 monolith 里已经在付同样的钱。 - -**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 就改了类型;不包裹的话 §13.5 的编译期折叠保证不成立。写法是"分配器特化:option OFF 时逐字折叠成今天的 `MapAlignedAllocator`"。 - -#### 拷贝账(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 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 持平**。 - -**这是 MGPipe 的一个结构性收益**:server 没有第二份 `BufferObject`/`PipeResource`,所以不存在"staging → server 侧 shadow"这次中间拷贝,也不需要为它设计一种只读采纳模式或 copy-on-write 升级。 - -| 路径 | 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 字节),验收看**总量**,不是只看 client 一侧的数字。 - -### 7.5 Ring 分配与背压 - -逐字移植 `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` 各自独立跑这套升级(各有自己的游标三元组)。 - -### 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` 状态。`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。** 这两个入口今天就是**纯前端操作**:`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)。 - -### 7.7 回读 - -| 路径 | monolith | 拆分后 | -|---|---|---| -| `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) | - -### 7.8 persistent map 与 ≥16MiB 采纳 - -三档,由**运行时 POST 探针**选择(遵循本项目"后端限制一律探针判定、绝不硬编码驱动名"的既定规则): - -- **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` 导入。理想但可用性未知。 - -**决策路径**:P0 的 spike B 在第一周给方向(导出 `HOST_VISIBLE|HOST_COHERENT` VkBuffer 的 fd,client `mmap` 后回读,在两台设备上各跑一次)。若两台都否,P11 从 8 天缩为 2 天的文档与负面对照。**绝不允许一个平台未知数挡住 267 天的接口工作**(D-B4)。 - -#### 7.8.1 client 侧的 persistent map 推送 - -**问题**(已在仓库确认):`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 函数**就直接写。拆分后没人推,字节丢失。 - -另外 `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 应用指针(四类,范围全部可算) - -| 类 | 范围 | 站点 | -|---|---|---| -| client 顶点数组(仅 DrawArrays 族) | `(first+count-1)*stride + elementSize` | `Managers.cpp:2560`、`VulkanRenderer.cpp:3737` | -| client 索引数组 | `count * indexSize` | `DirectGLES.cpp:4436`、`VulkanRenderer.cpp:4081` | -| client indirect / parameter 块 | `stride*(drawcount-1)+cmdSize` | `DirectGLES.cpp:276`、`DirectVulkan.cpp:303` | -| `MultiDraw*` 参数数组、`ClearBuffer*` value | `drawcount*4`、16B | `DirectVulkan.cpp:963-1057` | - -唯一无界的是**索引 draw 下的 client 顶点数组**:索引扫描(`TryComputeMaxIndexFromHostBytes`,`VulkanRenderer.cpp:3406-3470`)必须在 **client** 侧跑,只有 client 同时持有两个数组。 - -**这四类的归属、门控与陈旧索引纪律全部由 §4.8 与 §4.8.1 规定**(`MGHostSpan` 的四行消费者表在 §3.5.7):字节永远走 `SEG_STAGE`,指针永不过线;`minIndex/maxIndex` 是 flag 门控的 `MGPDrawInfo` 字段;reconcile 是**逐站点**表而不是一条笼统规则(`*IndirectCount` 明确**不**加 `SyncGpuWrites()`)。实现落在 `MG_Impl/Pipe/HostResolve.cpp`,两个 backend 共用。 - -`draw_vbo` 的 `kIndicesAreClient` 标志由"是否绑定了 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**,作为这张表的实测基线。 - ---- - -## 8. 控制面与同步 - -### 8.1 FlatBuffers 用法 - -**一份 schema `MobileGL/MG_Remote/Protocol/protocol.fbs`,两种用法:** -- **热路径 → FlatBuffers `struct`**(flatc 保证定长布局、无 vtable、无偏移间接、无需 verifier walk,只需边界检查),直接放进 ring:`[RecHeader | struct | 可选变长尾]`。`draw_vbo` 的固定头是 8+**56** = **64B**(**P0 实测修正**:`MGPDrawInfo` 的 `sizeof` 是 56 而不是 48,见 §3.5.7 的实测布局表;对比 table-per-command 的 ~90B 与一次 vtable 遍历)。这正是 `Feat/CS-Delta-IPC` 自己的 plan 第 55 行要求而实现没做的事。 -- **罕见/变长/需演进 → FlatBuffers `table`**,走 CTRL socket。 - -```fbs -namespace MobileGL.Wire; - -// ---------- 热路径 struct(进 ring;与 MGPipeTypes.h 的 POD 一一对应)---------- -struct PipeHandle { slot:uint; gen:uint; } -struct BlobRef { seg:uint; pad:uint; offset:ulong; size:ulong; } -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; } -// … 共 68 项(P0 实测),与 PipeCalls.def 逐条对应 … - -// ---------- 控制面 table(走 socket)---------- -table SegmentRef { id:uint; kind:ubyte; sizeBytes:ulong; name:string; } -table Hello { abiMajor:uint; abiMinor:uint; buildFingerprint:string; backendType:uint; - pid:uint; configBlob:[ubyte]; } -table Welcome { abiMajor:uint; abiMinor:uint; serverPid:uint; - cmdRing:SegmentRef; stageRing:SegmentRef; replyPool:SegmentRef; eventRing:SegmentRef; } -table CapsSnapshot { dynamicParameters:[ubyte]; // DynamicBackendParameters 逐字节 - rendererInfo:[ubyte]; formatCaps:[ubyte]; extensions:[string]; - apiVersion:string; - maxComputeWorkGroupCount:[int:3]; maxComputeWorkGroupSize:[int:3]; - 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; 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, - 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 上(用 `nm` 复核 `libMobileGL.so` 链接行没有新增库,不靠断言)。 - -### 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 会失败且不弹出消息,把流永久卡死)。 - -#### Publish 触发器 - -**不设"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,不等待**)。 -- **`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,`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`/`glFlush` 保持纯 no-op**(`Definitions.cpp:111-112`)——应用唯一的强制停顿手段在 monolith 里免费,拆分后也必须免费。 - -### 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` 各自的占用,升级路径见 §7.5。 -- **Present credit**:`eglSwapBuffers` 在 `presentsSent - presentAckSerial >= MOBILEGL_IPC_PRESENT_CREDIT`(**默认 1**,见 §9.1)时阻塞。 - -server 端**不发 credit 消息**:它对 `RingControl` 做 release store,consumer 每 64 条记录更新一次 `appliedSeq`,并在 `producerParked` 时敲反向门铃。 - -### 8.4 事件回传通道 - -`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`**,以及**每一次等待循环的每一轮**(present credit、`kNeedsAck`、ring/stage 满)。最后一条是必须的,见下。 - -#### 溢出策略(修一个双向死锁) - -具体死锁:client 卡在 `eglSwapBuffers` 等 present credit;server 的 apply 线程一边 apply 一边产 `EvLogLine` 与 `EvGpuWritten`;`SEG_EVENT` 满;apply 线程阻塞在生产上;`presentAckSerial` 永不前进;client 永不离开 `eglSwapBuffers`,因而永不排空。两边都死。 - -**策略**: -1. client **必须**在每个等待循环内排空 `SEG_EVENT`,不只是在入口点边界。 -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 水位 - -一个诱人的简化是让 `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 水位。 - -### 8.6 三个应先独立落到 `dev` 的 monolith 修复(可二分、monolith 自身受益) - -1. `glEndTransformFeedback` 的无条件无限 `ClientWaitSync`(`GL_Drawing.cpp:1326-1337`)→ 用既有 `MarkGpuWritten`/`SyncGpuWrites` 推迟到首次读。 -2. `glDispatchCompute` 的三次 `GetIntegeri_v` 校验查询(`GL_Drawing.cpp:719`)→ 改读 `CompileEnv::maxComputeWorkGroupCount`(`CompileEnv.h:52-54`)。 -3. ~~删除 `GetInteger64i_v`/`GetProgramiv` 两个死表项及两个 backend 的实现。~~ **(P0 实测修正)已在 P0 落地**(提交 "retire the two frontend queries that were never asked")。 - -(另有两项在 §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 状态求值,**不需要回复**)→ 发 `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`)。批量会饿死这些排空。 - -### 9.1 延迟是叠加的:credit 默认为 1 - -一个"credit=2 镜像系统已有预算、因此不引入新的停顿类别"的论证是错的:停顿**类别**确实不新,但**延迟会叠加**: - -- server 自己的 `Present` 在返回之前就已经等了 2-3 帧:`VulkanRenderer::Present` 末尾调 `FrameContext::WaitAndAcquireNextImage`,其第一条语句是 `vkWaitForFences(device, 1, &frame.imageInFlightFence, VK_TRUE, timeout)`(`FrameContext.cpp:288-290`)。`presentAckSerial` 因此只能在那次等待完成后才前进。 -- 一个被允许领先 2 个 present 的 client,叠在一个自身已领先 GPU 2-3 帧的 server 上 = **端到端 4-5 帧**,60Hz 下 66-83ms,对第一人称游戏不可接受。 -- 现有的验收门都看不见它:SSIM 是帧内容比较,`bench.sh` 量的是 FPS,都不是 input-to-photon。 - -**规则**:`MOBILEGL_IPC_PRESENT_CREDIT` **默认 1**(可配 1-4)。文档里写明叠加公式:`端到端 ≈ client credit + server FIF + 驱动深度`。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 搭 `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` 填满,§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 交付),让"水位饿死"表现为一个指标而不是一次无法解释的停顿。P8 增加一个无 present 的 split 用例。 - ---- - -## 10. 线程模型 - -### Client -- **v1 不加线程。** 编码在调用方 GL 线程上直接写进 ring。前端本来就是 per-context 单线程契约(`GLContext` 无 mutex;`EGLState::MakeCurrent` 强制一个 owner 线程,`EGLState/Core.cpp:1215-1220`,测试在 `MG_Test/EGLState/EGLStateTest.cpp:39-92`)。 -- **flow = per context,不是 per thread。** 今天恰好一个 flow。`eglMakeCurrent` 是 flow 所有权转移,在既有 `EGLOperationMutex`(`EGLImpl.cpp:241`)下发射。**顺手修既有漏洞**:`EGLImpl::ReleaseThread`(`:341-350`)与 `SwapInterval`(`:435-450`)今天不取该锁而另外三个(`MakeCurrent`/`SwapBuffers`/`DestroySurface`)取。 -- **外来线程的 sync/query**:读全部从 `RingControl` 无锁 acquire load 回答(比取 registry mutex 更好);少数必须发射的(`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 → 解码 → 更新 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`)保留但永不触发。 - -### 核心放置(是性能主张的前提) - -§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 大核上做的,拆分按构造就是回归,而"帧时在 monolith 10% 内"会以一个没人会正确归因的理由失败。 - -**规则**: -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. 每个阶段都必须报**逐线程 CPU 时间**,不只是墙钟帧时,这样"没有收益"的结论能被归因到放置 vs 编码成本。 - -### 拆机顺序(三条约束) -publish + server 排空并 ack → 停 apply 线程 → 关 transport →(client)排空 compile pool(必须先于 `glslang::FinalizeProcess()` 与 `pGLContext` 析构,`ShaderCompilePool.h:106-110`、`Init.cpp:56-62`)→ `MobileGL::Destroy()`(`EGLImpl.cpp:335-338`)→ 释放 sync/query handle(`GL_Sync.cpp:223-226`)。 - ---- - -## 11. EGL/窗口与进程生命周期 - -### 11.1 启动与握手 - -client 定位 server 的顺序: -1. `MOBILEGL_IPC_SERVER_PATH`(**主要机制**)。 -2. `dladdr(&MobileGL::Initialize)` → dirname → `libMobileGLServer.so`(**兜底**)。 - -把 `dladdr` 当主要机制会让两个桌面验收门都找不到 server:`MG_IntegrationTest/CMakeLists.txt:28-35` 在非 Android 上把 `MGL_ITEST_MOBILEGL_TARGET` 设成 `MobileGL_s`(**静态链接**),`dladdr` 解析到测试可执行文件自身的路径而不是库目录;trace replay 则由 `tools/trace_replay/CMakeLists.txt:285-290` 显式传 `-DMOBILEGL_LIBRARY=$`,其目录是 MobileGL 的构建输出目录,而 CMake 默认把 `add_executable` 放在定义它的目录的 binary dir。 - -**配套**:把 `MobileGLServer` 的 `RUNTIME_OUTPUT_DIRECTORY` 设成 `$`,并把 `"MOBILEGL_IPC_SERVER_PATH=$"` 加进每一条新的 ctest `ENVIRONMENT`(经 `mgl_itest_join_environment` 与 `${MGL_ITEST_COMMON_ENV}` 合并)以及 `add_trace_replay_test` 的 `SPLIT` 分支。**并复核绝对路径能否活过 CI 的 artifact 搬运**:`.github/workflows/test.yml:174-185` 只重写 `CTestTestfile.cmake` 里的 `cmake` 路径,不重写 `ENVIRONMENT` 值——若不行,改为在测试启动时由 harness 相对 `argv[0]` 解析。 - -启动方式:`socketpair(AF_UNIX, SOCK_STREAM)` + `fork`/`execve`,fd 3 = socket(Windows 见 §11.5)。**无文件系统 socket 路径、无 abstract namespace、Android 上无 SELinux 争议。** - -**子进程必须被强制成 monolith(修无界 fork 链)**:`MG_Config::Transport` 由 `ConfigLoader` 从环境变量读(与 `features.CoherentAsFlush = QueryEnvFlag(...)`(`ConfigLoader.cpp:185`)同形),而 `fork`/`execve` 的子进程会继承 `MOBILEGL_TRANSPORT=spawn`。server stub 里 `dlopen(libMobileGL.so)` + `dlsym("mobilegl_server_main")` 之后必然要起一个真 backend,即走 `MG_Backend::Init()`(`Init.cpp:48-70`)——变量还在,于是它再构造一个 `BackendObject_Remote` 并再 spawn 一次,首次 GL 调用时形成无界 fork 链。 -**规则**:(a) spawn 时构造**显式 envp**,剔除 `MOBILEGL_TRANSPORT` 与所有 `MOBILEGL_IPC_*`(只保留 server 真正需要的少数几个,如 `MOBILEGL_BACKEND_TYPE`、日志路径);(b) `mobilegl_server_main` 在能到达 `MG_Backend::Init()` 之前把 `MG_Config::Transport` 硬置为 `Monolith`。两条都做,任一条单独失效时另一条兜住。P0 增加一个 `MG_Test/Wire` 测试:spawn 一个 server 并断言进程树只多出**恰好一个**子进程。 - -`Hello{abiVersion, backendType, buildFingerprint, configBlob}` → `Welcome`。`configBlob` 转发 client 解析好的 `MG_Config::Features`,两半不可能对某个 quirk 开关有分歧。`buildFingerprint`(git hash + `PipeCalls.def` 的 hash)不匹配 → 握手期 `Fatal`。 - -### 11.2 `mobilegl_server_main` 的可见性 - -`CMakeLists.txt:497-510` 在**非 Debug** 构建上给共享目标设 `C_VISIBILITY_PRESET hidden` / `CXX_VISIBILITY_PRESET hidden` / `VISIBILITY_INLINES_HIDDEN ON`——而 plugin 与 FCL 出货的正是 RelWithDebInfo(`MobileGL/build.gradle` 的 `fordebug` 类型强制 `-DCMAKE_BUILD_TYPE=RelWithDebInfo`)。所以 `dlsym("mobilegl_server_main")` 在 Debug 下能用、在设备上静默失败。 - -**规则**:入口点声明为 -```cpp -extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int argc, char** argv); -``` -并在 P0 验收里加 `nm -D libMobileGL.so | grep mobilegl_server_main` 断言(与既有的 `nm --defined-only` 门并列)。若哪天 macOS/Windows 也要托管 server,还需同步 `MG_Impl/DyldInterpose/ExportedSymbols.txt` 与 `wgl.def`。 - -### 11.3 Android - -**minSdk 26 没有任何公开 NDK API 能扁平化 `ANativeWindow`**(NDK r27.3 的 `android/native_window.h` 无 parcel 符号;`libbinder_ndk` 是 API 29,`binder_ibinder.h:191`;`ASurfaceControl` 是 API 29,`surface_control.h:67`)。`Feat/CS-Delta-IPC` 的 `nativeBlob` "binder-flattened ANativeWindow"(`protocol.fbs:377-379`)不可实现。 - -- **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 必须从应用自身进程 spawn 一次**(见 §14 P0)。**(P0 实测修正)不能用 `posix_spawn`**:bionic 从 API 28 才声明它,minSdk 26 下出货的那条臂是 **`fork` + `execve`**;而且应用进程的 stdout/stderr 是 `/dev/null`,子进程要用 **marker 文件**而不是日志来证明自己活过。真机 exec 本身仍待验证(设备锁)。 -- **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。这个交互本身列为 P6 验收步骤的一部分,先于任何广度工作。 - -### 11.4 Linux / X11 - -`Window` 是 XID,`nativeToken:u64` 直接送。backend 自己 `XOpenDisplay(getenv("DISPLAY"))` 并构造 `VkXlibSurfaceCreateInfoKHR`(`VulkanRenderer.cpp:14486-14521`),只要同 `DISPLAY`/`XAUTHORITY` 就免费。Wayland 今天不支持(`BackendObject.h:529` TODO),维持。 -WSL/CI:**永不开窗** —— `EGL_PLATFORM=surfaceless` + `EnsureHeadlessPlatform()`(`HeadlessGL.cpp:160-196`,它存在正是因为一台带 WSLg `DISPLAY` 的工作站曾把这条 lane 弄挂)。 - -### 11.5 Windows - -`HWND` 进 `nativeToken`。Vulkan 可行(`hinstance` 是历史遗留,`VulkanRenderer.cpp:14456-14463`);**WGL/ANGLE-DXGI 对外进程 HWND 不受支持 → headless only**。 - -transport:默认 named pipe(asio `windows::stream_handle`)。**"继承句柄就免掉 accept/connect"这句在 asio 上不能直接照搬**:`windows::stream_handle` 的 IOCP 服务要求句柄是 **overlapped** 的,而 `CreatePipe` 造的匿名管道不是。所以句柄对必须这样造:用一个 GUID 唯一命名的 `CreateNamedPipeW(..., FILE_FLAG_OVERLAPPED)` 做 server 端,配一次 `CreateFileW(..., FILE_FLAG_OVERLAPPED)` 做 client 端,然后把 server 端句柄设为可继承并 `CreateProcess` 传下去。 - -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` 时重启并让 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. Roundtrip 清单与稳态零 roundtrip 论证 - -### 12.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 由 §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 收窄 +(P0 实测修正)**:**只有 `glBufferStorage`**(真同步)。`glRenderbufferStorage*` 的 OOM 探测惯用法在 41 个 fixture 里出现 0 次(9 次调用 / 5 个 fixture,无一在 3 个调用内跟 `glGetError`;语料里的成功性检查是 `glCheckFramebufferStatus`),故它**不标 `kNeedsAck`**、保持晚到/异步。纹理族在 monolith 里就已经推迟到 sync 时刻,同样**不标**(§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 次间接调用 + **56 B 固定头**(`MGPDrawInfo`,**P0 实测修正**,此前写 ~48 B)+ 按 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 划分里要不要这个字段)。 - **(P0 实测修正)调查结论 + 待拍板。** 已查明的事实三条:`FramebufferSrgb` 的**六个 backend 读点全部在消费一个编译期常量 `false`**(`IsCapabilityEnabled` 对它的返回值可被常量折叠),`DepthClamp` **一个读点都没有**;`glEnable(GL_FRAMEBUFFER_SRGB)` / `glEnable(GL_DEPTH_CLAMP)` 被**静默吞掉**——落到 `default:` 分支既不存储也**不报 `GL_INVALID_ENUM`**,应用无法察觉;41 个 trace fixture **无一**开启任一项(所以补上真存储不会改动任何既有 fixture 的渲染输出)。 - **调查方给出的建议(决定权在计划所有者)**:在渲染状态 chunk 表冻结**之前**给两者补上真实存储;并把 `FramebufferSrgb` 放进 D-B1 划分的 **pipeline 那一半**——它改变的是 attachment 的解释与 blend 的工作色彩空间,属于会重铸 pipeline 的子集,不是 `set_dynamic_state` 那一半。`DepthClamp` 的归属随实现方式定,可留到补存储时一并拍板。**在拍板之前不要冻结 chunk 表。** -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() { - MGLOG_D("Initializing MobileGL Backend..."); -#if MOBILEGL_BUILD_DISAGGREGATED - if (MG_Config::Transport != TransportKind::Monolith) { - pActiveBackendObject = MakeUnique(); - } else -#endif - switch (MG_Config::ActiveBackendType) { /* 原样不动 */ } - if (!InitSpecificBackendLibs()) { /* 原样 */ } - LogBackendInfo(); -} -``` -`BackendObject_Remote::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)。 - -**第四层——`nm --defined-only` 与 `.text` size 逐阶段完全相等——在本方案里不成立**(D-B5),由 §13.3 的五部分门取代,只保留两条字节级等式作断言、符号/尺寸漂移作信息性指标。 - -### 13.6 两个 CMake option 与 `inproc` 的角色隔离 - -**每一条部署路径都要求出货构建是 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。 - -**`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` 调用。 - -MGPipe 把这个数字降到 **2**: - -| 全局 | 还需要角色隔离吗 | 为什么 | -|---|---|---| -| `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 虚函数面 | - -两个全局的 shim 只需要 `operator->` / `operator bool` / `get()` / 赋值,而且**都不在 GL 热路径的每次访问上**(pipe 表在每个 MGPipe 调用处取一次,`pActiveBackendObject` 只在 EGL/caps 面)。**这条是 MGPipe 让 `inproc` 从"成本可疑的实验"变成"可交付形态"的直接原因。** - -### 13.7 `inproc` 作为产品交付物与运行时选择 - -`inproc` 不只是测试脚手架:同进程第二个 apply 线程 = monolith 的渲染线程。今天 `PrepareForDraw`(状态调和、VAO/FBO/纹理/program/render-state sync、UBO ring memcpy)加驱动调用全部同步跑在 `glDrawElements` 里;把它们搬到 apply 线程,对 GL 线程 CPU-bound 的应用(本项目的 profiling 史说 Minecraft 就是)是**手上最大的单一杠杆**,且不需要任何 IPC/shm/平台工作。 - -**`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_COHERENT_AS_FLUSH`);新增开关见附 B。 - -### 13.8 构建布局与测试接线 - -``` -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} - Client/ PipeEmitter.{h,cpp} EmitTables.cpp - BackendObject_Remote.{h,cpp} CapsMirror.{h,cpp} - 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_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.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. 分阶段实施计划 - -> **通用纪律(每个 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 公布。 - -### 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 新增)**:每 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 个读点)。**(P0 实测修正)后两项已落地**——`GetInteger64i_v` 与 `GetProgramiv` 的表项与两个 backend 实现已从 `GLFunctionsTable` 删除(提交 "retire the two frontend queries that were never asked");同时确认 **`GL_COMPUTE_WORK_GROUP_SIZE` 由 `GL_Program.cpp:928-946` 纯前端回答,不进 `MGPCaps`**,进 caps 的是 `GL_MAX_COMPUTE_WORK_GROUP_COUNT`/`_SIZE` 两个 compute 限制(§3.4.6);`RenderbufferObject::GetLifetimeId()`(**不加 `GetVersion()`**——推送模型里 `glRenderbufferStorage*` 本身就是一次 pipe 调用);D21 重键——**这一条是潜伏 bug 修复,先独立落 `dev`**。 -- 回答两个阻塞问题:`FramebufferSrgb`/`DepthClamp` 无存储是潜伏 bug 还是有意为之(§13.4-6,**必须在渲染状态 chunk 表冻结之前**);**语料里是否存在 `glRenderbufferStorage` 的 OOM 探测惯用法**(决定 `kNeedsAck` 要不要标它,§6.4)。 - **(P0 实测修正)两问均已调查完毕**:(a) OOM 探测惯用法在 41 个 fixture 里 **0 例**(9 次 `glRenderbufferStorage` 调用散在 5 个 fixture,无一在 3 个调用内跟 `glGetError`;实际的成功性检查是 `glCheckFramebufferStatus`)→ **`kNeedsAck` 只由 `glBufferStorage` 承担**,`glRenderbufferStorage*` 保持晚到/异步,整条 ack 路径省掉(§6.4、§12.2-7)。(b) `FramebufferSrgb` 有六个 backend 读点全在消费一个编译期常量 `false`、`DepthClamp` **零读点**,两者的 `glEnable` 被静默吞掉且不报 `GL_INVALID_ENUM`,41 个 fixture 无一开启任一项 → **调查结论 + 待拍板**,见 §13.4-6 与开放问题 10。 -- `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`。 - **(P0 实测修正)已证 vs 待证。** **已在主机侧证明**三条:(1) AGP **确实**会把一个被改名成 `lib*.so` 的 `add_executable` 打进 `lib/arm64-v8a/`,前提是把它的 `RUNTIME_OUTPUT_DIRECTORY` 重定向到 AGP 收集原生产物的那个目录(默认 runtime 输出路径 AGP 不看);(2) **`posix_spawn` 在 minSdk 26 上用不了**——bionic 从 **API 28** 才声明它,所以出货形态的那条臂是 **`fork` + `execve`**,本文其余处(§11.3 的注记)写 `posix_spawn` 的地方一并按此读;(3) 应用进程的 stdout/stderr 是 **`/dev/null`**,子进程"打一行日志"证明不了自己活过,**必须改成写一个 marker 文件**再由测试断言它出现。**待证**:`untrusted_app` 域内的真机 exec 本身(设备锁未解,on-device 运行仍欠着)——spike A 的核心结论因此**尚未闭合**。 -- **spike B(external memory 可行性,半天)**:最小程序,导出一个 `HOST_VISIBLE|HOST_COHERENT` VkBuffer 的 fd,`mmap` 后回读校验,在 `35d0befa`(Adreno 830)与 `3B159D009VZ00000`(Mali)各跑一次。与 `SCM_RIGHTS` 测试同批。**目的是让 P11 的结论在第一周就有方向**:若两台都不行,P11 缩为"记录并回退",省 6 天。 - **(P0 实测修正)已证 vs 待证。** 探针**已写好并在 lavapipe 上跑通**:**T1**(opaque-fd 的导出/导入)与 **T3**(host-pointer 导入)**两档都能完整往返**(导出 → 导入 → 回读字节相符)。**待证**:`35d0befa`(Adreno 830)与 `3B159D009VZ00000`(Mali)**两台真机都还没跑**(设备锁)。**所以 P11 的规模仍未定**——lavapipe 通过只说明探针本身正确,不构成任何移动端驱动的证据(§7.8 的三档选择、开放问题 3 保持开放)。 - -**验收**:`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 全绿。 -**(P0 实测修正)本条验收目前的状态**:主机侧(lavapipe/llvmpipe)部分已达成——动态 accessor 基线已取(§2.3.1)、spike B 探针 T1/T3 往返通过、spike A 的打包与 `posix_spawn` 不可用两点已定论;**两台设备的基线数字与两个 spike 的真机运行仍欠着**(设备锁),P0 因此**尚未整体验收通过**。 - -### 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` 绊线(§3.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 修正,§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-新臂。 - -**验收** -- 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 设计更慢"分开。 - -**★ 第 43 天(低端估计)— GO/NO-GO 决策点。** 此刻手上有:verify harness、双后端已推送的渲染状态、真实 CPU 增量与绝对 ns、Blaze3D 微基准、CSO 负面对照、**Track H 在两个 backend 的最便宜子系统上的实测单位成本**。两个出口(继续 / 收缩为 headless 工装用途或重新评估)与沉没成本口径写在 §0.5。 - -### 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 档下强制,§7.8.1);`InProcessTransport`;trace-replay 的 `SPLIT` 后缀与 `-DTRACE_TRANSPORT=` 接线(§13.8)。 - -**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 记录在案**,作为 §7.11 内存预算的实测基线;`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。 - -**验收**:P5 全部测试在 `MOBILEGL_TRANSPORT=spawn` 下绿;fork 链测试断言进程树只多一个子进程;`HeadlessGL` 的 fork 预检交互测试无孤儿 server(§11.3);`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、§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。 - -**验收**:~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)。 - -### P7 — DirectVulkan(Magma)全量迁移(80-104 天,可与 P5/P6/P8 并行) - -> 子系统 1(pipeline+动态状态)与子系统 4(VertexInput/VaoDrawMemo)已在 P2 交付,所以是 §5.5 的 85-111 减去 5-7。 - -**交付物**:§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)。 - -**验收**: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` 计数解析**,每一条前面都有 §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 而不是关键路径上。设备锁协议照旧。若实测周转仍主导排期,**诚实做法是加宽估时而不是削弱门**。 - ---- - -## 15. 风险与对策 - -| # | 风险 | 对策 | -|---|---|---| -| **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` 杠杆),必须据此重新评估是否值得** | - ---- - -## 16. 开放问题 - -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 表冻结之前回答**。**(P0 实测修正)事实已查清、结论待拍板**:`FramebufferSrgb` 六个读点消费的是编译期常量 `false`,`DepthClamp` 零读点;两者的 `glEnable` 被静默吞掉且不报 `GL_INVALID_ENUM`;41 个 fixture 无一开启。建议是在冻结前补真存储、并把 `FramebufferSrgb` 划进 D-B1 的 pipeline 半边(它改变 attachment/blend 的解释)——**由计划所有者拍板**,详见 §13.4-6。 -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 主张同时削弱。 - ---- - -## 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`。 - -> **(P0 实测修正)本表按功能分组,合计 68 条唯一调用**(不是"约 74")。按 `.def` 的 Class 列才是权威口径:**screen 10、ctx-query 6、CSO 13、`kCtxState` 17、`kCtxObject` 9、`kCtxVerb` 13**。下面各小标题的括号数是**旧的功能分组数**,其中 CSO 与 `set_*` 对 `bind_sampler_states`/`set_sampler_views` 重复计数、query 族被并进 screen、transfer 标 12 而实列 11。**`PipeCalls.def` 是唯一真相源,线上 opcode 就是行的位置,所以目录必须是唯一记录的集合。** - -### `MGPipeScreen`(14 → **10**,query 族 6 项归 `kCtxQuery`) - -| 调用 | 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 → **13**:`create`/`delete` × 5 + `bind` × 3) - -`create/delete` × `render_state` / `vertex_elements` / `sampler` / `sampler_view` / `shader`,`bind` × `render_state` / `vertex_elements` / `shader`。 -**sampler 与 sampler view 的绑定见下一组的 `bind_sampler_states` / `set_sampler_views`,此处不重复计。** -`create_render_state` 带 `B`(**只带 pipeline 子集的 chunk**);`create_shader_state` 带 `B`(SPIR-V + `ProgramArtifacts` 归档)。 - -### `MGPipeContext` — `set_*`(17 + 1 临时;**`kCtxState` = 16 `set_*` + 1 临时 = 17**,`set_texture_params` 计入 `kCtxObject`) - -`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,**实列 11**;在 `.def` 里分入 `kCtxObject` 9 与 `kCtxVerb` 13) - -`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;与 transfer 的动词合成 `kCtxVerb` 13) - -`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`(**P0 已删表项**)· `GetProgramiv`(**P0 已删表项**)· `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`,支持 `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(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` 兜底,§11.1) | -| `MOBILEGL_IPC_RING_MB` | 8 | `SEG_CMD` 大小 | -| `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 共用,§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` 的大核探测(§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 死亡后重启 + 全量重推(§11.6) | -| `MOBILEGL_IPC_IDLE_EXIT_S` | 30 | server 的最后保险看门狗(EOF 应当即时退出) | - -**显式不设立**:`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/README.md b/docs/Disaggregated/README.md new file mode 100644 index 000000000..06d8c9fac --- /dev/null +++ b/docs/Disaggregated/README.md @@ -0,0 +1,64 @@ +# MGPipe:MobileGL 前后端拆分 + +> 状态:**P0 已落地**(`feat/disaggregated@458ccde1`,基线 `dev@81b17c0b`)。下一步 P0.5 → P1 → P2,第 43 天 GO/NO-GO。见 `ROADMAP.md`。 + +## 是什么 + +MGPipe 是 MobileGL 前端(`MG_State` + `MG_Impl`)与后端(`MG_Backend`:Espryt = DirectGLES、Magma = DirectVulkan)之间的一份**显式接口**:gallium 形状、句柄寻址、只推不拉。它取代今天后端每 draw 直接读 `MG_State::pGLContext` 的做法,让后端拥有自己的状态机,并在此之上把前后端拆到**两个进程**。 + +接口本身是可独立交付的产物:即使 IPC 永不上线,`inproc`(同进程第二个 apply 线程)就是 monolith 的渲染线程。 + +## 架构(一段) + +``` +应用 GL 调用 + → MG_Impl(GL 语义、错误、shadow) + → MG_Impl/Pipe/Tracker:在每条 verb 之前 validate,把变化推成 MGPipe 调用 + → MGPipeScreen / MGPipeContext(两张函数指针表,71 条调用,单一真相源 PipeCalls.def) + monolith:直调 backend 函数 split:发射器写 SEG_CMD ring → server applier + → server 对象表(按 {slot, gen} 句柄索引的数组)+ PipeInputs(后端被推送的状态块) + → MG_Backend(Espryt / Magma),两个后端的 ring / pool / memo / lowering pass 原样不动 + ← MGPipeCallbacks(10 个具名反向回调 + 1 个正向终止符) +``` + +三种构建/运行形态共用**同一份 backend 实现**:`monolith`(默认,接口在进程内直调)、`inproc`(同进程两个线程,CI 形态与渲染线程交付物)、`spawn`(`fork`+`execve` 出 server 进程,SPSC 共享内存 ring + FlatBuffers 控制面)。 + +## 文件地图 + +| 文件 | 内容 | +|---|---| +| `ARCHITECTURE.md` | 已定稿的设计与架构:句柄与世代、调用目录、记录约定、tracker、纹理路径、shader 制品、反向通道、后端改造、传输、persistent map 分档、进程/EGL/平台、构建与纯度门、验证策略 | +| `ROADMAP.md` | P0…P13 阶段表、两条跑道、GO/NO-GO 清单、再基线检查点、仍然开放的问题 | +| `MEASUREMENTS.md` | P0 实测:spike A/B 结论、双设备四条 trace 的边界计数器基线、桌面数据点、语料事实、复现命令 | + +代码地图(P0 已落地的部分): + +| 路径 | 作用 | +|---|---| +| `MobileGL/MG_Pipe/` | `PipeCalls.def`(目录)、`PipeFields.def`(比对器字段表)、`Coverage.def`(读点覆盖)、`MGPipeTypes.h`(payload POD)、`MGPipeHandles.h`、`MGPipeHostSpan.h`、`MGPipeCallbacks.h`、`MGPipe.h`、`generated/*.inc`(G1–G7 产物,提交进树) | +| `scripts/gen_pipe.py` | 七个生成器 G1–G7;`gen_pipe_dirty_surface.py` 前端 mutator 面扫描;`gen_protocol.py` FlatBuffers 头再生成;`check_doc_citations.py` 本目录 `file:line` lint | +| `MobileGL/MG_Remote/` | `Protocol/protocol.fbs`(控制面 schema)、`Transport/`(`Ring`、`Doorbell`、`ShmSegment`、`FdPassing`、`Framing`、`InProcessTransport`、`ITransport`);仅 `MOBILEGL_BUILD_DISAGGREGATED=ON` 编译 | +| `MobileGL/MG_Util/Metrics/PipeStats.{h,cpp}` | 边界计数器(字节 / 动态 accessor 调用 / 六个 memo 门 / 上传形状),`MOBILEGL_PIPE_STATS=1` 开启 | +| `MobileGL/Config.h`、`MobileGL/ConfigLoader.cpp` | `MOBILEGL_PIPE_*` 八个开关 | +| `tools/spikes/server_stub`、`android-plugin/app/src/trace/cpp/spawn_spike.cpp` | spike A:Android 上以 `lib*.so` 打包并从应用进程 exec 第二个原生可执行文件 | +| `tools/spikes/extmem_probe/` | spike B:跨进程外部内存分档探针 | +| `MobileGL/MG_Test/Pipe/`、`MG_Test/Wire/`、`MG_Test/Util/PipeStatsTest.cpp` | 目录算术、wire 层五个套件、计数器测试 | + +## 术语 + +- **client / server**:前端进程 / 后端进程;monolith 下是同一进程的两个角色。 +- **verb**:会让 server 做事的命令(draw、dispatch、clear、blit、readback、XFB 跨度、query、纹理操作)。推送只发生在 verb 之前的 validate 时刻。 +- **CSO**:常量状态对象(render state、vertex elements、sampler、sampler view、shader),client 侧内容寻址,server 侧按句柄缓存。 +- **Track V / Track H**:值类读点的迁移(整块 POD 过线)/ 对象类读点的迁移(`SharedPtr<前端对象>` → 句柄)。 +- **`MGGen`**:server 私有的"我重铸了驱动对象"纪元,永不过线;与句柄里的 client 世代严格分开。 + +## 历史 + +本目录此前是一份 328 KB 的实施计划(`PLAN.md`)加 135 KB 的设计竞赛与三视角对抗性评审记录(`REVIEW.md`)。设计已定稿,本次改写只保留设计与架构本身;评审记录、被否决的替代方案与 v1→v2 的修订史留在 git 历史里: + +- `8b31de2f`:方案 A(replica GLContext + mutator 回放)与首轮评审; +- `1794ac94`:方案 B(MGPipe)、A/B 逐项对比、第二轮竞赛与对抗性评审; +- `8349babe`:合并为单一 MGPipe 计划,废弃方案 A; +- `87ee17c6`:折入 P0 实测修正。 + +`git show 87ee17c6:docs/Disaggregated/REVIEW.md` 可取回评审记录全文。此外还有一条已放弃的早期分支 `Feat/CS-Delta-IPC`,其可复用/改造/放弃的逐文件判定见 `8349babe` 版 `PLAN.md` §17。 diff --git a/docs/Disaggregated/REVIEW.md b/docs/Disaggregated/REVIEW.md deleted file mode 100644 index 105c4acc1..000000000 --- a/docs/Disaggregated/REVIEW.md +++ /dev/null @@ -1,302 +0,0 @@ -# 拆分设计评审记录(MGPipe) - -> 生成于 2026-09-05,配合同目录 `PLAN.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 | - -### 评审指出的致命缺陷(已在综合稿中处理) - -- 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.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. - -已验证的优点: -- 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=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** - - 问题:§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 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,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 "退回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 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 'the plan' 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/ROADMAP.md b/docs/Disaggregated/ROADMAP.md new file mode 100644 index 000000000..078934931 --- /dev/null +++ b/docs/Disaggregated/ROADMAP.md @@ -0,0 +1,90 @@ +# MGPipe 路线图 + +> 状态:P0 已落地(`feat/disaggregated@458ccde1`)。设计见 `ARCHITECTURE.md`,实测见 `MEASUREMENTS.md`。天数是各阶段所含子系统行的求和(低端 / 高端),总计 **267–337 人天**(不含 CTS 周转);两个工程师、P7 与 P5/P6/P8 并行约 7–9 个月,真正的约束是两台设备的争用。 + +## 通用纪律(每个 commit) + +默认 ALL target 必须完整构建;禁止提交热路径插桩(CI grep 门);**每个门必须能因它存在的理由变红**;Windows 机器不是正确性门;设备对比走 reboot-clean + 同热窗口配对 A/B,CPU 定频按项目协议;每阶段出口跑一次五部分门;每阶段性能判据是**逐线程 CPU 时间**。 + +两条跑道:**monolith 跑道** P0 → P0.5 → P1 → P2 → P3a → P4a → P3b/P4b → P7 → P8 → P13,每段可独立交付、可随时中止且 monolith 严格好于起点;**IPC 跑道** P5 → P6 → P9 → P10 → P11 → P12。 + +## 阶段 + +| 阶段 | 天 | 落地什么 | 验收门 | 依赖 | +|---|---|---|---|---| +| **P0** 卫生、度量、门、骨架 | 9–11 | ✅ 边界计数器(字节 / 动态 accessor / 六个 memo 门 / 上传形状);`PipeCalls.def` 完整目录 + payload POD + 七个生成器 + CI `pipe-gates`;`gen_pipe_dirty_surface.py`;`check_doc_citations.py`;八个 `MOBILEGL_PIPE_*` 开关;`MG_Remote/{Protocol,Transport}` 骨架(`SCM_RIGHTS` 第一优先、双 tail 双三元组的 `RingControl`、双向 doorbell、校验型 `Framing`、`ShmSegment`、`InProcessTransport`)+ `protocol.fbs` + `flatc-check` + `MG_Test/Wire` 五个套件;三个严格 no-op 收益(`GetInteger64i_v`/`GetProgramiv` 退役、`RenderbufferObject::GetLifetimeId()`、D21 XFB 计数槽重键);compute 限制进 `DynamicBackendParameters`;spike A、spike B;retrace 通道 `--env` 透传 | ✅ 单元/集成/40 trace 逐名不变;wire 层测试(fd 传递、doorbell、ring、封帧、inproc)绿;两台设备的字节/调用基线在案;spike A/B 出结论;citation lint 绿 | — | +| **P0.5** 值头与制品头抽取 | 6–9 | `MG_Pipe/MGPipeValueTypes.h`(`RenderStateParameters`、`SamplerParameters`、`PixelStoreParameters`、`VertexAttribute`… 不 include `MG_State/GLState`);`MG_State/GLState/ProgramState/ProgramArtifacts.h`(五个反射类型,不 include `ShaderObject.h`/`SpvcSession.h`,更新 7 个 includer);`Visit()` 归档 + `sizeof` 绊线;CI `-H` include 闭包断言 | 全套测试逐名不变(纯搬移);两条闭包断言绿且人为加回一个 `MG_State` include 能变红;`nm`/`.text` 变化可逐符号归因 | P0;**P1 与 P7 的硬前置** | +| **P1** `PipeInputs` 替换与 verify harness | 10–13 | `MG_Backend/MGPipe/PipeInputs.h`(Espryt 32 / Magma 55 访问器);`sed` 293 处 + 58 行非箭头清单逐条转换(显式交付物);逐 verb 类填充点(G5 表,~93 个边界站点);逐 verb 世代 poison;G4 影子比对器 + 第三种 CI 模式;20 处 `SyncPersistentMappedRange` + 6 处 `SyncGpuWrites` 的逐站点归属表 | pull 构建 `nm --defined-only` 不变、`.text` 差异逐行归因(空守卫/三元重写推迟到 P2);40 trace + 全部集成测试在 `MOBILEGL_PIPE_VERIFY=1` 下零分歧;故意损坏一个快照字段能让 verify 变红;故意在 `glGenerateMipmap` 的填充表漏一个字段能在**那条 verb** 上触发 poison Fatal | P0.5 | +| **P2** 渲染状态 CSO + 第一片 Track H + 残余值块 | 18–26 | `MG_Impl/Pipe/Tracker`(dirty 位、5 个聚合世代、抑制器骨架);`gen_pipe_dirty_surface.py` 首轮映射成门;`MGPipeRenderStateSpans` + G7 setter 一致性测试;`CsoCache`(64 项,键 = pipeline 子集);`create/bind_render_state` + `set_dynamic_state`(Espryt `SyncRenderState` 一行不动;Magma `ComputePipelineStateHash`/`GetOrCreatePipeline`/`ApplyDynamicDrawStateTail` 改从 CSO 与动态 payload 取);`set_pixel_pack_state`、`set_patch_state`、`set_vertex_attrib_defaults`;`set_residual_value_state` + `ResidualValueBlock` 绊线;**第一片 Track H**:Espryt 0b(`SlotAllocator` + 6 个 registry → slot 数组 + 删 `TwinLookupMemo`×3/`OwnerEquals`/`g_fbSlotCache`/GC)与 Magma 子系统 4(`VertexInputStateFactory`/`VaoDrawMemo` 重键,删前端 VAO 里的后端裸指针);`MOBILEGL_PIPE_LEGACY_MEMOS`;补 `FramebufferSrgb`/`DepthClamp` 存储 | 集成 × 2 后端 × {pull, push} 逐名相同;40 trace push 下 SSIM ≥ 0.99 双后端;verify 零分歧;`HandleRecycleScenario` 绿且重键前红;G7 测试绿且拿掉一个字段能红;两台设备配对逐线程 CPU p50/p99 不差且 tracker 绝对 ns 在上限内;Blaze3D blend-toggle 微基准;CSO 内容寻址关闭的负面对照 | P1 | +| **P3a** handle wave 1(Espryt):buffer、VAO | 18–23 | 7 个 `BufferBackendOps` → `resource_*`、`buffer_subdata_resident`(可 null)、`resource_flush_range`、`resource_readback`、`map_persistent`(不碰实现);pool 与延迟释放原样搬;vertex elements 三件(两个视图都带);`set_vertex_buffers`(`baseInstance` 显式字段);`set_index_buffer`;Adreno SIGSEGV workaround 保留 | 全套门;buffer/VAO 族场景(`LargeArenaAdoption`、`StorageBufferRegrow` 发布 `map-persistent-roundtrips`、`VertexAttribBinding`、`MultiDraw`、`PrimitiveRestart`…);Create/rd12/26.3/sodium trace;MC 26.3 在 Adreno 上 p99 不变。**再基线检查点 1:超过 27 天必须重定基线** | P2 | +| **P4a** handle wave 2(Espryt):FBO / 纹理 / sampler / program 身份与描述符 | 26–34 | `set_framebuffer_state`(解析后的 `ReadSurface`、内联格式、`ContentHash`、`{0,1}`);sampler CSO(含 `borderColorForm`);sampler view + `set_texture_params`;`set_sampler_views`/`bind_sampler_states`/`set_shader_images`;shader CSO(SPIR-V + 归档);`set_draw/dispatch_program`;`set_global_constants`;`CompositeResolver`;纹理/renderbuffer 的 `resource_*`。emulation 在 split 下显式 Fatal 直到 P8 | 全套门;framebuffer/纹理/program 族场景;**新增"只作 attachment / image 单元 / CopyImage 端点的纹理其 `glTexParameter` 生效"场景(落地前必须红)**;两台设备 `KHR-GL46.direct_state_access.framebuffers*` 与整个 `packed_pixels` 块(~3300 例,句柄复用压力测试)。**再基线检查点 1b:超过 39 天** | P3a | +| **P5** 传输 + inproc applier + 发射表 | 12 | `MG_Remote/Client` 发射表;`Server/PipeApplier`、`ServerLoop`(`mgl-srv-io` + `mgl-srv-apply`);`Init.cpp` 单一 hook 装 `BackendObject_Remote`;`MGPCaps` 快照;阻塞 `read_pixels`;client 侧保守 `MarkGpuWritten`;**client 侧块粒度 persistent-map 推送**;`InProcessTransport` 走与 spawn 相同的 G3 编解码;trace-replay `SPLIT` 后缀 + `-DTRACE_TRANSPORT=`;`MOBILEGL_TRANSPORT` 解析 | `DirectGLES.Split.*(ClearThenReadPixels|Triangle)` 在 `inproc` 下绿;OpenRA trace split SSIM ≥ 0.99;`PersistentCoherentMapScenario` 绿;两个角色峰值 RSS 在案;`persistent-map-push` 出数;未迁移字段读 = `Fatal{UnmigratedPipeInput}`。**第 99 天:首个 IPC 帧(缩减路径)** | P4a | +| **P6** spawn transport | 5 | `SocketTransport`(socketpair + fork/execve,envp 剔除 + 强制 monolith 双保险);`ServerMain`;`MOBILEGL_IPC_SERVER_PATH` + `dladdr` 兜底;有界重试握手;EOF 即时退出;device-lost latch | P5 全部测试在 `spawn` 下绿;进程树只多一个子进程;`HeadlessGL` fork 预检无孤儿;OpenRA 在 Adreno 830 上 split SSIM ≥ 0.99。**第 104 天:首个跨进程帧** | P5 | +| **P3b / P4b** 深化(Espryt) | 29–38 | memo 重键(`ResolvedDrawBuffers`、`ResolvedTextureBindingMemo`、`SamplerPassMemo`、image sweep、program registry…);server 删 `g_unitTextureSyncList`/`g_fboTextureSyncList`/`DirectGLES.cpp` 的 ~115 行 unit-bindings epoch 推导,**同时**在 Tracker 落地集合 hash 抑制器;dirty 归属反转(按存储属主键控的发射游标);`MGPSubRegion` 跨步描述符改造;XFB scatter 搬到 client;删 fragColor 重推导 workaround 与 `g_broadcastMemo*`;raw-depth-fetch sampler 原生化;回读 / pack state | ~25 个纹理场景、21 个 program 场景 + `MG_Test/ShaderTranspiler`;两台设备 `KHR-GL46.texture_*`/`internalformat.texture2d.*`/`shader_image_*`/`packed_pixels` 在 pull 基线 0.5 pp 内;每一个 Iris trace;**`TextureUploadShapeScenario`**(形状金标,Mali 帧时增量必须发布);view/owner 发射游标别名场景;verify 保留模式下 subdata 形状逐项相等;XFB 场景 + `capture_special_interleaved_test` | P4a | +| **P7** DirectVulkan(Magma)全量迁移 | 80–104 | §5.5 其余 10 个子系统(子系统 1、4 已在 P2):`SetupDrawSnapshot` 探测字段塌成 dirty mask;占位纹理原生化(~120 行删除);具名 UBO host payload(D-B8,`kCapNeedsHostUboBytes`);blit/depth-mipmap 内部 shader 烘焙 + 新鲜度测试;`VertexInputStateFactory` 裸指针写回删除;D18 容器纪律原样保留 | 集成 + 40 trace 在 Magma 的 push 与 split 下全绿;verify 零分歧;**`nm -D libMobileGLServer.so | grep glslang` 为空**;Iris trace 上 `stage-ubo-named` 逐帧字节发布;两台设备 CTS 0.5 pp 内。**再基线检查点 2:中点(第 40–52 工作日)完成子系统 < 40% 立即重定基线** | P0.5、P2;可与 P5/P6/P8 并行 | +| **P8** emulation 下放 + 索引宿主镜像 + 协议广度 | 12–16 | `MG_Impl/Pipe/HostResolve.cpp`(client 数组范围、最大索引扫描、`*IndirectCount` 解析,各带逐站点 reconcile);`MGHostSpan` split 填法;`Server/IndexHostMirror`;CopyImage 镜像搬到 client;`draw_vbo` 收编 multi-draw 族(分档仍在 server);viewport-array 回放验证;`generate_mipmap` 计划 + CPU 回退纹素;G3 分块路径;无 present fence tick + 无 present split 用例;`kCapDriverOrderedXfbCapture` | `'^DirectGLES\.Split\.'` 与 `'^DirectGLES\.'` 逐名相同(DirectVulkan 同);40 trace split 双后端 SSIM ≥ 0.99 含两个 `coherent_as_flush` Create fixture;`ClientArrayAfterComputeWriteScenario` 绿(去掉等待必须见几何缺失);`create-indirect` 上 `roundtrips-per-frame` 读零;`index-mirror-bytes`/`index-bytes-shipped` 逐用例发布。**第 145 天:全功能 split** | P6、P3b/P4b | +| **P9** 反向通道 | 10 | `SEG_REPLY` slot 池;阻塞 `read_pixels`;PBO 回读 fire-and-forget;`OnGpuWritten` 收窄;`OnBufferWriteback` 按操作级批处理 + epoch 排序;`OnXfbScatterReady` + client scatter;`OnTextureWriteback`;`OnMipLevelsGenerated`;纹理拉取四条缓解 + 终止符;`OnGlError` 有序 + `glBufferStorage` 的 ack;`OnCapsInvalidated`;`OnSurfaceChanged`;`OnLog` 分级 + 速率限制;`SEG_EVENT` 溢出策略 | 回读/XFB 场景在 split 下绿;`TextureRemintPullScenario` 绿且含无解用例(终止符前表现为 apply 线程挂死/超时);拉取计数逐 trace 发布;故障注入:credit 阻塞时灌满 `SEG_EVENT`、日志洪泛下注入 link 失败 | P8 | +| **P10** sync / query / present 节奏 | 6 | client 铸造 sync/query handle;轮询入口成门铃点 + `MOBILEGL_IPC_POLL_ESCALATE`;fence 完成度来自真的逐 fence 退休;DirectGLES 非 present fence tick;`present` 1:1;credit 默认 1 + 叠加公式;roundtrip 计数器与输入延迟直方图;三个独立 `dev` monolith 修复(`glEndTransformFeedback` 无限 `ClientWaitSync` → 推迟到首次读;`glDispatchCompute` 三次 `GetIntegeri_v` 校验 → 读 `CompileEnv`;D21 已落地) | query/XFB/`AsyncCompile` 场景在 split 下绿;40 个用例上 draw/state/upload 路径 roundtrip 读零,条件渲染与阻塞 query 次数逐用例发布;零 timeout 轮询在有界时间退出;`bench.sh` 配对 A/B:两侧都关采纳时 split 帧时在 monolith 10% 内,输入延迟 p50/p99 在案 | P9 | +| **P11** persistent map 与 ≥16 MiB 采纳 | 8 | POST 探针档位选择(T0 主攻,Adreno 可选 T1,T2 回退);`SEG_ADOPT` 生命周期绑 `completedFrameSerial`;`MOBILEGL_IPC_ADOPT_TIER` 负面对照 | `LargeArenaAdoptionScenario` 在所选档下绿;26.3 与两个 Create fixture SSIM ≥ 0.99;`StorageBufferRegrowScenario` 发布 `map-persistent-roundtrips`;Adreno 830 上 p99 帧时与峰值 RSS 对 monolith 采纳基线(163→21 ms / 40→115 fps / ~400 MB)**回归不超过 10%**;若 T2 成为某设备的永久答案,其实测代价写进文档 | P10、spike B(已答) | +| **P12** Android 生产窗口路径 | 10 | `android:process=":mgl"` Service 收 Java `Surface` → `ANativeWindow_fromSurface`;server 生命周期绑 Activity;FCL 用户 env 与 plugin APK V2 开关表接线 | Minecraft 经 FCL 在 spawn 模式下于 Adreno 830 双后端入世界;配对 reboot-clean bench + 输入延迟直方图;杀 server 产生干净 device-lost latch;SIGKILL 故障注入 | P11 | +| **P13** 退役 pull 路径 | 8–12 | 删 `SnapshotFromGLContext()` 非 verify 分支、`MGB_CTX`、`MOBILEGL_PIPE_PUSH`、`MOBILEGL_PIPE_LEGACY_MEMOS`;保留 `MOBILEGL_PIPE_VERIFY`;MGPipe recorder 金标模式;删 `set_residual_value_state`;`MG_Backend` 的 `MG_State` include 收缩到 `MGPipeValueTypes.h`;在计数器活着的情况下重调幸存缓存容量(`VaoDrawMemo` 2048、`SetupDrawSnapshot` 4、pipeline memo 8、`syncedTextureMemo` 8)并变成带 env 覆盖的调优参数;最终符号/尺寸/CPU 报告 | `static_assert(sizeof(ResidualValueBlock) == 0)` 编译通过;三道纯度门在非 verify 构建上转绿;verify 构建仍零分歧;recorder 金标在 40 trace 上建立;全套门(集成 × 2 后端 × {monolith, split}、单元、40 trace、两台设备 CTS 在 `81b17c0b` 基线 0.5 pp 内);**monolith 逐线程 CPU 在两台设备 p50/p99 上不差于 P0 基线** | P7、P8、P12 | + +累计(低端):P0 9 → P0.5 15 → P1 25 → P2 43 → P3a 61 → P4a 87 → P5 99 → P6 104 → P3b/P4b 133 → P8 145 → P9 155 → P10 161 → P11 169 → P12 179 → P13 187;P7 另 80–104,单跑道累计 267。 + +**CTS 周转单独计价**:`gl44to46` 约 56,271 例。逐阶段只跑该阶段可能影响的具名块(P4a `packed_pixels`、P3b/P4b `texture_*`/`shader_image_*`、P9 `transform_feedback*`);完整 caselist 只在五个架构边界(P0.5、P3a、P4a、P3b/P4b、P13)与每次合并 `dev` 之前跑,放 CI 不放关键路径。若周转仍主导排期,加宽估时而不是削弱门。 + +## 里程碑 + +- **第 25 天(P1 出口)**:verify harness 逐 draw 逐字段证明"推送等价于拉取"。零产品风险,**不是** GO/NO-GO。 +- **第 43 天(P2 出口):GO/NO-GO**。 +- 第 99 天:首个 `inproc` IPC 帧(缩减路径);第 104 天:首个跨进程帧;第 145 天:全功能 split;第 187 / 267 天:三道纯度门转绿。 + +## 第 43 天 GO/NO-GO 清单 + +手上必须有: + +- [ ] P1 交付的逐 draw 逐字段语义等价证明(40 trace + 全部集成测试零分歧) +- [ ] 两个后端上都已推送的渲染状态,`SyncRenderState` 693 行一行未动 +- [ ] 两片 Track H 的实测单位成本(Espryt 0b、Magma 子系统 4) +- [ ] 两台设备(Adreno 830 `35d0befa`、Mali `3B159D009VZ00000`)reboot-clean 配对的逐线程 CPU 时间增量,p50 与 p99 +- [ ] tracker 每 draw 的**绝对 ns**(上限从设备基线定:稳态每 draw 6.5–9.3 次 accessor + memo 探测,见 `MEASUREMENTS.md`) +- [ ] Blaze3D blend-toggle 微基准(enable/draw/disable/draw,MC batch 速率) +- [ ] 负面对照:关掉 CSO 内容寻址重跑,把"推送更慢"与"CSO 设计更慢"分开 + +判据与出口: + +- **继续**:两台设备 p50 与 p99 逐线程 CPU 增量都不为负;tracker 绝对 ns 在上限内;Track H 单位成本不超出估计的 50%。按两条跑道推进。 +- **收缩为 headless 工装用途或重新评估**:任一判据落空。**不回滚**:P0/P0.5/P1/P2 的产物(句柄基建与重键、两个头文件抽取、计数器、verify harness、渲染状态 CSO)全是自洽的 monolith 交付物,留在 `dev`;MGPipe 收缩为 `MG_Test` mock 后端 → MGPipe recorder(给 trace_replay 一种记录已解析状态的录制格式)+ `inproc` 渲染线程实验;IPC 跑道搁置到出现新判据。 +- 沉没成本:P0 与 P0.5 无论走哪条路都要花(后者本身是 monolith 净收益);真正只为 MGPipe 押上的是 P1 + P2 ≈ 28–39 天,NO-GO 分支下仍留下上述产物。 + +## 再基线检查点 + +| 触发 | 动作 | +|---|---| +| P3a > 27 天 | "窄句柄化"的前提错了,P4a 开始前重定基线 | +| P4a > 39 天 | 同上 | +| P7 中点(第 40–52 工作日)完成子系统 < 40% | 立即重定基线(P3a 的检查点发现不了 Magma 特有的超期) | + +任一触发,先跑 `inproc` 的证伪数字再决定是否继续。 + +## 仍然开放的问题 + +P0 已回答的不再列出(spike A 的域、spike B 的分档、`posix_spawn` 不可用、OOM 探测惯用法、`GetInteger64i_v`/`GetProgramiv` 退役、D21 与 `RenderbufferObject` lifetime id、动态 accessor 基线)。 + +1. **client 侧 dirty 走查的真实每 draw CPU 代价。** 拉取基线已实测为每 draw 6.5–9.3 次 accessor + memo 探测;推送要在这个数字下净减少。P2 的头号数字,逐线程 CPU + 绝对 ns,两台设备。 +2. **真实语料上纹理重铸拉取的发生率。** `ImageBindableHint` 预防主因,但整格式再生在普通 `glTexImage` 格式变更上就触发。若 MC/Iris fixture 上非平凡,保留 LRU 从默认 0 升为强制并拿真预算。 +3. **spike B 的 `untrusted_app` 域复核。** 两台设备的分档在 `shell` 域测得;T0 的 AHB socket 交接是每个与 SurfaceFlinger 共享 buffer 的应用都在走的路径,风险在 memfd/opaque-fd 腿上。从应用进程再跑一次 `extmem_probe`(spike A 的 exec 钩子已可用)。 +4. **渲染状态的 wire 粒度。** chunk 划分定下来后,CSO LRU 容量(暂定 64)与 `set_dynamic_state` 的 chunk 粒度由计数器定。 +5. **`FramebufferSrgb` / `DepthClamp` 的拍板。** 事实已清(无存储、`glEnable` 静默吞掉、六个读点恒 false、41 个 fixture 无一开启);建议在 chunk 表冻结前补真存储并把 `FramebufferSrgb` 划进 pipeline 半边。由计划所有者拍板,**拍板前不冻结 chunk 表**。 +6. **具名 UBO host payload 的形状(D-B8)。** 第一个数字已有:Magma 在 26.3 世界每帧重打包 331 KB 具名 UBO 字节,Espryt 为 0。要么冻结现在的第二变长尾形状,要么走备选(Magma 直接描述符绑定常驻 `VkBuffer` range,独立 `dev` PR + Iris 性能门)。 +7. **`MG_Util` 的切割缝。** server 需要 SPIRV-Cross pass 流水线、ESSL 转译缓存、格式处理器、POST 探针;client 需要 glslang phase A/B 与反射层。P0.5 解决了 `ProgramObject.h` 一处,`MG_Util` 内部是否有干净的 Transpile-vs-Reflect 缝未审计。 +8. **一份反射归档能否服务三个消费者**(Espryt 读前端表、Magma 跑 SPIRV-Reflect、`DirectVulkan.cpp` 为 `glGetProgramResource*` 又反射一遍)。 +9. **viewport-array 回放能否塞进一次 `draw_vbo`**:`EndViewportRoutingPasses` 会 `InvalidateSyncedRenderState`,各遍之间观察到的状态是否与今天一致未验证。 +10. **`ResidentSubData` 的不对称怎么收口。** null 项保住今天的行为;给 Magma 补真实现是行为变更,独立 `dev` PR。 +11. **`SEG_STAGE` 的上限。** 六类新字节需要 P8 之后用 MC in-world 与 Create 两类 fixture 的 `stage-*` 计数器给 p99 占用;G3 分块路径需要设计与测试。 +12. **P13 之后 split-only 渲染 bug 的 server 侧第二意见。** verify 构建 + recorder 只覆盖推送内容,不覆盖后端对它的解释。 +13. **烘焙后的内部 shader 能否在没有活 `ProgramObject` 的情况下表达 uniform location 与 UBO 布局。** 未做原型。 +14. **推送模型改变哪些按拉取模式调过的缓存命中率。** 幸存者容量在 P13 重调。 +15. **monolith 的 `*IndirectCount` 不调 `SyncGpuWrites()` 是不是潜在缺口**(compute 写的 indirect buffer)。独立 `dev` 问题,拆分不得借机顺手修。 +16. **索引宿主镜像的实际内存占用。** MC/Sodium/Iris 语料里 element-array buffer 总量未测;若显著超 64 MiB,退化路径的频率与代价必须实测。 +17. **create-indirect fixture 在 Adreno 830 上的失败**是 `dev@81b17c0b` 就有的(基线 APK 复现),不是本分支造成;它是 P3a/P8 验收清单里的用例,需要先在 `dev` 上修。 From bee22f9d26679ef74ba25a31be6d7c45c81d44de Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 21:57:11 -0400 Subject: [PATCH 030/529] [Docs] (Disaggregated): separate the dirty-surface totals from the immediate-publish subset in MEASUREMENTS - the sentence read as if 836 of the 92 immediate calls were RecordError; 836 is RecordError's share of all 926 calls, and most of the 92 immediate ones are RecordError --- docs/Disaggregated/MEASUREMENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Disaggregated/MEASUREMENTS.md b/docs/Disaggregated/MEASUREMENTS.md index 5551e1cba..0230f4b81 100644 --- a/docs/Disaggregated/MEASUREMENTS.md +++ b/docs/Disaggregated/MEASUREMENTS.md @@ -78,7 +78,7 @@ python3 tools/trace_replay/run_android_retrace_local.py \ ## 4. 桌面数据点与语料事实 - **llvmpipe / lavapipe 动态 accessor**(`GuiBatchScenario`,14 帧 / 26 draw,memo 冷):Espryt 20.65 / Magma 15.54 次/draw——落在预测区间内,且因场景太短偏高;真机稳态数字见 §3。 -- **dirty-surface 面**(`python3 scripts/gen_pipe_dirty_surface.py --summary`,本树):`MG_Impl/GLImpl` 41 个文件,926 次 mutator 调用,73 个不同 mutator;92 次(36 个即时发布点、7 个 mutator,836 次里绝大多数是 `RecordError`)位于同函数内也到达后端的入口,其余 834 次由紧随的 verb 发布。映射表是 73 条目的问题。 +- **dirty-surface 面**(`python3 scripts/gen_pipe_dirty_surface.py --summary`,本树):`MG_Impl/GLImpl` 41 个文件,926 次 mutator 调用,73 个不同 mutator(`RecordError` 一项就占 836 次);92 次(36 个即时发布点、7 个 mutator,绝大多数是 `RecordError`)位于同函数内也到达后端的入口,其余 834 次由紧随的 verb 发布。映射表是 73 条目的问题。 - **读点覆盖**(`python3 scripts/gen_pipe.py`):71 条调用(11 screen / 60 context)、63 个 verify payload、61 个 `PipeInputs` 字段;477 行后端读点清单 → 299 调用、5 client 自答、6 反向通道、167 结构性句柄、**0 UNMAPPED**。 - **OOM 探测惯用法**:41 个 trace fixture 中 0 例——全部语料只有 9 次 `glRenderbufferStorage` 调用散在 5 个 fixture,无一在其后 3 个调用内跟 `glGetError`;语料里真实的成功性检查是 `glCheckFramebufferStatus`。→ `glRenderbufferStorage*` 不 ack。 - **`FramebufferSrgb` / `DepthClamp`**:`FramebufferSrgb` 的六个后端读点全部消费一个编译期常量 `false`,`DepthClamp` 零读点;两者的 `glEnable` 落到 `RenderState.cpp` 的 `default:` 分支既不存储也不报 `GL_INVALID_ENUM`;41 个 fixture 无一开启任一项(补真存储不会改动任何既有 fixture 的输出)。 From 6e0e3df372c64cb34feaf0b293cf521e682f8007 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 21:58:28 -0400 Subject: [PATCH 031/529] [Docs] (Disaggregated): point the history note at commit hashes only - the retired drafts stay in git history; the README no longer names or characterises them --- docs/Disaggregated/README.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/docs/Disaggregated/README.md b/docs/Disaggregated/README.md index 06d8c9fac..0e1536cae 100644 --- a/docs/Disaggregated/README.md +++ b/docs/Disaggregated/README.md @@ -54,11 +54,4 @@ MGPipe 是 MobileGL 前端(`MG_State` + `MG_Impl`)与后端(`MG_Backend` ## 历史 -本目录此前是一份 328 KB 的实施计划(`PLAN.md`)加 135 KB 的设计竞赛与三视角对抗性评审记录(`REVIEW.md`)。设计已定稿,本次改写只保留设计与架构本身;评审记录、被否决的替代方案与 v1→v2 的修订史留在 git 历史里: - -- `8b31de2f`:方案 A(replica GLContext + mutator 回放)与首轮评审; -- `1794ac94`:方案 B(MGPipe)、A/B 逐项对比、第二轮竞赛与对抗性评审; -- `8349babe`:合并为单一 MGPipe 计划,废弃方案 A; -- `87ee17c6`:折入 P0 实测修正。 - -`git show 87ee17c6:docs/Disaggregated/REVIEW.md` 可取回评审记录全文。此外还有一条已放弃的早期分支 `Feat/CS-Delta-IPC`,其可复用/改造/放弃的逐文件判定见 `8349babe` 版 `PLAN.md` §17。 +本目录此前是一份 328 KB 的实施计划(`PLAN.md`)加 135 KB 的设计竞赛与三视角对抗性评审记录(`REVIEW.md`)。设计已定稿,本次改写只保留设计与架构本身;评审记录、早期草案与修订史留在 git 历史里(`8b31de2f`、`1794ac94`、`8349babe`、`87ee17c6`;`git show 87ee17c6:docs/Disaggregated/REVIEW.md` 可取回评审记录全文)。更早的一条已放弃分支 `Feat/CS-Delta-IPC` 的逐文件可复用判定见 `8349babe` 版 `PLAN.md` §17。 From fe3dc1dde8cfe19b2914c69e2cb3fa6d446cb51f Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 22:51:32 -0400 Subject: [PATCH 032/529] [CI] (Purity): add scripts/check_include_closure.py - the -H include-closure gate for the P0.5 headers with an always-on negative control, wired as a unit ctest and the include-graph-check job - ROADMAP P0.5 asks for a CI assertion on the include closure of the two headers the phase extracts, and ROADMAP.md:7 asks every gate to be able to fail for the reason it exists. `nm --undefined-only` cannot express either: a header that is included but whose types are never named leaves no symbol behind, and "included at all" is exactly the coupling P1 and P7 have to sever. The preprocessor's own `-H` transcript can. - Three probes, coded against the fixed path contract of the P0.5 brief so this package lands before the headers do: value-header (MG_Pipe/MGPipeValueTypes.h: no MG_State/, MG_Impl/, MG_Backend/, MG_Remote/), artifacts-header (ProgramArtifacts.h: no ShaderObject.h, ShaderTranspiler/, Config.h, MG_Backend/, BufferState/, ProgramState/ Shader*, plus a budget of two `glslang::` tokens for the two members D5 keeps verbatim) and wire-header (ITransport.h must not reach Includes.h - green today, so the gate has a live probe from its first commit). - The forbidden sets say nothing about glslang, spirv-cross or vulkan on purpose: Includes.h pulls all three unconditionally and both new headers are allowed , so a textually glslang-free closure is unsatisfiable by construction. P7 measures that with `nm -D | grep glslang` on the server binary instead. - Two modes because they check different things. Text mode walks literal #include lines, needs no compiler and no submodules, and is what the ctest runs (the CI `test` job's runner has neither); clang mode is the arbiter, and adds a -fsyntax-only pass proving the header is self-contained. `--mode both` additionally fails on a disagreement between the two violation sets, so text mode's blindness to `#if` cannot hide a hit. - -H parsing normalises before matching (today's transcripts contain TextureState/../SamplerState/SamplerObject.h) and accepts only `^\.+ ` lines, which discards the "Multiple include guards may be useful for:" paragraph g++ appends. Both are pinned by a canned-transcript check inside --self-test. - D9 skip semantics: a probe whose header does not exist prints SKIP and is counted, and --require-all turns every SKIP into a failure. That is what lets the gate land first and still stops an all-SKIP run from passing for free once the headers exist; the integrator flips --require-all on after all three P0.5 packages land. - --self-test is always on in both the ctest and the CI job: it synthesizes its TUs in a tempdir (it never touches a tracked file) and requires a negative control that does not depend on P0.5 at all - MGPipeHandles.h plus RenderState.h checked against the value-header list - to report RenderState.h as a depth-1 violation in every enabled mode. Zero trips anywhere is an ::error:: and exit 1, because a gate that cannot go red is not a gate. Controls 2 and 3 arm themselves as the two headers appear. - Registered as MobileGLPurity.IncludeClosure with LABELS unit so `ctest -L unit` runs it, and with no ENVIRONMENT property, which would replace the job env wholesale. --- .github/workflows/test.yml | 26 + MobileGL/MG_Test/CMakeLists.txt | 3 + MobileGL/MG_Test/Purity/CMakeLists.txt | 15 + scripts/check_include_closure.py | 659 +++++++++++++++++++++++++ 4 files changed, 703 insertions(+) create mode 100644 MobileGL/MG_Test/Purity/CMakeLists.txt create mode 100755 scripts/check_include_closure.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8d5a454c6..5c2368e76 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -322,6 +322,32 @@ jobs: - name: Fail if the committed header is stale run: git diff --exit-code -- MobileGL/MG_Remote/Protocol/generated/protocol_generated.h + # P0.5 interface-purity gate A (ARCHITECTURE.md:501): the two extracted headers' include closure, + # asserted on `-H` output because `nm --undefined-only` is blind to "included but not called" - + # a header whose types are never named leaves no symbol behind, and "included at all" is exactly + # the coupling P1 and P7 have to sever. Needs a preprocessor and three header submodules, no + # CMake configure and no glslang sources, so like pipe-gates it does not depend on build-linux. + # The script's own --self-test is always on: a negative control that stopped tripping fails the + # job, because a gate that cannot go red is not a gate (ROADMAP.md:7). + include-graph-check: + name: Include-closure purity gate + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Check out the three header submodules the closure needs + # ska/flat_hash_map.hpp, xxhash.h and vulkan/vulkan.h are the only submodule headers + # Includes.h reaches; glslang and spirv_cross are vendored under include/. + run: git submodule update --init include/ska 3rdparty/xxHash 3rdparty/Vulkan-Headers + + - name: Install clang + run: sudo apt-get update && sudo apt-get install -y clang-20 + + - name: Include-closure assertions and negative control + run: python3 scripts/check_include_closure.py --mode both --compiler clang++-20 --self-test + benchmark: runs-on: ubuntu-latest needs: build-linux diff --git a/MobileGL/MG_Test/CMakeLists.txt b/MobileGL/MG_Test/CMakeLists.txt index 045ebd81a..80a4da96d 100644 --- a/MobileGL/MG_Test/CMakeLists.txt +++ b/MobileGL/MG_Test/CMakeLists.txt @@ -88,6 +88,9 @@ add_subdirectory(Pipeline) # The MGPipe catalogue arithmetic: no GL context and no driver, just the .def, the seven # generated files and the payload layouts. add_subdirectory(Pipe) +# The P0.5 interface-purity gate: a python walk of #include lines, so it registers with no +# MobileGL build, no GL context and no submodules (scripts/check_include_closure.py). +add_subdirectory(Purity) add_subdirectory(ShaderTranspiler) add_subdirectory(Util) add_subdirectory(SelfTest) diff --git a/MobileGL/MG_Test/Purity/CMakeLists.txt b/MobileGL/MG_Test/Purity/CMakeLists.txt new file mode 100644 index 000000000..59e90d31e --- /dev/null +++ b/MobileGL/MG_Test/Purity/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.14) + +# The P0.5 include-closure assertions (ROADMAP P0.5; ARCHITECTURE.md:501 gate A): no MobileGL +# compilation, no GL context. Text mode only here, because this CTestTestfile also runs on the +# `test` job's runner (test.yml:149-203), which has no submodules; the compiler-backed run is the +# include-graph-check job and the developer's local `--mode both`. +find_package(Python3 COMPONENTS Interpreter) +if (Python3_Interpreter_FOUND) + add_test(NAME MobileGLPurity.IncludeClosure + COMMAND ${Python3_EXECUTABLE} ${MGL_ROOT}/scripts/check_include_closure.py --mode text --self-test) + # ctest -L unit (test.yml:198-203). NO ENVIRONMENT property: it replaces the job env (ARCHITECTURE.md:567). + set_tests_properties(MobileGLPurity.IncludeClosure PROPERTIES LABELS unit) +else() + message(STATUS "MobileGLPurity.IncludeClosure not registered: no python3 interpreter (Windows is not a correctness gate)") +endif() diff --git a/scripts/check_include_closure.py b/scripts/check_include_closure.py new file mode 100755 index 000000000..a4693c16e --- /dev/null +++ b/scripts/check_include_closure.py @@ -0,0 +1,659 @@ +#!/usr/bin/env python3 +# MobileGL - scripts/check_include_closure.py +# Copyright (c) 2025-2026 MobileGL-Dev +# Licensed under the GNU Lesser General Public License v3.0: +# https://www.gnu.org/licenses/gpl-3.0.txt +# https://www.gnu.org/licenses/lgpl-3.0.txt +# SPDX-License-Identifier: LGPL-3.0-only +# End of Source File Header +"""The P0.5 interface-purity gate: what the extracted headers are allowed to include. + +ROADMAP.md P0.5 asks for a CI assertion on the include CLOSURE of the two headers the +phase extracts, and for a negative control that goes red when an `MG_State` include is +added back (ROADMAP.md:7 - every gate must be able to fail for the reason it exists). +`nm --undefined-only` cannot do that job: a header that is included but whose types are +never named leaves no symbol behind, and "included at all" is exactly the coupling P1 +and P7 have to be able to sever. So the arbiter here is the preprocessor's own +`-H` transcript, plus a literal `#include` walk that needs no compiler at all. + + probe header forbidden in its closure + ---------------- --------------------------------------------- ------------------------------- + value-header MG_Pipe/MGPipeValueTypes.h MG_State/ MG_Impl/ MG_Backend/ MG_Remote/ + artifacts-header MG_State/.../ProgramState/ProgramArtifacts.h ShaderObject.h, ShaderTranspiler/, + Config.h, MG_Backend/, BufferState/, + ProgramState/Shader* (+ <= 2 `glslang::`) + wire-header MG_Remote/Transport/ITransport.h MobileGL/Includes.h + +The forbidden sets deliberately say nothing about glslang, spirv-cross or vulkan: +MobileGL/Includes.h pulls all three unconditionally (:53,:56,:59,:80-84,:130) and both +new headers are allowed ``, so a "textually glslang-free closure" assertion +would be unsatisfiable by construction. P7 measures that with `nm -D | grep glslang` on +the server binary instead - a symbol gate, not a text gate. + +Two modes, and they check different things: + + --mode text transitive walk of literal `#include` lines. No compiler, no + submodules, blind to `#if`. This is what the ctest runs, because the + CI `test` job's runner has no submodules checked out. + --mode clang `-H -E` on a one-line probe TU, which is the ground truth, plus a + `-fsyntax-only` pass that proves the header is self-contained. + --mode both run both and additionally fail if they disagree about the violations. + +D9 (brief section B.0): a probe whose header does not exist yet prints SKIP and is +counted; `--require-all` turns every SKIP into a failure. That is what lets this package +land BEFORE the two headers do, and what stops an all-SKIP run from passing for free +once they exist - the integrator flips `--require-all` on as the ratchet. + + python3 scripts/check_include_closure.py --mode text --self-test + python3 scripts/check_include_closure.py --mode both --compiler clang++-20 --self-test + python3 scripts/check_include_closure.py --mode both --self-test --require-all +""" + +import argparse +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +import tempfile + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +PREFIX = "include-closure: " + +# The probe manifest. Header/Tu/Forbidden are the fixed contract of brief section B.0: +# the gate is coded against these paths before the headers they name exist. +# +# Allow entries are deliberately awkward: each one needs a Reason that is printed in +# the summary of every run, so that a whitelist entry has to be argued for in the pull +# request instead of appearing quietly (test.yml:831-835 house rule). +PROBES = [ + { + "Name": "value-header", + "Header": "MobileGL/MG_Pipe/MGPipeValueTypes.h", + "Tu": "#include \n", + "Forbidden": [ + "MobileGL/MG_State/", + "MobileGL/MG_Impl/", + "MobileGL/MG_Backend/", + "MobileGL/MG_Remote/", + ], + "Allow": [], + "TextLimits": {}, + "Why": "MG_Pipe is below MG_State (ARCHITECTURE.md:550-561): the value types P1 hands " + "the backend must not drag the frontend state tree back in.", + }, + { + "Name": "artifacts-header", + "Header": "MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h", + "Tu": "#include \n", + "Forbidden": [ + "MobileGL/MG_State/GLState/ProgramState/ShaderObject.h", + "MobileGL/MG_Util/ShaderTranspiler/", + "MobileGL/Config.h", + "MobileGL/MG_Backend/", + "MobileGL/MG_State/GLState/BufferState/", + # prefix, on purpose: ShaderStage.h, ShaderCompileTask.h, + # ShaderCompileAdoptionMap.h, ShaderPreprocessCache.h, ShaderSourceKey.h + "MobileGL/MG_State/GLState/ProgramState/Shader", + ], + "Allow": [], + # D5: LinkArtifacts keeps exactly two glslang-typed members and no more. + "TextLimits": {"glslang::": 2}, + "Why": "P7 ships the reflection artifacts over the wire; the archive header must not " + "depend on the compiler front end that produced them.", + }, + { + "Name": "wire-header", + "Header": "MobileGL/MG_Remote/Transport/ITransport.h", + "Tu": "#include \n", + "Forbidden": ["MobileGL/Includes.h"], + "Allow": [], + "TextLimits": {}, + "Why": "WireLog.h:9-24 states the rule: nothing about a byte pipe needs the GL " + "frontend's umbrella header. Green today - it is the gate's own canary.", + }, +] + +# Deleting a probe must be red, not quietly green. +REQUIRED_PROBE_NAMES = ("value-header", "artifacts-header") + +# Measured to reproduce the closure the real build sees (666 lines for the negative +# control TU), without a CMake configure. +DEFAULT_CLANG_FLAGS = [ + "-std=gnu++23", + "-Iinclude", + "-IMobileGL", + "-IMobileGL/MG_Pipe", + "-I3rdparty/xxHash", + "-I3rdparty/Vulkan-Headers/include", +] + +# The only submodule headers Includes.h reaches; glslang and spirv_cross are vendored. +CLANG_MODE_PREREQS = [ + "include/ska/flat_hash_map.hpp", + "3rdparty/xxHash/xxhash.h", + "3rdparty/Vulkan-Headers/include/vulkan/vulkan.h", +] + +COMPILER_CANDIDATES = ["clang++-20", "clang++", "c++", "g++"] + +# Text-mode angle-bracket search path, in the order MOBILEGL_INCLUDE_DIR lists it +# (CMakeLists.txt:529-541); MG_Pipe is there twice on purpose (CMakeLists.txt:531,535). +SEARCH_DIRS = [ + "include", + "MobileGL", + "MobileGL/MG_Pipe", + "3rdparty/xxHash", + "3rdparty/Vulkan-Headers/include", +] + +# `-H` writes to stderr, one line per file actually opened: dots for depth, then the path +# as spelled on the search path. g++ appends a "Multiple include guards may be useful +# for:" paragraph of bare paths, which must not be mistaken for depth-0 includes. +H_LINE_RE = re.compile(r"^(\.+) (.*)$") + +INCLUDE_RE = re.compile(r'^[ \t]*#[ \t]*include[ \t]*([<"])([^>"]+)[>"]') + + +def say(message): + print(PREFIX + message) + + +def error(message): + print("::error::" + message) + + +def repo_relative(path, cwd): + """normpath the -H spelling, then express it repo-relative with forward slashes. + + Today's transcripts contain e.g. TextureState/../SamplerState/SamplerObject.h, which + only matches a forbidden prefix after normalisation. Anything outside the repo (the + toolchain's own headers, /usr/include) is not our business and returns None. + """ + absolute = os.path.normpath(os.path.join(cwd, path)) + try: + relative = os.path.relpath(absolute, REPO_ROOT) + except ValueError: # different drive on Windows + return None + if relative == os.pardir or relative.startswith(os.pardir + os.sep): + return None + return relative.replace(os.sep, "/") + + +def parse_h_output(text, cwd): + """-H transcript -> [(depth, repo_relative_or_None, raw_spelling)] in file order.""" + entries = [] + for line in text.splitlines(): + match = H_LINE_RE.match(line) + if not match: + continue + entries.append((len(match.group(1)), repo_relative(match.group(2), cwd), match.group(2))) + return entries + + +def resolve_include(spec, quoted, from_dir): + candidates = [] + if quoted: + candidates.append(os.path.join(from_dir, spec)) + for directory in SEARCH_DIRS: + candidates.append(os.path.join(REPO_ROOT, directory, spec)) + for candidate in candidates: + candidate = os.path.normpath(candidate) + if os.path.isfile(candidate): + return candidate + return None + + +def text_closure(tu_text, tu_dir): + """Transitive walk of literal #include lines. + + There are no macro-driven includes under MobileGL/ (3377 include lines, all literal), + so a literal walk is exact apart from `#if`, which this mode is blind to by design - + the compiler mode is the arbiter and --mode both cross-checks the two. + A file is walked once, which is what an include guard does to the -H transcript too. + """ + entries = [] + seen = set() + + def walk(text, from_dir, depth): + for line in text.splitlines(): + match = INCLUDE_RE.match(line) + if not match: + continue + path = resolve_include(match.group(2), match.group(1) == '"', from_dir) + if path is None: # unresolved (libstdc++, EGL/, GL/, glslang/, ...) = leaf + continue + if path in seen: + continue + seen.add(path) + entries.append((depth, repo_relative(path, REPO_ROOT), path)) + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + nested = handle.read() + except OSError: + continue + walk(nested, os.path.dirname(path), depth + 1) + + walk(tu_text, tu_dir, 1) + return entries + + +def find_violations(entries, forbidden, allow): + violations = [] + for index, (depth, relative, _raw) in enumerate(entries): + if relative is None: + continue + hit = next((f for f in forbidden if relative.startswith(f)), None) + if hit is None: + continue + if any(relative.startswith(a["Path"]) for a in allow): + continue + violations.append({"index": index, "depth": depth, "path": relative, "rule": hit}) + return violations + + +def chain_for(entries, index): + """Walk back to depth-1, depth-2, ... 1: the include chain that pulled the violation in.""" + chain = [] + wanted = entries[index][0] + cursor = index + while cursor >= 0 and wanted >= 1: + depth, relative, raw = entries[cursor] + if depth == wanted: + chain.append((depth, relative or raw)) + wanted -= 1 + cursor -= 1 + return list(reversed(chain)) + + +def count_text_limits(header_path, limits): + """Token budgets on the header's own text (D5 pins `glslang::` at 2).""" + problems = [] + if not limits: + return problems + with open(header_path, "r", encoding="utf-8", errors="replace") as handle: + text = handle.read() + for token, budget in sorted(limits.items()): + found = text.count(token) + if found > budget: + problems.append("{} occurs {} times, budget is {}".format(token, found, budget)) + return problems + + +def pick_compiler(explicit): + if explicit: + found = shutil.which(explicit) or (explicit if os.path.isfile(explicit) else None) + if not found: + error("compiler not found: " + explicit) + sys.exit(1) + return found + env_cxx = os.environ.get("CXX") + if env_cxx: + found = shutil.which(env_cxx) or (env_cxx if os.path.isfile(env_cxx) else None) + if found: + return found + for candidate in COMPILER_CANDIDATES: + found = shutil.which(candidate) + if found: + return found + error("no C++ preprocessor found (tried $CXX, " + ", ".join(COMPILER_CANDIDATES) + ")") + sys.exit(1) + + +def flags_from_compile_commands(path): + """Any entry whose `file` is under MobileGL/ carries the flags the real build uses.""" + with open(path, "r", encoding="utf-8") as handle: + database = json.load(handle) + keep_prefixes = ("-D", "-I", "-isystem", "-std") + for entry in database: + source = entry.get("file", "") + if "MobileGL/" not in source.replace(os.sep, "/"): + continue + tokens = entry.get("arguments") + if tokens is None: + tokens = shlex.split(entry.get("command", "")) + flags = [] + skip_next = False + for token in tokens[1:]: + if skip_next: + skip_next = False + continue + if token in ("-o", "-c", "-MD", "-MF", "-MT", "-MMD", "-MQ"): + skip_next = token in ("-o", "-MF", "-MT", "-MQ") + continue + if token == "-isystem": + skip_next = False + flags.append(token) + continue + if token.startswith(keep_prefixes): + flags.append(token) + continue + # the input file and everything else is dropped + return flags, entry.get("directory", REPO_ROOT) + error("no MobileGL/ entry in " + path) + sys.exit(1) + + +def check_clang_prereqs(): + missing = [p for p in CLANG_MODE_PREREQS if not os.path.isfile(os.path.join(REPO_ROOT, p))] + if missing: + error("clang mode needs " + ", ".join(missing) + " - run from a full checkout: " + "git submodule update --init include/ska 3rdparty/xxHash 3rdparty/Vulkan-Headers") + sys.exit(1) + + +def run_preprocessor(compiler, flags, tu_path, cwd): + command = [compiler] + list(flags) + ["-H", "-E", "-o", os.devnull, tu_path] + completed = subprocess.run(command, cwd=cwd, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + return completed + + +def run_syntax_only(compiler, flags, tu_path, cwd): + command = [compiler] + list(flags) + ["-fsyntax-only", tu_path] + return subprocess.run(command, cwd=cwd, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + + +def closure_for_tu(mode, tu_text, context): + """Return (entries, note). `context` carries compiler/flags/cwd/tmpdir.""" + if mode == "text": + return text_closure(tu_text, REPO_ROOT), None + tu_path = os.path.join(context["tmpdir"], "probe_%d.cpp" % context["counter"][0]) + context["counter"][0] += 1 + with open(tu_path, "w", encoding="utf-8", newline="\n") as handle: + handle.write(tu_text) + completed = run_preprocessor(context["compiler"], context["flags"], tu_path, context["cwd"]) + if completed.returncode != 0: + return parse_h_output(completed.stderr, context["cwd"]), completed.stderr.strip() + return parse_h_output(completed.stderr, context["cwd"]), None + + +def print_chain(entries, violation): + for depth, path in chain_for(entries, violation["index"]): + say(" " + (" " * (depth - 1)) + path) + + +def run_probe(probe, mode, context, results): + header_abs = os.path.join(REPO_ROOT, probe["Header"]) + entries, note = closure_for_tu(mode, probe["Tu"], context) + if note: + error("{} preprocessing failed:\n{}".format(probe["Name"], note)) + results.append({"probe": probe["Name"], "mode": mode, "status": "ERROR", + "headers": len(entries), "forbidden": 0}) + return "ERROR", set() + + violations = find_violations(entries, probe["Forbidden"], probe["Allow"]) + text_problems = count_text_limits(header_abs, probe["TextLimits"]) + status = "OK" if not violations and not text_problems else "FORBIDDEN" + say("{} {} {} headers in closure, {} forbidden (mode={}, cxx={})".format( + probe["Name"], status, len(entries), len(violations), mode, + context["compiler_label"] if mode == "clang" else "-")) + for allowed in probe["Allow"]: + say(" ALLOW {} - {}".format(allowed["Path"], allowed["Reason"])) + for violation in violations: + say(" forbidden: {} (rule {}) reached by:".format(violation["path"], violation["rule"])) + print_chain(entries, violation) + for problem in text_problems: + say(" text limit: " + problem) + + results.append({"probe": probe["Name"], "mode": mode, "status": status, + "headers": len(entries), "forbidden": len(violations), + "violations": [v["path"] for v in violations], + "text_problems": text_problems}) + return status, {v["path"] for v in violations} + + +def run_self_contained_check(probe, context): + """Second assertion in clang mode: the header compiles on its own.""" + tu_path = os.path.join(context["tmpdir"], "syntax_%s.cpp" % probe["Name"].replace("-", "_")) + with open(tu_path, "w", encoding="utf-8", newline="\n") as handle: + handle.write(probe["Tu"]) + completed = run_syntax_only(context["compiler"], context["flags"], tu_path, context["cwd"]) + if completed.returncode == 0: + say("{} SELF-CONTAINED OK (-fsyntax-only, cxx={})".format( + probe["Name"], context["compiler_label"])) + return True + say("{} SELF-CONTAINED FAILED (-fsyntax-only, cxx={})".format( + probe["Name"], context["compiler_label"])) + for line in completed.stderr.strip().splitlines()[:20]: + say(" " + line) + return False + + +# -------------------------------------------------------------------------------------- +# self-test: the always-on proof that the gate is capable of failing +# -------------------------------------------------------------------------------------- + +PARSER_CANNED_GXX = """. MobileGL/MG_Pipe/MGPipeHandles.h +.. MobileGL/Includes.h +... MobileGL/MG_State/GLState/TextureState/../SamplerState/SamplerObject.h +Multiple include guards may be useful for: +/usr/include/bits/byteswap.h +/usr/include/bits/confname.h +""" + + +def self_test_parser(): + """Check 4: the trailer paragraph is discarded and a `../` spelling normalises.""" + problems = [] + entries = parse_h_output(PARSER_CANNED_GXX, REPO_ROOT) + if len(entries) != 3: + problems.append("g++ -H trailer not discarded: parsed {} lines, expected 3".format(len(entries))) + depths = [e[0] for e in entries] + if depths != [1, 2, 3]: + problems.append("depths misparsed: {}".format(depths)) + normalised = entries[-1][1] if entries else None + if normalised != "MobileGL/MG_State/GLState/SamplerState/SamplerObject.h": + problems.append("`../` spelling did not normalise: {}".format(normalised)) + elif not find_violations(entries, PROBES[0]["Forbidden"], []): + problems.append("normalised path did not match the value-header forbidden list") + if problems: + for problem in problems: + say(" self-test parser: " + problem) + return False + say(" self-test parser: trailer discarded, `../` normalised and matched") + return True + + +def negative_controls(): + """The three canned TUs of B.3, gated on which headers exist yet.""" + controls = [ + { + "Name": "control-1 (independent of P0.5)", + "Tu": "#include \n" + "#include \n", + "Forbidden": PROBES[0]["Forbidden"], + "ExpectDepth1": "MobileGL/MG_State/GLState/RenderState/RenderState.h", + }, + ] + value_header = os.path.join(REPO_ROOT, PROBES[0]["Header"]) + if os.path.isfile(value_header): + controls.append({ + "Name": "control-2 (MG_State include added back to the value header's TU)", + "Tu": PROBES[0]["Tu"] + "#include \n", + "Forbidden": PROBES[0]["Forbidden"], + "ExpectDepth1": "MobileGL/MG_State/GLState/RenderState/RenderState.h", + }) + artifacts_header = os.path.join(REPO_ROOT, PROBES[1]["Header"]) + if os.path.isfile(artifacts_header): + controls.append({ + "Name": "control-3 (ShaderObject.h added back to the artifacts header's TU)", + "Tu": PROBES[1]["Tu"] + "#include \n", + "Forbidden": PROBES[1]["Forbidden"], + "ExpectDepth1": "MobileGL/MG_State/GLState/ProgramState/ShaderObject.h", + }) + return controls + + +def self_test_text_limit(context): + """Check 3b: a third `glslang::` token in a copy of the artifacts header must trip.""" + header = os.path.join(REPO_ROOT, PROBES[1]["Header"]) + if not os.path.isfile(header): + return None + copy = os.path.join(context["tmpdir"], "ProgramArtifacts_with_a_third_glslang.h") + with open(header, "r", encoding="utf-8", errors="replace") as handle: + text = handle.read() + with open(copy, "w", encoding="utf-8", newline="\n") as handle: + handle.write(text + "\n// synthesized third token: glslang::TIntermediate\n") + problems = count_text_limits(copy, PROBES[1]["TextLimits"]) + if problems: + say(" self-test text limit: tripped as expected ({})".format(problems[0])) + return True + say(" self-test text limit: a third `glslang::` token did NOT trip the budget") + return False + + +def self_test(modes, context): + say("self-test: negative controls (must trip) in mode(s) " + ", ".join(modes)) + ok = self_test_parser() + trips = 0 + for control in negative_controls(): + for mode in modes: + entries, note = closure_for_tu(mode, control["Tu"], context) + if note: + say(" self-test {} [{}]: preprocessing failed".format(control["Name"], mode)) + ok = False + continue + violations = find_violations(entries, control["Forbidden"], []) + at_depth_1 = [v for v in violations + if v["depth"] == 1 and v["path"] == control["ExpectDepth1"]] + if violations and at_depth_1: + trips += 1 + say(" self-test {} [{}]: tripped, {} violation(s), {} at depth 1".format( + control["Name"], mode, len(violations), control["ExpectDepth1"])) + else: + ok = False + say(" self-test {} [{}]: DID NOT TRIP ({} violation(s), depth-1 hit: {})".format( + control["Name"], mode, len(violations), bool(at_depth_1))) + limit_result = self_test_text_limit(context) + if limit_result is False: + ok = False + if trips == 0: + error("negative control did not trip: the closure gate is not checking anything") + return False + say("self-test: {} negative-control trip(s), parser checks {}".format( + trips, "OK" if ok else "FAILED")) + return ok + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--mode", choices=["text", "clang", "both"], default="text", + help="literal #include walk, the preprocessor's -H transcript, or both") + parser.add_argument("--compiler", default=None, + help="preprocessor to use; default $CXX, then " + ", ".join(COMPILER_CANDIDATES)) + parser.add_argument("--compile-commands", default=None, + help="take -D/-I/-isystem/-std from a compile_commands.json entry under MobileGL/") + parser.add_argument("--probe", action="append", default=None, + help="restrict to this probe (repeatable)") + parser.add_argument("--self-test", action="store_true", + help="run the negative controls and the parser checks (always on in CI)") + parser.add_argument("--require-all", action="store_true", + help="a SKIP (header not present yet) is a failure") + parser.add_argument("--json", default=None, help="write the machine-readable result here") + args = parser.parse_args() + + names = [p["Name"] for p in PROBES] + for required in REQUIRED_PROBE_NAMES: + if required not in names: + error("probe `{}` is missing from the manifest in {}".format( + required, os.path.basename(__file__))) + return 1 + + modes = ["text", "clang"] if args.mode == "both" else [args.mode] + if "clang" in modes: + check_clang_prereqs() + + tmpdir = tempfile.mkdtemp(prefix="mgl-include-closure-") + context = { + "tmpdir": tmpdir, + "counter": [0], + "cwd": REPO_ROOT, + "flags": list(DEFAULT_CLANG_FLAGS), + "compiler": None, + "compiler_label": "-", + } + if "clang" in modes: + compiler = pick_compiler(args.compiler) + context["compiler"] = compiler + context["compiler_label"] = compiler + if args.compile_commands: + flags, directory = flags_from_compile_commands(args.compile_commands) + context["flags"] = flags + context["cwd"] = directory + say("flags from {} ({} tokens), cwd={}".format( + args.compile_commands, len(flags), directory)) + say("compiler: " + compiler) + + selected = [p for p in PROBES if args.probe is None or p["Name"] in args.probe] + if args.probe: + unknown = sorted(set(args.probe) - set(names)) + if unknown: + error("unknown probe(s): " + ", ".join(unknown)) + return 1 + + results = [] + problems = 0 + skipped = 0 + for probe in selected: + # D9: a header that does not exist yet is one SKIP line, counted once, and a + # failure only under --require-all (the ratchet the integrator flips). + if not os.path.isfile(os.path.join(REPO_ROOT, probe["Header"])): + say("{} SKIP header not present yet: {}".format(probe["Name"], probe["Header"])) + results.append({"probe": probe["Name"], "mode": "+".join(modes), "status": "SKIP", + "headers": 0, "forbidden": 0}) + skipped += 1 + if args.require_all: + problems += 1 + say("{} SKIP is a failure under --require-all: {} does not exist".format( + probe["Name"], probe["Header"])) + continue + per_mode_status = {} + per_mode_violations = {} + for mode in modes: + status, violations = run_probe(probe, mode, context, results) + per_mode_status[mode] = status + per_mode_violations[mode] = violations + if any(s not in ("OK",) for s in per_mode_status.values()): + problems += 1 + if len(modes) > 1: + sets = list(per_mode_violations.values()) + if sets[0] != sets[1]: + problems += 1 + say("{} MODE DISAGREEMENT - text and clang do not see the same violations".format( + probe["Name"])) + say(" text : " + (", ".join(sorted(per_mode_violations["text"])) or "(none)")) + say(" clang: " + (", ".join(sorted(per_mode_violations["clang"])) or "(none)")) + if "clang" in modes: + if not run_self_contained_check(probe, context): + problems += 1 + + self_test_ok = True + if args.self_test: + self_test_ok = self_test(modes, context) + if not self_test_ok: + problems += 1 + + say("{} probes, {} skipped, {} problem(s)".format(len(selected), skipped, problems)) + + if args.json: + with open(args.json, "w", encoding="utf-8", newline="\n") as handle: + json.dump({"modes": modes, "results": results, "skipped": skipped, + "problems": problems, "self_test": self_test_ok, + "require_all": args.require_all}, handle, indent=2) + handle.write("\n") + + if problems: + error("include-closure gate failed: {} problem(s); see the chains above".format(problems)) + return 1 + return 0 + + +if __name__ == "__main__": + sys.setrecursionlimit(10000) + sys.exit(main()) From 2318f6ae44e2daf002fc1d0d191323cdecdbd5c2 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 22:51:32 -0400 Subject: [PATCH 033/529] [Tooling] (Purity): add scripts/symbol_report.py - per-symbol nm/.text attribution between two libMobileGL.so builds - P0.5's acceptance gate requires every `nm --defined-only -S` delta to be explainable per symbol, and nothing in the tree reads nm or size today. - The problem the tool exists to solve: de-nesting a type renames every mangled name that mentions it, including inside template arguments, so a raw nm diff of a pure move looks catastrophic. --strip-scope 'A::B::C::' rewrites 'A::B::C::X' to 'A::B::X' on the demangled name before comparing, which folds those into a renamed-only bucket - same normalised name, byte-identical size - and leaves the real churn visible. - Buckets sorted by |delta|: removed, added, resized, renamed-only, unchanged, plus .text/.data/.bss/Total from `size --format=sysv`; --only-names narrows the listing, --markdown/--json write the report the integrator pastes into the merge commit. - Always exits 0 (this is informational, ARCHITECTURE.md:507); --fail-on-added-bytes is accepted and documented as reserved for the day it becomes a hard gate. - The docstring carries the guard rails a reader would otherwise supply by assumption: same CMAKE_BUILD_TYPE (the visibility presets differ per configuration), LTO off on both sides, same compiler - and every report prints both paths and their byte sizes. - --self-test runs two canned nm/size transcripts through the same parser and bucketer and pins all five buckets, including that a de-nested member folds to renamed rather than to an added+removed pair. --- scripts/symbol_report.py | 386 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100755 scripts/symbol_report.py diff --git a/scripts/symbol_report.py b/scripts/symbol_report.py new file mode 100755 index 000000000..df65da3cc --- /dev/null +++ b/scripts/symbol_report.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +# MobileGL - scripts/symbol_report.py +# Copyright (c) 2025-2026 MobileGL-Dev +# Licensed under the GNU Lesser General Public License v3.0: +# https://www.gnu.org/licenses/gpl-3.0.txt +# https://www.gnu.org/licenses/lgpl-3.0.txt +# SPDX-License-Identifier: LGPL-3.0-only +# End of Source File Header +"""Per-symbol nm/.text attribution between two libMobileGL.so builds. + +P0.5's acceptance gate says every `nm --defined-only -S` delta has to be explainable per +symbol (ROADMAP.md:16). The awkward part is that de-nesting a type renames every mangled +name that mentions it - `ProgramObject::LinkArtifacts` -> `LinkArtifacts` shows up inside +every `std::vector<...>` instantiation too - so a raw nm diff of a pure move looks +catastrophic. `--strip-scope` folds those into a "renamed-only" bucket: same normalised +demangled name, byte-identical size. What is left over is the report's real content. + + python3 scripts/symbol_report.py --before old.so --after new.so + python3 scripts/symbol_report.py --before old.so --after new.so \\ + --strip-scope 'MobileGL::MG_State::GLState::ProgramObject::' \\ + --only-names 'TypeFacts,ResourceReflection,XfbVarying,LinkArtifacts,SpirvArtifacts' \\ + --markdown ~/w7/p05-symbol-report.md + +GUARD RAILS - the comparison is meaningless unless both .so files were built with: + * the same CMAKE_BUILD_TYPE (the visibility presets differ between configurations, + CMakeLists.txt:578-598, so a Debug/Release pair "adds" thousands of symbols), + * MOBILEGL_ENABLE_LTO OFF on both sides (CMakeLists.txt:108,137-146 - LTO merges and + renames at will and nothing here is attributable afterwards), + * the same compiler and standard library. +The header of every report prints both paths and their byte sizes so a mismatched pair is +visible in the output rather than only in the reader's assumptions. + +This tool is informational (ARCHITECTURE.md:507, job name `monolith-symbol-report` +ARCHITECTURE.md:568) and always exits 0; `--fail-on-added-bytes` is reserved for the day +it becomes a hard gate. +""" + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys + +PREFIX = "symbol-report: " + +# `nm --defined-only -S` prints " ", and " " +# for a defined symbol whose size the object file does not carry (absolute symbols, +# assembler labels). +NM_SIZED_RE = re.compile(r"^([0-9a-fA-F]+)\s+([0-9a-fA-F]+)\s+(\S)\s+(.+)$") +NM_UNSIZED_RE = re.compile(r"^([0-9a-fA-F]+)\s+(\S)\s+(.+)$") + +# `size --format=sysv` prints "section size addr" rows plus a Total row. +SIZE_ROW_RE = re.compile(r"^(\S+)\s+(\d+)(?:\s+(\d+))?\s*$") + +INTERESTING_SECTIONS = (".text", ".data", ".bss", ".rodata", "Total") + + +def say(message): + print(PREFIX + message) + + +def parse_nm(text): + """nm --defined-only -S transcript -> {mangled: (type, size_or_None)}.""" + symbols = {} + for line in text.splitlines(): + line = line.rstrip() + if not line: + continue + match = NM_SIZED_RE.match(line) + if match: + symbols[match.group(4)] = (match.group(3), int(match.group(2), 16)) + continue + match = NM_UNSIZED_RE.match(line) + if match: + symbols[match.group(3)] = (match.group(2), None) + return symbols + + +def parse_size(text): + """size --format=sysv transcript -> {section: bytes}.""" + sections = {} + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("section"): + continue + match = SIZE_ROW_RE.match(stripped) + if not match: + continue + sections[match.group(1)] = int(match.group(2)) + return sections + + +def demangle(names, cxxfilt): + """Batch-demangle through c++filt; a plain identifier passes through unchanged.""" + ordered = list(names) + if not ordered: + return {} + if not cxxfilt or not (shutil.which(cxxfilt) or os.path.isfile(cxxfilt)): + return {name: name for name in ordered} + completed = subprocess.run([cxxfilt], input="\n".join(ordered) + "\n", + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + lines = completed.stdout.splitlines() + if len(lines) != len(ordered): + return {name: name for name in ordered} + return dict(zip(ordered, lines)) + + +def parent_scope(prefix): + """`A::B::C::` -> `A::B::` (drop the last named component, keep the enclosing scope). + + A de-nesting moves `A::B::C::X` to `A::B::X`, so folding the two spellings together + means deleting the `C::` component, NOT the whole prefix - deleting the whole prefix + would turn the before side into a bare `X` that no after-side name matches. + """ + body = prefix[:-2] if prefix.endswith("::") else prefix + cut = body.rfind("::") + return body[:cut + 2] if cut >= 0 else "" + + +def normalise(name, strip_scopes, rename_map): + """Textual folding of the demangled name: this is what makes a de-nesting a rename.""" + for scope in strip_scopes: + name = name.replace(scope, parent_scope(scope)) + for old, new in rename_map: + name = name.replace(old, new) + return name + + +def build_side(path, nm_tool, size_tool, cxxfilt, strip_scopes, rename_map, canned=None): + if canned is None: + nm_text = subprocess.run([nm_tool, "--defined-only", "-S", path], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, check=True).stdout + size_text = subprocess.run([size_tool, "--format=sysv", path], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, check=True).stdout + else: + nm_text, size_text = canned + + raw = parse_nm(nm_text) + demangled = demangle(raw.keys(), cxxfilt) + table = {} + for mangled, (kind, size) in raw.items(): + key = normalise(demangled.get(mangled, mangled), strip_scopes, rename_map) + table.setdefault(key, []).append({"mangled": mangled, "type": kind, + "size": size or 0, + "demangled": demangled.get(mangled, mangled)}) + folded = {} + for key, entries in table.items(): + folded[key] = { + "size": sum(e["size"] for e in entries), + "count": len(entries), + "mangled": sorted(e["mangled"] for e in entries), + "type": entries[0]["type"], + } + return folded, parse_size(size_text), len(raw) + + +def bucket(before, after, only_names, threshold): + def wanted(name): + return not only_names or any(n in name for n in only_names) + + removed, added, resized, renamed, unchanged = [], [], [], [], 0 + for name in sorted(set(before) | set(after)): + b = before.get(name) + a = after.get(name) + if b is None: + if wanted(name): + added.append({"name": name, "delta": a["size"], "before": 0, "after": a["size"]}) + continue + if a is None: + if wanted(name): + removed.append({"name": name, "delta": -b["size"], "before": b["size"], "after": 0}) + continue + if a["size"] != b["size"] and abs(a["size"] - b["size"]) > threshold: + if wanted(name): + resized.append({"name": name, "delta": a["size"] - b["size"], + "before": b["size"], "after": a["size"]}) + continue + if a["mangled"] != b["mangled"]: + # same normalised name, byte-identical size: the pure-move signature + if wanted(name): + renamed.append({"name": name, "delta": 0, + "before": b["mangled"][0], "after": a["mangled"][0]}) + continue + unchanged += 1 + for group in (removed, added, resized): + group.sort(key=lambda row: (-abs(row["delta"]), row["name"])) + renamed.sort(key=lambda row: row["name"]) + return removed, added, resized, renamed, unchanged + + +def markdown_table(title, rows, columns): + lines = ["", "### {} ({})".format(title, len(rows)), ""] + if not rows: + lines.append("_none_") + lines.append("") + return lines + lines.append("| " + " | ".join(columns) + " |") + lines.append("|" + "|".join(["---"] * len(columns)) + "|") + for row in rows: + lines.append("| " + " | ".join(str(cell).replace("|", "\\|") for cell in row) + " |") + lines.append("") + return lines + + +CANNED_BEFORE = ("""0000000000001000 0000000000000010 T MobileGL::MG_State::GLState::ProgramObject::LinkArtifacts::Reset() +0000000000002000 0000000000000020 T MobileGL::MG_State::GLState::ProgramObject::Link() +0000000000003000 0000000000000030 T MobileGL::Gone() +0000000000004000 0000000000000040 T MobileGL::Grew() +0000000000005000 T MobileGL::NoSize() +""", """libBefore.so : +section size addr +.text 1000 100 +.data 200 2000 +.bss 300 3000 +Total 1500 +""") + +CANNED_AFTER = ("""0000000000001000 0000000000000010 T MobileGL::MG_State::GLState::LinkArtifacts::Reset() +0000000000002000 0000000000000020 T MobileGL::MG_State::GLState::ProgramObject::Link() +0000000000004000 0000000000000050 T MobileGL::Grew() +0000000000006000 0000000000000060 T MobileGL::BrandNew() +0000000000005000 T MobileGL::NoSize() +""", """libAfter.so : +section size addr +.text 1100 100 +.data 200 2000 +.bss 300 3000 +Total 1600 +""") + + +def self_test(): + strip = ["MobileGL::MG_State::GLState::ProgramObject::"] + before, before_sections, before_count = build_side(None, None, None, None, strip, [], + canned=CANNED_BEFORE) + after, after_sections, after_count = build_side(None, None, None, None, strip, [], + canned=CANNED_AFTER) + removed, added, resized, renamed, unchanged = bucket(before, after, [], 0) + problems = [] + if before_count != 5 or after_count != 5: + problems.append("nm parse counts: {} / {} (expected 5 / 5)".format(before_count, after_count)) + if [r["name"] for r in removed] != ["MobileGL::Gone()"]: + problems.append("removed bucket: {}".format([r["name"] for r in removed])) + if [r["name"] for r in added] != ["MobileGL::BrandNew()"]: + problems.append("added bucket: {}".format([r["name"] for r in added])) + if [(r["name"], r["delta"]) for r in resized] != [("MobileGL::Grew()", 0x10)]: + problems.append("resized bucket: {}".format([(r["name"], r["delta"]) for r in resized])) + # `ProgramObject::LinkArtifacts::Reset` -> `LinkArtifacts::Reset` folds to the same + # normalised name at the same size: renamed-only, not added+removed. + if [r["name"] for r in renamed] != ["MobileGL::MG_State::GLState::LinkArtifacts::Reset()"]: + problems.append("renamed bucket: {}".format([r["name"] for r in renamed])) + if unchanged != 2: # ProgramObject::Link() and NoSize() + problems.append("unchanged count: {} (expected 2)".format(unchanged)) + if before_sections.get(".text") != 1000 or after_sections.get("Total") != 1600: + problems.append("size --format=sysv parse: {} / {}".format(before_sections, after_sections)) + for problem in problems: + say("self-test: " + problem) + say("self-test: " + ("OK (2 canned transcripts, 5 buckets)" if not problems else "FAILED")) + return 0 if not problems else 1 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--before", help="the baseline libMobileGL.so") + parser.add_argument("--after", help="the libMobileGL.so under test") + parser.add_argument("--nm", default="nm") + parser.add_argument("--size", default="size") + parser.add_argument("--cxxfilt", default="c++filt") + parser.add_argument("--markdown", default=None, help="write the Markdown report here") + parser.add_argument("--json", default=None, help="write the machine-readable result here") + parser.add_argument("--strip-scope", action="append", default=[], metavar="PREFIX", + help="de-nest this scope in demangled names before comparing: PREFIX " + "'A::B::C::' rewrites every 'A::B::C::X' to 'A::B::X' (repeatable). " + "This is what folds a de-nesting into a rename instead of an " + "added+removed pair, including inside template arguments.") + parser.add_argument("--rename-map", action="append", default=[], metavar="OLD=NEW", + help="textual OLD=NEW substitution on demangled names (repeatable)") + parser.add_argument("--only-names", default=None, + help="comma-separated: list only symbols whose demangled text contains one") + parser.add_argument("--threshold", type=int, default=0, + help="ignore size deltas of at most this many bytes") + parser.add_argument("--fail-on-added-bytes", type=int, default=None, + help="reserved for a future hard gate; currently informational only") + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args() + + if args.self_test: + return self_test() + + if not args.before or not args.after: + parser.error("--before and --after are required (or use --self-test)") + + rename_map = [] + for entry in args.rename_map: + if "=" not in entry: + parser.error("--rename-map expects OLD=NEW, got " + entry) + old, new = entry.split("=", 1) + rename_map.append((old, new)) + only_names = [n.strip() for n in args.only_names.split(",")] if args.only_names else [] + + before, before_sections, before_count = build_side( + args.before, args.nm, args.size, args.cxxfilt, args.strip_scope, rename_map) + after, after_sections, after_count = build_side( + args.after, args.nm, args.size, args.cxxfilt, args.strip_scope, rename_map) + + removed, added, resized, renamed, unchanged = bucket(before, after, only_names, args.threshold) + + before_text = before_sections.get(".text", 0) + after_text = after_sections.get(".text", 0) + delta = after_text - before_text + percent = (100.0 * delta / before_text) if before_text else 0.0 + + say("before: {} ({} bytes on disk)".format(args.before, os.path.getsize(args.before))) + say("after : {} ({} bytes on disk)".format(args.after, os.path.getsize(args.after))) + if args.strip_scope: + say("strip-scope: " + " ; ".join(args.strip_scope)) + if rename_map: + say("rename-map: " + " ; ".join("{}={}".format(o, n) for o, n in rename_map)) + say(".text {} -> {} ({:+d}, {:+.3f}%)".format(before_text, after_text, delta, percent)) + for section in INTERESTING_SECTIONS: + if section == ".text": + continue + b = before_sections.get(section) + a = after_sections.get(section) + if b is None and a is None: + continue + say("{} {} -> {} ({:+d})".format(section, b or 0, a or 0, (a or 0) - (b or 0))) + say("{} -> {} defined symbols: {} added, {} removed, {} resized, {} renamed".format( + before_count, after_count, len(added), len(removed), len(resized), len(renamed))) + # The two counts differ by the folding: several mangled symbols can share one + # normalised demangled name (local aliases, `.cold` parts, identical-COMDAT clones). + say("{} -> {} normalised names, {} unchanged (name, size and mangling all identical)".format( + len(before), len(after), unchanged)) + if only_names: + say("listing restricted to names containing: " + ", ".join(only_names)) + if args.fail_on_added_bytes is not None: + say("--fail-on-added-bytes is reserved; this run stays informational") + + lines = ["# MobileGL symbol report", "", + "| side | path | file bytes | .text |", + "|---|---|---|---|", + "| before | `{}` | {} | {} |".format(args.before, os.path.getsize(args.before), before_text), + "| after | `{}` | {} | {} |".format(args.after, os.path.getsize(args.after), after_text), + "", + "`.text` {} -> {} ({:+d}, {:+.3f}%). {} -> {} defined symbols: " + "{} added, {} removed, {} resized, {} renamed, {} unchanged.".format( + before_text, after_text, delta, percent, before_count, after_count, + len(added), len(removed), len(resized), len(renamed), unchanged)] + lines += markdown_table("Removed", [(r["name"], r["before"]) for r in removed], + ["symbol", "bytes"]) + lines += markdown_table("Added", [(r["name"], r["after"]) for r in added], + ["symbol", "bytes"]) + lines += markdown_table("Resized", [(r["name"], r["before"], r["after"], "{:+d}".format(r["delta"])) + for r in resized], + ["symbol", "before", "after", "delta"]) + lines += markdown_table("Renamed only (same size)", + [(r["name"], r["before"], r["after"]) for r in renamed], + ["normalised symbol", "before mangling", "after mangling"]) + report = "\n".join(lines) + "\n" + + if args.markdown: + with open(args.markdown, "w", encoding="utf-8", newline="\n") as handle: + handle.write(report) + say("markdown written to " + args.markdown) + else: + print(report) + + if args.json: + with open(args.json, "w", encoding="utf-8", newline="\n") as handle: + json.dump({"before": args.before, "after": args.after, + "sections_before": before_sections, "sections_after": after_sections, + "removed": removed, "added": added, "resized": resized, + "renamed": renamed, "unchanged": unchanged}, handle, indent=2) + handle.write("\n") + say("json written to " + args.json) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 8566a288f85be7be8ba7186fdf52d3dfc67b87df Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 22:52:39 -0400 Subject: [PATCH 034/529] [Refactor] (Pipe, State): extract MGPipeValueTypes.h - move the render-state, sampler and vertex value types and their enums out of MG_State/GLState so MG_Pipe no longer reaches RenderState.h; pure move, member order and namespaces unchanged - ROADMAP P0.5 / ARCHITECTURE.md value-header manifest: MGPipeTypes.h embedded RenderStateParameters and PixelStoreParameters through RenderState.h, which drags FramebufferObject.h and the whole texture/renderbuffer/sampler chain into MG_Pipe; purity gate A (no MG_State/MG_Impl/MG_Backend/MG_Remote in the closure) could not be armed for anything in MG_Pipe while that include existed. - MGPipeValueTypes.h is a verbatim cut, comments included: the eleven RenderState.h enums (all of them - a split would be the maintenance trap), PixelStoreParameters, PerBufferBlendState, StencilFaceState, RenderStateParameters (member order untouched: DirectGLES' offsetof spans and PipeSpanTable.inc name the members), the six SamplerObject.h enums and SamplerParameters (BorderColorForm stays Uint8, it sets the tail padding), and VertexAttribute / VertexBufferBindingPoint / VertexAttributeVersion, which keep namespace MobileGL::MG_State::GLState with a forward-declared BufferObject so no mangled name changes. - MAX_DRAW_BUFFERS becomes inline constexpr kMGMaxDrawBuffers in namespace MobileGL and FramebufferObject::MAX_DRAW_BUFFERS is defined from it, so the eighty existing spellings keep working and the two cannot drift. No other constant is added. - The four MG_State headers become forwarders (include the value header, keep their class definitions); RenderState.cpp gains a direct FramebufferObject.h include because it spells FramebufferObject::MAX_DRAW_BUFFERS and only ever got that header transitively. No other TU lost a transitive include: the full build (Release, clang, tests + integration tests) passed without touching anything under MG_Backend, MG_Impl or MG_Util. - DynamicBackendParameters does NOT move (SizeT members and TextureTarget-taking member functions make that a type change, not a move); MGPipeTypes.h keeps its BackendObject.h include and the debt comment now says so, which is why gate A asserts MGPipeValueTypes.h rather than MGPipeTypes.h. - New trip wires in the header: trivially-copyable + exact sizeof for PixelStoreParameters/PerBufferBlendState/StencilFaceState (28), SamplerParameters (100), VertexAttributeVersion (6), RenderStateParameters (1168, standard layout, BlendStates before LogicOp, BlendStates sized by kMGMaxDrawBuffers). Their runtime twins ValueTypeLayoutsArePinned and the carrier check ResidualBlockIsExactlyItsTwoValueStructsPlusPatchTail (Pack at 1168, CapabilityBits at 1200) are added to PipeCatalogueTest without a new include. - gen_pipe.py's "field lists of their own in P0.5" comment now says P1 (the comparator needs std::array support first); PipeVerify.inc regenerated. - Verified: ctest -L unit 1460/1460 and -L integration-gpu green; ctest -N names a superset of feat/disaggregated@6672778b (two added, none lost); one definition per moved type; the -H closure of the new header contains no MG_State/MG_Backend/ MG_Impl/MG_Remote header and the header compiles alone; nm --defined-only -S against the base libMobileGL.so: 0 added / 0 removed / 0 resized, .text byte-identical, the only differing bytes are the build-id and two stamp strings. --- MobileGL/MG_Pipe/MGPipeTypes.h | 18 +- MobileGL/MG_Pipe/MGPipeValueTypes.h | 549 ++++++++++++++++++ MobileGL/MG_Pipe/generated/PipeVerify.inc | 10 +- .../FramebufferState/FramebufferObject.h | 3 +- .../GLState/RenderState/RenderState.cpp | 1 + .../GLState/RenderState/RenderState.h | 360 +----------- .../GLState/SamplerState/SamplerObject.h | 86 +-- .../VertexArrayState/VertexArrayObject.h | 56 +- MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp | 33 ++ scripts/gen_pipe.py | 15 +- 10 files changed, 610 insertions(+), 521 deletions(-) create mode 100644 MobileGL/MG_Pipe/MGPipeValueTypes.h diff --git a/MobileGL/MG_Pipe/MGPipeTypes.h b/MobileGL/MG_Pipe/MGPipeTypes.h index 7d8f73d4e..710a90c80 100644 --- a/MobileGL/MG_Pipe/MGPipeTypes.h +++ b/MobileGL/MG_Pipe/MGPipeTypes.h @@ -22,19 +22,19 @@ // these structs (generated/PipeWire.inc) are memcpy'd; a field silently changing width is a // protocol break that no test would otherwise see. // -// P0.5 DEBT, recorded here so it is impossible to miss: two payloads reach into headers -// this directory is eventually forbidden to see - MGPCaps embeds MG_Backend's -// DynamicBackendParameters, and ResidualValueBlock embeds MG_State's RenderStateParameters -// and PixelStoreParameters. Both are deliberate: the caps block IS that struct (section -// 4.4.1) and the residual block is the migration carrier for Track V (section 6.3). P0.5 -// extracts MGPipeValueTypes.h and both includes below go away; until then purity gate A -// (section 10.3) cannot be armed for this header. +// P0.5 DEBT, half repaid. The MG_State half is gone: ResidualValueBlock's +// RenderStateParameters and PixelStoreParameters now come from MGPipeValueTypes.h, so +// this header no longer reaches into MG_State. What remains is MGPCaps embedding +// MG_Backend's DynamicBackendParameters - deliberate, the caps block IS that struct +// (section 4.4.1) - and that one include is what still keeps purity gate A (section +// 10.3) off this header; the gate asserts MGPipeValueTypes.h instead. The caps block +// needs fixed-width members before it can move (a type change, not a move): P1/P7. #include -#include +#include "MGPipeValueTypes.h" namespace MobileGL::MG_Pipe { using MG_Backend::DynamicBackendParameters; - // Both live directly in namespace MobileGL today; P0.5 moves them into + // Both live directly in namespace MobileGL and, since P0.5, are declared in // MG_Pipe/MGPipeValueTypes.h. using MobileGL::PixelStoreParameters; using MobileGL::RenderStateParameters; diff --git a/MobileGL/MG_Pipe/MGPipeValueTypes.h b/MobileGL/MG_Pipe/MGPipeValueTypes.h new file mode 100644 index 000000000..2b44d1e7a --- /dev/null +++ b/MobileGL/MG_Pipe/MGPipeValueTypes.h @@ -0,0 +1,549 @@ +// MobileGL - MobileGL/MG_Pipe/MGPipeValueTypes.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#ifndef MOBILEGL_MG_PIPE_VALUE_TYPES_H // belt and braces: this file is reachable both as +#define MOBILEGL_MG_PIPE_VALUE_TYPES_H // and <...> (CMakeLists.txt:531,535) +#include +#include // includes only + +#include // offsetof +#include + +// The value types MG_Pipe payloads embed (plan B section 6.3; ARCHITECTURE.md section on +// the value header): the render-state, pixel-store, sampler and vertex-attribute value +// structs and the enums they are made of. They lived in MG_State::GLState until P0.5; +// the MG_State headers that used to define them now include this file, so every existing +// spelling (namespace and name) compiles unchanged. +// +// PURITY: nothing from MG_State, MG_Impl, MG_Backend or MG_Remote - +// scripts/check_include_closure.py probe "value-header" (ROADMAP P0.5; ARCHITECTURE.md +// section 10.3 gate A). Adding one turns CI red. MG_Pipe never includes MG_State back. + +namespace MobileGL { + // GL_MAX_DRAW_BUFFERS as MobileGL advertises it. FramebufferObject::MAX_DRAW_BUFFERS is + // defined from this constant, so the two cannot drift. + inline constexpr Uint kMGMaxDrawBuffers = 8; + + enum class BlendFactor { + Zero, + One, + SrcColor, + OneMinusSrcColor, + DstColor, + OneMinusDstColor, + SrcAlpha, + OneMinusSrcAlpha, + DstAlpha, + OneMinusDstAlpha, + ConstantColor, + OneMinusConstantColor, + ConstantAlpha, + OneMinusConstantAlpha, + // Dual-source blend factors (GL_SRC1_*, glBindFragDataLocationIndexed); require the + // dualSrcBlend device feature. + Src1Color, + OneMinusSrc1Color, + Src1Alpha, + OneMinusSrc1Alpha, + BlendFactorCount, + Unknown = -1 + }; + + enum class BlendEquation { + Add, + Subtract, + ReverseSubtract, + Min, + Max, + BlendEquationCount, + Unknown = -1 + }; + + enum class LogicOperation { + Clear, + And, + AndReverse, + Copy, + AndInverted, + Noop, + Xor, + Or, + Nor, + Equiv, + Invert, + OrReverse, + CopyInverted, + OrInverted, + Nand, + Set, + LogicOperationCount, + Unknown = -1 + }; + + enum class DepthTestFunc { + Never, + Less, + Equal, + LessEqual, + Greater, + NotEqual, + GreaterEqual, + Always, + DepthTestFuncCount, + Unknown = -1 + }; + + enum class StencilOperation { + Keep, + Zero, + Replace, + IncrementClamp, + DecrementClamp, + Invert, + IncrementWrap, + DecrementWrap, + StencilOperationCount, + Unknown = -1 + }; + + enum class StencilFace { + Front, + Back, + StencilFaceCount, + Unknown = -1 + }; + + enum class PixelStoreParam { + // Pack Parameters + PackAlignment, + PackRowLength, + PackImageHeight, + PackSkipRows, + PackSkipPixels, + PackSkipImages, + PackSwapBytes, + PackLSBFirst, + + // Unpack Parameters + UnpackAlignment, + UnpackRowLength, + UnpackImageHeight, + UnpackSkipRows, + UnpackSkipPixels, + UnpackSkipImages, + UnpackSwapBytes, + UnpackLSBFirst, + + PixelStoreParamCount, + Unknown = -1 + }; + + enum class CullFaceMode { + Front, + Back, + FrontAndBack, + CullFaceModeCount, + Unknown = -1 + }; + + enum class FrontFaceMode { + CounterClockwise, + Clockwise, + FrontFaceModeCount, + Unknown = -1 + }; + + enum class ProvokingVertexMode { + FirstVertex, + LastVertex, + ProvokingVertexModeCount, + Unknown = -1 + }; + + enum class CapabilityInput { + Blend, + ClipDistance0, + ClipDistance1, + ClipDistance2, + ClipDistance3, + ClipDistance4, + ClipDistance5, + ClipDistance6, + ClipDistance7, + ColorLogicOp, + CullFace, + DebugOutput, + DebugOutputSynchronous, + DepthClamp, + DepthTest, + Dither, + FramebufferSrgb, + LineSmooth, + Multisample, + PolygonOffsetFill, + PolygonOffsetLine, + PolygonOffsetPoint, + PolygonSmooth, + PrimitiveRestart, + PrimitiveRestartFixedIndex, + RasterizerDiscard, + SampleAlphaToCoverage, + SampleAlphaToOne, + SampleCoverage, + SampleShading, + SampleMask, + ScissorTest, + StencilTest, + TextureCubeMapSeamless, + ProgramPointSize, + CapabilityInputCount, + Unknown = -1 + }; + + struct PixelStoreParameters { + Bool SwapBytes = false; + Bool LSBFirst = false; + Int RowLength = 0; + Int ImageHeight = 0; + Int SkipPixels = 0; + Int SkipRows = 0; + Int SkipImages = 0; + Int Alignment = 4; + }; + + struct PerBufferBlendState { + Bool Enabled = false; + BlendFactor SrcFactorRGB = BlendFactor::One; + BlendFactor DstFactorRGB = BlendFactor::Zero; + BlendFactor SrcFactorAlpha = BlendFactor::One; + BlendFactor DstFactorAlpha = BlendFactor::Zero; + BlendEquation ColorEquation = BlendEquation::Add; + BlendEquation AlphaEquation = BlendEquation::Add; + }; + + struct StencilFaceState { + DepthTestFunc Func = DepthTestFunc::Always; + Int Ref = 0; + Uint32 ValueMask = 0xffffffffu; + Uint32 WriteMask = 0xffffffffu; + StencilOperation FailOp = StencilOperation::Keep; + StencilOperation PassDepthFailOp = StencilOperation::Keep; + StencilOperation PassDepthPassOp = StencilOperation::Keep; + }; + + struct RenderStateParameters { + // ARB_viewport_array / GL 4.6 core 13.6.1: the viewport, the scissor rectangle, the depth + // range and the scissor-test enable are all arrays indexed by gl_ViewportIndex, and the + // spec floor for MAX_VIEWPORTS is 16. MobileGL advertises exactly 16 on both backends, so + // this is also what GL_MAX_VIEWPORTS reports (see the backend loaders' caps.MaxViewports). + static constexpr Uint MAX_VIEWPORTS = 16; + + // Rasterization + // The viewport rectangle is FLOAT state as of GL 4.1 - ViewportIndexedf writes fractional + // values and GetFloati_v(GL_VIEWPORT) must hand them back bit-exact + // (KHR-GL43.viewport_array.viewport_api compares with ==, no tolerance). glViewport's + // integers are simply one way to write it. Index 0 is what a program that never assigns + // gl_ViewportIndex rasterizes against, and what the classic glViewport / + // glGetIntegerv(GL_VIEWPORT) pair addresses. Both backends rasterize the rectangle + // rounded back to integers; the STATE stays exact, which is the half the conformance + // suite checks (see the KNOWN INFIDELITY note in AdvertisedLimitsScenario.cpp). + Array Viewports{}; // x, y, width, height + Float LineWidth = 1.0f; + Float PointSize = 1.0f; + // GL_PATCH_VERTICES: how many vertices one tessellation patch consumes. + Uint PatchVertices = 3; + // GL_PATCH_DEFAULT_OUTER_LEVEL / GL_PATCH_DEFAULT_INNER_LEVEL (glPatchParameterfv). The + // tessellation levels used when a program has an evaluation stage and NO control stage - + // GL's fixed-function pass-through (4.6 core 11.2.2). Both backends have to synthesize + // that stage, and they bake these numbers into it, so a change here makes an already-built + // one stale exactly as PATCH_VERTICES does. Default 1.0, per table 23.44. + FloatVec4 PatchDefaultOuterLevel = FloatVec4(1.0f, 1.0f, 1.0f, 1.0f); + FloatVec2 PatchDefaultInnerLevel = FloatVec2(1.0f, 1.0f); + Float PolygonOffsetFactor = 0.0f; + Float PolygonOffsetUnits = 0.0f; + // GL_POLYGON_OFFSET_CLAMP (GL 4.6 core 14.6.5 / GL_EXT_polygon_offset_clamp): the maximum + // magnitude of the offset glPolygonOffsetClamp's third argument allows. Zero - the default + // - means "no clamp", which is exactly the behaviour glPolygonOffset leaves behind. + Float PolygonOffsetClamp = 0.0f; + + // glClipControl (GL 4.5 core 13.5). Defaults per table 23.7 are the pre-4.5 fixed + // behaviour: origin at the lower left, depth mapped from -1..1. + GLenum ClipOrigin = GL_LOWER_LEFT; + GLenum ClipDepthMode = GL_NEGATIVE_ONE_TO_ONE; + + // Blending + Array BlendStates; + LogicOperation LogicOp = LogicOperation::Copy; + + // Depth + Bool DepthTestEnabled = false; + DepthTestFunc DepthFunc = DepthTestFunc::Less; + Bool DepthMask = true; + + // Color Mask. Per-draw-buffer state (glColorMaski); glColorMask broadcasts to all buffers. + // Every entry is initialized to all-true in RenderState's constructor. + Array ColorMasks; + + // Clear State + FloatVec4 ClearColor = FloatVec4(0.0f, 0.0f, 0.0f, 1.0f); + Float ClearDepth = 1.0f; + Uint32 ClearStencil = 0; + FloatVec4 BlendColor = FloatVec4(0.0f, 0.0f, 0.0f, 0.0f); + // Per-viewport depth range (glDepthRangeIndexed / glDepthRangeArrayv). Every entry is + // initialized to (0, 1) in RenderState's constructor - a default member initializer would + // not survive the Array<> aggregate. Kept float rather than double: DepthRangeArrayv takes + // GLdouble, but the value reaches the hardware as VkViewport::minDepth/maxDepth (float) on + // Magma and glDepthRangef on Espryt, so a double store would only widen the readback and + // then lose it again at the same place. + Array DepthRanges{}; + Float SampleCoverageValue = 1.0f; + Bool SampleCoverageInvert = false; + Uint32 SampleMaskValue = 0xffffffffu; + // glMinSampleShading (ARB_sample_shading / GL 4.0 core 14.3.1). The fraction of samples + // that get their own independent shading when GL_SAMPLE_SHADING is enabled; the initial + // value is 0, and the value is clamped to [0, 1] on the way in. + Float MinSampleShadingValue = 0.0f; + Array StencilStates{}; + + // Cull Face + Bool CullFaceEnabled = false; + CullFaceMode CullFaceModeSetting = CullFaceMode::Back; + FrontFaceMode FrontFaceModeSetting = FrontFaceMode::CounterClockwise; + ProvokingVertexMode ProvokingVertexModeSetting = ProvokingVertexMode::LastVertex; + + // Hints (glHint). All GL 3.3 core hint targets default to GL_DONT_CARE. + GLenum LineSmoothHint = GL_DONT_CARE; + GLenum PolygonSmoothHint = GL_DONT_CARE; + GLenum TextureCompressionHint = GL_DONT_CARE; + GLenum FragmentShaderDerivativeHint = GL_DONT_CARE; + + // Point parameters (glPointParameter). Only the two GL 3.3 core pnames. + Float PointFadeThresholdSize = 1.0f; + GLenum PointSpriteCoordOrigin = GL_UPPER_LEFT; + + // Color clamping (glClampColor). Core profile exposes only GL_CLAMP_READ_COLOR. + GLenum ClampReadColor = GL_FIXED_ONLY; + + // Polygon rasterization mode (glPolygonMode). Core profile sets front and back together, + // but GL_POLYGON_MODE still reports both slots, so keep them separate for a faithful query. + GLenum PolygonModeFront = GL_FILL; + GLenum PolygonModeBack = GL_FILL; + + // Primitive restart index (glPrimitiveRestartIndex); consumed when GL_PRIMITIVE_RESTART is + // enabled during an indexed draw. Default 0. + Uint32 PrimitiveRestartIndex = 0; + + // Scissor + Bool ColorLogicOpEnabled = false; + Bool DebugOutputEnabled = false; + Bool DebugOutputSynchronousEnabled = false; + Bool DitherEnabled = true; + Bool LineSmoothEnabled = false; + Bool MultisampleEnabled = true; + Bool PolygonOffsetFillEnabled = false; + Bool PolygonOffsetLineEnabled = false; + Bool PolygonOffsetPointEnabled = false; + Bool PolygonSmoothEnabled = false; + Bool PrimitiveRestartEnabled = false; + Bool PrimitiveRestartFixedIndexEnabled = false; + Bool RasterizerDiscardEnabled = false; + Bool SampleAlphaToCoverageEnabled = false; + Bool SampleAlphaToOneEnabled = false; + Bool SampleCoverageEnabled = false; + Bool SampleMaskEnabled = false; + Bool SampleShadingEnabled = false; + Bool StencilTestEnabled = false; + Bool ProgramPointSizeEnabled = false; + // glEnable(GL_SCISSOR_TEST) enables the test for EVERY viewport, glEnablei for one + // (GL 4.6 core 17.3.2), so this is 16 bits and not a bool. Bit 0 is what the classic + // glIsEnabled(GL_SCISSOR_TEST) reports and what both backends currently consume. Unlike + // ClipDistanceEnabledMask below it DOES bump the pipeline version, because DirectGLES + // turns it into a real glEnable/glDisable. + Uint32 ScissorTestEnabledMask = 0; + Array ScissorBoxes{}; // x, y, width, height + // One bit per viewport, set the first time the application writes that index's scissor + // rectangle - glScissor broadcasts and sets all 16, glScissorIndexed/glScissorArrayv set + // the indices they name. It exists because the RECTANGLE cannot answer "has the + // application spoken?": ScissorBoxes starts all-zero (its spec initial value is the size + // of a window the frontend does not know yet, see the RenderState constructor), and + // glScissor(0, 0, 0, 0) is a legal GL state meaning "the scissor test rejects every + // fragment". A backend that reads an empty rectangle as the never-written sentinel + // therefore INVERTS that request into "accept every fragment"; DirectGLES did exactly + // that and KHR-GL43.viewport_array.scissor_zero_dimension caught it. Deliberately beside + // ScissorBoxes so it shares their tail span (after LogicOp) and DirectGLES' span memcmp + // picks a transition up like any other state. + Uint32 ScissorBoxWrittenMask = 0; + // glEnable(GL_CLIP_DISTANCE0 + i) for i in [0, 8), one bit each. A bitmask rather than + // eight bools because every consumer wants the set, not an individual flag, and because + // the SYNC_CAPABILITY/SET_CAPABILITY macros key off a "Enabled" field name that + // eight numbered capabilities cannot share. Lives in the tail span (after LogicOp), so + // DirectGLES' span memcmp picks a change up like any other capability. + Uint32 ClipDistanceEnabledMask = 0; + }; + + enum class SamplerFilterMode { + Nearest, + Linear, + SamplerFilterCount, + Unknown = -1 + }; + + enum class SamplerMipmapMode { + None, + Nearest, + Linear, + SamplerMipmapModeCount, + Unknown = -1 + }; + + enum class SamplerWrapMode { + ClampToEdge, + MirroredRepeat, + Repeat, + ClampToBorder, + MirrorClampToEdge, + SamplerWrapModeCount, + Unknown = -1 + }; + + enum class SamplerCompareMode { + None, + CompareToTexture, + SamplerCompareModeCount, + Unknown = -1 + }; + + enum class SamplerCompareFunc { + Never, + Less, + Equal, + LessEqual, + Greater, + NotEqual, + GreaterEqual, + Always, + SamplerCompareFuncCount, + Unknown = -1 + }; + + // Which of the three GL_TEXTURE_BORDER_COLOR entry-point families last wrote the border colour, + // and therefore which of the three stored representations is AUTHORITATIVE. GL 4.6 core 8.10: + // TexParameterIiv/Iuiv store an integer border colour "unmodified, with an internal data type of + // integer", TexParameterfv stores a floating-point one, and the derived forms are only a + // convenience for a getter of the other spelling. A backend cannot pick the right driver entry + // point (glSamplerParameterIiv vs fv) or the right VkBorderColor family without this: numerically + // the three representations are always populated, so the value alone says nothing about the form. + enum class BorderColorForm : Uint8 { + Float, + Int, + Uint + }; + + struct SamplerParameters { + SamplerWrapMode wrapS = SamplerWrapMode::Repeat; + SamplerWrapMode wrapT = SamplerWrapMode::Repeat; + SamplerWrapMode wrapR = SamplerWrapMode::Repeat; + SamplerFilterMode minFilter = SamplerFilterMode::Nearest; + SamplerFilterMode magFilter = SamplerFilterMode::Linear; + SamplerMipmapMode mipmapMode = SamplerMipmapMode::Linear; + Float minLod = -1000.0f; + Float maxLod = 1000.0f; + Float lodBias = 0.0f; + Float maxAnisotropy = 1.0f; + // GL 4.6 core table 23.18 / GLES 3.2 table 21.16: TEXTURE_COMPARE_FUNC starts at LEQUAL, + // for both sampler objects and the sampler state a texture object carries. + SamplerCompareFunc compareFunc = SamplerCompareFunc::LessEqual; + SamplerCompareMode compareMode = SamplerCompareMode::None; + // TEXTURE_BORDER_COLOR is sampler state (GL 4.6 core table 23.18), so it belongs here and + // not on the texture - a texture object reaches it through the sampler object it owns. The + // three representations are the float, integer and unsigned-integer forms glSamplerParameterfv, + // glSamplerParameterIiv and glSamplerParameterIuiv set; whichever is written last defines + // the colour and the other two follow it, so a getter always has an answer. + FloatVec4 borderColor = {0.0f, 0.0f, 0.0f, 0.0f}; + IntVec4 borderColorI = {0, 0, 0, 0}; + UintVec4 borderColorUI = {0, 0, 0, 0}; + BorderColorForm borderColorForm = BorderColorForm::Float; + }; + + namespace MG_State::GLState { + class BufferObject; + + struct VertexAttribute { + Bool Enabled = false; + int Size = 4; + DataType Type = DataType::Float32; + Bool Normalized = false; + // The RESOLVED byte distance between consecutive elements, never the raw + // glVertexAttrib*Pointer argument: a pointer call's stride 0 means "tightly + // packed" and is resolved to the element size here, so a zero that survives + // into this field can only have come from the binding model, where a zero + // VERTEX_BINDING_STRIDE means the opposite - every vertex reads the SAME + // element and the fetch address never advances (GL 4.6 core 10.3.1). Backends + // consume this verbatim; collapsing 0 back into the element size is what made + // KHR-GL43.vertex_attrib_binding.basic-input-case7/8 read past the buffer. + int Stride = 0; + SizeT Offset = 0; + Bool IsInteger = false; + // GL_BGRA vertex size: four components in reversed (B,G,R,A) memory order. Size stays 4. + // Set only by the long (L) format entry points. It is NOT implied by + // Type == Float64: VertexAttribFormat(GL_DOUBLE) also reads doubles from memory but + // asks for them *converted to float*, while VertexAttribLFormat keeps all 64 bits + // (GL 4.6 core 10.3.2). Backends have to tell the two apart, and it is what + // GL_VERTEX_ATTRIB_ARRAY_LONG reports. + Bool IsLong = false; + Bool IsBgra = false; + Uint Divisor = 0; + SharedPtr Buffer; + + // GL 4.6 core table 23.3: VERTEX_ATTRIB_ARRAY_STRIDE and _POINTER are the + // arguments of the last glVertexAttrib*Pointer call on this attribute, + // reported verbatim, and NOTHING else writes them - not glVertexAttribFormat, + // not glBindVertexBuffer. Stride/Offset above are the *resolved* draw inputs + // and the binding model does overwrite those, so the two views have to be + // stored apart or the binding-model sequence reports a legacy state it never + // set (KHR-GL4x.vertex_attrib_binding.basic-state3). + int LegacyStride = 0; + SizeT LegacyPointer = 0; + }; + + // ARB_vertex_attrib_binding separate binding point. Attributes configured through the + // binding-point API are resolved eagerly into the flat VertexAttribute view above, so + // backends keep consuming resolved attributes and never see binding points. + struct VertexBufferBindingPoint { + SharedPtr Buffer; + SizeT Offset = 0; + // GL 4.6 core table 23.4: the initial VERTEX_BINDING_STRIDE is 16, not 0. + int Stride = 16; + Uint Divisor = 0; + }; + + struct VertexAttributeVersion { + Uint16 FormatVersion = 0; + Uint16 BufferVersion = 0; + Uint16 SwitchVersion = 0; + }; + } // namespace MG_State::GLState + + // ---- trip wires (P0.5). Sizes are what every ABI MobileGL ships on produces: every + // member is a fixed-width scalar, an enum of one, or an array of those - no pointer, no + // SizeT - except the vertex types, which carry SharedPtr by design and are + // therefore not trivially copyable (MGPipeTypes.h carries them as a blob). + static_assert(std::is_trivially_copyable_v && sizeof(PixelStoreParameters) == 28); + static_assert(std::is_trivially_copyable_v && sizeof(PerBufferBlendState) == 28); + static_assert(std::is_trivially_copyable_v && sizeof(StencilFaceState) == 28); + static_assert(std::is_trivially_copyable_v); + static_assert(std::is_standard_layout_v); // offsetof legality + static_assert(sizeof(RenderStateParameters) == 1168, + "RenderStateParameters changed size; MGL_RESIDUAL_BLOCK_SIZE and the Espryt spans depend on it"); + static_assert(offsetof(RenderStateParameters, BlendStates) < offsetof(RenderStateParameters, LogicOp)); + static_assert(std::tuple_size_v == kMGMaxDrawBuffers); + static_assert(std::is_trivially_copyable_v && sizeof(SamplerParameters) == 100); + static_assert(std::is_trivially_copyable_v && + sizeof(MG_State::GLState::VertexAttributeVersion) == 6); +} // namespace MobileGL +#endif // MOBILEGL_MG_PIPE_VALUE_TYPES_H diff --git a/MobileGL/MG_Pipe/generated/PipeVerify.inc b/MobileGL/MG_Pipe/generated/PipeVerify.inc index db652e6e9..e6f676ec9 100644 --- a/MobileGL/MG_Pipe/generated/PipeVerify.inc +++ b/MobileGL/MG_Pipe/generated/PipeVerify.inc @@ -230,11 +230,11 @@ inline Bool MGPipeFieldEqual(const T& a, const T& b) { } else if constexpr (requires(const T& x, const T& y) { x == y; }) { return a == b; } else { - // MEMCMP FALLBACK. Only reached by the payload members that are still MG_State / - // MG_Backend value structs (RenderStateParameters, PixelStoreParameters, - // DynamicBackendParameters) and by MGHostSpan. Those are exactly the types P0.5 - // moves into MGPipeValueTypes.h, at which point they get field lists of their own - // and this branch stops being reachable from any payload. + // MEMCMP FALLBACK. Only reached by the payload members that are still value structs + // without a field list (RenderStateParameters, PixelStoreParameters, + // DynamicBackendParameters) and by MGHostSpan. P0.5 moved the first two into + // MGPipeValueTypes.h; P1 gives them field lists of their own, at which point this + // branch stops being reachable from any payload. return std::memcmp(&a, &b, sizeof(T)) == 0; } } diff --git a/MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.h b/MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.h index aebe21620..8e3bf1569 100644 --- a/MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.h +++ b/MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.h @@ -11,6 +11,7 @@ #include #include #include +#include namespace MobileGL { enum class FramebufferTarget { @@ -103,7 +104,7 @@ namespace MobileGL { class FramebufferObject { public: - static constexpr Uint MAX_DRAW_BUFFERS = 8; + static constexpr Uint MAX_DRAW_BUFFERS = MobileGL::kMGMaxDrawBuffers; using TargetEnum = FramebufferTarget; using FramebufferAttachmentObjectArray = diff --git a/MobileGL/MG_State/GLState/RenderState/RenderState.cpp b/MobileGL/MG_State/GLState/RenderState/RenderState.cpp index 8945b5b61..475b31c39 100644 --- a/MobileGL/MG_State/GLState/RenderState/RenderState.cpp +++ b/MobileGL/MG_State/GLState/RenderState/RenderState.cpp @@ -7,6 +7,7 @@ // End of Source File Header #include "RenderState.h" +#include #include "MG_Util/Debug/Log.h" #include "MG_Util/Types.h" diff --git a/MobileGL/MG_State/GLState/RenderState/RenderState.h b/MobileGL/MG_State/GLState/RenderState/RenderState.h index 6e301db33..654126ec7 100644 --- a/MobileGL/MG_State/GLState/RenderState/RenderState.h +++ b/MobileGL/MG_State/GLState/RenderState/RenderState.h @@ -8,367 +8,9 @@ #pragma once #include -#include -#include +#include namespace MobileGL { - enum class BlendFactor { - Zero, - One, - SrcColor, - OneMinusSrcColor, - DstColor, - OneMinusDstColor, - SrcAlpha, - OneMinusSrcAlpha, - DstAlpha, - OneMinusDstAlpha, - ConstantColor, - OneMinusConstantColor, - ConstantAlpha, - OneMinusConstantAlpha, - // Dual-source blend factors (GL_SRC1_*, glBindFragDataLocationIndexed); require the - // dualSrcBlend device feature. - Src1Color, - OneMinusSrc1Color, - Src1Alpha, - OneMinusSrc1Alpha, - BlendFactorCount, - Unknown = -1 - }; - - enum class BlendEquation { - Add, - Subtract, - ReverseSubtract, - Min, - Max, - BlendEquationCount, - Unknown = -1 - }; - - enum class LogicOperation { - Clear, - And, - AndReverse, - Copy, - AndInverted, - Noop, - Xor, - Or, - Nor, - Equiv, - Invert, - OrReverse, - CopyInverted, - OrInverted, - Nand, - Set, - LogicOperationCount, - Unknown = -1 - }; - - enum class DepthTestFunc { - Never, - Less, - Equal, - LessEqual, - Greater, - NotEqual, - GreaterEqual, - Always, - DepthTestFuncCount, - Unknown = -1 - }; - - enum class StencilOperation { - Keep, - Zero, - Replace, - IncrementClamp, - DecrementClamp, - Invert, - IncrementWrap, - DecrementWrap, - StencilOperationCount, - Unknown = -1 - }; - - enum class StencilFace { - Front, - Back, - StencilFaceCount, - Unknown = -1 - }; - - enum class PixelStoreParam { - // Pack Parameters - PackAlignment, - PackRowLength, - PackImageHeight, - PackSkipRows, - PackSkipPixels, - PackSkipImages, - PackSwapBytes, - PackLSBFirst, - - // Unpack Parameters - UnpackAlignment, - UnpackRowLength, - UnpackImageHeight, - UnpackSkipRows, - UnpackSkipPixels, - UnpackSkipImages, - UnpackSwapBytes, - UnpackLSBFirst, - - PixelStoreParamCount, - Unknown = -1 - }; - - enum class CullFaceMode { - Front, - Back, - FrontAndBack, - CullFaceModeCount, - Unknown = -1 - }; - - enum class FrontFaceMode { - CounterClockwise, - Clockwise, - FrontFaceModeCount, - Unknown = -1 - }; - - enum class ProvokingVertexMode { - FirstVertex, - LastVertex, - ProvokingVertexModeCount, - Unknown = -1 - }; - - enum class CapabilityInput { - Blend, - ClipDistance0, - ClipDistance1, - ClipDistance2, - ClipDistance3, - ClipDistance4, - ClipDistance5, - ClipDistance6, - ClipDistance7, - ColorLogicOp, - CullFace, - DebugOutput, - DebugOutputSynchronous, - DepthClamp, - DepthTest, - Dither, - FramebufferSrgb, - LineSmooth, - Multisample, - PolygonOffsetFill, - PolygonOffsetLine, - PolygonOffsetPoint, - PolygonSmooth, - PrimitiveRestart, - PrimitiveRestartFixedIndex, - RasterizerDiscard, - SampleAlphaToCoverage, - SampleAlphaToOne, - SampleCoverage, - SampleShading, - SampleMask, - ScissorTest, - StencilTest, - TextureCubeMapSeamless, - ProgramPointSize, - CapabilityInputCount, - Unknown = -1 - }; - - struct PixelStoreParameters { - Bool SwapBytes = false; - Bool LSBFirst = false; - Int RowLength = 0; - Int ImageHeight = 0; - Int SkipPixels = 0; - Int SkipRows = 0; - Int SkipImages = 0; - Int Alignment = 4; - }; - - struct PerBufferBlendState { - Bool Enabled = false; - BlendFactor SrcFactorRGB = BlendFactor::One; - BlendFactor DstFactorRGB = BlendFactor::Zero; - BlendFactor SrcFactorAlpha = BlendFactor::One; - BlendFactor DstFactorAlpha = BlendFactor::Zero; - BlendEquation ColorEquation = BlendEquation::Add; - BlendEquation AlphaEquation = BlendEquation::Add; - }; - - struct StencilFaceState { - DepthTestFunc Func = DepthTestFunc::Always; - Int Ref = 0; - Uint32 ValueMask = 0xffffffffu; - Uint32 WriteMask = 0xffffffffu; - StencilOperation FailOp = StencilOperation::Keep; - StencilOperation PassDepthFailOp = StencilOperation::Keep; - StencilOperation PassDepthPassOp = StencilOperation::Keep; - }; - - struct RenderStateParameters { - // ARB_viewport_array / GL 4.6 core 13.6.1: the viewport, the scissor rectangle, the depth - // range and the scissor-test enable are all arrays indexed by gl_ViewportIndex, and the - // spec floor for MAX_VIEWPORTS is 16. MobileGL advertises exactly 16 on both backends, so - // this is also what GL_MAX_VIEWPORTS reports (see the backend loaders' caps.MaxViewports). - static constexpr Uint MAX_VIEWPORTS = 16; - - // Rasterization - // The viewport rectangle is FLOAT state as of GL 4.1 - ViewportIndexedf writes fractional - // values and GetFloati_v(GL_VIEWPORT) must hand them back bit-exact - // (KHR-GL43.viewport_array.viewport_api compares with ==, no tolerance). glViewport's - // integers are simply one way to write it. Index 0 is what a program that never assigns - // gl_ViewportIndex rasterizes against, and what the classic glViewport / - // glGetIntegerv(GL_VIEWPORT) pair addresses. Both backends rasterize the rectangle - // rounded back to integers; the STATE stays exact, which is the half the conformance - // suite checks (see the KNOWN INFIDELITY note in AdvertisedLimitsScenario.cpp). - Array Viewports{}; // x, y, width, height - Float LineWidth = 1.0f; - Float PointSize = 1.0f; - // GL_PATCH_VERTICES: how many vertices one tessellation patch consumes. - Uint PatchVertices = 3; - // GL_PATCH_DEFAULT_OUTER_LEVEL / GL_PATCH_DEFAULT_INNER_LEVEL (glPatchParameterfv). The - // tessellation levels used when a program has an evaluation stage and NO control stage - - // GL's fixed-function pass-through (4.6 core 11.2.2). Both backends have to synthesize - // that stage, and they bake these numbers into it, so a change here makes an already-built - // one stale exactly as PATCH_VERTICES does. Default 1.0, per table 23.44. - FloatVec4 PatchDefaultOuterLevel = FloatVec4(1.0f, 1.0f, 1.0f, 1.0f); - FloatVec2 PatchDefaultInnerLevel = FloatVec2(1.0f, 1.0f); - Float PolygonOffsetFactor = 0.0f; - Float PolygonOffsetUnits = 0.0f; - // GL_POLYGON_OFFSET_CLAMP (GL 4.6 core 14.6.5 / GL_EXT_polygon_offset_clamp): the maximum - // magnitude of the offset glPolygonOffsetClamp's third argument allows. Zero - the default - // - means "no clamp", which is exactly the behaviour glPolygonOffset leaves behind. - Float PolygonOffsetClamp = 0.0f; - - // glClipControl (GL 4.5 core 13.5). Defaults per table 23.7 are the pre-4.5 fixed - // behaviour: origin at the lower left, depth mapped from -1..1. - GLenum ClipOrigin = GL_LOWER_LEFT; - GLenum ClipDepthMode = GL_NEGATIVE_ONE_TO_ONE; - - // Blending - Array BlendStates; - LogicOperation LogicOp = LogicOperation::Copy; - - // Depth - Bool DepthTestEnabled = false; - DepthTestFunc DepthFunc = DepthTestFunc::Less; - Bool DepthMask = true; - - // Color Mask. Per-draw-buffer state (glColorMaski); glColorMask broadcasts to all buffers. - // Every entry is initialized to all-true in RenderState's constructor. - Array ColorMasks; - - // Clear State - FloatVec4 ClearColor = FloatVec4(0.0f, 0.0f, 0.0f, 1.0f); - Float ClearDepth = 1.0f; - Uint32 ClearStencil = 0; - FloatVec4 BlendColor = FloatVec4(0.0f, 0.0f, 0.0f, 0.0f); - // Per-viewport depth range (glDepthRangeIndexed / glDepthRangeArrayv). Every entry is - // initialized to (0, 1) in RenderState's constructor - a default member initializer would - // not survive the Array<> aggregate. Kept float rather than double: DepthRangeArrayv takes - // GLdouble, but the value reaches the hardware as VkViewport::minDepth/maxDepth (float) on - // Magma and glDepthRangef on Espryt, so a double store would only widen the readback and - // then lose it again at the same place. - Array DepthRanges{}; - Float SampleCoverageValue = 1.0f; - Bool SampleCoverageInvert = false; - Uint32 SampleMaskValue = 0xffffffffu; - // glMinSampleShading (ARB_sample_shading / GL 4.0 core 14.3.1). The fraction of samples - // that get their own independent shading when GL_SAMPLE_SHADING is enabled; the initial - // value is 0, and the value is clamped to [0, 1] on the way in. - Float MinSampleShadingValue = 0.0f; - Array StencilStates{}; - - // Cull Face - Bool CullFaceEnabled = false; - CullFaceMode CullFaceModeSetting = CullFaceMode::Back; - FrontFaceMode FrontFaceModeSetting = FrontFaceMode::CounterClockwise; - ProvokingVertexMode ProvokingVertexModeSetting = ProvokingVertexMode::LastVertex; - - // Hints (glHint). All GL 3.3 core hint targets default to GL_DONT_CARE. - GLenum LineSmoothHint = GL_DONT_CARE; - GLenum PolygonSmoothHint = GL_DONT_CARE; - GLenum TextureCompressionHint = GL_DONT_CARE; - GLenum FragmentShaderDerivativeHint = GL_DONT_CARE; - - // Point parameters (glPointParameter). Only the two GL 3.3 core pnames. - Float PointFadeThresholdSize = 1.0f; - GLenum PointSpriteCoordOrigin = GL_UPPER_LEFT; - - // Color clamping (glClampColor). Core profile exposes only GL_CLAMP_READ_COLOR. - GLenum ClampReadColor = GL_FIXED_ONLY; - - // Polygon rasterization mode (glPolygonMode). Core profile sets front and back together, - // but GL_POLYGON_MODE still reports both slots, so keep them separate for a faithful query. - GLenum PolygonModeFront = GL_FILL; - GLenum PolygonModeBack = GL_FILL; - - // Primitive restart index (glPrimitiveRestartIndex); consumed when GL_PRIMITIVE_RESTART is - // enabled during an indexed draw. Default 0. - Uint32 PrimitiveRestartIndex = 0; - - // Scissor - Bool ColorLogicOpEnabled = false; - Bool DebugOutputEnabled = false; - Bool DebugOutputSynchronousEnabled = false; - Bool DitherEnabled = true; - Bool LineSmoothEnabled = false; - Bool MultisampleEnabled = true; - Bool PolygonOffsetFillEnabled = false; - Bool PolygonOffsetLineEnabled = false; - Bool PolygonOffsetPointEnabled = false; - Bool PolygonSmoothEnabled = false; - Bool PrimitiveRestartEnabled = false; - Bool PrimitiveRestartFixedIndexEnabled = false; - Bool RasterizerDiscardEnabled = false; - Bool SampleAlphaToCoverageEnabled = false; - Bool SampleAlphaToOneEnabled = false; - Bool SampleCoverageEnabled = false; - Bool SampleMaskEnabled = false; - Bool SampleShadingEnabled = false; - Bool StencilTestEnabled = false; - Bool ProgramPointSizeEnabled = false; - // glEnable(GL_SCISSOR_TEST) enables the test for EVERY viewport, glEnablei for one - // (GL 4.6 core 17.3.2), so this is 16 bits and not a bool. Bit 0 is what the classic - // glIsEnabled(GL_SCISSOR_TEST) reports and what both backends currently consume. Unlike - // ClipDistanceEnabledMask below it DOES bump the pipeline version, because DirectGLES - // turns it into a real glEnable/glDisable. - Uint32 ScissorTestEnabledMask = 0; - Array ScissorBoxes{}; // x, y, width, height - // One bit per viewport, set the first time the application writes that index's scissor - // rectangle - glScissor broadcasts and sets all 16, glScissorIndexed/glScissorArrayv set - // the indices they name. It exists because the RECTANGLE cannot answer "has the - // application spoken?": ScissorBoxes starts all-zero (its spec initial value is the size - // of a window the frontend does not know yet, see the RenderState constructor), and - // glScissor(0, 0, 0, 0) is a legal GL state meaning "the scissor test rejects every - // fragment". A backend that reads an empty rectangle as the never-written sentinel - // therefore INVERTS that request into "accept every fragment"; DirectGLES did exactly - // that and KHR-GL43.viewport_array.scissor_zero_dimension caught it. Deliberately beside - // ScissorBoxes so it shares their tail span (after LogicOp) and DirectGLES' span memcmp - // picks a transition up like any other state. - Uint32 ScissorBoxWrittenMask = 0; - // glEnable(GL_CLIP_DISTANCE0 + i) for i in [0, 8), one bit each. A bitmask rather than - // eight bools because every consumer wants the set, not an individual flag, and because - // the SYNC_CAPABILITY/SET_CAPABILITY macros key off a "Enabled" field name that - // eight numbered capabilities cannot share. Lives in the tail span (after LogicOp), so - // DirectGLES' span memcmp picks a change up like any other capability. - Uint32 ClipDistanceEnabledMask = 0; - }; - namespace MG_State { namespace GLState { class RenderState { diff --git a/MobileGL/MG_State/GLState/SamplerState/SamplerObject.h b/MobileGL/MG_State/GLState/SamplerState/SamplerObject.h index 9f97b2d67..90b4619c7 100644 --- a/MobileGL/MG_State/GLState/SamplerState/SamplerObject.h +++ b/MobileGL/MG_State/GLState/SamplerState/SamplerObject.h @@ -8,93 +8,9 @@ #pragma once #include -#include +#include namespace MobileGL { - enum class SamplerFilterMode { - Nearest, - Linear, - SamplerFilterCount, - Unknown = -1 - }; - - enum class SamplerMipmapMode { - None, - Nearest, - Linear, - SamplerMipmapModeCount, - Unknown = -1 - }; - - enum class SamplerWrapMode { - ClampToEdge, - MirroredRepeat, - Repeat, - ClampToBorder, - MirrorClampToEdge, - SamplerWrapModeCount, - Unknown = -1 - }; - - enum class SamplerCompareMode { - None, - CompareToTexture, - SamplerCompareModeCount, - Unknown = -1 - }; - - enum class SamplerCompareFunc { - Never, - Less, - Equal, - LessEqual, - Greater, - NotEqual, - GreaterEqual, - Always, - SamplerCompareFuncCount, - Unknown = -1 - }; - - // Which of the three GL_TEXTURE_BORDER_COLOR entry-point families last wrote the border colour, - // and therefore which of the three stored representations is AUTHORITATIVE. GL 4.6 core 8.10: - // TexParameterIiv/Iuiv store an integer border colour "unmodified, with an internal data type of - // integer", TexParameterfv stores a floating-point one, and the derived forms are only a - // convenience for a getter of the other spelling. A backend cannot pick the right driver entry - // point (glSamplerParameterIiv vs fv) or the right VkBorderColor family without this: numerically - // the three representations are always populated, so the value alone says nothing about the form. - enum class BorderColorForm : Uint8 { - Float, - Int, - Uint - }; - - struct SamplerParameters { - SamplerWrapMode wrapS = SamplerWrapMode::Repeat; - SamplerWrapMode wrapT = SamplerWrapMode::Repeat; - SamplerWrapMode wrapR = SamplerWrapMode::Repeat; - SamplerFilterMode minFilter = SamplerFilterMode::Nearest; - SamplerFilterMode magFilter = SamplerFilterMode::Linear; - SamplerMipmapMode mipmapMode = SamplerMipmapMode::Linear; - Float minLod = -1000.0f; - Float maxLod = 1000.0f; - Float lodBias = 0.0f; - Float maxAnisotropy = 1.0f; - // GL 4.6 core table 23.18 / GLES 3.2 table 21.16: TEXTURE_COMPARE_FUNC starts at LEQUAL, - // for both sampler objects and the sampler state a texture object carries. - SamplerCompareFunc compareFunc = SamplerCompareFunc::LessEqual; - SamplerCompareMode compareMode = SamplerCompareMode::None; - // TEXTURE_BORDER_COLOR is sampler state (GL 4.6 core table 23.18), so it belongs here and - // not on the texture - a texture object reaches it through the sampler object it owns. The - // three representations are the float, integer and unsigned-integer forms glSamplerParameterfv, - // glSamplerParameterIiv and glSamplerParameterIuiv set; whichever is written last defines - // the colour and the other two follow it, so a getter always has an answer. - FloatVec4 borderColor = {0.0f, 0.0f, 0.0f, 0.0f}; - IntVec4 borderColorI = {0, 0, 0, 0}; - UintVec4 borderColorUI = {0, 0, 0, 0}; - BorderColorForm borderColorForm = BorderColorForm::Float; - }; - namespace MG_State { namespace GLState { class SamplerObject { diff --git a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h index 2435c3756..8a6d22f67 100644 --- a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h +++ b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h @@ -10,65 +10,11 @@ #include #include "../BufferState/BufferObject.h" #include "MG_Util/Types.h" +#include namespace MobileGL { namespace MG_State { namespace GLState { - struct VertexAttribute { - Bool Enabled = false; - int Size = 4; - DataType Type = DataType::Float32; - Bool Normalized = false; - // The RESOLVED byte distance between consecutive elements, never the raw - // glVertexAttrib*Pointer argument: a pointer call's stride 0 means "tightly - // packed" and is resolved to the element size here, so a zero that survives - // into this field can only have come from the binding model, where a zero - // VERTEX_BINDING_STRIDE means the opposite - every vertex reads the SAME - // element and the fetch address never advances (GL 4.6 core 10.3.1). Backends - // consume this verbatim; collapsing 0 back into the element size is what made - // KHR-GL43.vertex_attrib_binding.basic-input-case7/8 read past the buffer. - int Stride = 0; - SizeT Offset = 0; - Bool IsInteger = false; - // GL_BGRA vertex size: four components in reversed (B,G,R,A) memory order. Size stays 4. - // Set only by the long (L) format entry points. It is NOT implied by - // Type == Float64: VertexAttribFormat(GL_DOUBLE) also reads doubles from memory but - // asks for them *converted to float*, while VertexAttribLFormat keeps all 64 bits - // (GL 4.6 core 10.3.2). Backends have to tell the two apart, and it is what - // GL_VERTEX_ATTRIB_ARRAY_LONG reports. - Bool IsLong = false; - Bool IsBgra = false; - Uint Divisor = 0; - SharedPtr Buffer; - - // GL 4.6 core table 23.3: VERTEX_ATTRIB_ARRAY_STRIDE and _POINTER are the - // arguments of the last glVertexAttrib*Pointer call on this attribute, - // reported verbatim, and NOTHING else writes them - not glVertexAttribFormat, - // not glBindVertexBuffer. Stride/Offset above are the *resolved* draw inputs - // and the binding model does overwrite those, so the two views have to be - // stored apart or the binding-model sequence reports a legacy state it never - // set (KHR-GL4x.vertex_attrib_binding.basic-state3). - int LegacyStride = 0; - SizeT LegacyPointer = 0; - }; - - // ARB_vertex_attrib_binding separate binding point. Attributes configured through the - // binding-point API are resolved eagerly into the flat VertexAttribute view above, so - // backends keep consuming resolved attributes and never see binding points. - struct VertexBufferBindingPoint { - SharedPtr Buffer; - SizeT Offset = 0; - // GL 4.6 core table 23.4: the initial VERTEX_BINDING_STRIDE is 16, not 0. - int Stride = 16; - Uint Divisor = 0; - }; - - struct VertexAttributeVersion { - Uint16 FormatVersion = 0; - Uint16 BufferVersion = 0; - Uint16 SwitchVersion = 0; - }; - class VertexArrayObject { public: // Storage capacity, not the GL-visible limit. GL_MAX_VERTEX_ATTRIBS is reported as diff --git a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp index ab9907dd0..5ce193a47 100644 --- a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp +++ b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp @@ -113,6 +113,39 @@ TEST(PipeCatalogue, ResidualBlockSizeIsPinned) { EXPECT_GE(sizeof(ResidualValueBlock), sizeof(RenderStateParameters) + sizeof(PixelStoreParameters)); } +// P0.5 moved the value structs into MG_Pipe/MGPipeValueTypes.h. These are the runtime twins +// of that header's static assertions, so the numbers show up in ctest output on every +// platform - including one where a static assertion is skipped. Every number here is also +// what MGL_RESIDUAL_BLOCK_SIZE (MGPipeTypes.h) and the Espryt offsetof spans depend on. +TEST(PipeCatalogue, ValueTypeLayoutsArePinned) { + EXPECT_EQ(sizeof(PixelStoreParameters), 28u); + EXPECT_EQ(sizeof(PerBufferBlendState), 28u); + EXPECT_EQ(sizeof(StencilFaceState), 28u); + EXPECT_EQ(sizeof(RenderStateParameters), 1168u); + EXPECT_EQ(sizeof(SamplerParameters), 100u); + EXPECT_EQ(sizeof(MG_State::GLState::VertexAttributeVersion), 6u); + EXPECT_TRUE(std::is_trivially_copyable_v); + EXPECT_TRUE(std::is_trivially_copyable_v); + EXPECT_TRUE(std::is_trivially_copyable_v); + EXPECT_TRUE(std::is_trivially_copyable_v); + EXPECT_TRUE(std::is_standard_layout_v); + EXPECT_TRUE(std::is_trivially_copyable_v); + EXPECT_TRUE(std::is_trivially_copyable_v); + EXPECT_LT(offsetof(RenderStateParameters, BlendStates), offsetof(RenderStateParameters, LogicOp)); + EXPECT_EQ(std::tuple_size_v, static_cast(kMGMaxDrawBuffers)); + EXPECT_EQ(std::tuple_size_v, static_cast(kMGMaxDrawBuffers)); + EXPECT_EQ(kMGMaxDrawBuffers, 8u); +} + +// The move did not alter the carrier: the residual block is still the render-state struct, +// then the pack struct, then the 8-aligned capability word, at the offsets it had before. +TEST(PipeCatalogue, ResidualBlockIsExactlyItsTwoValueStructsPlusPatchTail) { + EXPECT_EQ(offsetof(ResidualValueBlock, RenderState), 0u); + EXPECT_EQ(offsetof(ResidualValueBlock, Pack), sizeof(RenderStateParameters)); + EXPECT_EQ(offsetof(ResidualValueBlock, CapabilityBits), 1200u); + EXPECT_EQ(offsetof(ResidualValueBlock, PatchVertices), 1208u); +} + // G3's opcode numbering is the wire protocol. Position in PipeCalls.def, 1-based, no holes. TEST(PipeCatalogue, WireOpcodesAreThePositionsInTheCatalogue) { EXPECT_EQ(static_cast(MGPWireOp::GetCaps), 1); diff --git a/scripts/gen_pipe.py b/scripts/gen_pipe.py index 8b128798a..795885934 100644 --- a/scripts/gen_pipe.py +++ b/scripts/gen_pipe.py @@ -161,8 +161,9 @@ def parse_calls(): # The member types the G4 comparator falls back to memcmp for (see gen_verify): the -# MG_State / MG_Backend value structs and MGHostSpan. They are not call payloads and get -# field lists of their own in P0.5. Nothing else may be missing from PipeFields.def. +# MG_Pipe / MG_Backend value structs and MGHostSpan. They are not call payloads and get +# field lists of their own in P1 (P0.5 moved the types). Nothing else may be missing from +# PipeFields.def. MEMCMP_FALLBACK_TYPES = { "RenderStateParameters", "PixelStoreParameters", @@ -407,11 +408,11 @@ def gen_verify(payloads): } else if constexpr (requires(const T& x, const T& y) { x == y; }) { return a == b; } else { - // MEMCMP FALLBACK. Only reached by the payload members that are still MG_State / - // MG_Backend value structs (RenderStateParameters, PixelStoreParameters, - // DynamicBackendParameters) and by MGHostSpan. Those are exactly the types P0.5 - // moves into MGPipeValueTypes.h, at which point they get field lists of their own - // and this branch stops being reachable from any payload. + // MEMCMP FALLBACK. Only reached by the payload members that are still value structs + // without a field list (RenderStateParameters, PixelStoreParameters, + // DynamicBackendParameters) and by MGHostSpan. P0.5 moved the first two into + // MGPipeValueTypes.h; P1 gives them field lists of their own, at which point this + // branch stops being reachable from any payload. return std::memcmp(&a, &b, sizeof(T)) == 0; } } From da249f30e2bc0ef159ef6ada06f6065022b86289 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 22:47:07 -0400 Subject: [PATCH 035/529] [Refactor] (Program): extract ProgramArtifacts.h - move TypeFacts, ResourceReflection, XfbVarying, LinkArtifacts and SpirvArtifacts to namespace scope with in-class aliases so every existing spelling compiles unchanged; pure move - P0.5 of the MGPipe disaggregation (ROADMAP P0.5, ARCHITECTURE.md:260): the five reflection types a link produces now live in a header that includes only and , so a future server-side consumer can name them without dragging ShaderObject.h / SpvcSession.h / the transpiler behind it. - Struct bodies move verbatim, comments included, re-indented one level; no field is added, removed, reordered or re-typed. The two glslang-typed members (LinkArtifacts::program, uniformInitialValues) stay as they are (B.0 D5); the comment mentions of glslang types are respelled without the scope token so the include-closure gate's "glslang:: exactly twice" limit holds. - kInvalidUniformOffset moves to namespace scope (SpirvArtifacts defaults to it); ProgramObject::kInvalidUniformOffset is defined from it, so the two cannot drift. - ProgramObject keeps in-class aliases (fully qualified on the right-hand side) for all nine names, so none of the 8 includers nor any ProgramObject::X spelling changes. - ProgramArtifactsTest pins the aliases as the same types (is_same_v) and TypeFacts as a 44-byte POD; its first include is the new header, so it is also the proof that the header is self-contained. --- .../GLState/ProgramState/ProgramArtifacts.h | 400 ++++++++++++++++++ .../GLState/ProgramState/ProgramObject.h | 385 +---------------- MobileGL/MG_Test/Program/CMakeLists.txt | 21 + .../MG_Test/Program/ProgramArtifactsTest.cpp | 47 ++ 4 files changed, 482 insertions(+), 371 deletions(-) create mode 100644 MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h create mode 100755 MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h new file mode 100644 index 000000000..4afd48a2f --- /dev/null +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h @@ -0,0 +1,400 @@ +// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include // String/Vector/Array/UnorderedMap/SharedPtr + GL enums. DEBT NOTE (MGPipeTypes.h:24-31 style): + // Includes.h:80-84 still pulls glslang and :59 spirv_cross_c.h; this header is glslang-free BY + // SYMBOL (what P7's `nm -D | grep glslang` measures), not by preprocessed text. A textually + // glslang-free closure needs MG_Util/Types.h split off Includes.h (Types.h:11 includes it + // back) - out of scope for P0.5. +#include // std::set (LinkArtifacts); NOT provided by Includes.h on its own terms +// PURITY: no ShaderObject.h, no SpvcSession.h, nothing under MG_Util/ShaderTranspiler/, no Config.h, +// no MG_Backend/, no BufferState/. scripts/check_include_closure.py probe "artifacts-header" (ROADMAP P0.5; +// ARCHITECTURE.md:260) asserts that closure. the glslang scope token appears exactly twice below (B.0 D5: the two +// glslang-typed LinkArtifacts members moved verbatim) and the gate pins that count. + +namespace MobileGL::MG_State::GLState { + // Sentinel for a uniform location without global-UBO backing storage (should not + // survive linking: GenerateBinary falls back to tail-allocated scratch storage). + // Namespace scope so SpirvArtifacts::reservedNumSamplesOffset can default to it; + // ProgramObject::kInvalidUniformOffset is defined from this one. + inline constexpr Uint kInvalidUniformOffset = ~0u; + + // Everything the query surface ever asked a glslang TType, flattened. Twenty + // predicates, no recursion: nothing post-link ever walks a struct, a type name or the + // AST, so a POD covers the whole surface exactly. + struct TypeFacts { + Bool isArray = false; + // A runtime-sized array (a storage block's unsized trailing member) is an array + // that is NOT sized; GL_ARRAY_SIZE reports 0 for it. + Bool isSizedArray = false; + Bool isMatrix = false; + Bool isVector = false; + Bool isOpaque = false; + Bool isTexture = false; + Bool isImage = false; + Bool isDouble = false; // getBasicType() == EbtDouble + Bool isVoid = false; // getBasicType() == EbtVoid (hidden block members) + Bool isBuffer = false; // getQualifier().storage == EvqBuffer + Bool isPatch = false; // getQualifier().patch + Bool hasIndex = false; // getQualifier().hasIndex() + Bool hasFormat = false; // getQualifier().hasFormat() + Int vectorSize = 0; + Int matrixCols = 0; + Int matrixRows = 0; + Int layoutIndex = 0; // getQualifier().layoutIndex + Uint layoutFormat = 0; // getQualifier().getFormat() + // glslang TLayoutMatrix, widened. For a uniform this is already RESOLVED against + // the owning block's qualifier, so the getUniformBlock() fallback the old + // accessors carried is gone. + Int layoutMatrix = 0; + // glslang TBasicType, widened - ApplyUniformInitialValues and the typed + // glGetUniform* paths compare against a handful of enumerators. + Int basicType = 0; + }; + + // One glslang TObjectReflection, flattened. Used for uniforms, blocks, pipe inputs + // and pipe outputs alike, because glslang reflects all four as TObjectReflection. + struct ResourceReflection { + String name; + GLenum glDefineType = 0; + Int offset = -1; + // TObjectReflection::size, RAW. For a uniform prefer `arraySize` below, which is + // the resolved GL_UNIFORM_SIZE answer. + Int size = 0; + // TObjectReflection::index - for a uniform, the TPROGRAM block index owning it + // (-1 for a default-block one; translate with GlBlockIndexFromTProgram). + Int index = -1; + Int counterIndex = -1; + Int arrayStride = 0; + Int topLevelArraySize = 0; + Int topLevelArrayStride = 0; + Int binding = -1; + Int location = -1; // layoutLocation() + // EShLanguageMask of the stages that reference it; 0 means "declared but read by + // nobody", which is what the dead-default-block-uniform filter tests. + Uint32 stages = 0; + // GL_UNIFORM_SIZE / GL_ARRAY_SIZE, already resolved through the + // isSizedArray()/getOuterArraySize()/size fallback. + GLint arraySize = 1; + TypeFacts type; + }; + + using UniformReflection = ResourceReflection; + using BlockReflection = ResourceReflection; + using PipeInputReflection = ResourceReflection; + using PipeOutputReflection = ResourceReflection; + + // Transform feedback (GL 3.0 core: glTransformFeedbackVaryings applies on + // the NEXT link; the linked snapshot below is what draws and queries see). + struct XfbVarying { + String name; + GLenum type = GL_FLOAT; + GLint size = 1; // array element count + Uint32 bufferIndex = 0; // capture buffer slot + Uint32 offsetBytes = 0; // offset within the capture buffer + Uint32 byteSize = 0; // bytes captured per vertex for this varying + // Offset within the gap-free record a backend that cannot express the GL + // layout captures into; see NeedsScatteredTransformFeedbackCapture. + Uint32 packedOffsetBytes = 0; + + // GL 4.6 core 11.1.2.1 / 7.3.1.1: a member of an output interface block is + // captured under ".". `name` keeps that GL spelling (it is + // what the interface queries and the ESSL backend's driver-side capture list + // need, since SPIRV-Cross re-emits the block under its own type name), while + // the three fields below carry what a SPIR-V backend needs instead: the + // decoration target is the block's *instance* variable and the member index + // inside it. blockMemberIndex < 0 means "not a block member". + String blockInstanceName; + String blockName; + Int blockMemberIndex = -1; + // Which element of an arrayed block member this capture names, -1 for "the + // member as a whole". SPIR-V cannot decorate a single array element, so a + // backend needs the element index to tell a full run from a partial one. + Int blockMemberElement = -1; + }; + + // ---- P1: everything a link PRODUCES, in one movable block ---- + // + // The membership rule is mechanical, not editorial: this is exactly the field list + // ResetLinkArtifacts() clears (plus the four it forgot to - infoLog, + // linkedFragDataLocation/Index and the geometry strip-capture pair - which are just + // as much link output). Nothing else belongs here. + // + // Why a struct: once glLinkProgram runs on a worker (P1 stage 4) the worker writes + // its OWN LinkArtifacts and the GL thread publishes it with a single move, instead + // of thirty cross-thread field assignments. Until then this is a pure refactor. + // + // Access rule (invariant I5): the member below is private and reachable ONLY + // through ProgramObject::Artifacts(), which calls EnsureLinkJoined() first. That is + // what makes "every read of link output joins the pending link" a property the + // compiler checks rather than a review item - a new reader cannot spell the field + // without going through the gate. m_artifacts lives in ProgramObject and is private + // there; the type being namespace-scope changes nothing about that gate. + // ---- the owned mirror of glslang's reflection ---- + // + // WHY THIS EXISTS. Every GL query about a linked program used to be answered by + // asking the live glslang TProgram - program->getUniform(i).getType()->isMatrix() + // and friends. That made the TProgram part of the program's PERMANENT state, which + // in turn made the whole front end (parse + link) unskippable: the L1 shader + // translation memo could hand back the SPIR-V but the reflection still had to be + // rebuilt from a freshly parsed AST. + // + // These three tables are a snapshot of everything the query surface ever reads off + // the TProgram, in PLAIN OWNED VALUES - no TType*, no TString, nothing pointing into + // a glslang pool. Taken once at the tail of DoReflection (SnapshotGlslangReflection), + // they are copyable, immutable after the link, and safe to memoize and share between + // ProgramObjects and threads. Once they are filled, `program` is dead weight to + // everything except DoReflection itself. + // + // INDEXED BY TPROGRAM INDEX, deliberately: that is the space uniformIndexInTProgram, + // glUniformIndexToTProgram and tProgramUniformIndexToGl already speak, so every + // accessor that used to call program->getUniform(i) indexes uniformReflection[i] + // instead, unchanged in every other respect. + + struct LinkArtifacts { + // Live only between LinkProgram() and the end of DoReflection. Everything after + // that reads the owned mirror below; a link served from the L1 memo never + // constructs one at all, so this is null for such a program and MUST NOT be + // dereferenced outside DoReflection. + SharedPtr program; + + // The owned reflection snapshot. Indexed by TProgram index; see the structs above. + Vector uniformReflection; + Vector blockReflection; + Vector pipeInputReflection; + Vector pipeOutputReflection; + // Program-level scalars glslang answers off the linked intermediates. + // Whether the program's LAST stage is the fragment stage. A color number - and so a + // color index - exists only there; a separable tess/geometry/vertex program's + // outputs are varyings and must report -1 (KHR-GL43.program_interface_query. + // separate-programs-tess-control). + Bool lastStageIsFragment = false; + Array computeLocalSize{}; + // Replaces program->getUniformIndex(name). Maps the reflected name to its + // TProgram uniform index. + UnorderedMap uniformIndexByName; + + // Attributes (Vertex in) + Vector attribs; + Vector attribTypes; + + // FragData (Frag out): the per-link snapshot of the explicit request maps. + UnorderedMap linkedFragDataLocation; + UnorderedMap linkedFragDataIndex; + + // GL-facing index spaces (see the translation helpers above): GL active-uniform + // index <-> glslang TProgram uniform index, GL uniform-block index <-> TProgram + // block index. -1 marks a TProgram entry GL does not expose (dead default-block + // uniforms swept into MGL_GLOBAL_UBO by the relaxed parse, and that block itself). + Vector glUniformIndexToTProgram; + Vector tProgramUniformIndexToGl; + Vector glBlockIndexToTProgram; + Vector tProgramBlockIndexToGl; + // GL_UNIFORM_BLOCK index space: ACTUAL uniform blocks only, a strict subsequence of + // glBlockIndexToTProgram above. + // + // That list is the BLOCK space - everything the relaxed parse produced except + // MGL_GLOBAL_UBO - and it is what the backends walk and what every block-keyed table + // here (uniformBlockBinding, uniformBlockIndexByName, blockReflection ordering) is + // indexed by. It is NOT the GL uniform-block list: MobileGL does not pass + // EShReflectionSeparateBuffers to buildReflection, so glslang routes BUFFER blocks + // through indexToUniformBlock too, and the list therefore also carries every shader + // storage block and every synthesized gl_AtomicCounterBlock_N. GL 4.6 core 7.6 gives + // those their own enumerations (GL_SHADER_STORAGE_BLOCK and + // GL_ACTIVE_ATOMIC_COUNTER_BUFFERS respectively), and GL_ACTIVE_UNIFORM_BLOCKS / + // glGetActiveUniformBlock*/glGetUniformBlockIndex must not see either. + // + // Kept as a SECOND space rather than filtering the first in place: DirectGLES assigns + // one ESSL uniform-buffer binding point per entry of the block list as it walks it + // (Managers.cpp CacheResourceLocations and the matching per-draw loop in + // DirectGLES.cpp), so compacting that list would renumber every backend binding + // point, and tProgramBlockIndexToGl[i] < 0 is what DoReflection and + // BuildGlobalUboRouting read as "member of the synthesized global UBO". + Vector glUniformBlockIndexToBlock; // GL uniform-block index -> block index + Vector blockIndexToGlUniformBlock; // block index -> GL uniform-block index (-1) + // Per-link merged snapshot of the layout(location = N) qualifiers the attached + // shaders' default-block uniforms declared, as glslang recorded them at the point + // its relaxed remap dropped them (the relaxed parse drops them from reflection; the + // DoReflection assigner restores them from here). + UnorderedMap linkedExplicitUniformLocations; + // Per-link snapshot of the default-block uniform INITIALIZERS the attached shaders + // declared ("uniform int i = 1;"). Desktop GLSL says that value is what the uniform + // reads until the application overwrites it, and relinking restores it - but the + // relaxed parse turns those uniforms into members of MGL_GLOBAL_UBO, where SPIR-V + // cannot carry an initializer, so the value only survives as this side-channel. + // Applied into the uniform shadow at the phase-B publish (ApplyUniformInitialValues). + Vector uniformInitialValues; + UnorderedMap uniformLocations; + // ---- "written since link" (see MarkUniformWrittenAtLocation) ---- + // In LinkArtifacts deliberately: a link is exactly the event that retracts every + // write (GL resets uniforms to their initial values), so living here means the set + // is cleared by the same three paths that clear the rest of a link's output - + // Link()'s whole-struct reset, ResetLinkArtifacts, and the publish's move - and no + // fourth reset site can be forgotten. Empty (and never allocated) for a program + // that never asked to be separable. + Vector writtenUniformLocationBits; + Vector writtenUniformIndexBits; + Vector writtenUniformIndices; + // Ordered by location, + // aka. uniformIndexInTProgram[loc] == "uniform index of TProgram at location `loc`" + Vector uniformIndexInTProgram; + // ditto. Will be set at glUniform1i + Vector uniformSamplerOrImageUnitIndex; + // Sampler/image layout(binding = N) initial texture/image units, captured by + // TMglGlslIoResolver at mapIO's collect callback - the last point at which the + // qualifier still says what the shader declared. An OUTPUT of the link, not an + // input to it: nothing supplies this map, the resolver fills it. + UnorderedMap explicitOpaqueUniformBindings; + + // Ordered by uniform block index + // index is DIFFERENT from binding!!! + // + // Let's define UniformBlockIndex == the order at glslang getUniformBlock() + // aka `i = glGetUniformBlockIndex(prog, "BlockName")` implies: + // `prog->getUniformBlock(i) == "BlockName"` + // These stuff are present for GL semantics, not for backend inspection + // These may change after-link (because GL spec decided to have `glUniformBlockBinding`) + UnorderedMap uniformBlockIndexByName; + Vector uniformBlockBinding; + // glShaderStorageBlockBinding overrides, keyed by GL block name. See + // SetShaderStorageBlockBinding for why this one is by name and not by index. + // + // ALSO SEEDED AT LINK, by ProgramLinkTask::SeedDefaultStorageBlockBindings, with the + // GL-mandated binding 0 for every storage block whose shader declared no + // layout(binding = N). Those blocks have no other way to be told apart from a block + // that declared one: glslang's IO mapper invents a binding and writes it into the + // qualifier, so the reflection reports the invention. A seed is therefore "GL's + // default binding for this block", and a later glShaderStorageBlockBinding simply + // overwrites it - default and rebind travel one path. + UnorderedMap shaderStorageBlockBinding; + // Block type names of the storage blocks the program's shaders declared with NO + // layout(binding = N). Input to the seeding above; filled during mapIO by + // TMglGlslIoResolver, which is the last observer that can still tell a declared + // binding from an invented one - and, unlike the per-shader lexer this replaced, + // sees the declaration with its macros expanded. + std::set storageBlocksWithoutBinding; + // The same list for UNIFORM blocks, and it is needed for the same reason: glslang's + // auto-mapper assigns every uniform block a binding whether or not the shader asked + // for one, so uniformBlockBinding below cannot tell "declared 1" from "invented 1". + // GL 4.6 core 7.6.2 requires an unqualified block to report ZERO. + std::set uniformBlocksWithoutBinding; + + Uint activeUniformCount = 0; + // This program's fragment stage read gl_NumSamples, so the source pipeline lowered it + // onto the reserved default-block uniform (ShaderTranspiler::NUM_SAMPLES_UNIFORM_NAME) + // and the draw path owes it the draw framebuffer's sample count before every draw. + // + // PHASE A on purpose, even though the byte offset it needs is phase-B output: the + // gate has to be answerable without joining the SPIR-V job, or every draw of every + // program would pay a join to discover it has nothing to write. + Bool usesReservedNumSamples = false; + Uint maxUniformLocation = 0; + Int uniformNameMaxLength = 0; + Int attribInNameMaxLength = 0; + Int uniformBlockNameMaxLength = 0; + + String infoLog; + Bool linkStatus = false; + + // Transform feedback: the linked snapshot (the request lives outside, on the + // GL-thread-owned side). + Vector xfbVaryings; + // The glTransformFeedbackVaryings request list exactly as this link consumed it, + // INCLUDING the gl_NextBuffer / gl_SkipComponentsN pseudo-varyings that + // xfbVaryings deliberately drops (they steer the capture layout and must never + // reach a backend's varying list). GL_TRANSFORM_FEEDBACK_VARYING enumerates the + // full request, pseudo-varyings and all, so the interface query needs its own copy. + Vector xfbInterfaceNames; + Vector xfbStrides; + Vector gsStripTriangles; + Bool gsStripCaptureFixup = false; + GLenum gsInputPrimitive = GL_NONE; + // GL_TESS_CONTROL_OUTPUT_VERTICES: the `layout(vertices = N) out` of the linked + // tessellation control stage, or 0 when the program has none. Checked against + // GL_MAX_PATCH_VERTICES at link (GL 4.6 core 11.2.1.1). + Int tcsOutputVertices = 0; + // The rest of the geometry stage's link properties, and the tessellation evaluation + // stage's. Every one of these is a glGetProgramiv answer that had no source at all: + // the query surface listed the geometry pnames only to fall through to + // GL_INVALID_ENUM, and the GL_TESS_GEN_* pnames were not mentioned anywhere. They + // come from the linked intermediates for the same reason gsInputPrimitive and + // tcsOutputVertices do - glslang has already merged the compilation units' layout + // qualifiers and diagnosed contradictions, so the linked program is the thing that + // knows. + GLenum gsOutputPrimitive = GL_NONE; + Int gsMaxVertices = 0; + Int gsInvocations = 0; + // The tessellation evaluation stage's layout: GL_QUADS / GL_TRIANGLES / GL_ISOLINES, + // GL_EQUAL / GL_FRACTIONAL_EVEN / GL_FRACTIONAL_ODD, GL_CW / GL_CCW, and point mode. + GLenum tessGenMode = GL_NONE; + GLenum tessGenSpacing = GL_NONE; + GLenum tessGenVertexOrder = GL_NONE; + Bool tessGenPointMode = false; + GLenum xfbBufferMode = GL_INTERLEAVED_ATTRIBS; + Int xfbVaryingNameMaxLength = 0; + Bool xfbNeedsScatteredCapture = false; + Uint32 xfbPackedStride = 0; + }; + + // ---- everything phase B of a link produces, in one movable block ---- + // + // The membership rule is the same mechanical one LinkArtifacts uses: this is exactly + // what ProgramSpirvTask writes, which is what makes moving it THE publish. It is + // deliberately NOT part of LinkArtifacts, and that separation is what routes the five + // readers of SPIR-V-derived data through their own join gate by compiler rather than + // by review - m_spirv is private and Spirv() is the only spelling that reaches it. + // + // Why these three and nothing else: `generatedSpirv` has no GL-thread reader at all + // (every consumer is a backend draw/prepare path), and `uniformOffsets` + + // `globalUboScratch` are the ONLY things glUniform*/glGetUniform* need that are + // derived from the OPTIMIZED SPIR-V rather than from glslang reflection - spirv-opt + // runs in place and can delete a uniform, or the whole global UBO, so the offsets + // cannot be lifted out of glslang's reflection instead. + struct SpirvArtifacts { + Vector> generatedSpirv; + Bool enableSpirvValidation = false; + // Byte offset of each uniform location inside globalUboScratch, or + // kInvalidUniformOffset. Sized maxUniformLocation + 1 by the routing pass. + Vector uniformOffsets; + Vector globalUboScratch; + // Byte offset of the reserved gl_NumSamples stand-in inside globalUboScratch, or + // kInvalidUniformOffset. Taken by NAME from the SPIR-V metadata rather than through + // uniformOffsets, because the member has no GL location at all: the link task keeps + // it out of the GL-visible uniform index space so no application can see or write it. + Uint reservedNumSamplesOffset = kInvalidUniformOffset; + // False for a program whose SPIR-V was never produced (phase B cancelled at + // teardown or by a relink) or whose optimizer run failed. GL has no way to + // retract a LINK_STATUS it already reported true, so such a program stays + // "linked" and every reflection answer it has given stays correct - it is simply + // not drawable, which the backends already express through their link-status + // gates. + Bool spirvStatus = false; + // Whether these modules KEPT their 64-bit floats instead of being narrowed to 32 + // (ShaderTranspiler::DemoteFloat64Pass). Decided per PROGRAM, never per module - the + // global UBO is one buffer all stages read, so two stages disagreeing about whether a + // `uniform double` occupies 4 or 8 bytes would put every uniform after it at a + // different offset in each. Recorded here rather than re-derived from the backend + // because it is the layout THESE modules were built with: it is what the routing + // table's offsets mean, and glUniform*d / glGetUniform*v have to write and read the + // width the shader actually declares. + Bool nativeFloat64 = false; + // Whether gl_PointSize was demoted out of THESE modules' tessellation/geometry + // stages into an ordinary varying (ShaderCompiler:: + // DemoteTessellationGeometryPointSizeForProgram) because the backend cannot host + // the built-in there. Per PROGRAM by construction - a consumer whose producer + // kept the built-in would read garbage - and recorded here rather than + // re-derived because it cannot be: the rewrite's whole point is that the final + // bytes no longer declare the capability that armed it. The backends read it to + // respell a "gl_PointSize" transform-feedback capture as the carrier + // (ShaderCompiler::POINT_SIZE_CAPTURE_CARRIER_NAME). The GL reflection surface + // deliberately keeps answering "gl_PointSize": demotion happens after phase A, + // so every query keeps the truthful GL spelling. + Bool pointSizeDemoted = false; + }; +} // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h index 506f0facf..de2d60698 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h @@ -9,6 +9,7 @@ #pragma once #include #include "ShaderObject.h" +#include "ProgramArtifacts.h" #include #include @@ -38,70 +39,19 @@ namespace MobileGL::MG_State::GLState { // 1024 GL 4.3 requires. static constexpr Int MAX_UNIFORM_LOCATIONS = static_cast(glslang::TQualifier::layoutLocationEnd); - // Everything the query surface ever asked a glslang::TType, flattened. Twenty - // predicates, no recursion: nothing post-link ever walks a struct, a type name or the - // AST, so a POD covers the whole surface exactly. - struct TypeFacts { - Bool isArray = false; - // A runtime-sized array (a storage block's unsized trailing member) is an array - // that is NOT sized; GL_ARRAY_SIZE reports 0 for it. - Bool isSizedArray = false; - Bool isMatrix = false; - Bool isVector = false; - Bool isOpaque = false; - Bool isTexture = false; - Bool isImage = false; - Bool isDouble = false; // getBasicType() == EbtDouble - Bool isVoid = false; // getBasicType() == EbtVoid (hidden block members) - Bool isBuffer = false; // getQualifier().storage == EvqBuffer - Bool isPatch = false; // getQualifier().patch - Bool hasIndex = false; // getQualifier().hasIndex() - Bool hasFormat = false; // getQualifier().hasFormat() - Int vectorSize = 0; - Int matrixCols = 0; - Int matrixRows = 0; - Int layoutIndex = 0; // getQualifier().layoutIndex - Uint layoutFormat = 0; // getQualifier().getFormat() - // glslang::TLayoutMatrix, widened. For a uniform this is already RESOLVED against - // the owning block's qualifier, so the getUniformBlock() fallback the old - // accessors carried is gone. - Int layoutMatrix = 0; - // glslang::TBasicType, widened - ApplyUniformInitialValues and the typed - // glGetUniform* paths compare against a handful of enumerators. - Int basicType = 0; - }; - - // One glslang::TObjectReflection, flattened. Used for uniforms, blocks, pipe inputs - // and pipe outputs alike, because glslang reflects all four as TObjectReflection. - struct ResourceReflection { - String name; - GLenum glDefineType = 0; - Int offset = -1; - // TObjectReflection::size, RAW. For a uniform prefer `arraySize` below, which is - // the resolved GL_UNIFORM_SIZE answer. - Int size = 0; - // TObjectReflection::index - for a uniform, the TPROGRAM block index owning it - // (-1 for a default-block one; translate with GlBlockIndexFromTProgram). - Int index = -1; - Int counterIndex = -1; - Int arrayStride = 0; - Int topLevelArraySize = 0; - Int topLevelArrayStride = 0; - Int binding = -1; - Int location = -1; // layoutLocation() - // EShLanguageMask of the stages that reference it; 0 means "declared but read by - // nobody", which is what the dead-default-block-uniform filter tests. - Uint32 stages = 0; - // GL_UNIFORM_SIZE / GL_ARRAY_SIZE, already resolved through the - // isSizedArray()/getOuterArraySize()/size fallback. - GLint arraySize = 1; - TypeFacts type; - }; - - using UniformReflection = ResourceReflection; - using BlockReflection = ResourceReflection; - using PipeInputReflection = ResourceReflection; + // The five reflection/artifact types live at namespace scope in ProgramArtifacts.h + // (P0.5). Re-exported here so every existing spelling (ProgramObject::LinkArtifacts, + // ProgramObject::TypeFacts, ...) compiles unchanged. Fully qualified on the right-hand + // side on purpose: an unqualified `TypeFacts` would name the alias being declared. + using TypeFacts = MobileGL::MG_State::GLState::TypeFacts; + using ResourceReflection = MobileGL::MG_State::GLState::ResourceReflection; + using UniformReflection = ResourceReflection; + using BlockReflection = ResourceReflection; + using PipeInputReflection = ResourceReflection; using PipeOutputReflection = ResourceReflection; + using XfbVarying = MobileGL::MG_State::GLState::XfbVarying; + using LinkArtifacts = MobileGL::MG_State::GLState::LinkArtifacts; + using SpirvArtifacts = MobileGL::MG_State::GLState::SpirvArtifacts; ProgramObject(Uint externalIndex) : m_externalIndex(externalIndex), m_lifetimeId(AllocateLifetimeId()) {} // Cancel-not-join, exactly like ~ShaderObject: the link job owns its inputs, so an @@ -544,7 +494,7 @@ namespace MobileGL::MG_State::GLState { } // Sentinel for a uniform location without global-UBO backing storage (should not // survive linking: GenerateBinary falls back to tail-allocated scratch storage). - static constexpr Uint kInvalidUniformOffset = ~0u; + static constexpr Uint kInvalidUniformOffset = MobileGL::MG_State::GLState::kInvalidUniformOffset; // PHASE B (joins the SPIR-V job; see EnsureSpirvJoined). // // BOUNDS-CHECKED, and that is not defensive padding - it is the load-bearing half of @@ -1141,313 +1091,6 @@ namespace MobileGL::MG_State::GLState { return it == m_shaders.end() ? -1 : (Int)std::distance(m_shaders.begin(), it); } - // Transform feedback (GL 3.0 core: glTransformFeedbackVaryings applies on - // the NEXT link; the linked snapshot below is what draws and queries see). - struct XfbVarying { - String name; - GLenum type = GL_FLOAT; - GLint size = 1; // array element count - Uint32 bufferIndex = 0; // capture buffer slot - Uint32 offsetBytes = 0; // offset within the capture buffer - Uint32 byteSize = 0; // bytes captured per vertex for this varying - // Offset within the gap-free record a backend that cannot express the GL - // layout captures into; see NeedsScatteredTransformFeedbackCapture. - Uint32 packedOffsetBytes = 0; - - // GL 4.6 core 11.1.2.1 / 7.3.1.1: a member of an output interface block is - // captured under ".". `name` keeps that GL spelling (it is - // what the interface queries and the ESSL backend's driver-side capture list - // need, since SPIRV-Cross re-emits the block under its own type name), while - // the three fields below carry what a SPIR-V backend needs instead: the - // decoration target is the block's *instance* variable and the member index - // inside it. blockMemberIndex < 0 means "not a block member". - String blockInstanceName; - String blockName; - Int blockMemberIndex = -1; - // Which element of an arrayed block member this capture names, -1 for "the - // member as a whole". SPIR-V cannot decorate a single array element, so a - // backend needs the element index to tell a full run from a partial one. - Int blockMemberElement = -1; - }; - - // ---- P1: everything a link PRODUCES, in one movable block ---- - // - // The membership rule is mechanical, not editorial: this is exactly the field list - // ResetLinkArtifacts() clears (plus the four it forgot to - infoLog, - // linkedFragDataLocation/Index and the geometry strip-capture pair - which are just - // as much link output). Nothing else belongs here. - // - // Why a struct: once glLinkProgram runs on a worker (P1 stage 4) the worker writes - // its OWN LinkArtifacts and the GL thread publishes it with a single move, instead - // of thirty cross-thread field assignments. Until then this is a pure refactor. - // - // Access rule (invariant I5): the member below is private and reachable ONLY - // through ProgramObject::Artifacts(), which calls EnsureLinkJoined() first. That is - // what makes "every read of link output joins the pending link" a property the - // compiler checks rather than a review item - a new reader cannot spell the field - // without going through the gate. - // ---- the owned mirror of glslang's reflection ---- - // - // WHY THIS EXISTS. Every GL query about a linked program used to be answered by - // asking the live glslang::TProgram - program->getUniform(i).getType()->isMatrix() - // and friends. That made the TProgram part of the program's PERMANENT state, which - // in turn made the whole front end (parse + link) unskippable: the L1 shader - // translation memo could hand back the SPIR-V but the reflection still had to be - // rebuilt from a freshly parsed AST. - // - // These three tables are a snapshot of everything the query surface ever reads off - // the TProgram, in PLAIN OWNED VALUES - no TType*, no TString, nothing pointing into - // a glslang pool. Taken once at the tail of DoReflection (SnapshotGlslangReflection), - // they are copyable, immutable after the link, and safe to memoize and share between - // ProgramObjects and threads. Once they are filled, `program` is dead weight to - // everything except DoReflection itself. - // - // INDEXED BY TPROGRAM INDEX, deliberately: that is the space uniformIndexInTProgram, - // glUniformIndexToTProgram and tProgramUniformIndexToGl already speak, so every - // accessor that used to call program->getUniform(i) indexes uniformReflection[i] - // instead, unchanged in every other respect. - - struct LinkArtifacts { - // Live only between LinkProgram() and the end of DoReflection. Everything after - // that reads the owned mirror below; a link served from the L1 memo never - // constructs one at all, so this is null for such a program and MUST NOT be - // dereferenced outside DoReflection. - SharedPtr program; - - // The owned reflection snapshot. Indexed by TProgram index; see the structs above. - Vector uniformReflection; - Vector blockReflection; - Vector pipeInputReflection; - Vector pipeOutputReflection; - // Program-level scalars glslang answers off the linked intermediates. - // Whether the program's LAST stage is the fragment stage. A color number - and so a - // color index - exists only there; a separable tess/geometry/vertex program's - // outputs are varyings and must report -1 (KHR-GL43.program_interface_query. - // separate-programs-tess-control). - Bool lastStageIsFragment = false; - Array computeLocalSize{}; - // Replaces program->getUniformIndex(name). Maps the reflected name to its - // TProgram uniform index. - UnorderedMap uniformIndexByName; - - // Attributes (Vertex in) - Vector attribs; - Vector attribTypes; - - // FragData (Frag out): the per-link snapshot of the explicit request maps. - UnorderedMap linkedFragDataLocation; - UnorderedMap linkedFragDataIndex; - - // GL-facing index spaces (see the translation helpers above): GL active-uniform - // index <-> glslang TProgram uniform index, GL uniform-block index <-> TProgram - // block index. -1 marks a TProgram entry GL does not expose (dead default-block - // uniforms swept into MGL_GLOBAL_UBO by the relaxed parse, and that block itself). - Vector glUniformIndexToTProgram; - Vector tProgramUniformIndexToGl; - Vector glBlockIndexToTProgram; - Vector tProgramBlockIndexToGl; - // GL_UNIFORM_BLOCK index space: ACTUAL uniform blocks only, a strict subsequence of - // glBlockIndexToTProgram above. - // - // That list is the BLOCK space - everything the relaxed parse produced except - // MGL_GLOBAL_UBO - and it is what the backends walk and what every block-keyed table - // here (uniformBlockBinding, uniformBlockIndexByName, blockReflection ordering) is - // indexed by. It is NOT the GL uniform-block list: MobileGL does not pass - // EShReflectionSeparateBuffers to buildReflection, so glslang routes BUFFER blocks - // through indexToUniformBlock too, and the list therefore also carries every shader - // storage block and every synthesized gl_AtomicCounterBlock_N. GL 4.6 core 7.6 gives - // those their own enumerations (GL_SHADER_STORAGE_BLOCK and - // GL_ACTIVE_ATOMIC_COUNTER_BUFFERS respectively), and GL_ACTIVE_UNIFORM_BLOCKS / - // glGetActiveUniformBlock*/glGetUniformBlockIndex must not see either. - // - // Kept as a SECOND space rather than filtering the first in place: DirectGLES assigns - // one ESSL uniform-buffer binding point per entry of the block list as it walks it - // (Managers.cpp CacheResourceLocations and the matching per-draw loop in - // DirectGLES.cpp), so compacting that list would renumber every backend binding - // point, and tProgramBlockIndexToGl[i] < 0 is what DoReflection and - // BuildGlobalUboRouting read as "member of the synthesized global UBO". - Vector glUniformBlockIndexToBlock; // GL uniform-block index -> block index - Vector blockIndexToGlUniformBlock; // block index -> GL uniform-block index (-1) - // Per-link merged snapshot of the layout(location = N) qualifiers the attached - // shaders' default-block uniforms declared, as glslang recorded them at the point - // its relaxed remap dropped them (the relaxed parse drops them from reflection; the - // DoReflection assigner restores them from here). - UnorderedMap linkedExplicitUniformLocations; - // Per-link snapshot of the default-block uniform INITIALIZERS the attached shaders - // declared ("uniform int i = 1;"). Desktop GLSL says that value is what the uniform - // reads until the application overwrites it, and relinking restores it - but the - // relaxed parse turns those uniforms into members of MGL_GLOBAL_UBO, where SPIR-V - // cannot carry an initializer, so the value only survives as this side-channel. - // Applied into the uniform shadow at the phase-B publish (ApplyUniformInitialValues). - Vector uniformInitialValues; - UnorderedMap uniformLocations; - // ---- "written since link" (see MarkUniformWrittenAtLocation) ---- - // In LinkArtifacts deliberately: a link is exactly the event that retracts every - // write (GL resets uniforms to their initial values), so living here means the set - // is cleared by the same three paths that clear the rest of a link's output - - // Link()'s whole-struct reset, ResetLinkArtifacts, and the publish's move - and no - // fourth reset site can be forgotten. Empty (and never allocated) for a program - // that never asked to be separable. - Vector writtenUniformLocationBits; - Vector writtenUniformIndexBits; - Vector writtenUniformIndices; - // Ordered by location, - // aka. uniformIndexInTProgram[loc] == "uniform index of TProgram at location `loc`" - Vector uniformIndexInTProgram; - // ditto. Will be set at glUniform1i - Vector uniformSamplerOrImageUnitIndex; - // Sampler/image layout(binding = N) initial texture/image units, captured by - // TMglGlslIoResolver at mapIO's collect callback - the last point at which the - // qualifier still says what the shader declared. An OUTPUT of the link, not an - // input to it: nothing supplies this map, the resolver fills it. - UnorderedMap explicitOpaqueUniformBindings; - - // Ordered by uniform block index - // index is DIFFERENT from binding!!! - // - // Let's define UniformBlockIndex == the order at glslang getUniformBlock() - // aka `i = glGetUniformBlockIndex(prog, "BlockName")` implies: - // `prog->getUniformBlock(i) == "BlockName"` - // These stuff are present for GL semantics, not for backend inspection - // These may change after-link (because GL spec decided to have `glUniformBlockBinding`) - UnorderedMap uniformBlockIndexByName; - Vector uniformBlockBinding; - // glShaderStorageBlockBinding overrides, keyed by GL block name. See - // SetShaderStorageBlockBinding for why this one is by name and not by index. - // - // ALSO SEEDED AT LINK, by ProgramLinkTask::SeedDefaultStorageBlockBindings, with the - // GL-mandated binding 0 for every storage block whose shader declared no - // layout(binding = N). Those blocks have no other way to be told apart from a block - // that declared one: glslang's IO mapper invents a binding and writes it into the - // qualifier, so the reflection reports the invention. A seed is therefore "GL's - // default binding for this block", and a later glShaderStorageBlockBinding simply - // overwrites it - default and rebind travel one path. - UnorderedMap shaderStorageBlockBinding; - // Block type names of the storage blocks the program's shaders declared with NO - // layout(binding = N). Input to the seeding above; filled during mapIO by - // TMglGlslIoResolver, which is the last observer that can still tell a declared - // binding from an invented one - and, unlike the per-shader lexer this replaced, - // sees the declaration with its macros expanded. - std::set storageBlocksWithoutBinding; - // The same list for UNIFORM blocks, and it is needed for the same reason: glslang's - // auto-mapper assigns every uniform block a binding whether or not the shader asked - // for one, so uniformBlockBinding below cannot tell "declared 1" from "invented 1". - // GL 4.6 core 7.6.2 requires an unqualified block to report ZERO. - std::set uniformBlocksWithoutBinding; - - Uint activeUniformCount = 0; - // This program's fragment stage read gl_NumSamples, so the source pipeline lowered it - // onto the reserved default-block uniform (ShaderTranspiler::NUM_SAMPLES_UNIFORM_NAME) - // and the draw path owes it the draw framebuffer's sample count before every draw. - // - // PHASE A on purpose, even though the byte offset it needs is phase-B output: the - // gate has to be answerable without joining the SPIR-V job, or every draw of every - // program would pay a join to discover it has nothing to write. - Bool usesReservedNumSamples = false; - Uint maxUniformLocation = 0; - Int uniformNameMaxLength = 0; - Int attribInNameMaxLength = 0; - Int uniformBlockNameMaxLength = 0; - - String infoLog; - Bool linkStatus = false; - - // Transform feedback: the linked snapshot (the request lives outside, on the - // GL-thread-owned side). - Vector xfbVaryings; - // The glTransformFeedbackVaryings request list exactly as this link consumed it, - // INCLUDING the gl_NextBuffer / gl_SkipComponentsN pseudo-varyings that - // xfbVaryings deliberately drops (they steer the capture layout and must never - // reach a backend's varying list). GL_TRANSFORM_FEEDBACK_VARYING enumerates the - // full request, pseudo-varyings and all, so the interface query needs its own copy. - Vector xfbInterfaceNames; - Vector xfbStrides; - Vector gsStripTriangles; - Bool gsStripCaptureFixup = false; - GLenum gsInputPrimitive = GL_NONE; - // GL_TESS_CONTROL_OUTPUT_VERTICES: the `layout(vertices = N) out` of the linked - // tessellation control stage, or 0 when the program has none. Checked against - // GL_MAX_PATCH_VERTICES at link (GL 4.6 core 11.2.1.1). - Int tcsOutputVertices = 0; - // The rest of the geometry stage's link properties, and the tessellation evaluation - // stage's. Every one of these is a glGetProgramiv answer that had no source at all: - // the query surface listed the geometry pnames only to fall through to - // GL_INVALID_ENUM, and the GL_TESS_GEN_* pnames were not mentioned anywhere. They - // come from the linked intermediates for the same reason gsInputPrimitive and - // tcsOutputVertices do - glslang has already merged the compilation units' layout - // qualifiers and diagnosed contradictions, so the linked program is the thing that - // knows. - GLenum gsOutputPrimitive = GL_NONE; - Int gsMaxVertices = 0; - Int gsInvocations = 0; - // The tessellation evaluation stage's layout: GL_QUADS / GL_TRIANGLES / GL_ISOLINES, - // GL_EQUAL / GL_FRACTIONAL_EVEN / GL_FRACTIONAL_ODD, GL_CW / GL_CCW, and point mode. - GLenum tessGenMode = GL_NONE; - GLenum tessGenSpacing = GL_NONE; - GLenum tessGenVertexOrder = GL_NONE; - Bool tessGenPointMode = false; - GLenum xfbBufferMode = GL_INTERLEAVED_ATTRIBS; - Int xfbVaryingNameMaxLength = 0; - Bool xfbNeedsScatteredCapture = false; - Uint32 xfbPackedStride = 0; - }; - - // ---- everything phase B of a link produces, in one movable block ---- - // - // The membership rule is the same mechanical one LinkArtifacts uses: this is exactly - // what ProgramSpirvTask writes, which is what makes moving it THE publish. It is - // deliberately NOT part of LinkArtifacts, and that separation is what routes the five - // readers of SPIR-V-derived data through their own join gate by compiler rather than - // by review - m_spirv is private and Spirv() is the only spelling that reaches it. - // - // Why these three and nothing else: `generatedSpirv` has no GL-thread reader at all - // (every consumer is a backend draw/prepare path), and `uniformOffsets` + - // `globalUboScratch` are the ONLY things glUniform*/glGetUniform* need that are - // derived from the OPTIMIZED SPIR-V rather than from glslang reflection - spirv-opt - // runs in place and can delete a uniform, or the whole global UBO, so the offsets - // cannot be lifted out of glslang's reflection instead. - struct SpirvArtifacts { - Vector> generatedSpirv; - Bool enableSpirvValidation = false; - // Byte offset of each uniform location inside globalUboScratch, or - // kInvalidUniformOffset. Sized maxUniformLocation + 1 by the routing pass. - Vector uniformOffsets; - Vector globalUboScratch; - // Byte offset of the reserved gl_NumSamples stand-in inside globalUboScratch, or - // kInvalidUniformOffset. Taken by NAME from the SPIR-V metadata rather than through - // uniformOffsets, because the member has no GL location at all: the link task keeps - // it out of the GL-visible uniform index space so no application can see or write it. - Uint reservedNumSamplesOffset = kInvalidUniformOffset; - // False for a program whose SPIR-V was never produced (phase B cancelled at - // teardown or by a relink) or whose optimizer run failed. GL has no way to - // retract a LINK_STATUS it already reported true, so such a program stays - // "linked" and every reflection answer it has given stays correct - it is simply - // not drawable, which the backends already express through their link-status - // gates. - Bool spirvStatus = false; - // Whether these modules KEPT their 64-bit floats instead of being narrowed to 32 - // (ShaderTranspiler::DemoteFloat64Pass). Decided per PROGRAM, never per module - the - // global UBO is one buffer all stages read, so two stages disagreeing about whether a - // `uniform double` occupies 4 or 8 bytes would put every uniform after it at a - // different offset in each. Recorded here rather than re-derived from the backend - // because it is the layout THESE modules were built with: it is what the routing - // table's offsets mean, and glUniform*d / glGetUniform*v have to write and read the - // width the shader actually declares. - Bool nativeFloat64 = false; - // Whether gl_PointSize was demoted out of THESE modules' tessellation/geometry - // stages into an ordinary varying (ShaderCompiler:: - // DemoteTessellationGeometryPointSizeForProgram) because the backend cannot host - // the built-in there. Per PROGRAM by construction - a consumer whose producer - // kept the built-in would read garbage - and recorded here rather than - // re-derived because it cannot be: the rewrite's whole point is that the final - // bytes no longer declare the capability that armed it. The backends read it to - // respell a "gl_PointSize" transform-feedback capture as the carrier - // (ShaderCompiler::POINT_SIZE_CAPTURE_CARRIER_NAME). The GL reflection surface - // deliberately keeps answering "gl_PointSize": demotion happens after phase A, - // so every query keeps the truthful GL spelling. - Bool pointSizeDemoted = false; - }; - // ---- artifacts-only helpers, shared with ProgramLinkTask ---- // Static and taking the block explicitly, because from stage 4 the link BODY needs // them while its artifacts still live on the job node, not on any ProgramObject. The diff --git a/MobileGL/MG_Test/Program/CMakeLists.txt b/MobileGL/MG_Test/Program/CMakeLists.txt index 95104cc41..9fb4f9a46 100644 --- a/MobileGL/MG_Test/Program/CMakeLists.txt +++ b/MobileGL/MG_Test/Program/CMakeLists.txt @@ -275,3 +275,24 @@ gtest_discover_tests(OptimisticStatusTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS gtest_discover_tests(AsyncTeardownTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) # Same reason again: several cases leave A links outstanding while B compiles and links. gtest_discover_tests(XfbFrontendOrderInvarianceTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) + +# P0.5: the ProgramArtifacts.h header on its own - the alias identity proofs, the POD trip +# wire and the archive field tables. Its first include is the artifacts header, so this +# binary is also the compile-time proof that the header is self-contained. +add_executable( + ProgramArtifactsTest + ProgramArtifactsTest.cpp +) + +target_include_directories(ProgramArtifactsTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL +) + +target_link_libraries( + ProgramArtifactsTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + +gtest_discover_tests(ProgramArtifactsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp b/MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp new file mode 100755 index 000000000..78436c456 --- /dev/null +++ b/MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp @@ -0,0 +1,47 @@ +// MobileGL - MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// First include on purpose: the artifacts header must be self-contained (P0.5 gate A). +#include +#include +// For the alias checks only. +#include + +#include + +namespace { + using namespace MobileGL; + using namespace MobileGL::MG_State::GLState; + + // P0.5 moved the five types to namespace scope and left in-class aliases behind so the + // existing spellings compile unchanged. The classic failure of that move is a COPY: two + // same-named definitions that both compile and silently split the type. is_same_v is the + // compile-time proof that ProgramObject::X and GLState::X are one type. + TEST(ProgramArtifacts, AliasesAreTheSameTypes) { + static_assert(std::is_same_v); + static_assert(std::is_same_v); + static_assert(std::is_same_v); + static_assert(std::is_same_v); + static_assert(std::is_same_v); + static_assert(std::is_same_v); + static_assert(std::is_same_v); + static_assert(std::is_same_v); + static_assert(std::is_same_v); + static_assert(ProgramObject::kInvalidUniformOffset == kInvalidUniformOffset); + EXPECT_EQ(ProgramObject::kInvalidUniformOffset, kInvalidUniformOffset); + EXPECT_EQ(kInvalidUniformOffset, ~0u); + } + + // 13 Bool + 3 bytes of padding + 7 x 4-byte scalars on every ABI. + TEST(ProgramArtifacts, TypeFactsIsPodOf44Bytes) { + static_assert(std::is_trivially_copyable_v); + static_assert(std::is_standard_layout_v); + EXPECT_EQ(sizeof(TypeFacts), 44u); + EXPECT_EQ(alignof(TypeFacts), 4u); + } +} // namespace From fee3902472df30782ee4a7e869b39a7e7c6d3784 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 22:48:48 -0400 Subject: [PATCH 036/529] [Refactor] (Program): stop ProgramObject.h including SpvcSession.h - it names no spirv-cross or SPIRV-Reflect symbol - The include line was the header's only match for spvc_*/SpvReflect*/SpvcMetadata/ SpvcSession; it only ever forwarded and the session type to the eight includers, every one of which builds without it (no consumer needed a direct include added). - One less transpiler header behind ProgramObject.h, on the way to a ProgramArtifacts.h closure that stays clear of MG_Util/ShaderTranspiler/ (P0.5 gate A). --- MobileGL/MG_State/GLState/ProgramState/ProgramObject.h | 1 - 1 file changed, 1 deletion(-) diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h index de2d60698..cee4f358b 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h @@ -12,7 +12,6 @@ #include "ProgramArtifacts.h" #include -#include namespace MobileGL::MG_State::GLState { // The link job. Only ever held by SharedPtr here, so a forward declaration is enough - From 09d2bb11b74c055146b2b5393a8d6a7dbdf80ee9 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 22:52:19 -0400 Subject: [PATCH 037/529] [Feat] (Program): add the reflection-archive field tables and sizeof trip wires to ProgramArtifacts.h - One VisitFields table per type (ARCHITECTURE.md:259), a free constrained template so the moved struct bodies stay verbatim and one table serves both the const (serialize) and non-const (deserialize) direction. LinkArtifacts::program is deliberately absent: it is null for every archived instance and must never be serialized. - Trip wires: TypeFacts is pinned at 44 bytes on every ABI; the container-bearing four are pinned per standard library - libstdc++ 64-bit here (128/128/1056/88, measured on this build), the libc++ branch left inert for the integrator to pin from the NDK build, MSVC unasserted. A member added without a table entry changes the size and the assertion message sends the author to the table. - ProgramArtifactsTest counts the tables (20/14/11/57/8; const and non-const walks agree, names distinct), proves constness passes through, and records every sizeof as a ctest property so a new toolchain's numbers are readable from any `ctest -V` log. --- .../GLState/ProgramState/ProgramArtifacts.h | 176 ++++++++++++++++++ .../MG_Test/Program/ProgramArtifactsTest.cpp | 86 +++++++++ 2 files changed, 262 insertions(+) diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h index 4afd48a2f..016aa230e 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h @@ -397,4 +397,180 @@ namespace MobileGL::MG_State::GLState { // so every query keeps the truthful GL spelling. Bool pointSizeDemoted = false; }; + + // ---- the archive field tables (ARCHITECTURE.md:259): ONE table per type, serving both directions ---- + // + // Visitor contract: v(const char* name, Field&) - Field is const when Self is const, so a + // single table serves the serializer (const) and the deserializer (non-const); `Self` + // deduces either. A visitor recurses into TypeFacts / ResourceReflection / XfbVarying by + // calling VisitFields on the element it was handed; the tables never recurse themselves. + // + // Free constrained templates rather than members so the struct bodies above stay a verbatim + // move. The sizeof trip wires below are what keep these tables honest: a member added to a + // struct changes its size, trips the assertion, and the message sends the author here. + template + requires std::same_as, TypeFacts> + void VisitFields(Self& a, V&& v) { + v("isArray", a.isArray); + v("isSizedArray", a.isSizedArray); + v("isMatrix", a.isMatrix); + v("isVector", a.isVector); + v("isOpaque", a.isOpaque); + v("isTexture", a.isTexture); + v("isImage", a.isImage); + v("isDouble", a.isDouble); + v("isVoid", a.isVoid); + v("isBuffer", a.isBuffer); + v("isPatch", a.isPatch); + v("hasIndex", a.hasIndex); + v("hasFormat", a.hasFormat); + v("vectorSize", a.vectorSize); + v("matrixCols", a.matrixCols); + v("matrixRows", a.matrixRows); + v("layoutIndex", a.layoutIndex); + v("layoutFormat", a.layoutFormat); + v("layoutMatrix", a.layoutMatrix); + v("basicType", a.basicType); + } // 20 fields + + template + requires std::same_as, ResourceReflection> + void VisitFields(Self& a, V&& v) { + v("name", a.name); + v("glDefineType", a.glDefineType); + v("offset", a.offset); + v("size", a.size); + v("index", a.index); + v("counterIndex", a.counterIndex); + v("arrayStride", a.arrayStride); + v("topLevelArraySize", a.topLevelArraySize); + v("topLevelArrayStride", a.topLevelArrayStride); + v("binding", a.binding); + v("location", a.location); + v("stages", a.stages); + v("arraySize", a.arraySize); + v("type", a.type); // visited as a value; the visitor recurses with VisitFields(a.type, v) if it wants to + } // 14 fields + + template + requires std::same_as, XfbVarying> + void VisitFields(Self& a, V&& v) { + v("name", a.name); + v("type", a.type); + v("size", a.size); + v("bufferIndex", a.bufferIndex); + v("offsetBytes", a.offsetBytes); + v("byteSize", a.byteSize); + v("packedOffsetBytes", a.packedOffsetBytes); + v("blockInstanceName", a.blockInstanceName); + v("blockName", a.blockName); + v("blockMemberIndex", a.blockMemberIndex); + v("blockMemberElement", a.blockMemberElement); + } // 11 fields + + // Every member EXCEPT `program`: it is null for every archived instance by construction + // (ProgramTranslationCache.h asserts that at insert) and must never be serialized - it is + // the live glslang TProgram that only DoReflection may touch. 57 of the 58 members. + template + requires std::same_as, LinkArtifacts> + void VisitFields(Self& a, V&& v) { + v("uniformReflection", a.uniformReflection); + v("blockReflection", a.blockReflection); + v("pipeInputReflection", a.pipeInputReflection); + v("pipeOutputReflection", a.pipeOutputReflection); + v("lastStageIsFragment", a.lastStageIsFragment); + v("computeLocalSize", a.computeLocalSize); + v("uniformIndexByName", a.uniformIndexByName); + v("attribs", a.attribs); + v("attribTypes", a.attribTypes); + v("linkedFragDataLocation", a.linkedFragDataLocation); + v("linkedFragDataIndex", a.linkedFragDataIndex); + v("glUniformIndexToTProgram", a.glUniformIndexToTProgram); + v("tProgramUniformIndexToGl", a.tProgramUniformIndexToGl); + v("glBlockIndexToTProgram", a.glBlockIndexToTProgram); + v("tProgramBlockIndexToGl", a.tProgramBlockIndexToGl); + v("glUniformBlockIndexToBlock", a.glUniformBlockIndexToBlock); + v("blockIndexToGlUniformBlock", a.blockIndexToGlUniformBlock); + v("linkedExplicitUniformLocations", a.linkedExplicitUniformLocations); + v("uniformInitialValues", a.uniformInitialValues); + v("uniformLocations", a.uniformLocations); + v("writtenUniformLocationBits", a.writtenUniformLocationBits); + v("writtenUniformIndexBits", a.writtenUniformIndexBits); + v("writtenUniformIndices", a.writtenUniformIndices); + v("uniformIndexInTProgram", a.uniformIndexInTProgram); + v("uniformSamplerOrImageUnitIndex", a.uniformSamplerOrImageUnitIndex); + v("explicitOpaqueUniformBindings", a.explicitOpaqueUniformBindings); + v("uniformBlockIndexByName", a.uniformBlockIndexByName); + v("uniformBlockBinding", a.uniformBlockBinding); + v("shaderStorageBlockBinding", a.shaderStorageBlockBinding); + v("storageBlocksWithoutBinding", a.storageBlocksWithoutBinding); + v("uniformBlocksWithoutBinding", a.uniformBlocksWithoutBinding); + v("activeUniformCount", a.activeUniformCount); + v("usesReservedNumSamples", a.usesReservedNumSamples); + v("maxUniformLocation", a.maxUniformLocation); + v("uniformNameMaxLength", a.uniformNameMaxLength); + v("attribInNameMaxLength", a.attribInNameMaxLength); + v("uniformBlockNameMaxLength", a.uniformBlockNameMaxLength); + v("infoLog", a.infoLog); + v("linkStatus", a.linkStatus); + v("xfbVaryings", a.xfbVaryings); + v("xfbInterfaceNames", a.xfbInterfaceNames); + v("xfbStrides", a.xfbStrides); + v("gsStripTriangles", a.gsStripTriangles); + v("gsStripCaptureFixup", a.gsStripCaptureFixup); + v("gsInputPrimitive", a.gsInputPrimitive); + v("tcsOutputVertices", a.tcsOutputVertices); + v("gsOutputPrimitive", a.gsOutputPrimitive); + v("gsMaxVertices", a.gsMaxVertices); + v("gsInvocations", a.gsInvocations); + v("tessGenMode", a.tessGenMode); + v("tessGenSpacing", a.tessGenSpacing); + v("tessGenVertexOrder", a.tessGenVertexOrder); + v("tessGenPointMode", a.tessGenPointMode); + v("xfbBufferMode", a.xfbBufferMode); + v("xfbVaryingNameMaxLength", a.xfbVaryingNameMaxLength); + v("xfbNeedsScatteredCapture", a.xfbNeedsScatteredCapture); + v("xfbPackedStride", a.xfbPackedStride); + } // 57 fields (58 members minus `program`) + + template + requires std::same_as, SpirvArtifacts> + void VisitFields(Self& a, V&& v) { + v("generatedSpirv", a.generatedSpirv); + v("enableSpirvValidation", a.enableSpirvValidation); + v("uniformOffsets", a.uniformOffsets); + v("globalUboScratch", a.globalUboScratch); + v("reservedNumSamplesOffset", a.reservedNumSamplesOffset); + v("spirvStatus", a.spirvStatus); + v("nativeFloat64", a.nativeFloat64); + v("pointSizeDemoted", a.pointSizeDemoted); + } // 8 fields + + // ---- trip wires ---- + // TypeFacts is a POD on every ABI: 13 Bool + 3 bytes of padding + 7 x 4-byte scalars. + static_assert(std::is_trivially_copyable_v && sizeof(TypeFacts) == 44, + "TypeFacts changed: add the field to VisitFields(TypeFacts) (and its serializer when one exists), then update this number"); + // The container-bearing structs have one size per standard library (std::string and + // std::set differ between libstdc++ and libc++), so their numbers are pinned PER STL: + // libstdc++ (the Linux CI toolchain) here, libc++ (the NDK) by the integrator, MSVC + // unasserted. ProgramArtifactsTest records every sizeof as a ctest property on every + // platform, which is where a new toolchain's numbers are read from. +#if defined(__GLIBCXX__) && !defined(_GLIBCXX_DEBUG) && (SIZE_MAX == UINT64_MAX) +#define MGL_RESOURCEREFLECTION_SIZE 128 +#define MGL_XFBVARYING_SIZE 128 +#define MGL_LINKARTIFACTS_SIZE 1056 +#define MGL_SPIRVARTIFACTS_SIZE 88 +#elif defined(_LIBCPP_VERSION) && (SIZE_MAX == UINT64_MAX) && defined(MGL_ARTIFACT_SIZES_LIBCXX_PINNED) + // The integrator pins these from the NDK build (brief C.4); until then this branch is inert. +#endif +#ifdef MGL_LINKARTIFACTS_SIZE + static_assert(sizeof(ResourceReflection) == MGL_RESOURCEREFLECTION_SIZE, + "ResourceReflection changed size: add the field to VisitFields(ResourceReflection) (and its serializer when one exists), then update this number"); + static_assert(sizeof(XfbVarying) == MGL_XFBVARYING_SIZE, + "XfbVarying changed size: add the field to VisitFields(XfbVarying) (and its serializer when one exists), then update this number"); + static_assert(sizeof(LinkArtifacts) == MGL_LINKARTIFACTS_SIZE, + "LinkArtifacts changed size: add the field to VisitFields(LinkArtifacts) (and its serializer when one exists), then update this number"); + static_assert(sizeof(SpirvArtifacts) == MGL_SPIRVARTIFACTS_SIZE, + "SpirvArtifacts changed size: add the field to VisitFields(SpirvArtifacts) (and its serializer when one exists), then update this number"); +#endif } // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp b/MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp index 78436c456..72727aabc 100755 --- a/MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp @@ -12,6 +12,9 @@ // For the alias checks only. #include +#include +#include +#include #include namespace { @@ -44,4 +47,87 @@ namespace { EXPECT_EQ(sizeof(TypeFacts), 44u); EXPECT_EQ(alignof(TypeFacts), 4u); } + + // A visitor that counts what a table hands it and checks the names are distinct - the + // archive's one field table per type has to name every member exactly once. + struct CountingVisitor { + std::size_t count = 0; + std::set names; + template + void operator()(const char* name, Field&) { + ++count; + names.insert(name); + } + }; + + template + std::size_t CountFields() { + T value{}; + CountingVisitor mutableVisitor; + VisitFields(value, mutableVisitor); + const T& constValue = value; + CountingVisitor constVisitor; + VisitFields(constValue, constVisitor); + EXPECT_EQ(mutableVisitor.count, constVisitor.count) << "const and non-const walks disagree"; + EXPECT_EQ(mutableVisitor.names.size(), mutableVisitor.count) << "a field name is listed twice"; + EXPECT_EQ(mutableVisitor.names, constVisitor.names); + return mutableVisitor.count; + } + + // The field counts are the member counts of the moved structs (LinkArtifacts minus its + // never-archived `program`). A member added without a table entry is caught by the sizeof + // trip wires in the header; a table entry dropped without a member change is caught here. + TEST(ProgramArtifacts, VisitFieldsCoversEveryMember) { + EXPECT_EQ(CountFields(), 20u); + EXPECT_EQ(CountFields(), 14u); + EXPECT_EQ(CountFields(), 11u); + EXPECT_EQ(CountFields(), 57u); + EXPECT_EQ(CountFields(), 8u); + } + + // A const walk hands the visitor const references, a non-const walk mutable ones: the one + // table really serves both directions. + TEST(ProgramArtifacts, VisitFieldsPassesConstnessThrough) { + LinkArtifacts artifacts; + std::size_t mutableFields = 0; + VisitFields(artifacts, [&](const char*, auto& field) { + static_assert(!std::is_const_v>); + ++mutableFields; + }); + const LinkArtifacts& constArtifacts = artifacts; + std::size_t constFields = 0; + VisitFields(constArtifacts, [&](const char*, auto& field) { + static_assert(std::is_const_v>); + ++constFields; + }); + EXPECT_EQ(mutableFields, constFields); + // The table can write through: a deserializer's shape. + VisitFields(artifacts, [](const char* name, auto& field) { + if constexpr (std::is_same_v, Bool>) { + if (std::string(name) == "linkStatus") field = true; + } + }); + EXPECT_TRUE(artifacts.linkStatus); + } + + // The sizeof numbers, visible in every `ctest -V` log on every platform: this is where the + // integrator reads a new toolchain's values from before pinning them in the header. + TEST(ProgramArtifacts, SizesArePinnedOnThisToolchain) { + RecordProperty("sizeof_TypeFacts", static_cast(sizeof(TypeFacts))); + RecordProperty("sizeof_ResourceReflection", static_cast(sizeof(ResourceReflection))); + RecordProperty("sizeof_XfbVarying", static_cast(sizeof(XfbVarying))); + RecordProperty("sizeof_LinkArtifacts", static_cast(sizeof(LinkArtifacts))); + RecordProperty("sizeof_SpirvArtifacts", static_cast(sizeof(SpirvArtifacts))); + std::printf("sizeof: TypeFacts=%zu ResourceReflection=%zu XfbVarying=%zu LinkArtifacts=%zu SpirvArtifacts=%zu\n", + sizeof(TypeFacts), sizeof(ResourceReflection), sizeof(XfbVarying), sizeof(LinkArtifacts), + sizeof(SpirvArtifacts)); +#if defined(__GLIBCXX__) && !defined(_GLIBCXX_DEBUG) && (SIZE_MAX == UINT64_MAX) + EXPECT_EQ(sizeof(ResourceReflection), static_cast(MGL_RESOURCEREFLECTION_SIZE)); + EXPECT_EQ(sizeof(XfbVarying), static_cast(MGL_XFBVARYING_SIZE)); + EXPECT_EQ(sizeof(LinkArtifacts), static_cast(MGL_LINKARTIFACTS_SIZE)); + EXPECT_EQ(sizeof(SpirvArtifacts), static_cast(MGL_SPIRVARTIFACTS_SIZE)); +#else + RecordProperty("sizes_pinned", "no: not the libstdc++ 64-bit toolchain"); +#endif + } } // namespace From b566bf4db9360121829aa5130badd1ea4fc7585d Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 23:13:24 -0400 Subject: [PATCH 038/529] [Fix] (Purity, Program, Pipe): close the review minors of the three P0.5 packages - check_include_closure.py keeps the directory of a two-token -isystem, makes --require-all fail on a missing required header even when --probe narrowed the run, and removes its temp dir at exit - ProgramArtifactsTest follows whichever STL branch the header pinned (#ifdef the size macro) instead of re-spelling the libstdc++ condition, and loses its stray executable bit - MGPipeTypes.h's debt comment says what its closure still reaches (TextureEnum.h via BackendObject.h), which is why gate A asserts MGPipeValueTypes.h and not this header --- MobileGL/MG_Pipe/MGPipeTypes.h | 3 ++- .../GLState/ProgramState/ProgramArtifacts.h | 2 +- .../MG_Test/Program/ProgramArtifactsTest.cpp | 2 +- scripts/check_include_closure.py | 20 ++++++++++++++++++- 4 files changed, 23 insertions(+), 4 deletions(-) mode change 100755 => 100644 MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp diff --git a/MobileGL/MG_Pipe/MGPipeTypes.h b/MobileGL/MG_Pipe/MGPipeTypes.h index 710a90c80..7e84044d7 100644 --- a/MobileGL/MG_Pipe/MGPipeTypes.h +++ b/MobileGL/MG_Pipe/MGPipeTypes.h @@ -24,7 +24,8 @@ // // P0.5 DEBT, half repaid. The MG_State half is gone: ResidualValueBlock's // RenderStateParameters and PixelStoreParameters now come from MGPipeValueTypes.h, so -// this header no longer reaches into MG_State. What remains is MGPCaps embedding +// this header no longer reaches RenderState.h (its closure still touches TextureEnum.h, +// through BackendObject.h, for the reason in the next sentence). What remains is MGPCaps embedding // MG_Backend's DynamicBackendParameters - deliberate, the caps block IS that struct // (section 4.4.1) - and that one include is what still keeps purity gate A (section // 10.3) off this header; the gate asserts MGPipeValueTypes.h instead. The caps block diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h index 016aa230e..b4c736cb8 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h @@ -15,7 +15,7 @@ #include // std::set (LinkArtifacts); NOT provided by Includes.h on its own terms // PURITY: no ShaderObject.h, no SpvcSession.h, nothing under MG_Util/ShaderTranspiler/, no Config.h, // no MG_Backend/, no BufferState/. scripts/check_include_closure.py probe "artifacts-header" (ROADMAP P0.5; -// ARCHITECTURE.md:260) asserts that closure. the glslang scope token appears exactly twice below (B.0 D5: the two +// ARCHITECTURE.md:260) asserts that closure. The glslang scope token appears exactly twice below (B.0 D5: the two // glslang-typed LinkArtifacts members moved verbatim) and the gate pins that count. namespace MobileGL::MG_State::GLState { diff --git a/MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp b/MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp old mode 100755 new mode 100644 index 72727aabc..e1f9239b0 --- a/MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp @@ -121,7 +121,7 @@ namespace { std::printf("sizeof: TypeFacts=%zu ResourceReflection=%zu XfbVarying=%zu LinkArtifacts=%zu SpirvArtifacts=%zu\n", sizeof(TypeFacts), sizeof(ResourceReflection), sizeof(XfbVarying), sizeof(LinkArtifacts), sizeof(SpirvArtifacts)); -#if defined(__GLIBCXX__) && !defined(_GLIBCXX_DEBUG) && (SIZE_MAX == UINT64_MAX) +#ifdef MGL_LINKARTIFACTS_SIZE // whichever STL branch the header pinned, this twin follows it EXPECT_EQ(sizeof(ResourceReflection), static_cast(MGL_RESOURCEREFLECTION_SIZE)); EXPECT_EQ(sizeof(XfbVarying), static_cast(MGL_XFBVARYING_SIZE)); EXPECT_EQ(sizeof(LinkArtifacts), static_cast(MGL_LINKARTIFACTS_SIZE)); diff --git a/scripts/check_include_closure.py b/scripts/check_include_closure.py index a4693c16e..5465edafc 100755 --- a/scripts/check_include_closure.py +++ b/scripts/check_include_closure.py @@ -50,6 +50,7 @@ """ import argparse +import atexit import json import os import re @@ -317,7 +318,12 @@ def flags_from_compile_commands(path): tokens = shlex.split(entry.get("command", "")) flags = [] skip_next = False + keep_next = False for token in tokens[1:]: + if keep_next: + keep_next = False + flags.append(token) + continue if skip_next: skip_next = False continue @@ -325,8 +331,9 @@ def flags_from_compile_commands(path): skip_next = token in ("-o", "-MF", "-MT", "-MQ") continue if token == "-isystem": - skip_next = False + # two-token form: keep the flag AND its directory flags.append(token) + keep_next = True continue if token.startswith(keep_prefixes): flags.append(token) @@ -570,6 +577,7 @@ def main(): check_clang_prereqs() tmpdir = tempfile.mkdtemp(prefix="mgl-include-closure-") + atexit.register(shutil.rmtree, tmpdir, True) context = { "tmpdir": tmpdir, "counter": [0], @@ -600,6 +608,16 @@ def main(): results = [] problems = 0 skipped = 0 + if args.require_all: + # The ratchet is about the two P0.5 headers existing, whether or not --probe + # narrowed this run to something else: a required header that is missing is a + # failure even when its probe was not selected. + selected_names = {probe["Name"] for probe in selected} + for probe in PROBES: + if probe["Name"] in REQUIRED_PROBE_NAMES and probe["Name"] not in selected_names and not os.path.isfile(os.path.join(REPO_ROOT, probe["Header"])): + problems += 1 + say("{} is required under --require-all but its header does not exist: {}".format( + probe["Name"], probe["Header"])) for probe in selected: # D9: a header that does not exist yet is one SKIP line, counted once, and a # failure only under --require-all (the ratchet the integrator flips). From 5d99ee435f2be5814ac5df5217945c5b31253f91 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 23:13:28 -0400 Subject: [PATCH 039/529] [CI] (Purity): require both P0.5 closure probes now that the headers exist - the ctest and the include-graph-check job pass --require-all, so a SKIP on MGPipeValueTypes.h or ProgramArtifacts.h is red from here on --- .github/workflows/test.yml | 2 +- MobileGL/MG_Test/Purity/CMakeLists.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5c2368e76..30195aa45 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -346,7 +346,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y clang-20 - name: Include-closure assertions and negative control - run: python3 scripts/check_include_closure.py --mode both --compiler clang++-20 --self-test + run: python3 scripts/check_include_closure.py --mode both --compiler clang++-20 --self-test --require-all benchmark: runs-on: ubuntu-latest diff --git a/MobileGL/MG_Test/Purity/CMakeLists.txt b/MobileGL/MG_Test/Purity/CMakeLists.txt index 59e90d31e..587e85f23 100644 --- a/MobileGL/MG_Test/Purity/CMakeLists.txt +++ b/MobileGL/MG_Test/Purity/CMakeLists.txt @@ -7,7 +7,7 @@ cmake_minimum_required(VERSION 3.14) find_package(Python3 COMPONENTS Interpreter) if (Python3_Interpreter_FOUND) add_test(NAME MobileGLPurity.IncludeClosure - COMMAND ${Python3_EXECUTABLE} ${MGL_ROOT}/scripts/check_include_closure.py --mode text --self-test) + COMMAND ${Python3_EXECUTABLE} ${MGL_ROOT}/scripts/check_include_closure.py --mode text --self-test --require-all) # ctest -L unit (test.yml:198-203). NO ENVIRONMENT property: it replaces the job env (ARCHITECTURE.md:567). set_tests_properties(MobileGLPurity.IncludeClosure PROPERTIES LABELS unit) else() From 5635e33ffe0198b62d24f4980c06581ae8114c1b Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 23:22:15 -0400 Subject: [PATCH 040/529] [Docs] (Disaggregated): record the P0.5 landing - README status, the ROADMAP P0.5 row (measured outcome, the 8-includer correction, the DynamicBackendParameters exception) and the ARCHITECTURE note on which header gate A asserts --- docs/Disaggregated/ARCHITECTURE.md | 2 +- docs/Disaggregated/README.md | 2 +- docs/Disaggregated/ROADMAP.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/Disaggregated/ARCHITECTURE.md b/docs/Disaggregated/ARCHITECTURE.md index d939e6134..dbdcda8a0 100644 --- a/docs/Disaggregated/ARCHITECTURE.md +++ b/docs/Disaggregated/ARCHITECTURE.md @@ -257,7 +257,7 @@ GL 是每 unit 每 target 各一个绑定;shader 看见哪一个取决于 samp - `CreateShaderState` 的 payload 是逐 stage SPIR-V + 反射归档(`LinkArtifacts` + `SpirvArtifacts` 全结构体),**不是源码**。"server 从源码重新 link"这条路显式关闭:链接真 `ProgramObject` 就链接 glslang。glslang 全在 client,SPIRV-Cross(`TranspileSpirvToEssl`)全在 server,文件级切割。没有 `MOBILEGL_IPC_PROGRAM` 开关、没有 server 侧 compile pool。 - 归档机制:`Visit()` + `sizeof` 绊线(`static_assert(sizeof(LinkArtifacts) == MGL_LINKARTIFACTS_SIZE)`),一份字段表服务序列化两个方向。必须覆盖四个 `ResourceReflection`(各带 `TypeFacts`)、`uniformSamplerOrImageUnitIndex`、`uniformBlockBinding`、`shaderStorageBlockBinding`(按名字)、`explicitOpaqueUniformBindings`、`xfbVaryings/xfbStrides/xfbPackedStride/xfbNeedsScatteredCapture`、`computeLocalSize`、GS/TCS/TES 事实、`usesReservedNumSamples`、`uniformOffsets`。`XfbVarying` 带两套拼写(GL 名字 + block 实例/成员/元素)。 -- **P0.5 硬前置**:反射类型今天声明在 `ProgramObject.h` 里,而它 include `ShaderObject.h`(→ glslang)与 `SpvcSession.h`(→ spirv_reflect)。P0.5 把 `TypeFacts`、`ResourceReflection`、`XfbVarying`、`LinkArtifacts`、`SpirvArtifacts` 抽到 `MG_State/GLState/ProgramState/ProgramArtifacts.h`(只 include `` 与容器),更新 7 个 includer,加 CI `-H` 闭包断言。同批抽取 `MG_Pipe/MGPipeValueTypes.h`(`MAX_DRAW_BUFFERS`、`PerBufferBlendState`、`StencilFaceState`、`PixelStoreParameters`、`RenderStateParameters`、`SamplerParameters`、`BorderColorForm`、`VertexAttribute`、`VertexBufferBindingPoint`),它不 include `MG_State/GLState` 任何东西;`MGPipeTypes.h` 今天为此临时 include 了 `BackendObject.h` 与 `RenderState.h`(文件头注明为 P0.5 债务)。没有这一步,P7 的 `nm -D | grep glslang` 判据不可达。 +- **P0.5 硬前置**:反射类型今天声明在 `ProgramObject.h` 里,而它 include `ShaderObject.h`(→ glslang)与 `SpvcSession.h`(→ spirv_reflect)。P0.5 把 `TypeFacts`、`ResourceReflection`、`XfbVarying`、`LinkArtifacts`、`SpirvArtifacts` 抽到 `MG_State/GLState/ProgramState/ProgramArtifacts.h`(只 include `` 与容器),8 个 includer 靠类内 `using` 别名零改动,加 CI `-H` 闭包断言(已落地,见 `ROADMAP.md` P0.5 行;`DynamicBackendParameters` 留在 `BackendObject.h`,所以闭包门 A 断言的是 `MGPipeValueTypes.h` 而不是 `MGPipeTypes.h`)。同批抽取 `MG_Pipe/MGPipeValueTypes.h`(`MAX_DRAW_BUFFERS`、`PerBufferBlendState`、`StencilFaceState`、`PixelStoreParameters`、`RenderStateParameters`、`SamplerParameters`、`BorderColorForm`、`VertexAttribute`、`VertexBufferBindingPoint`),它不 include `MG_State/GLState` 任何东西;`MGPipeTypes.h` 今天为此临时 include 了 `BackendObject.h` 与 `RenderState.h`(文件头注明为 P0.5 债务)。没有这一步,P7 的 `nm -D | grep glslang` 判据不可达。 - server 侧惰性特化(D-B2):后端 program 还依赖 8 个额外输入(draw FBO 的 snorm/unorm clamp mask、fragColor 广播数、storage-block 绑定签名、atomic counter 集、活的 image 格式、patch 参数;Magma 另加 FragCoord-Y-flip 的 default-FB 高度与 XFB 布局),`create_shader_state` 发布**制品**,server 在 verb 时刻从已推送状态特化——正是两个后端今天的做法,也是 gallium `st_variant` 的做法。 - 后端 link/compile 失败不需要同步返回:今天只是一行 `MGLOG_E` 加 bind program 0 的空 draw,`GL_LINK_STATUS` 永不撤回,同步查询由 client 从 `ProgramObject` 回答。`OnLog` 逐字复现——由此要求日志按严重级分级(§8.3)。 - Magma 的两个内部 shader(blit、depth-mipmap)烘焙成签进树的 SPIR-V + uniform location + UBO 布局,用一个 `MG_Test` 重跑树内 glslang 逐字节比对守新鲜度(`MOBILEGL_BAKED_INTERNAL_SHADERS`,P7);顺带把一次 glslang 编译从 monolith 启动路径上删掉。 diff --git a/docs/Disaggregated/README.md b/docs/Disaggregated/README.md index 0e1536cae..bb4785770 100644 --- a/docs/Disaggregated/README.md +++ b/docs/Disaggregated/README.md @@ -1,6 +1,6 @@ # MGPipe:MobileGL 前后端拆分 -> 状态:**P0 已落地**(`feat/disaggregated@458ccde1`,基线 `dev@81b17c0b`)。下一步 P0.5 → P1 → P2,第 43 天 GO/NO-GO。见 `ROADMAP.md`。 +> 状态:**P0、P0.5 已落地**(`feat/disaggregated@5d99ee43`,基线 `dev@50fb1343`)。下一步 P1 → P2,第 43 天 GO/NO-GO。见 `ROADMAP.md`。 ## 是什么 diff --git a/docs/Disaggregated/ROADMAP.md b/docs/Disaggregated/ROADMAP.md index 078934931..e6336aa56 100644 --- a/docs/Disaggregated/ROADMAP.md +++ b/docs/Disaggregated/ROADMAP.md @@ -13,7 +13,7 @@ | 阶段 | 天 | 落地什么 | 验收门 | 依赖 | |---|---|---|---|---| | **P0** 卫生、度量、门、骨架 | 9–11 | ✅ 边界计数器(字节 / 动态 accessor / 六个 memo 门 / 上传形状);`PipeCalls.def` 完整目录 + payload POD + 七个生成器 + CI `pipe-gates`;`gen_pipe_dirty_surface.py`;`check_doc_citations.py`;八个 `MOBILEGL_PIPE_*` 开关;`MG_Remote/{Protocol,Transport}` 骨架(`SCM_RIGHTS` 第一优先、双 tail 双三元组的 `RingControl`、双向 doorbell、校验型 `Framing`、`ShmSegment`、`InProcessTransport`)+ `protocol.fbs` + `flatc-check` + `MG_Test/Wire` 五个套件;三个严格 no-op 收益(`GetInteger64i_v`/`GetProgramiv` 退役、`RenderbufferObject::GetLifetimeId()`、D21 XFB 计数槽重键);compute 限制进 `DynamicBackendParameters`;spike A、spike B;retrace 通道 `--env` 透传 | ✅ 单元/集成/40 trace 逐名不变;wire 层测试(fd 传递、doorbell、ring、封帧、inproc)绿;两台设备的字节/调用基线在案;spike A/B 出结论;citation lint 绿 | — | -| **P0.5** 值头与制品头抽取 | 6–9 | `MG_Pipe/MGPipeValueTypes.h`(`RenderStateParameters`、`SamplerParameters`、`PixelStoreParameters`、`VertexAttribute`… 不 include `MG_State/GLState`);`MG_State/GLState/ProgramState/ProgramArtifacts.h`(五个反射类型,不 include `ShaderObject.h`/`SpvcSession.h`,更新 7 个 includer);`Visit()` 归档 + `sizeof` 绊线;CI `-H` include 闭包断言 | 全套测试逐名不变(纯搬移);两条闭包断言绿且人为加回一个 `MG_State` include 能变红;`nm`/`.text` 变化可逐符号归因 | P0;**P1 与 P7 的硬前置** | +| **P0.5** 值头与制品头抽取 | 6–9 | ✅(`5d99ee43`)`MG_Pipe/MGPipeValueTypes.h`(`RenderStateParameters`、`SamplerParameters`、`PixelStoreParameters`、`VertexAttribute`… 不 include `MG_State/GLState`);`MG_State/GLState/ProgramState/ProgramArtifacts.h`(五个反射类型,不 include `ShaderObject.h`/`SpvcSession.h`,8 个 includer 零改动——类内 `using` 别名保住每一种既有拼写);`Visit()` 归档 + `sizeof` 绊线;CI `-H` include 闭包断言(`scripts/check_include_closure.py`,text + clang 两模式、自带阴性对照、`--require-all` 棘轮;`scripts/symbol_report.py` 做逐符号归因)。实测落地:测试名零删除、`MG_Backend` 零 diff、`.text` 字节不变、符号 0 增 / 0 删 / 42 重命名;`DynamicBackendParameters` 未搬(含 `SizeT` 与 `TextureTarget` 成员,搬动不是纯移动),`MGPipeTypes.h` 仍 include `BackendObject.h`,闭包门 A 因此断言 `MGPipeValueTypes.h` | 全套测试逐名不变(纯搬移);两条闭包断言绿且人为加回一个 `MG_State` include 能变红;`nm`/`.text` 变化可逐符号归因 | P0;**P1 与 P7 的硬前置** | | **P1** `PipeInputs` 替换与 verify harness | 10–13 | `MG_Backend/MGPipe/PipeInputs.h`(Espryt 32 / Magma 55 访问器);`sed` 293 处 + 58 行非箭头清单逐条转换(显式交付物);逐 verb 类填充点(G5 表,~93 个边界站点);逐 verb 世代 poison;G4 影子比对器 + 第三种 CI 模式;20 处 `SyncPersistentMappedRange` + 6 处 `SyncGpuWrites` 的逐站点归属表 | pull 构建 `nm --defined-only` 不变、`.text` 差异逐行归因(空守卫/三元重写推迟到 P2);40 trace + 全部集成测试在 `MOBILEGL_PIPE_VERIFY=1` 下零分歧;故意损坏一个快照字段能让 verify 变红;故意在 `glGenerateMipmap` 的填充表漏一个字段能在**那条 verb** 上触发 poison Fatal | P0.5 | | **P2** 渲染状态 CSO + 第一片 Track H + 残余值块 | 18–26 | `MG_Impl/Pipe/Tracker`(dirty 位、5 个聚合世代、抑制器骨架);`gen_pipe_dirty_surface.py` 首轮映射成门;`MGPipeRenderStateSpans` + G7 setter 一致性测试;`CsoCache`(64 项,键 = pipeline 子集);`create/bind_render_state` + `set_dynamic_state`(Espryt `SyncRenderState` 一行不动;Magma `ComputePipelineStateHash`/`GetOrCreatePipeline`/`ApplyDynamicDrawStateTail` 改从 CSO 与动态 payload 取);`set_pixel_pack_state`、`set_patch_state`、`set_vertex_attrib_defaults`;`set_residual_value_state` + `ResidualValueBlock` 绊线;**第一片 Track H**:Espryt 0b(`SlotAllocator` + 6 个 registry → slot 数组 + 删 `TwinLookupMemo`×3/`OwnerEquals`/`g_fbSlotCache`/GC)与 Magma 子系统 4(`VertexInputStateFactory`/`VaoDrawMemo` 重键,删前端 VAO 里的后端裸指针);`MOBILEGL_PIPE_LEGACY_MEMOS`;补 `FramebufferSrgb`/`DepthClamp` 存储 | 集成 × 2 后端 × {pull, push} 逐名相同;40 trace push 下 SSIM ≥ 0.99 双后端;verify 零分歧;`HandleRecycleScenario` 绿且重键前红;G7 测试绿且拿掉一个字段能红;两台设备配对逐线程 CPU p50/p99 不差且 tracker 绝对 ns 在上限内;Blaze3D blend-toggle 微基准;CSO 内容寻址关闭的负面对照 | P1 | | **P3a** handle wave 1(Espryt):buffer、VAO | 18–23 | 7 个 `BufferBackendOps` → `resource_*`、`buffer_subdata_resident`(可 null)、`resource_flush_range`、`resource_readback`、`map_persistent`(不碰实现);pool 与延迟释放原样搬;vertex elements 三件(两个视图都带);`set_vertex_buffers`(`baseInstance` 显式字段);`set_index_buffer`;Adreno SIGSEGV workaround 保留 | 全套门;buffer/VAO 族场景(`LargeArenaAdoption`、`StorageBufferRegrow` 发布 `map-persistent-roundtrips`、`VertexAttribBinding`、`MultiDraw`、`PrimitiveRestart`…);Create/rd12/26.3/sodium trace;MC 26.3 在 Adreno 上 p99 不变。**再基线检查点 1:超过 27 天必须重定基线** | P2 | From 087685d19b2cb72a4a754ae333963c80a09009ac Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 23:27:10 -0400 Subject: [PATCH 041/529] [CI] (Purity): install libx11-dev for the include-graph-check job - Includes.h defines VK_USE_PLATFORM_XLIB_KHR before vulkan.h, so the clang-mode probes and the negative controls need X11/Xlib.h on the runner; run 34008829271 failed on exactly that --- .github/workflows/test.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30195aa45..80a43fc0a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -342,8 +342,11 @@ jobs: # Includes.h reaches; glslang and spirv_cross are vendored under include/. run: git submodule update --init include/ska 3rdparty/xxHash 3rdparty/Vulkan-Headers - - name: Install clang - run: sudo apt-get update && sudo apt-get install -y clang-20 + - name: Install clang and the X11 headers vulkan.h pulls on Linux + # Includes.h defines VK_USE_PLATFORM_XLIB_KHR before , which then + # includes ; without libx11-dev every clang-mode probe dies in the + # preprocessor and the gate reports 5 problems that have nothing to do with purity. + run: sudo apt-get update && sudo apt-get install -y clang-20 libx11-dev - name: Include-closure assertions and negative control run: python3 scripts/check_include_closure.py --mode both --compiler clang++-20 --self-test --require-all From bf86b1ede6f9a43b4114fe2603f68a0bc2aa233c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 01:35:45 -0400 Subject: [PATCH 042/529] [Feat] (Pipe): land the P1 contract - PipeInputsSwitch.h with MGB_CTX, the 63-field PipeInputs block with type-identical accessors, FillPoints.def and its G5b generator, the MOBILEGL_PIPE_PUSH/VERIFY options and the three verify knobs; pull build unchanged - MG_Pipe/PipeInputsSwitch.h is the strangler switch (ARCHITECTURE.md 9.2): MGB_CTX is the live GLContext in the pull build and &gPipeInputs under MOBILEGL_PIPE_PUSH, so the pull arm's pGLContext spelling stays outside MG_Backend/ and purity gate C's grep. - MG_Backend/MGPipe/PipeInputs.h holds one struct with every accessor a backend reads (63: the 61 Coverage.def rows plus GetBoundTransformFeedbackLifetimeId and HasOpenTransformFeedbackSpan), each keeping its GLContext name, parameters and return type so the site conversion is type-neutral; V fields are copied values, O fields are SharedPtr copies or pointers into the context, the seven F fields forward to the live context from MG_Impl/Pipe/PipeFill.cpp and are the only sticky ones (Coverage.def's MGP_COVERAGE_STICKY_LIST argues each: argument-keyed lookups and reverse-channel writes, never a version or generation counter). - MG_Pipe/FillPoints.def is the verb table: one row per GLFunctionsTable function pointer in declaration order (69), nine classes and the may-read field rows; gen_pipe.py parses the struct and refuses a row set that is not exactly its member set, then emits generated/PipeFillPoints.inc (verb enum, class tables, per-class field masks with the sticky fields OR'ed in). MGP_FILL(Verb) in MG_Impl/Pipe/PipeFill.h is the fill point; MGPipeFillForVerb only bumps the serial, records the verb and stamps the sticky fields here - the per-class copies land in the next commit, the fill points in MG_Impl after. - MOBILEGL_PIPE_POISON is derived once in PipeInputs.h from MOBILEGL_PIPE_PUSH and the DEBUG level, MOBILEGL_BUILD_DISAGGREGATED or MOBILEGL_PIPE_VERIFY (the tree has no MOBILEGL_DEBUG); under it every accessor is a read-side freshness check that aborts with Fatal{UnmigratedPipeInput, "Field@Verb"}. - CMake: MOBILEGL_PIPE_PUSH and MOBILEGL_PIPE_VERIFY options (VERIFY forces PUSH on), the two new sources appended only under PUSH, the compile definitions; Config.h/ConfigLoader.cpp gain PipeVerifyFatal / PipeVerifyCorrupt / PipePoisonOmit under #if MOBILEGL_PIPE_PUSH so the pull build's FeaturesTable does not change size. - Pull build proof: symbol_report.py against the 087685d1 baseline reports 0 added / 0 removed / 0 resized / 0 renamed and a .text delta of 0; ctest -N names unchanged; gen_pipe --check clean; unit tests green in the pull, push and verify builds. --- CMakeLists.txt | 28 + MobileGL/Config.h | 20 + MobileGL/ConfigLoader.cpp | 7 + MobileGL/MG_Backend/MGPipe/PipeInputs.cpp | 145 ++++ MobileGL/MG_Backend/MGPipe/PipeInputs.h | 671 ++++++++++++++++++ MobileGL/MG_Impl/Pipe/PipeFill.cpp | 100 +++ MobileGL/MG_Impl/Pipe/PipeFill.h | 27 + MobileGL/MG_Pipe/Coverage.def | 31 +- MobileGL/MG_Pipe/FillPoints.def | 267 +++++++ MobileGL/MG_Pipe/MGPipe.h | 5 + MobileGL/MG_Pipe/PipeInputsSwitch.h | 28 + MobileGL/MG_Pipe/generated/PipeFillPoints.inc | 300 ++++++++ MobileGL/MG_Pipe/generated/PipeFilled.inc | 33 +- MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp | 2 +- scripts/gen_pipe.py | 200 +++++- 15 files changed, 1834 insertions(+), 30 deletions(-) create mode 100644 MobileGL/MG_Backend/MGPipe/PipeInputs.cpp create mode 100644 MobileGL/MG_Backend/MGPipe/PipeInputs.h create mode 100644 MobileGL/MG_Impl/Pipe/PipeFill.cpp create mode 100644 MobileGL/MG_Impl/Pipe/PipeFill.h create mode 100644 MobileGL/MG_Pipe/FillPoints.def create mode 100644 MobileGL/MG_Pipe/PipeInputsSwitch.h create mode 100644 MobileGL/MG_Pipe/generated/PipeFillPoints.inc diff --git a/CMakeLists.txt b/CMakeLists.txt index 8224159b3..8f8f0ef97 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,6 +22,11 @@ option(MOBILEGL_IOS "Build MobileGL for iOS instead of macOS when # gates keep (section 10.3). option(MOBILEGL_BUILD_DISAGGREGATED "Build the MG_Remote transport layer (two-process shape)" OFF) option(MOBILEGL_BUILD_SERVER_SPIKE "Build the P0 spike-A MobileGLServer delivery-chain executable (Android only)" OFF) +# The PipeInputs strangler (ARCHITECTURE.md 9.2). OFF is the pull build and must stay +# byte-identical to a tree without either option: MGB_CTX is the live GLContext, no +# MGPipe/PipeInputs source is compiled, every MGP_FILL is ((void)0). +option(MOBILEGL_PIPE_PUSH "Backends read frontend state through the MGPipe PipeInputs block instead of MG_State::pGLContext (ARCHITECTURE.md 9.2 phase A)" OFF) +option(MOBILEGL_PIPE_VERIFY "Compile SnapshotFromGLContext() and the G4 per-verb shadow comparator; implies MOBILEGL_PIPE_PUSH; never shipped" OFF) set(MOBILEGL_LOG_ACTIVE_LEVEL "MOBILEGL_LOG_LEVEL_INFO" CACHE STRING "MobileGL active log level macro") set(MOBILEGL_VULKAN_LIBRARY "" CACHE FILEPATH "Vulkan loader/MoltenVK library to link for iOS builds") @@ -451,6 +456,22 @@ if (MOBILEGL_BUILD_DISAGGREGATED AND set(MOBILEGL_BUILD_DISAGGREGATED OFF) endif() +# MOBILEGL_PIPE_VERIFY implies MOBILEGL_PIPE_PUSH: the comparator compares the pushed block +# against a snapshot, so there has to be a pushed block. A normal variable, not a forced +# cache write, for the same reason as the disaggregated fallback above. +if (MOBILEGL_PIPE_VERIFY AND NOT MOBILEGL_PIPE_PUSH) + message(STATUS "MobileGL: MOBILEGL_PIPE_VERIFY=ON forces MOBILEGL_PIPE_PUSH ON for this configure") + set(MOBILEGL_PIPE_PUSH ON) +endif() + +if (MOBILEGL_PIPE_PUSH) + message(STATUS "MobileGL: PipeInputs push ON, appending the MGPipe fill sources") + list(APPEND SOURCE_FILES + MobileGL/MG_Backend/MGPipe/PipeInputs.cpp + MobileGL/MG_Impl/Pipe/PipeFill.cpp + ) +endif() + if (MOBILEGL_BUILD_DISAGGREGATED) message(STATUS "MobileGL: disaggregated transport ON, appending MG_Remote sources") list(APPEND SOURCE_FILES @@ -524,6 +545,13 @@ if (MOBILEGL_BUILD_DISAGGREGATED) list(APPEND MOBILEGL_COMPILE_DEF -DMOBILEGL_BUILD_DISAGGREGATED=1) endif() +if (MOBILEGL_PIPE_PUSH) + list(APPEND MOBILEGL_COMPILE_DEF -DMOBILEGL_PIPE_PUSH=1) +endif() +if (MOBILEGL_PIPE_VERIFY) + list(APPEND MOBILEGL_COMPILE_DEF -DMOBILEGL_PIPE_VERIFY=1) +endif() + message(STATUS "MOBILEGL_COMPILE_DEF=${MOBILEGL_COMPILE_DEF}") set(MOBILEGL_INCLUDE_DIR diff --git a/MobileGL/Config.h b/MobileGL/Config.h index 0c59110ee..4a6dd7b68 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -330,6 +330,26 @@ namespace MobileGL::MG_Config { // the semantic gate that replaces byte identity, and it catches the dangerous // direction - a dirty bit that fires too RARELY - which no purity gate can see. Bool PipeVerify = false; +#if MOBILEGL_PIPE_PUSH + // The three knobs of the MOBILEGL_PIPE_VERIFY build (P1 brief D2). Compiled only + // under MOBILEGL_PIPE_PUSH so the pull build's FeaturesTable does not change size. + // MOBILEGL_PIPE_VERIFY_FATAL: the first divergence aborts (default). 0 logs and + // counts instead, for triage and for the lane that must survive to read its own + // log. Tri-state parse like PipeLegacyMemos: only an explicit falsy value turns it + // off. + Bool PipeVerifyFatal = true; + // MOBILEGL_PIPE_VERIFY_CORRUPT: a field name from kMGPipeInputFieldNames[]; the + // comparator perturbs that field in the SNAPSHOT arm before the entry compare, so a + // green verify run goes red naming it (negative control A). Unknown name is + // Fatal{PipeVerifyBadKnob}. + String PipeVerifyCorrupt; + // MOBILEGL_PIPE_POISON_OMIT: :; the filler skips the STAMP (not + // the value) of that field for that verb, an omission indistinguishable from a + // forgotten FillPoints.def row, so that verb's read of it is + // Fatal{UnmigratedPipeInput} (negative control B). Unknown name is + // Fatal{PipeVerifyBadKnob}. + String PipePoisonOmit; +#endif // MOBILEGL_PIPE_STATS: dump the boundary counters (bytes, calls, roundtrips, // texture pulls, upload shapes, residual-block bytes, index mirror bytes). Bool PipeStats = false; diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index f4087c563..5c0aa5919 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -244,6 +244,13 @@ namespace MobileGL::MG_ConfigLoader { // that starts with MOBILEGL_ is visible to these queries by construction. features.PipePush = QueryEnvUint64("MOBILEGL_PIPE_PUSH", 0); features.PipeVerify = QueryEnvFlag("MOBILEGL_PIPE_VERIFY"); +#if MOBILEGL_PIPE_PUSH + // Defaults ON: read as a tri-state so only an explicitly falsy value turns it off. + features.PipeVerifyFatal = + QueryEnvQuirkOverride("MOBILEGL_PIPE_VERIFY_FATAL") != MG_Config::QuirkOverride::ForceOff; + QueryEnvVariable("MOBILEGL_PIPE_VERIFY_CORRUPT", features.PipeVerifyCorrupt, ""); + QueryEnvVariable("MOBILEGL_PIPE_POISON_OMIT", features.PipePoisonOmit, ""); +#endif features.PipeStats = QueryEnvFlag("MOBILEGL_PIPE_STATS"); // Defaults ON, so the flag has to be read as a tri-state rather than as a plain // truthy check: unset must keep the memos, and only an explicitly falsy value may diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp b/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp new file mode 100644 index 000000000..0a9b57730 --- /dev/null +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp @@ -0,0 +1,145 @@ +// MobileGL - MobileGL/MG_Backend/MGPipe/PipeInputs.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The backend-side half of the PipeInputs block: the poison Fatal with its verb name, the +// name lookups the runtime knobs need, and - in a verify build - the per-field equality and +// the corruption injector the comparator uses. Compiled only under MOBILEGL_PIPE_PUSH +// (CMakeLists.txt appends it to SOURCE_FILES there), so the pull build never sees it. Spells +// no MG_State global: everything that reads the live context lives in MG_Impl/Pipe/PipeFill.cpp. +#include + +#include + +namespace MobileGL::MG_Pipe { + const char* MGPipeVerbName(MGPipeVerb verb) { + const auto index = static_cast(verb); + return index < kMGPipeVerbCount ? kMGPipeVerbNames[index] : ""; + } + + [[noreturn]] void MGPipeInputPoisonFatalForVerb(MGPipeInputField field, MGPipeVerb verb) { + MGPipeInputPoisonFatal(field, MGPipeVerbName(verb)); + } + + Optional MGPipeFindInputField(const char* name) { + if (name == nullptr) return std::nullopt; + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + if (std::strcmp(kMGPipeInputFieldNames[i], name) == 0) return static_cast(i); + } + return std::nullopt; + } + + Optional MGPipeFindVerb(const char* name) { + if (name == nullptr) return std::nullopt; + for (SizeT i = 0; i < kMGPipeVerbCount; ++i) { + if (std::strcmp(kMGPipeVerbNames[i], name) == 0) return static_cast(i); + } + return std::nullopt; + } + +#if MOBILEGL_PIPE_VERIFY + namespace { + // Every overload is declared up front: the array overloads recurse into their element + // type, and a call inside a template only sees what was declared before the template. + template + Bool StorageEqual(const T& a, const T& b); + template + Bool StorageEqual(T* const& a, T* const& b); + template + Bool StorageEqual(const SharedPtr& a, const SharedPtr& b); + template + Bool StorageEqual(const T (&a)[N], const T (&b)[N]); + Bool StorageEqual(const PipeInputs::IndexedCapabilities& a, const PipeInputs::IndexedCapabilities& b); + template + void CorruptStorage(T& v); + template + void CorruptStorage(T*& p); + template + void CorruptStorage(SharedPtr& p); + template + void CorruptStorage(T (&a)[N]); + void CorruptStorage(PipeInputs::IndexedCapabilities& c); + + // ---- equality over one field's storage ---- + // O-class storage compares by identity: a raw pointer into the context, or the object a + // SharedPtr owns. Everything else goes through G4's MGPipeFieldEqual, recursing through + // C arrays element-wise. + template + Bool StorageEqual(T* const& a, T* const& b) { + return a == b; + } + template + Bool StorageEqual(const SharedPtr& a, const SharedPtr& b) { + return a.get() == b.get(); + } + template + Bool StorageEqual(const T (&a)[N], const T (&b)[N]) { + for (SizeT i = 0; i < N; ++i) { + if (!StorageEqual(a[i], b[i])) return false; + } + return true; + } + Bool StorageEqual(const PipeInputs::IndexedCapabilities& a, const PipeInputs::IndexedCapabilities& b) { + return StorageEqual(a.Blend, b.Blend) && StorageEqual(a.ScissorTest, b.ScissorTest); + } + template + Bool StorageEqual(const T& a, const T& b) { + return MGPipeFieldEqual(a, b); + } + + // ---- corruption of one field's storage ---- + // Every shape is perturbed in a way the comparator above must see: a Bool flips, a + // scalar or enum moves by one, a pointer becomes null, a SharedPtr is dropped, an array + // corrupts its first element, and any other struct has its first byte XOR'ed with 0x5A. + template + void CorruptStorage(T*& p) { + p = nullptr; + } + template + void CorruptStorage(SharedPtr& p) { + p.reset(); + } + template + void CorruptStorage(T (&a)[N]) { + CorruptStorage(a[0]); + } + void CorruptStorage(PipeInputs::IndexedCapabilities& c) { + CorruptStorage(c.Blend); + } + template + void CorruptStorage(T& v) { + if constexpr (std::is_same_v) { + v = !v; + } else if constexpr (std::is_enum_v) { + v = static_cast(static_cast>(v) + 1); + } else if constexpr (std::is_arithmetic_v) { + v = static_cast(v + 1); + } else { + static_assert(std::is_trivially_copyable_v, "PipeInputs storage must be trivially copyable"); + unsigned char first = 0; + std::memcpy(&first, &v, 1); + first ^= 0x5A; + std::memcpy(&v, &first, 1); + } + } + } // namespace + + Bool MGPipeInputsFieldEqual(MGPipeInputField field, PipeInputs& a, PipeInputs& b) { + // A forwarded field has no storage and is equal by definition; VisitStorage answers + // false for it, hence the explicit sticky test first. + if (kMGPipeInputFieldSticky[static_cast(field)]) return true; + return PipeInputs::VisitStorage(field, a, b, [](const auto& x, const auto& y) { return StorageEqual(x, y); }); + } + + Bool MGPipeApplyVerifyCorruption(PipeInputs& snapshot, MGPipeInputField field) { + return PipeInputs::VisitStorage(field, snapshot, snapshot, [](auto& x, auto&) { + CorruptStorage(x); + return true; + }); + } +#endif // MOBILEGL_PIPE_VERIFY +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.h b/MobileGL/MG_Backend/MGPipe/PipeInputs.h new file mode 100644 index 000000000..ab21f4f28 --- /dev/null +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.h @@ -0,0 +1,671 @@ +// MobileGL - MobileGL/MG_Backend/MGPipe/PipeInputs.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include +// The frontend types the accessors return. Allowed here: P13 keeps this include for the +// verify arm (ARCHITECTURE.md 9.5). This header spells no MG_State global - every read of +// the live context happens on the client side, in MG_Impl/Pipe/PipeFill.cpp. +#include + +// MOBILEGL_PIPE_POISON: the per-verb generation stamps and the read-side +// Fatal{UnmigratedPipeInput} check. Derived here, once. The repository's debug gate is +// MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG (Defines.h); the verify CI build is +// Release/INFO with MOBILEGL_BUILD_DISAGGREGATED=OFF, so the third arm is what arms the poison +// there without dragging MG_Remote in. +#if MOBILEGL_PIPE_PUSH && (MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG || MOBILEGL_BUILD_DISAGGREGATED || \ + MOBILEGL_PIPE_VERIFY) +#define MOBILEGL_PIPE_POISON 1 +#else +#define MOBILEGL_PIPE_POISON 0 +#endif + +namespace MobileGL::MG_Pipe { + // PipeInputs.cpp. The poison Fatal with the verb's name ("" before the first + // verb): MGLOG_F + std::abort(), live at every log level on purpose - this is not + // MOBILEGL_ASSERT, which is inert in INFO builds. + [[noreturn]] void MGPipeInputPoisonFatalForVerb(MGPipeInputField field, MGPipeVerb verb); + // kMGPipeVerbNames[verb], or "" for kVerbCount (no verb has been filled yet). + const char* MGPipeVerbName(MGPipeVerb verb); + // Name lookups for the runtime knobs (MOBILEGL_PIPE_VERIFY_CORRUPT names a field, + // MOBILEGL_PIPE_POISON_OMIT a Verb:Field pair). Empty on an unknown name. + Optional MGPipeFindInputField(const char* name); + Optional MGPipeFindVerb(const char* name); + + // The read-side poison check, on every non-forwarded accessor. Under MOBILEGL_PIPE_POISON + // a read of a field whose stamp is older than the current verb serial is + // Fatal{UnmigratedPipeInput, "Field@Verb"}; otherwise the accessor is a plain load. +#if MOBILEGL_PIPE_POISON +#define MGP_INPUT_CHECK(Field) \ + do { \ + if (!::MobileGL::MG_Pipe::MGPipeInputFieldIsFresh(m_filled, (Field))) { \ + ::MobileGL::MG_Pipe::MGPipeInputPoisonFatalForVerb((Field), m_currentVerb); \ + } \ + } while (0) +#else +#define MGP_INPUT_CHECK(Field) ((void)0) +#endif + // The compare-at-read hook of the MOBILEGL_PIPE_VERIFY comparator (P1 brief D8): re-reads + // the same accessor with the same indices from the live context and compares. Armed by + // the comparator commit; until then every build's accessor is a load. +#define MGP_INPUT_VERIFY_READ(Field, Index0, Index1) ((void)0) + + // The V/O storage of every field that has storage, by field id. The seven F-class + // (forwarded) fields have none. PipeInputs::VisitStorage dispatches on this list, which + // is what keeps the comparator and the corruption injector one function each instead of + // two sixty-way switches. + // clang-format off +#define MGP_INPUT_STORAGE_LIST(X) \ + X(GetActiveTextureUnit, m_activeTextureUnit) \ + X(GetBlendColor, m_blendColor) \ + X(GetBlendEquationIndexed, m_blendEquation) \ + X(GetBlendFuncIndexed, m_blendFunc) \ + X(GetBoundTransformFeedbackName, m_boundTransformFeedbackName) \ + X(GetBoundVertexArray, m_boundVertexArray) \ + X(GetBufferBindingSlot, m_bufferBindingSlot) \ + X(GetBufferBindingPoint, m_bufferBindingPointBase) \ + X(GetTouchedBufferBindingPointCount, m_touchedBindingPointCount) \ + X(GetClampReadColor, m_clampReadColor) \ + X(GetClearColor, m_clearColor) \ + X(GetClearDepth, m_clearDepth) \ + X(GetClearStencil, m_clearStencil) \ + X(GetColorMaskIndexed, m_colorMask) \ + X(GetCullFaceMode, m_cullFaceMode) \ + X(GetCurrentVertexAttribute, m_currentVertexAttribute) \ + X(GetDepthFunc, m_depthFunc) \ + X(GetDepthMask, m_depthMask) \ + X(GetDepthRangeIndexed, m_depthRange) \ + X(GetFramebufferBindingSlot, m_framebufferBindingSlot) \ + X(GetImageTextureBinding, m_imageTextureBindingBase) \ + X(GetLineWidth, m_lineWidth) \ + X(GetLogicOp, m_logicOp) \ + X(GetMaxTouchedTextureUnit, m_maxTouchedTextureUnit) \ + X(GetMinSampleShadingValue, m_minSampleShadingValue) \ + X(GetPatchDefaultInnerLevel, m_patchDefaultInnerLevel) \ + X(GetPatchDefaultOuterLevel, m_patchDefaultOuterLevel) \ + X(GetPatchVertices, m_patchVertices) \ + X(GetPipelineStateVersion, m_pipelineStateVersion) \ + X(GetPixelStoreParameters, m_pixelStore) \ + X(GetPolygonModeFront, m_polygonModeFront) \ + X(GetPolygonOffsetFactor, m_polygonOffsetFactor) \ + X(GetPolygonOffsetUnits, m_polygonOffsetUnits) \ + X(GetPrimitiveRestartIndex, m_primitiveRestartIndex) \ + X(GetProgramForDispatch, m_programForDispatch) \ + X(GetProgramForDraw, m_programForDraw) \ + X(GetProvokingVertexMode, m_provokingVertexMode) \ + X(GetRenderStateParameters, m_renderState) \ + X(GetRenderStateParametersVersion, m_renderStateParametersVersion) \ + X(GetSamplingResolutionGeneration, m_samplingResolutionGeneration) \ + X(GetScissorBox, m_scissorBox) \ + X(GetStencilState, m_stencil) \ + X(GetTextureBindGeneration, m_textureBindGeneration) \ + X(GetTextureContextId, m_textureContextId) \ + X(GetTextureUnitObject, m_textureUnitBase) \ + X(GetTransformFeedbackCapturedVertices, m_transformFeedbackCapturedVertices) \ + X(GetTransformFeedbackGeneration, m_transformFeedbackGeneration) \ + X(GetTransformFeedbackPausedPrimitiveCounter, m_transformFeedbackPausedPrimitiveCounter) \ + X(GetTransformFeedbackProgram, m_transformFeedbackProgram) \ + X(GetViewport, m_viewport) \ + X(GetViewportIndexed, m_viewportIndexed) \ + X(IsCapabilityEnabled, m_capability) \ + X(IsCapabilityEnabledIndexed, m_capabilityIndexed) \ + X(IsTransformFeedbackActive, m_transformFeedbackActive) \ + X(IsTransformFeedbackPaused, m_transformFeedbackPaused) \ + X(GetBoundTransformFeedbackLifetimeId, m_boundTransformFeedbackLifetimeId) + // clang-format on + + // The seven F-class fields, for the arithmetic below and for the sticky table's proof. + inline constexpr SizeT kMGPipeForwardedFieldCount = 7; + + // The block the backends read instead of GLContext (ARCHITECTURE.md 9.2 phase A, P1 brief + // D4). One struct, three storage classes, and every accessor keeps the NAME, PARAMETERS + // and RETURN TYPE of its GLContext counterpart (MG_State/GLState/Core.h) so the strangler + // sed is type-neutral: + // + // V (value) copied out of GLContext at fill time by calling the same accessor; + // no derivation logic is re-implemented here, which is what keeps the + // copy semantically identical by construction. + // O (object reference) a SharedPtr copy, or a raw pointer to the live GLContext-owned + // slot/array for the accessors that return a non-const reference into + // the context. Identity is what phase C turns into a handle. + // F (forwarded) argument-keyed lookups and reverse-channel calls, defined out of + // line in MG_Impl/Pipe/PipeFill.cpp (the client side, where the live + // context may be spelled). Sticky: stamped once by the first fill that + // sees a live context. + // + // Every non-forwarded accessor is MGP_INPUT_CHECK (poison) -> MGP_INPUT_VERIFY_READ + // (compare-at-read) -> the storage. Both macros expand to nothing when their switch is + // off, so a plain MOBILEGL_PIPE_PUSH build's accessor is a load. + struct PipeInputs { + using GLContext = MG_State::GLState::GLContext; + using BufferObject = MG_State::GLState::BufferObject; + using BufferTarget = ::MobileGL::BufferTarget; + using FramebufferObject = MG_State::GLState::FramebufferObject; + using FramebufferTarget = ::MobileGL::FramebufferTarget; + using VertexArrayObject = MG_State::GLState::VertexArrayObject; + using ProgramObject = MG_State::GLState::ProgramObject; + using ITextureObject = MG_State::GLState::ITextureObject; + using TextureUnit = MG_State::GLState::TextureUnit; + using ImageTextureBinding = MG_State::GLState::ImageTextureBinding; + using CurrentVertexAttributeValue = MG_State::GLState::CurrentVertexAttributeValue; + + static constexpr SizeT kBufferTargetCount = static_cast(BufferTarget::BufferTargetCount); + static constexpr SizeT kFramebufferTargetCount = static_cast(FramebufferTarget::FramebufferTargetCount); + static constexpr SizeT kCapabilityCount = static_cast(CapabilityInput::CapabilityInputCount); + static constexpr SizeT kMaxViewports = RenderStateParameters::MAX_VIEWPORTS; + static constexpr SizeT kMaxVertexAttribs = VertexArrayObject::MAX_VERTEX_ATTRIBS; + static constexpr SizeT kStencilFaceCount = static_cast(StencilFace::StencilFaceCount); + + // IsCapabilityEnabledIndexed's two indexed capabilities, the only ones GLContext keeps + // indexed state for (RenderState::IsCapabilityEnabledIndexed). + struct IndexedCapabilities { + Bool Blend[kMGMaxDrawBuffers]; + Bool ScissorTest[kMaxViewports]; + }; + + // ---- identity / liveness (not fields) ---- + // Whether a live GLContext exists. Forwarded (PipeFill.cpp): under push MGB_CTX_LIVE + // must be true as soon as a context exists, fill or no fill, which is what today's + // null-context guards test. + Bool IsLive() const; + // The live GLContext's address at the last fill; serves MGB_CTX_IDENTITY. + const void* ContextIdentity() const { return m_contextIdentity; } + // The verb of the last fill, kVerbCount before the first one. + MGPipeVerb CurrentVerb() const { return m_currentVerb; } +#if MOBILEGL_PIPE_POISON + const MGPipeFilledState& FilledState() const { return m_filled; } +#endif + + // ---- V: values ---- + Int GetActiveTextureUnit() const { + MGP_INPUT_CHECK(MGPipeInputField::GetActiveTextureUnit); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetActiveTextureUnit, 0, 0); + return m_activeTextureUnit; + } + const FloatVec4& GetBlendColor() const { + MGP_INPUT_CHECK(MGPipeInputField::GetBlendColor); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetBlendColor, 0, 0); + return m_blendColor; + } + void GetBlendEquationIndexed(Uint index, BlendEquation& color, BlendEquation& alpha) const { + MGP_INPUT_CHECK(MGPipeInputField::GetBlendEquationIndexed); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetBlendEquationIndexed, index, 0); + if (index >= kMGMaxDrawBuffers) { + MOBILEGL_ASSERT(false, "Blend equation index out of range: %u", index); + return; + } + color = m_blendEquation[index][0]; + alpha = m_blendEquation[index][1]; + } + void GetBlendFuncIndexed(Uint index, BlendFactor& srcRGB, BlendFactor& dstRGB, BlendFactor& srcAlpha, + BlendFactor& dstAlpha) const { + MGP_INPUT_CHECK(MGPipeInputField::GetBlendFuncIndexed); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetBlendFuncIndexed, index, 0); + if (index >= kMGMaxDrawBuffers) { + MOBILEGL_ASSERT(false, "Blend func index out of range: %u", index); + return; + } + srcRGB = m_blendFunc[index][0]; + dstRGB = m_blendFunc[index][1]; + srcAlpha = m_blendFunc[index][2]; + dstAlpha = m_blendFunc[index][3]; + } + // Dead field: filled, read by no backend since the D21 XFB counter-slot rekey; kept so + // the vendored inventory row keeps its mapping (Coverage.def). + Uint GetBoundTransformFeedbackName() const { + MGP_INPUT_CHECK(MGPipeInputField::GetBoundTransformFeedbackName); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetBoundTransformFeedbackName, 0, 0); + return m_boundTransformFeedbackName; + } + SizeT GetTouchedBufferBindingPointCount(BufferTarget target) const { + MGP_INPUT_CHECK(MGPipeInputField::GetTouchedBufferBindingPointCount); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetTouchedBufferBindingPointCount, static_cast(target), 0); + return m_touchedBindingPointCount[static_cast(target)]; + } + GLenum GetClampReadColor() const { + MGP_INPUT_CHECK(MGPipeInputField::GetClampReadColor); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetClampReadColor, 0, 0); + return m_clampReadColor; + } + const FloatVec4& GetClearColor() const { + MGP_INPUT_CHECK(MGPipeInputField::GetClearColor); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetClearColor, 0, 0); + return m_clearColor; + } + Float GetClearDepth() const { + MGP_INPUT_CHECK(MGPipeInputField::GetClearDepth); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetClearDepth, 0, 0); + return m_clearDepth; + } + Uint32 GetClearStencil() const { + MGP_INPUT_CHECK(MGPipeInputField::GetClearStencil); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetClearStencil, 0, 0); + return m_clearStencil; + } + BoolVec4 GetColorMaskIndexed(Uint index) const { + MGP_INPUT_CHECK(MGPipeInputField::GetColorMaskIndexed); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetColorMaskIndexed, index, 0); + return m_colorMask[index]; + } + CullFaceMode GetCullFaceMode() const { + MGP_INPUT_CHECK(MGPipeInputField::GetCullFaceMode); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetCullFaceMode, 0, 0); + return m_cullFaceMode; + } + const CurrentVertexAttributeValue& GetCurrentVertexAttribute(Uint index) const { + MGP_INPUT_CHECK(MGPipeInputField::GetCurrentVertexAttribute); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetCurrentVertexAttribute, index, 0); + if (index >= kMaxVertexAttribs) { + static const CurrentVertexAttributeValue defaultValue{}; + MGLOG_E_ONCE("PipeInputs::GetCurrentVertexAttribute: index %u is out of range", index); + return defaultValue; + } + return m_currentVertexAttribute[index]; + } + DepthTestFunc GetDepthFunc() const { + MGP_INPUT_CHECK(MGPipeInputField::GetDepthFunc); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetDepthFunc, 0, 0); + return m_depthFunc; + } + Bool GetDepthMask() const { + MGP_INPUT_CHECK(MGPipeInputField::GetDepthMask); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetDepthMask, 0, 0); + return m_depthMask; + } + const FloatVec2& GetDepthRangeIndexed(Uint index) const { + MGP_INPUT_CHECK(MGPipeInputField::GetDepthRangeIndexed); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetDepthRangeIndexed, index, 0); + if (index >= kMaxViewports) { + MOBILEGL_ASSERT(false, "Depth range index out of range: %u", index); + return m_depthRange[0]; + } + return m_depthRange[index]; + } + Float GetLineWidth() const { + MGP_INPUT_CHECK(MGPipeInputField::GetLineWidth); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetLineWidth, 0, 0); + return m_lineWidth; + } + LogicOperation GetLogicOp() const { + MGP_INPUT_CHECK(MGPipeInputField::GetLogicOp); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetLogicOp, 0, 0); + return m_logicOp; + } + Int GetMaxTouchedTextureUnit() const { + MGP_INPUT_CHECK(MGPipeInputField::GetMaxTouchedTextureUnit); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetMaxTouchedTextureUnit, 0, 0); + return m_maxTouchedTextureUnit; + } + Float GetMinSampleShadingValue() const { + MGP_INPUT_CHECK(MGPipeInputField::GetMinSampleShadingValue); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetMinSampleShadingValue, 0, 0); + return m_minSampleShadingValue; + } + const FloatVec2& GetPatchDefaultInnerLevel() const { + MGP_INPUT_CHECK(MGPipeInputField::GetPatchDefaultInnerLevel); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPatchDefaultInnerLevel, 0, 0); + return m_patchDefaultInnerLevel; + } + const FloatVec4& GetPatchDefaultOuterLevel() const { + MGP_INPUT_CHECK(MGPipeInputField::GetPatchDefaultOuterLevel); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPatchDefaultOuterLevel, 0, 0); + return m_patchDefaultOuterLevel; + } + Uint GetPatchVertices() const { + MGP_INPUT_CHECK(MGPipeInputField::GetPatchVertices); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPatchVertices, 0, 0); + return m_patchVertices; + } + Uint GetPipelineStateVersion() const { + MGP_INPUT_CHECK(MGPipeInputField::GetPipelineStateVersion); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPipelineStateVersion, 0, 0); + return m_pipelineStateVersion; + } + Uint GetRenderStateParametersVersion() const { + MGP_INPUT_CHECK(MGPipeInputField::GetRenderStateParametersVersion); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetRenderStateParametersVersion, 0, 0); + return m_renderStateParametersVersion; + } + PixelStoreParameters GetPixelStoreParameters(Bool isUnpack) const { + MGP_INPUT_CHECK(MGPipeInputField::GetPixelStoreParameters); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPixelStoreParameters, isUnpack ? 1u : 0u, 0); + return m_pixelStore[isUnpack ? 1 : 0]; + } + GLenum GetPolygonModeFront() const { + MGP_INPUT_CHECK(MGPipeInputField::GetPolygonModeFront); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPolygonModeFront, 0, 0); + return m_polygonModeFront; + } + Float GetPolygonOffsetFactor() const { + MGP_INPUT_CHECK(MGPipeInputField::GetPolygonOffsetFactor); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPolygonOffsetFactor, 0, 0); + return m_polygonOffsetFactor; + } + Float GetPolygonOffsetUnits() const { + MGP_INPUT_CHECK(MGPipeInputField::GetPolygonOffsetUnits); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPolygonOffsetUnits, 0, 0); + return m_polygonOffsetUnits; + } + Uint32 GetPrimitiveRestartIndex() const { + MGP_INPUT_CHECK(MGPipeInputField::GetPrimitiveRestartIndex); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPrimitiveRestartIndex, 0, 0); + return m_primitiveRestartIndex; + } + ProvokingVertexMode GetProvokingVertexMode() const { + MGP_INPUT_CHECK(MGPipeInputField::GetProvokingVertexMode); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetProvokingVertexMode, 0, 0); + return m_provokingVertexMode; + } + const RenderStateParameters& GetRenderStateParameters() const { + MGP_INPUT_CHECK(MGPipeInputField::GetRenderStateParameters); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetRenderStateParameters, 0, 0); + return m_renderState; + } + Uint64 GetSamplingResolutionGeneration() const { + MGP_INPUT_CHECK(MGPipeInputField::GetSamplingResolutionGeneration); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetSamplingResolutionGeneration, 0, 0); + return m_samplingResolutionGeneration; + } + const IntVec4& GetScissorBox() const { + MGP_INPUT_CHECK(MGPipeInputField::GetScissorBox); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetScissorBox, 0, 0); + return m_scissorBox; + } + const StencilFaceState& GetStencilState(StencilFace face) const { + MGP_INPUT_CHECK(MGPipeInputField::GetStencilState); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetStencilState, static_cast(face), 0); + return m_stencil[face == StencilFace::Back ? 1 : 0]; + } + Uint64 GetTextureBindGeneration() const { + MGP_INPUT_CHECK(MGPipeInputField::GetTextureBindGeneration); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetTextureBindGeneration, 0, 0); + return m_textureBindGeneration; + } + Uint64 GetTextureContextId() const { + MGP_INPUT_CHECK(MGPipeInputField::GetTextureContextId); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetTextureContextId, 0, 0); + return m_textureContextId; + } + Uint64 GetTransformFeedbackCapturedVertices() const { + MGP_INPUT_CHECK(MGPipeInputField::GetTransformFeedbackCapturedVertices); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetTransformFeedbackCapturedVertices, 0, 0); + return m_transformFeedbackCapturedVertices; + } + Uint64 GetTransformFeedbackGeneration() const { + MGP_INPUT_CHECK(MGPipeInputField::GetTransformFeedbackGeneration); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetTransformFeedbackGeneration, 0, 0); + return m_transformFeedbackGeneration; + } + Uint64 GetTransformFeedbackPausedPrimitiveCounter() const { + MGP_INPUT_CHECK(MGPipeInputField::GetTransformFeedbackPausedPrimitiveCounter); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetTransformFeedbackPausedPrimitiveCounter, 0, 0); + return m_transformFeedbackPausedPrimitiveCounter; + } + Uint64 GetBoundTransformFeedbackLifetimeId() const { + MGP_INPUT_CHECK(MGPipeInputField::GetBoundTransformFeedbackLifetimeId); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetBoundTransformFeedbackLifetimeId, 0, 0); + return m_boundTransformFeedbackLifetimeId; + } + IntVec4 GetViewport() const { + MGP_INPUT_CHECK(MGPipeInputField::GetViewport); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetViewport, 0, 0); + return m_viewport; + } + const FloatVec4& GetViewportIndexed(Uint index) const { + MGP_INPUT_CHECK(MGPipeInputField::GetViewportIndexed); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetViewportIndexed, index, 0); + if (index >= kMaxViewports) { + MOBILEGL_ASSERT(false, "Viewport index out of range: %u", index); + return m_viewportIndexed[0]; + } + return m_viewportIndexed[index]; + } + Bool IsCapabilityEnabled(CapabilityInput cap) const { + MGP_INPUT_CHECK(MGPipeInputField::IsCapabilityEnabled); + MGP_INPUT_VERIFY_READ(MGPipeInputField::IsCapabilityEnabled, static_cast(cap), 0); + const auto index = static_cast(cap); + return index < kCapabilityCount ? m_capability[index] : false; + } + // Blend and ScissorTest are the only indexed capabilities GLContext keeps; no backend + // asks for another (VulkanRenderer asks Blend). Any other cap is a read the fill cannot + // have served: Fatal{UnmigratedPipeInput} naming the field and the verb, the cap in a + // preceding MGLOG_E. + Bool IsCapabilityEnabledIndexed(CapabilityInput cap, Uint index) const { + MGP_INPUT_CHECK(MGPipeInputField::IsCapabilityEnabledIndexed); + MGP_INPUT_VERIFY_READ(MGPipeInputField::IsCapabilityEnabledIndexed, static_cast(cap), index); + if (cap == CapabilityInput::Blend) { + return index < kMGMaxDrawBuffers ? m_capabilityIndexed.Blend[index] : false; + } + if (cap == CapabilityInput::ScissorTest) { + return index < kMaxViewports ? m_capabilityIndexed.ScissorTest[index] : false; + } + MGLOG_E("PipeInputs::IsCapabilityEnabledIndexed: no indexed storage for cap=%d (index=%u)", + static_cast(cap), index); + MGPipeInputPoisonFatalForVerb(MGPipeInputField::IsCapabilityEnabledIndexed, m_currentVerb); + } + Bool IsTransformFeedbackActive() const { + MGP_INPUT_CHECK(MGPipeInputField::IsTransformFeedbackActive); + MGP_INPUT_VERIFY_READ(MGPipeInputField::IsTransformFeedbackActive, 0, 0); + return m_transformFeedbackActive; + } + Bool IsTransformFeedbackPaused() const { + MGP_INPUT_CHECK(MGPipeInputField::IsTransformFeedbackPaused); + MGP_INPUT_VERIFY_READ(MGPipeInputField::IsTransformFeedbackPaused, 0, 0); + return m_transformFeedbackPaused; + } + + // ---- O: object references ---- + const SharedPtr& GetBoundVertexArray() { + MGP_INPUT_CHECK(MGPipeInputField::GetBoundVertexArray); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetBoundVertexArray, 0, 0); + return m_boundVertexArray; + } + // A target the fill left null (one outside GlobalBufferTargets / BufferBindPointTargets, + // or a read before any fill) is a read the fill cannot have served: the poison Fatal, + // the target in a preceding MGLOG_E. + BindingSlot& GetBufferBindingSlot(BufferTarget target) { + MGP_INPUT_CHECK(MGPipeInputField::GetBufferBindingSlot); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetBufferBindingSlot, static_cast(target), 0); + const auto index = static_cast(target); + if (index >= kBufferTargetCount || m_bufferBindingSlot[index] == nullptr) { + MGLOG_E("PipeInputs::GetBufferBindingSlot: no slot for target=%d", static_cast(target)); + MGPipeInputPoisonFatalForVerb(MGPipeInputField::GetBufferBindingSlot, m_currentVerb); + } + return *m_bufferBindingSlot[index]; + } + BindingSlotRange1D& GetBufferBindingPoint(BufferTarget target, Uint index) { + MGP_INPUT_CHECK(MGPipeInputField::GetBufferBindingPoint); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetBufferBindingPoint, static_cast(target), index); + const auto targetIndex = static_cast(target); + if (targetIndex >= kBufferTargetCount || m_bufferBindingPointBase[targetIndex] == nullptr) { + MGLOG_E("PipeInputs::GetBufferBindingPoint: no binding points for target=%d (index=%u)", + static_cast(target), index); + MGPipeInputPoisonFatalForVerb(MGPipeInputField::GetBufferBindingPoint, m_currentVerb); + } + // The live storage is Array, N> + // (BufferState.h), so base[index] is the live slot GLContext would hand out. + return m_bufferBindingPointBase[targetIndex][index]; + } + BindingSlot& GetFramebufferBindingSlot(FramebufferTarget target) { + MGP_INPUT_CHECK(MGPipeInputField::GetFramebufferBindingSlot); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetFramebufferBindingSlot, static_cast(target), 0); + const auto index = static_cast(target); + if (index >= kFramebufferTargetCount || m_framebufferBindingSlot[index] == nullptr) { + MGLOG_E("PipeInputs::GetFramebufferBindingSlot: no slot for target=%d", static_cast(target)); + MGPipeInputPoisonFatalForVerb(MGPipeInputField::GetFramebufferBindingSlot, m_currentVerb); + } + return *m_framebufferBindingSlot[index]; + } + ImageTextureBinding& GetImageTextureBinding(Int unit) { + MGP_INPUT_CHECK(MGPipeInputField::GetImageTextureBinding); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetImageTextureBinding, static_cast(unit), 0); + if (m_imageTextureBindingBase == nullptr) { + MGPipeInputPoisonFatalForVerb(MGPipeInputField::GetImageTextureBinding, m_currentVerb); + } + return m_imageTextureBindingBase[unit]; + } + const ImageTextureBinding& GetImageTextureBinding(Int unit) const { + MGP_INPUT_CHECK(MGPipeInputField::GetImageTextureBinding); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetImageTextureBinding, static_cast(unit), 0); + if (m_imageTextureBindingBase == nullptr) { + MGPipeInputPoisonFatalForVerb(MGPipeInputField::GetImageTextureBinding, m_currentVerb); + } + return m_imageTextureBindingBase[unit]; + } + const SharedPtr& GetProgramForDispatch() { + MGP_INPUT_CHECK(MGPipeInputField::GetProgramForDispatch); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetProgramForDispatch, 0, 0); + return m_programForDispatch; + } + const SharedPtr& GetProgramForDraw() { + MGP_INPUT_CHECK(MGPipeInputField::GetProgramForDraw); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetProgramForDraw, 0, 0); + return m_programForDraw; + } + const SharedPtr& GetTransformFeedbackProgram() const { + MGP_INPUT_CHECK(MGPipeInputField::GetTransformFeedbackProgram); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetTransformFeedbackProgram, 0, 0); + return m_transformFeedbackProgram; + } + TextureUnit& GetTextureUnitObject(Int unit) { + MGP_INPUT_CHECK(MGPipeInputField::GetTextureUnitObject); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetTextureUnitObject, static_cast(unit), 0); + if (m_textureUnitBase == nullptr) { + MGPipeInputPoisonFatalForVerb(MGPipeInputField::GetTextureUnitObject, m_currentVerb); + } + return m_textureUnitBase[unit]; + } + + // ---- F: forwarded to the live context (MG_Impl/Pipe/PipeFill.cpp); sticky ---- + // Each takes an argument that is not verb state - a GL name, a lifetime id, a target - + // i.e. it is a lookup or a reverse-channel write, not a state read; there is no value + // the filler could copy and no verb whose fill could make it stale. Phase C replaces + // them with handle tables and callbacks. + SizeT GetBufferBindingPointCount(BufferTarget target) const; + const SharedPtr& GetProgramObject(Uint index); + const SharedPtr& GetTextureObject(Uint index); + Bool HasOpenTransformFeedbackSpan(Uint64 lifetimeId) const; + void InvalidateCompileEnv(); + Bool ValidateProgramName(Uint index) const; + // Dropped with an MGLOG_E_ONCE when no context is live; today's guarded sites never + // reach it without one. + void RecordError(ErrorCode code, UniquePtr info); + + // ---- the storage visitor ---- + // Calls fn(a., b.) for the field's storage and returns its result; returns + // false without calling fn for a forwarded field, which has none. The comparator's + // per-field equality and the verify corruption injector are both one call of this. + template + static Bool VisitStorage(MGPipeInputField field, PipeInputs& a, PipeInputs& b, Fn&& fn) { + switch (field) { +#define MGP_INPUT_VISIT(Field, Member) \ + case MGPipeInputField::Field: \ + return fn(a.Member, b.Member); + MGP_INPUT_STORAGE_LIST(MGP_INPUT_VISIT) +#undef MGP_INPUT_VISIT + default: + return false; + } + } + + private: + friend void MGPipeFillForVerb(MGPipeVerb verb); + friend void SnapshotFromGLContext(PipeInputs& snapshot, const MGPipeFieldMask& mask); + + // ---- identity ---- + const void* m_contextIdentity = nullptr; + Bool m_live = false; + MGPipeVerb m_currentVerb = MGPipeVerb::kVerbCount; +#if MOBILEGL_PIPE_POISON + MGPipeFilledState m_filled{}; +#endif + + // ---- V ---- + Int m_activeTextureUnit = 0; + FloatVec4 m_blendColor{}; + BlendEquation m_blendEquation[kMGMaxDrawBuffers][2]{}; + BlendFactor m_blendFunc[kMGMaxDrawBuffers][4]{}; + Uint m_boundTransformFeedbackName = 0; + SizeT m_touchedBindingPointCount[kBufferTargetCount]{}; + GLenum m_clampReadColor = 0; + FloatVec4 m_clearColor{}; + Float m_clearDepth = 0.f; + Uint32 m_clearStencil = 0; + BoolVec4 m_colorMask[kMGMaxDrawBuffers]{}; + CullFaceMode m_cullFaceMode{}; + CurrentVertexAttributeValue m_currentVertexAttribute[kMaxVertexAttribs]{}; + DepthTestFunc m_depthFunc{}; + Bool m_depthMask = false; + FloatVec2 m_depthRange[kMaxViewports]{}; + Float m_lineWidth = 0.f; + LogicOperation m_logicOp{}; + Int m_maxTouchedTextureUnit = -1; + Float m_minSampleShadingValue = 0.f; + FloatVec2 m_patchDefaultInnerLevel{}; + FloatVec4 m_patchDefaultOuterLevel{}; + Uint m_patchVertices = 0; + Uint m_pipelineStateVersion = 0; + Uint m_renderStateParametersVersion = 0; + PixelStoreParameters m_pixelStore[2]{}; // [0] = pack, [1] = unpack + GLenum m_polygonModeFront = 0; + Float m_polygonOffsetFactor = 0.f; + Float m_polygonOffsetUnits = 0.f; + Uint32 m_primitiveRestartIndex = 0; + ProvokingVertexMode m_provokingVertexMode{}; + RenderStateParameters m_renderState{}; + Uint64 m_samplingResolutionGeneration = 0; + Uint64 m_textureBindGeneration = 0; + Uint64 m_textureContextId = 0; + IntVec4 m_scissorBox{}; + StencilFaceState m_stencil[kStencilFaceCount]{}; + Uint64 m_transformFeedbackCapturedVertices = 0; + Uint64 m_transformFeedbackGeneration = 0; + Uint64 m_transformFeedbackPausedPrimitiveCounter = 0; + Uint64 m_boundTransformFeedbackLifetimeId = 0; + IntVec4 m_viewport{}; + FloatVec4 m_viewportIndexed[kMaxViewports]{}; + Bool m_capability[kCapabilityCount]{}; + IndexedCapabilities m_capabilityIndexed{}; + Bool m_transformFeedbackActive = false; + Bool m_transformFeedbackPaused = false; + + // ---- O ---- + SharedPtr m_boundVertexArray; + BindingSlot* m_bufferBindingSlot[kBufferTargetCount]{}; + BindingSlotRange1D* m_bufferBindingPointBase[kBufferTargetCount]{}; + BindingSlot* m_framebufferBindingSlot[kFramebufferTargetCount]{}; + ImageTextureBinding* m_imageTextureBindingBase = nullptr; + SharedPtr m_programForDispatch; + SharedPtr m_programForDraw; + SharedPtr m_transformFeedbackProgram; + TextureUnit* m_textureUnitBase = nullptr; + }; + + // The single global the backends read through MGB_CTX (ARCHITECTURE.md 9.2). An inline + // variable: no .cpp is needed for the definition. + inline PipeInputs gPipeInputs{}; + + // Every field has storage or is forwarded, and nothing else. +#define MGP_INPUT_COUNT_ONE(Field, Member) +1 + static_assert(0 MGP_INPUT_STORAGE_LIST(MGP_INPUT_COUNT_ONE) + kMGPipeForwardedFieldCount == kMGPipeInputFieldCount, + "MGP_INPUT_STORAGE_LIST plus the seven forwarded fields is not the PipeInputs field set"); +#undef MGP_INPUT_COUNT_ONE + // The docs budget ~20 KB; the block is a few KB. + static_assert(sizeof(PipeInputs) < 20 * 1024, "PipeInputs outgrew its budget"); + +#if MOBILEGL_PIPE_VERIFY + // PipeInputs.cpp. Per-field equality for the entry compare (P1 brief D8): V by value + // through G4's MGPipeFieldEqual (bitwise floats, field-wise structs), O by identity, F + // always equal (no storage). + Bool MGPipeInputsFieldEqual(MGPipeInputField field, PipeInputs& a, PipeInputs& b); + // PipeInputs.cpp. Negative control A: perturbs one field's storage (flip a Bool, +1 a + // scalar, ^0x5A the first byte of a struct, null a pointer). Returns false for a forwarded + // field, which has nothing to corrupt. + Bool MGPipeApplyVerifyCorruption(PipeInputs& snapshot, MGPipeInputField field); +#endif +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp new file mode 100644 index 000000000..9c73aec40 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -0,0 +1,100 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/PipeFill.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The client side of the PipeInputs block (ARCHITECTURE.md 9.2 phase A): the only place in +// the push arm that reads MG_State::pGLContext. Holds the per-verb filler, the F-class +// forwarders and IsLive. Compiled only under MOBILEGL_PIPE_PUSH (CMakeLists.txt appends it +// to SOURCE_FILES there). +// +// Contract commit (P1 c1): the filler bumps the verb serial, records the verb and the +// context identity, and stamps the seven sticky fields once; the per-class field copies and +// stamps land in c2, the verify snapshot and comparator in c4. +#include +#include +#include + +namespace MobileGL::MG_Pipe { + namespace { + MG_State::GLState::GLContext* LiveContext() { return MG_State::pGLContext.get(); } + + template + const SharedPtr& NullShared() { + static const SharedPtr null; + return null; + } + } // namespace + + // ---- liveness ---- + Bool PipeInputs::IsLive() const { return LiveContext() != nullptr; } + + // ---- the seven F-class forwarders ---- + SizeT PipeInputs::GetBufferBindingPointCount(BufferTarget target) const { + const auto* ctx = LiveContext(); + return ctx != nullptr ? ctx->GetBufferBindingPointCount(target) : 0; + } + + const SharedPtr& PipeInputs::GetProgramObject(Uint index) { + auto* ctx = LiveContext(); + return ctx != nullptr ? ctx->GetProgramObject(index) : NullShared(); + } + + const SharedPtr& PipeInputs::GetTextureObject(Uint index) { + auto* ctx = LiveContext(); + return ctx != nullptr ? ctx->GetTextureObject(index) : NullShared(); + } + + Bool PipeInputs::HasOpenTransformFeedbackSpan(Uint64 lifetimeId) const { + const auto* ctx = LiveContext(); + return ctx != nullptr && ctx->HasOpenTransformFeedbackSpan(lifetimeId); + } + + void PipeInputs::InvalidateCompileEnv() { + if (auto* ctx = LiveContext()) ctx->InvalidateCompileEnv(); + } + + Bool PipeInputs::ValidateProgramName(Uint index) const { + const auto* ctx = LiveContext(); + return ctx != nullptr && ctx->ValidateProgramName(index); + } + + void PipeInputs::RecordError(ErrorCode code, UniquePtr info) { + auto* ctx = LiveContext(); + if (ctx == nullptr) { + MGLOG_E_ONCE("PipeInputs::RecordError: no live context, dropping error %d", static_cast(code)); + return; + } + ctx->RecordError(code, Move(info)); + } + + // ---- the filler ---- + void MGPipeFillForVerb(MGPipeVerb verb) { + PipeInputs& inputs = gPipeInputs; +#if MOBILEGL_PIPE_POISON + // Starts at 1, so FilledGen == 0 means "never filled". + ++inputs.m_filled.CurrentVerbSerial; +#endif + inputs.m_currentVerb = verb; + auto* ctx = LiveContext(); + if (ctx == nullptr) { + inputs.m_live = false; + inputs.m_contextIdentity = nullptr; + return; + } + inputs.m_live = true; + inputs.m_contextIdentity = ctx; +#if MOBILEGL_PIPE_POISON + // The sticky (forwarded) fields are stamped once by the first fill that sees a live + // context and stay fresh through the Sticky -> FilledGen != 0 branch of + // MGPipeInputFieldIsFresh. + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + if (kMGPipeInputFieldSticky[i] && inputs.m_filled.FilledGen[i] == 0) inputs.m_filled.FilledGen[i] = 1; + } +#endif + // c2: copy and stamp every field in kMGPipeClassFieldMask[kMGPipeVerbClass[verb]]. + } +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.h b/MobileGL/MG_Impl/Pipe/PipeFill.h new file mode 100644 index 000000000..73cea5eb2 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/PipeFill.h @@ -0,0 +1,27 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/PipeFill.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +// The fill point (ARCHITECTURE.md 9.2, P1 brief D7). MG_Impl spells MGP_FILL(Verb); as the +// statement immediately before every call through gBackendFunctionsTable.GL - after every +// early return the call is behind, inside the loop body for a call made in a loop - so the +// frontend fills the PipeInputs block for exactly the verbs that reach a backend. In the +// pull build the macro is ((void)0) and the pull build is byte-identical to a tree without +// it. +#if MOBILEGL_PIPE_PUSH +#include +namespace MobileGL::MG_Pipe { + // PipeFill.cpp. Bumps the per-verb serial, records the verb, and (from c2 on) copies + // every field in the verb class's may-read mask out of the live GLContext, stamping each + // with the new serial. + void MGPipeFillForVerb(MGPipeVerb verb); +} // namespace MobileGL::MG_Pipe +#define MGP_FILL(Verb) ::MobileGL::MG_Pipe::MGPipeFillForVerb(::MobileGL::MG_Pipe::MGPipeVerb::Verb) +#else +#define MGP_FILL(Verb) ((void)0) +#endif diff --git a/MobileGL/MG_Pipe/Coverage.def b/MobileGL/MG_Pipe/Coverage.def index f5ee58e1d..583b5af01 100644 --- a/MobileGL/MG_Pipe/Coverage.def +++ b/MobileGL/MG_Pipe/Coverage.def @@ -31,11 +31,13 @@ X(GetBlendColor, SetDynamicState) \ X(GetBlendEquationIndexed, CreateRenderState) \ X(GetBlendFuncIndexed, CreateRenderState) \ + /* dead: no backend reads it since D21; kept for inventory row 594 */ \ X(GetBoundTransformFeedbackName, SetStreamOutputTargets) \ X(GetBoundVertexArray, BindVertexElements) \ /* Polymorphic over BufferTarget: its rows split across set_vertex_buffers, */ \ - /* set_index_buffer, set_indirect_buffers and set_shader_buffers once the */ \ - /* inventory carries the target argument (P1). Named for the plan's explicit */ \ + /* set_index_buffer, set_indirect_buffers and set_shader_buffers when the */ \ + /* inventory is re-vendored carrying the target argument (deferred out of P1: */ \ + /* the extractor lives in MobileGL-CS). Named for the plan's explicit */ \ /* replacement of the DrawIndirect/Parameter pair. */ \ X(GetBufferBindingSlot, SetIndirectBuffers) \ X(GetBufferBindingPoint, SetShaderBuffers) \ @@ -94,7 +96,30 @@ X(IsTransformFeedbackPaused, PauseStreamOutput) \ X(InvalidateCompileEnv, kClientResolved) \ X(ValidateProgramName, kClientResolved) \ - X(RecordError, kReverseChannel) + X(RecordError, kReverseChannel) \ + /* The D21 XFB counter-slot rekey's reads (VulkanRenderer.cpp); the calls they */ \ + /* map to are GetTransformFeedbackGeneration's. */ \ + X(GetBoundTransformFeedbackLifetimeId, SetStreamOutputTargets) \ + X(HasOpenTransformFeedbackSpan, SetStreamOutputTargets) + +// X(Accessor, Reason) - the STICKY fields (P1 brief D6): the only PipeInputs fields whose +// value is valid across verbs, so the poison's per-verb generation does not apply to them. +// Exactly the seven F-class (forwarded) accessors, and the argument for each is the same: +// it takes an argument that is not verb state - a GL name, a lifetime id, a target - i.e. +// it is a lookup or a reverse-channel write, not a state read; there is no value the +// filler could copy and no verb whose fill could make it stale; phase C replaces them +// with handle tables and callbacks. None of the version/generation accessors is sticky: +// those change under verbs and are precisely what the poison must protect. The verify +// lane's Fatal{UnmigratedPipeInput} is fixed by a FillPoints.def row, never by a row here. +// gen_pipe.py refuses a name that is not an accessor above. +#define MGP_COVERAGE_STICKY_LIST(X) \ + X(GetBufferBindingPointCount, "keyed by target: a constexpr capacity table, not verb state") \ + X(GetProgramObject, "keyed by GL name: an object lookup, not verb state") \ + X(GetTextureObject, "keyed by GL name: an object lookup, not verb state") \ + X(HasOpenTransformFeedbackSpan, "keyed by lifetime id: an object lookup, not verb state") \ + X(ValidateProgramName, "keyed by GL name: a name-table lookup, not verb state") \ + X(InvalidateCompileEnv, "reverse channel: a write into the frontend, not a state read") \ + X(RecordError, "reverse channel: a write into the frontend, not a state read") // X(DeltaKind, PipeCall) - for inventory rows with no accessor in the member column. // Read by gen_pipe.py ONLY, never by the C++ preprocessor: the delta kinds are the diff --git a/MobileGL/MG_Pipe/FillPoints.def b/MobileGL/MG_Pipe/FillPoints.def new file mode 100644 index 000000000..ab15bbea4 --- /dev/null +++ b/MobileGL/MG_Pipe/FillPoints.def @@ -0,0 +1,267 @@ +// MobileGL - MobileGL/MG_Pipe/FillPoints.def +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The per-verb fill points of the PipeInputs strangler (ARCHITECTURE.md 9.2, phase A; the +// P1 brief D7). Three hand-maintained lists, read by scripts/gen_pipe.py (G5b) into +// generated/PipeFillPoints.inc: +// +// MGP_FILL_VERB_LIST every verb the frontend calls through GLFunctionsTable, with its class +// MGP_FILL_CLASS_LIST the verb classes +// MGP_FILL_FIELD_LIST the may-read table: which PipeInputs fields a class of verb may read +// +// The verb set IS the function-pointer member set of MG_Backend::GLFunctionsTable +// (MG_Backend/BackendObject.h), in declaration order: gen_pipe.py parses that struct and +// refuses a row set that is not exactly its member set in that order, so the MGPipeVerb enum +// and the table cannot drift apart. MG_Impl spells MGP_FILL(Verb) immediately before every +// call through the table (83 statements over these 69 verbs); Present and SetSwapInterval go +// through BackendObject virtuals and read no frontend state, so they are not verbs here. +// +// The seven sticky fields (Coverage.def, MGP_COVERAGE_STICKY_LIST) are implicit in every +// class and are not listed. The verify lane is the oracle for this table: a +// Fatal{UnmigratedPipeInput, "Field@Verb"} found there is fixed by adding the (class, field) +// row, never by marking the field sticky. +// +// gen_pipe.py's block regexes end at a blank line: keep the empty line after each macro. +// +// clang-format off + +// X(Verb, Class) - one row per function-pointer member of MG_Backend::GLFunctionsTable (BackendObject.h), +// in declaration order. gen_pipe.py parses that struct and refuses a row set that is not exactly its member set. +#define MGP_FILL_VERB_LIST(X) \ + X(DrawArrays, kDraw) \ + X(DrawElements, kDraw) \ + X(DrawElementsBaseVertex, kDraw) \ + X(MultiDrawArrays, kDraw) \ + X(MultiDrawElements, kDraw) \ + X(MultiDrawElementsBaseVertex, kDraw) \ + X(MultiDrawElementsIndirect, kDraw) \ + X(MultiDrawArraysIndirect, kDraw) \ + X(MultiDrawElementsIndirectCount, kDraw) \ + X(MultiDrawArraysIndirectCount, kDraw) \ + X(DrawRangeElementsBaseVertex, kDraw) \ + X(DrawRangeElements, kDraw) \ + X(DrawElementsInstancedBaseVertexBaseInstance, kDraw) \ + X(DrawElementsInstancedBaseVertex, kDraw) \ + X(DrawElementsInstancedBaseInstance, kDraw) \ + X(DrawElementsInstanced, kDraw) \ + X(DrawArraysInstancedBaseInstance, kDraw) \ + X(DrawArraysInstanced, kDraw) \ + X(DrawElementsIndirect, kDraw) \ + X(DrawArraysIndirect, kDraw) \ + X(Clear, kClear) \ + X(ClearBufferfi, kClear) \ + X(ClearBufferfv, kClear) \ + X(ClearBufferuiv, kClear) \ + X(ClearBufferiv, kClear) \ + X(ClearNamedFramebufferfv, kClear) \ + X(ClearNamedFramebufferfi, kClear) \ + X(ClearNamedFramebufferiv, kClear) \ + X(ClearNamedFramebufferuiv, kClear) \ + X(BlitFramebuffer, kBlitOrCopy) \ + X(BlitNamedFramebuffer, kBlitOrCopy) \ + X(CopyTexImage2D, kBlitOrCopy) \ + X(CopyTexSubImage2D, kBlitOrCopy) \ + X(CopyImageSubData, kBlitOrCopy) \ + X(GenerateMipmap, kTextureOp) \ + X(ReadPixels, kReadback) \ + X(GetTexImage, kReadback) \ + X(GetTextureImage, kReadback) \ + X(DispatchCompute, kDispatch) \ + X(DispatchComputeIndirect, kDispatch) \ + X(MemoryBarrier, kQuery) \ + X(MemoryBarrierByRegion, kQuery) \ + X(BindImageTexture, kTextureOp) \ + X(GetIntegeri_v, kQuery) \ + X(ShaderStorageBlockBinding, kProgramOp) \ + X(FenceSync, kQuery) \ + X(ClientWaitSync, kQuery) \ + X(WaitSync, kQuery) \ + X(DeleteSync, kQuery) \ + X(GetSyncStatus, kQuery) \ + X(IsTimerQuerySupported, kQuery) \ + X(BeginTimeElapsedQuery, kQuery) \ + X(EndTimeElapsedQuery, kQuery) \ + X(QueryCounterTimestamp, kQuery) \ + X(IsQueryResultAvailable, kQuery) \ + X(GetQueryResult64, kQuery) \ + X(DeleteBackendQuery, kQuery) \ + X(BeginOcclusionQuery, kQuery) \ + X(EndOcclusionQuery, kQuery) \ + X(BeginXfbPrimitivesQuery, kQuery) \ + X(EndXfbPrimitivesQuery, kQuery) \ + X(PatchParameteri, kQuery) \ + X(BeginTransformFeedback, kXfbSpan) \ + X(EndTransformFeedback, kXfbSpan) \ + X(PauseTransformFeedback, kXfbSpan) \ + X(ResumeTransformFeedback, kXfbSpan) \ + X(BindTransformFeedback, kXfbSpan) \ + X(DeleteTransformFeedback, kXfbSpan) \ + X(GetGpuTimestampNs, kQuery) + +// X(Class) - the nine verb classes (ARCHITECTURE.md:153 names eight; kProgramOp is split out because +// ShaderStorageBlockBinding is the one non-draw verb that syncs Espryt's render state and textures). +#define MGP_FILL_CLASS_LIST(X) \ + X(kDraw) X(kDispatch) X(kClear) X(kBlitOrCopy) X(kTextureOp) X(kReadback) X(kXfbSpan) X(kProgramOp) X(kQuery) + +// X(Class, Field) - the may-read table. A field named here is filled and stamped at every verb of the class; +// a read of a field NOT named here is Fatal{UnmigratedPipeInput, "Field@Verb"} in a poison build. +// Derived from the verified reachability of every backend read (both backends, union), P1 brief D7. +#define MGP_FILL_FIELD_LIST(X) \ + /* kDraw: every draw entry of both backends */ \ + X(kDraw, GetBoundVertexArray) \ + X(kDraw, GetProgramForDraw) \ + X(kDraw, GetBufferBindingSlot) \ + X(kDraw, GetBufferBindingPoint) \ + X(kDraw, GetTouchedBufferBindingPointCount) \ + X(kDraw, GetTextureUnitObject) \ + X(kDraw, GetTextureContextId) \ + X(kDraw, GetTextureBindGeneration) \ + X(kDraw, GetMaxTouchedTextureUnit) \ + X(kDraw, GetSamplingResolutionGeneration) \ + X(kDraw, GetImageTextureBinding) \ + X(kDraw, GetCurrentVertexAttribute) \ + X(kDraw, GetRenderStateParameters) \ + X(kDraw, GetRenderStateParametersVersion) \ + X(kDraw, GetPipelineStateVersion) \ + X(kDraw, GetViewport) \ + X(kDraw, GetViewportIndexed) \ + X(kDraw, GetDepthRangeIndexed) \ + X(kDraw, GetScissorBox) \ + X(kDraw, IsCapabilityEnabled) \ + X(kDraw, IsCapabilityEnabledIndexed) \ + X(kDraw, GetBlendColor) \ + X(kDraw, GetBlendFuncIndexed) \ + X(kDraw, GetBlendEquationIndexed) \ + X(kDraw, GetColorMaskIndexed) \ + X(kDraw, GetLogicOp) \ + X(kDraw, GetDepthFunc) \ + X(kDraw, GetDepthMask) \ + X(kDraw, GetStencilState) \ + X(kDraw, GetCullFaceMode) \ + X(kDraw, GetPolygonModeFront) \ + X(kDraw, GetPolygonOffsetFactor) \ + X(kDraw, GetPolygonOffsetUnits) \ + X(kDraw, GetLineWidth) \ + X(kDraw, GetMinSampleShadingValue) \ + X(kDraw, GetProvokingVertexMode) \ + X(kDraw, GetPatchVertices) \ + X(kDraw, GetPatchDefaultOuterLevel) \ + X(kDraw, GetPatchDefaultInnerLevel) \ + X(kDraw, GetPrimitiveRestartIndex) \ + X(kDraw, GetFramebufferBindingSlot) \ + X(kDraw, IsTransformFeedbackActive) \ + X(kDraw, IsTransformFeedbackPaused) \ + X(kDraw, GetTransformFeedbackProgram) \ + X(kDraw, GetTransformFeedbackGeneration) \ + X(kDraw, GetBoundTransformFeedbackLifetimeId) \ + X(kDraw, GetTransformFeedbackCapturedVertices) \ + /* kDispatch: the patch fields are Espryt's SyncCurrentProgram -> */ \ + /* AttachPassthroughTessControlStage (Managers.cpp) */ \ + X(kDispatch, GetProgramForDispatch) \ + X(kDispatch, GetBufferBindingSlot) \ + X(kDispatch, GetBufferBindingPoint) \ + X(kDispatch, GetTouchedBufferBindingPointCount) \ + X(kDispatch, GetTextureUnitObject) \ + X(kDispatch, GetTextureContextId) \ + X(kDispatch, GetTextureBindGeneration) \ + X(kDispatch, GetMaxTouchedTextureUnit) \ + X(kDispatch, GetSamplingResolutionGeneration) \ + X(kDispatch, GetImageTextureBinding) \ + X(kDispatch, GetFramebufferBindingSlot) \ + X(kDispatch, GetPatchVertices) \ + X(kDispatch, GetPatchDefaultOuterLevel) \ + X(kDispatch, GetPatchDefaultInnerLevel) \ + /* kClear */ \ + X(kClear, GetRenderStateParameters) \ + X(kClear, GetRenderStateParametersVersion) \ + X(kClear, GetViewport) \ + X(kClear, IsCapabilityEnabled) \ + X(kClear, GetFramebufferBindingSlot) \ + X(kClear, GetClearColor) \ + X(kClear, GetClearDepth) \ + X(kClear, GetClearStencil) \ + X(kClear, GetScissorBox) \ + X(kClear, GetColorMaskIndexed) \ + X(kClear, GetDepthMask) \ + X(kClear, GetStencilState) \ + X(kClear, GetTextureUnitObject) \ + X(kClear, GetTextureContextId) \ + X(kClear, GetSamplingResolutionGeneration) \ + X(kClear, GetTextureBindGeneration) \ + X(kClear, GetMaxTouchedTextureUnit) \ + X(kClear, GetImageTextureBinding) \ + /* kBlitOrCopy */ \ + X(kBlitOrCopy, GetFramebufferBindingSlot) \ + X(kBlitOrCopy, IsCapabilityEnabled) \ + X(kBlitOrCopy, GetScissorBox) \ + X(kBlitOrCopy, IsTransformFeedbackActive) \ + X(kBlitOrCopy, IsTransformFeedbackPaused) \ + X(kBlitOrCopy, GetRenderStateParameters) \ + X(kBlitOrCopy, GetRenderStateParametersVersion) \ + X(kBlitOrCopy, GetViewport) \ + X(kBlitOrCopy, GetActiveTextureUnit) \ + X(kBlitOrCopy, GetTextureUnitObject) \ + X(kBlitOrCopy, GetTextureContextId) \ + X(kBlitOrCopy, GetSamplingResolutionGeneration) \ + X(kBlitOrCopy, GetTextureBindGeneration) \ + X(kBlitOrCopy, GetMaxTouchedTextureUnit) \ + X(kBlitOrCopy, GetImageTextureBinding) \ + X(kBlitOrCopy, GetColorMaskIndexed) \ + X(kBlitOrCopy, GetDepthMask) \ + X(kBlitOrCopy, GetStencilState) \ + /* kTextureOp */ \ + X(kTextureOp, GetActiveTextureUnit) \ + X(kTextureOp, GetTextureUnitObject) \ + X(kTextureOp, GetImageTextureBinding) \ + X(kTextureOp, GetTextureContextId) \ + X(kTextureOp, GetSamplingResolutionGeneration) \ + X(kTextureOp, GetTextureBindGeneration) \ + X(kTextureOp, GetMaxTouchedTextureUnit) \ + /* kReadback */ \ + X(kReadback, GetPixelStoreParameters) \ + X(kReadback, GetBufferBindingSlot) \ + X(kReadback, GetFramebufferBindingSlot) \ + X(kReadback, GetActiveTextureUnit) \ + X(kReadback, GetTextureUnitObject) \ + X(kReadback, GetClampReadColor) \ + X(kReadback, IsCapabilityEnabled) \ + X(kReadback, GetRenderStateParameters) \ + X(kReadback, GetRenderStateParametersVersion) \ + X(kReadback, GetViewport) \ + X(kReadback, GetTextureContextId) \ + X(kReadback, GetSamplingResolutionGeneration) \ + X(kReadback, GetTextureBindGeneration) \ + X(kReadback, GetMaxTouchedTextureUnit) \ + X(kReadback, GetImageTextureBinding) \ + /* kXfbSpan */ \ + X(kXfbSpan, GetTransformFeedbackProgram) \ + X(kXfbSpan, GetBufferBindingPoint) \ + X(kXfbSpan, GetTouchedBufferBindingPointCount) \ + X(kXfbSpan, GetTransformFeedbackCapturedVertices) \ + X(kXfbSpan, IsTransformFeedbackActive) \ + X(kXfbSpan, IsTransformFeedbackPaused) \ + X(kXfbSpan, GetTransformFeedbackGeneration) \ + X(kXfbSpan, GetBoundTransformFeedbackLifetimeId) \ + /* kProgramOp: ShaderStorageBlockBinding syncs Espryt's render state and textures */ \ + X(kProgramOp, GetRenderStateParameters) \ + X(kProgramOp, GetRenderStateParametersVersion) \ + X(kProgramOp, GetViewport) \ + X(kProgramOp, IsCapabilityEnabled) \ + X(kProgramOp, GetFramebufferBindingSlot) \ + X(kProgramOp, GetTextureUnitObject) \ + X(kProgramOp, GetTextureContextId) \ + X(kProgramOp, GetSamplingResolutionGeneration) \ + X(kProgramOp, GetTextureBindGeneration) \ + X(kProgramOp, GetMaxTouchedTextureUnit) \ + X(kProgramOp, GetImageTextureBinding) \ + /* kQuery: Magma's transform feedback query end reads the paused counter */ \ + /* (DirectVulkan.cpp); every other verb in the class reads nothing and */ \ + /* its fill is a serial bump */ \ + X(kQuery, GetTransformFeedbackPausedPrimitiveCounter) + +// clang-format on diff --git a/MobileGL/MG_Pipe/MGPipe.h b/MobileGL/MG_Pipe/MGPipe.h index 85e26d81a..5a1db9df0 100644 --- a/MobileGL/MG_Pipe/MGPipe.h +++ b/MobileGL/MG_Pipe/MGPipe.h @@ -85,6 +85,11 @@ namespace MobileGL::MG_Pipe { // G5: PipeInputs field ids and the per-verb poison generations. #include "generated/PipeFilled.inc" + // G5b: the verb enum (one per GLFunctionsTable entry), the verb classes and their + // may-read field masks - what MGPipeFillForVerb fills and what a poison build lets a + // verb read (FillPoints.def). +#include "generated/PipeFillPoints.inc" + // G6: the backend read inventory's coverage table. #include "generated/PipeCoverage.inc" diff --git a/MobileGL/MG_Pipe/PipeInputsSwitch.h b/MobileGL/MG_Pipe/PipeInputsSwitch.h new file mode 100644 index 000000000..317e9882c --- /dev/null +++ b/MobileGL/MG_Pipe/PipeInputsSwitch.h @@ -0,0 +1,28 @@ +// MobileGL - MobileGL/MG_Pipe/PipeInputsSwitch.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#ifndef MOBILEGL_MG_PIPE_INPUTS_SWITCH_H // belt and braces: reachable as and <..> (CMakeLists.txt:531,535) +#define MOBILEGL_MG_PIPE_INPUTS_SWITCH_H +// The strangler switch (ARCHITECTURE.md 9.2). Every backend read of frontend state is spelled +// MGB_CTX->Accessor(...). Pull arm: the live GLContext, so the pull build is the tree before P1 +// token for token. Push arm: the PipeInputs block the frontend fills at every verb boundary. +// The pull arm is the ONLY place under MobileGL/ outside MG_State and MG_Impl that may spell +// pGLContext; purity gate C greps MG_Backend/ for that token. +#if MOBILEGL_PIPE_PUSH +#include +#define MGB_CTX (&::MobileGL::MG_Pipe::gPipeInputs) +#define MGB_CTX_LIVE (::MobileGL::MG_Pipe::gPipeInputs.IsLive()) +#define MGB_CTX_IDENTITY (::MobileGL::MG_Pipe::gPipeInputs.ContextIdentity()) +#else +#include +#define MGB_CTX (::MobileGL::MG_State::pGLContext) +#define MGB_CTX_LIVE (::MobileGL::MG_State::pGLContext != nullptr) +#define MGB_CTX_IDENTITY (static_cast(::MobileGL::MG_State::pGLContext.get())) +#endif +#endif diff --git a/MobileGL/MG_Pipe/generated/PipeFillPoints.inc b/MobileGL/MG_Pipe/generated/PipeFillPoints.inc new file mode 100644 index 000000000..1afd3e511 --- /dev/null +++ b/MobileGL/MG_Pipe/generated/PipeFillPoints.inc @@ -0,0 +1,300 @@ +// MobileGL - MobileGL/MG_Pipe/generated/PipeFillPoints.inc +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// G5b: the verb enum, the verb classes and their may-read field masks. +// +// GENERATED by scripts/gen_pipe.py from FillPoints.def, Coverage.def and MG_Backend/BackendObject.h - DO NOT EDIT. +// Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result. +// This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe. + +// One verb per function-pointer member of MG_Backend::GLFunctionsTable, in declaration +// order, so the enum IS the table's member list. MG_Impl spells MGP_FILL(Verb) before every +// call through the table; MGPipeFillForVerb fills exactly the fields of the verb's class +// (plus the sticky fields, OR'ed into every mask) and stamps them with the new serial. A +// read of any other field is Fatal{UnmigratedPipeInput, "Field@Verb"} in a poison build. + +enum class MGPipeVerb : Uint8 { + DrawArrays, + DrawElements, + DrawElementsBaseVertex, + MultiDrawArrays, + MultiDrawElements, + MultiDrawElementsBaseVertex, + MultiDrawElementsIndirect, + MultiDrawArraysIndirect, + MultiDrawElementsIndirectCount, + MultiDrawArraysIndirectCount, + DrawRangeElementsBaseVertex, + DrawRangeElements, + DrawElementsInstancedBaseVertexBaseInstance, + DrawElementsInstancedBaseVertex, + DrawElementsInstancedBaseInstance, + DrawElementsInstanced, + DrawArraysInstancedBaseInstance, + DrawArraysInstanced, + DrawElementsIndirect, + DrawArraysIndirect, + Clear, + ClearBufferfi, + ClearBufferfv, + ClearBufferuiv, + ClearBufferiv, + ClearNamedFramebufferfv, + ClearNamedFramebufferfi, + ClearNamedFramebufferiv, + ClearNamedFramebufferuiv, + BlitFramebuffer, + BlitNamedFramebuffer, + CopyTexImage2D, + CopyTexSubImage2D, + CopyImageSubData, + GenerateMipmap, + ReadPixels, + GetTexImage, + GetTextureImage, + DispatchCompute, + DispatchComputeIndirect, + MemoryBarrier, + MemoryBarrierByRegion, + BindImageTexture, + GetIntegeri_v, + ShaderStorageBlockBinding, + FenceSync, + ClientWaitSync, + WaitSync, + DeleteSync, + GetSyncStatus, + IsTimerQuerySupported, + BeginTimeElapsedQuery, + EndTimeElapsedQuery, + QueryCounterTimestamp, + IsQueryResultAvailable, + GetQueryResult64, + DeleteBackendQuery, + BeginOcclusionQuery, + EndOcclusionQuery, + BeginXfbPrimitivesQuery, + EndXfbPrimitivesQuery, + PatchParameteri, + BeginTransformFeedback, + EndTransformFeedback, + PauseTransformFeedback, + ResumeTransformFeedback, + BindTransformFeedback, + DeleteTransformFeedback, + GetGpuTimestampNs, + kVerbCount, +}; + +inline constexpr SizeT kMGPipeVerbCount = static_cast(MGPipeVerb::kVerbCount); +static_assert(kMGPipeVerbCount == 69, "the GLFunctionsTable verb set moved"); + +inline constexpr const char* kMGPipeVerbNames[kMGPipeVerbCount] = { + "DrawArrays", + "DrawElements", + "DrawElementsBaseVertex", + "MultiDrawArrays", + "MultiDrawElements", + "MultiDrawElementsBaseVertex", + "MultiDrawElementsIndirect", + "MultiDrawArraysIndirect", + "MultiDrawElementsIndirectCount", + "MultiDrawArraysIndirectCount", + "DrawRangeElementsBaseVertex", + "DrawRangeElements", + "DrawElementsInstancedBaseVertexBaseInstance", + "DrawElementsInstancedBaseVertex", + "DrawElementsInstancedBaseInstance", + "DrawElementsInstanced", + "DrawArraysInstancedBaseInstance", + "DrawArraysInstanced", + "DrawElementsIndirect", + "DrawArraysIndirect", + "Clear", + "ClearBufferfi", + "ClearBufferfv", + "ClearBufferuiv", + "ClearBufferiv", + "ClearNamedFramebufferfv", + "ClearNamedFramebufferfi", + "ClearNamedFramebufferiv", + "ClearNamedFramebufferuiv", + "BlitFramebuffer", + "BlitNamedFramebuffer", + "CopyTexImage2D", + "CopyTexSubImage2D", + "CopyImageSubData", + "GenerateMipmap", + "ReadPixels", + "GetTexImage", + "GetTextureImage", + "DispatchCompute", + "DispatchComputeIndirect", + "MemoryBarrier", + "MemoryBarrierByRegion", + "BindImageTexture", + "GetIntegeri_v", + "ShaderStorageBlockBinding", + "FenceSync", + "ClientWaitSync", + "WaitSync", + "DeleteSync", + "GetSyncStatus", + "IsTimerQuerySupported", + "BeginTimeElapsedQuery", + "EndTimeElapsedQuery", + "QueryCounterTimestamp", + "IsQueryResultAvailable", + "GetQueryResult64", + "DeleteBackendQuery", + "BeginOcclusionQuery", + "EndOcclusionQuery", + "BeginXfbPrimitivesQuery", + "EndXfbPrimitivesQuery", + "PatchParameteri", + "BeginTransformFeedback", + "EndTransformFeedback", + "PauseTransformFeedback", + "ResumeTransformFeedback", + "BindTransformFeedback", + "DeleteTransformFeedback", + "GetGpuTimestampNs", +}; + +enum class MGPipeVerbClass : Uint8 { + kDraw, + kDispatch, + kClear, + kBlitOrCopy, + kTextureOp, + kReadback, + kXfbSpan, + kProgramOp, + kQuery, + kClassCount, +}; + +inline constexpr SizeT kMGPipeVerbClassCount = static_cast(MGPipeVerbClass::kClassCount); +static_assert(kMGPipeVerbClassCount == 9, "the verb class set moved"); + +inline constexpr const char* kMGPipeVerbClassNames[kMGPipeVerbClassCount] = { + "kDraw", + "kDispatch", + "kClear", + "kBlitOrCopy", + "kTextureOp", + "kReadback", + "kXfbSpan", + "kProgramOp", + "kQuery", +}; + +inline constexpr MGPipeVerbClass kMGPipeVerbClass[kMGPipeVerbCount] = { + MGPipeVerbClass::kDraw, // DrawArrays + MGPipeVerbClass::kDraw, // DrawElements + MGPipeVerbClass::kDraw, // DrawElementsBaseVertex + MGPipeVerbClass::kDraw, // MultiDrawArrays + MGPipeVerbClass::kDraw, // MultiDrawElements + MGPipeVerbClass::kDraw, // MultiDrawElementsBaseVertex + MGPipeVerbClass::kDraw, // MultiDrawElementsIndirect + MGPipeVerbClass::kDraw, // MultiDrawArraysIndirect + MGPipeVerbClass::kDraw, // MultiDrawElementsIndirectCount + MGPipeVerbClass::kDraw, // MultiDrawArraysIndirectCount + MGPipeVerbClass::kDraw, // DrawRangeElementsBaseVertex + MGPipeVerbClass::kDraw, // DrawRangeElements + MGPipeVerbClass::kDraw, // DrawElementsInstancedBaseVertexBaseInstance + MGPipeVerbClass::kDraw, // DrawElementsInstancedBaseVertex + MGPipeVerbClass::kDraw, // DrawElementsInstancedBaseInstance + MGPipeVerbClass::kDraw, // DrawElementsInstanced + MGPipeVerbClass::kDraw, // DrawArraysInstancedBaseInstance + MGPipeVerbClass::kDraw, // DrawArraysInstanced + MGPipeVerbClass::kDraw, // DrawElementsIndirect + MGPipeVerbClass::kDraw, // DrawArraysIndirect + MGPipeVerbClass::kClear, // Clear + MGPipeVerbClass::kClear, // ClearBufferfi + MGPipeVerbClass::kClear, // ClearBufferfv + MGPipeVerbClass::kClear, // ClearBufferuiv + MGPipeVerbClass::kClear, // ClearBufferiv + MGPipeVerbClass::kClear, // ClearNamedFramebufferfv + MGPipeVerbClass::kClear, // ClearNamedFramebufferfi + MGPipeVerbClass::kClear, // ClearNamedFramebufferiv + MGPipeVerbClass::kClear, // ClearNamedFramebufferuiv + MGPipeVerbClass::kBlitOrCopy, // BlitFramebuffer + MGPipeVerbClass::kBlitOrCopy, // BlitNamedFramebuffer + MGPipeVerbClass::kBlitOrCopy, // CopyTexImage2D + MGPipeVerbClass::kBlitOrCopy, // CopyTexSubImage2D + MGPipeVerbClass::kBlitOrCopy, // CopyImageSubData + MGPipeVerbClass::kTextureOp, // GenerateMipmap + MGPipeVerbClass::kReadback, // ReadPixels + MGPipeVerbClass::kReadback, // GetTexImage + MGPipeVerbClass::kReadback, // GetTextureImage + MGPipeVerbClass::kDispatch, // DispatchCompute + MGPipeVerbClass::kDispatch, // DispatchComputeIndirect + MGPipeVerbClass::kQuery, // MemoryBarrier + MGPipeVerbClass::kQuery, // MemoryBarrierByRegion + MGPipeVerbClass::kTextureOp, // BindImageTexture + MGPipeVerbClass::kQuery, // GetIntegeri_v + MGPipeVerbClass::kProgramOp, // ShaderStorageBlockBinding + MGPipeVerbClass::kQuery, // FenceSync + MGPipeVerbClass::kQuery, // ClientWaitSync + MGPipeVerbClass::kQuery, // WaitSync + MGPipeVerbClass::kQuery, // DeleteSync + MGPipeVerbClass::kQuery, // GetSyncStatus + MGPipeVerbClass::kQuery, // IsTimerQuerySupported + MGPipeVerbClass::kQuery, // BeginTimeElapsedQuery + MGPipeVerbClass::kQuery, // EndTimeElapsedQuery + MGPipeVerbClass::kQuery, // QueryCounterTimestamp + MGPipeVerbClass::kQuery, // IsQueryResultAvailable + MGPipeVerbClass::kQuery, // GetQueryResult64 + MGPipeVerbClass::kQuery, // DeleteBackendQuery + MGPipeVerbClass::kQuery, // BeginOcclusionQuery + MGPipeVerbClass::kQuery, // EndOcclusionQuery + MGPipeVerbClass::kQuery, // BeginXfbPrimitivesQuery + MGPipeVerbClass::kQuery, // EndXfbPrimitivesQuery + MGPipeVerbClass::kQuery, // PatchParameteri + MGPipeVerbClass::kXfbSpan, // BeginTransformFeedback + MGPipeVerbClass::kXfbSpan, // EndTransformFeedback + MGPipeVerbClass::kXfbSpan, // PauseTransformFeedback + MGPipeVerbClass::kXfbSpan, // ResumeTransformFeedback + MGPipeVerbClass::kXfbSpan, // BindTransformFeedback + MGPipeVerbClass::kXfbSpan, // DeleteTransformFeedback + MGPipeVerbClass::kQuery, // GetGpuTimestampNs +}; + +// One bit per MGPipeInputField. The 7 sticky fields are OR'ed into every class. +struct MGPipeFieldMask { + Uint64 Words[2]; +}; + +inline constexpr Bool MGPipeFieldMaskHas(const MGPipeFieldMask& mask, MGPipeInputField field) { + const SizeT index = static_cast(field); + return (mask.Words[index / 64] >> (index % 64)) & 1u; +} + +inline constexpr MGPipeFieldMask kMGPipeClassFieldMask[kMGPipeVerbClassCount] = { + // kDraw: 54 fields (47 own + 7 sticky) + {{0x7ffbfff7bfffc3eeull, 0x0000000000000000ull}}, + // kDispatch: 21 fields (14 own + 7 sticky) + {{0x5c00f2281d3003c0ull, 0x0000000000000000ull}}, + // kClear: 25 fields (18 own + 7 sticky) + {{0x5c50ffa001347900ull, 0x0000000000000000ull}}, + // kBlitOrCopy: 25 fields (18 own + 7 sticky) + {{0x5f50ffa001344101ull, 0x0000000000000000ull}}, + // kTextureOp: 14 fields (7 own + 7 sticky) + {{0x5c00f22001200101ull, 0x0000000000000000ull}}, + // kReadback: 22 fields (15 own + 7 sticky) + {{0x5c50f3a041300541ull, 0x0000000000000000ull}}, + // kXfbSpan: 15 fields (8 own + 7 sticky) + {{0x7f0b402000000380ull, 0x0000000000000000ull}}, + // kProgramOp: 18 fields (11 own + 7 sticky) + {{0x5c50f3a001300100ull, 0x0000000000000000ull}}, + // kQuery: 8 fields (1 own + 7 sticky) + {{0x5c04402000000100ull, 0x0000000000000000ull}}, +}; + +static_assert(kMGPipeInputFieldCount <= 2 * 64, "MGPipeFieldMask needs another word"); diff --git a/MobileGL/MG_Pipe/generated/PipeFilled.inc b/MobileGL/MG_Pipe/generated/PipeFilled.inc index 7dec6e867..91e82514b 100644 --- a/MobileGL/MG_Pipe/generated/PipeFilled.inc +++ b/MobileGL/MG_Pipe/generated/PipeFilled.inc @@ -22,8 +22,8 @@ // field stamps it with that serial, and reading a non-sticky field whose stamp is older is // Fatal{UnmigratedPipeInput} (section 6.2.2). // -// P0 is the skeleton: the enum, the tables and the assertion helper exist, PipeInputs -// itself lands in P1. +// PipeInputs itself is MG_Backend/MGPipe/PipeInputs.h (P1); the verb enum and the +// per-class fill masks are G5b, generated/PipeFillPoints.inc. enum class MGPipeInputField : Uint16 { GetActiveTextureUnit, @@ -87,11 +87,13 @@ enum class MGPipeInputField : Uint16 { InvalidateCompileEnv, ValidateProgramName, RecordError, + GetBoundTransformFeedbackLifetimeId, + HasOpenTransformFeedbackSpan, kFieldCount, }; inline constexpr SizeT kMGPipeInputFieldCount = static_cast(MGPipeInputField::kFieldCount); -static_assert(kMGPipeInputFieldCount == 61, "the PipeInputs field set moved"); +static_assert(kMGPipeInputFieldCount == 63, "the PipeInputs field set moved"); inline constexpr const char* kMGPipeInputFieldNames[kMGPipeInputFieldCount] = { "GetActiveTextureUnit", @@ -155,11 +157,13 @@ inline constexpr const char* kMGPipeInputFieldNames[kMGPipeInputFieldCount] = { "InvalidateCompileEnv", "ValidateProgramName", "RecordError", + "GetBoundTransformFeedbackLifetimeId", + "HasOpenTransformFeedbackSpan", }; -// Fields whose value is valid ACROSS verbs. Every entry is false in P0 and each -// true has to be argued for in P1 when the fillers land: a sticky field is a field -// the poison cannot protect. +// Fields whose value is valid ACROSS verbs: a sticky field is a field the poison +// cannot protect, so every true is argued for in Coverage.def's +// MGP_COVERAGE_STICKY_LIST (the seven forwarded, argument-keyed accessors). inline constexpr Bool kMGPipeInputFieldSticky[kMGPipeInputFieldCount] = { false, // GetActiveTextureUnit false, // GetBlendColor @@ -169,7 +173,7 @@ inline constexpr Bool kMGPipeInputFieldSticky[kMGPipeInputFieldCount] = { false, // GetBoundVertexArray false, // GetBufferBindingSlot false, // GetBufferBindingPoint - false, // GetBufferBindingPointCount + true, // GetBufferBindingPointCount: keyed by target: a constexpr capacity table, not verb state false, // GetTouchedBufferBindingPointCount false, // GetClampReadColor false, // GetClearColor @@ -198,7 +202,7 @@ inline constexpr Bool kMGPipeInputFieldSticky[kMGPipeInputFieldCount] = { false, // GetPrimitiveRestartIndex false, // GetProgramForDispatch false, // GetProgramForDraw - false, // GetProgramObject + true, // GetProgramObject: keyed by GL name: an object lookup, not verb state false, // GetProvokingVertexMode false, // GetRenderStateParameters false, // GetRenderStateParametersVersion @@ -207,7 +211,7 @@ inline constexpr Bool kMGPipeInputFieldSticky[kMGPipeInputFieldCount] = { false, // GetStencilState false, // GetTextureBindGeneration false, // GetTextureContextId - false, // GetTextureObject + true, // GetTextureObject: keyed by GL name: an object lookup, not verb state false, // GetTextureUnitObject false, // GetTransformFeedbackCapturedVertices false, // GetTransformFeedbackGeneration @@ -219,10 +223,13 @@ inline constexpr Bool kMGPipeInputFieldSticky[kMGPipeInputFieldCount] = { false, // IsCapabilityEnabledIndexed false, // IsTransformFeedbackActive false, // IsTransformFeedbackPaused - false, // InvalidateCompileEnv - false, // ValidateProgramName - false, // RecordError + true, // InvalidateCompileEnv: reverse channel: a write into the frontend, not a state read + true, // ValidateProgramName: keyed by GL name: a name-table lookup, not verb state + true, // RecordError: reverse channel: a write into the frontend, not a state read + false, // GetBoundTransformFeedbackLifetimeId + true, // HasOpenTransformFeedbackSpan: keyed by lifetime id: an object lookup, not verb state }; +inline constexpr SizeT kMGPipeInputStickyFieldCount = 7; // Which call is expected to have filled a field by the time a verb reads it. Names // come from Coverage.def, so this table and the coverage table cannot disagree. @@ -288,6 +295,8 @@ inline constexpr const char* kMGPipeInputFieldFilledBy[kMGPipeInputFieldCount] = "kClientResolved", // pseudo-call: not filled by a forward record "kClientResolved", // pseudo-call: not filled by a forward record "kReverseChannel", // pseudo-call: not filled by a forward record + "SetStreamOutputTargets", + "SetStreamOutputTargets", }; struct MGPipeFilledState { diff --git a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp index 5ce193a47..42578b3af 100644 --- a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp +++ b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp @@ -222,7 +222,7 @@ TEST(PipeCatalogue, CoverageAccountsForEveryInventoryRow) { // G5's field ids come from the same accessor list as the coverage table, and every field // starts un-filled: reading one before its verb fills it is the poison's whole job. TEST(PipeCatalogue, PipeInputFieldsStartUnfilled) { - EXPECT_EQ(kMGPipeInputFieldCount, 61u); + EXPECT_EQ(kMGPipeInputFieldCount, 63u); MGPipeFilledState state{}; state.CurrentVerbSerial = 1; EXPECT_FALSE(MGPipeInputFieldIsFresh(state, MGPipeInputField::GetRenderStateParameters)); diff --git a/scripts/gen_pipe.py b/scripts/gen_pipe.py index 795885934..2a644d804 100644 --- a/scripts/gen_pipe.py +++ b/scripts/gen_pipe.py @@ -8,11 +8,14 @@ # End of Source File Header """The seven MGPipe generators, G1..G7 (plan B section 4.1). -Reads the three hand-maintained sources of truth +Reads the four hand-maintained sources of truth MobileGL/MG_Pipe/PipeCalls.def the call catalogue MobileGL/MG_Pipe/PipeFields.def per-payload field lists for the verify comparator - MobileGL/MG_Pipe/Coverage.def accessor -> call mapping for the read inventory + MobileGL/MG_Pipe/Coverage.def accessor -> call mapping for the read inventory, + and the sticky-field list + MobileGL/MG_Pipe/FillPoints.def verb -> class and class -> may-read field tables + (G5b), checked against MG_Backend::GLFunctionsTable plus the vendored copy of the backend read inventory @@ -38,6 +41,7 @@ PIPE_DIR = os.path.join(REPO_ROOT, "MobileGL", "MG_Pipe") GENERATED_DIR = os.path.join(PIPE_DIR, "generated") INVENTORY = os.path.join(REPO_ROOT, "scripts", "data", "backend_read_inventory.md") +FUNCTION_TABLE_HEADER = os.path.join(REPO_ROOT, "MobileGL", "MG_Backend", "BackendObject.h") GENERATED_BANNER = """// MobileGL - MobileGL/MG_Pipe/generated/{name} // Copyright (c) 2025-2026 MobileGL-Dev @@ -209,7 +213,18 @@ def parse_coverage(): sys.exit("Coverage.def: MGP_COVERAGE_DELTA_LIST is missing") for kind, call in re.findall(r"X\(([^,]+),\s*(\w+)\)", block.group(1)): deltas.append((kind.strip(), call)) - return accessors, deltas + sticky = [] + block = re.search(r"#define MGP_COVERAGE_STICKY_LIST\(X\)(.*?)\n\n", text, re.S) + if not block: + sys.exit("Coverage.def: MGP_COVERAGE_STICKY_LIST is missing") + accessor_names = set(name for name, _ in accessors) + for name, reason in re.findall(r"X\((\w+)\s*,\s*\"([^\"]*)\"\)", block.group(1)): + if name not in accessor_names: + sys.exit("Coverage.def: sticky field %s is not an accessor in MGP_COVERAGE_ACCESSOR_LIST" % name) + if name in dict(sticky): + sys.exit("Coverage.def: sticky field %s is listed twice" % name) + sticky.append((name, reason)) + return accessors, deltas, sticky INVENTORY_ROW_RE = re.compile(r"^\|\s*(\d+)\s*\|([^|]*)\|([^|]*)\|([^|]*)\|") @@ -444,8 +459,9 @@ def gen_verify(payloads): return "\n".join(out) + "\n" -def gen_filled(accessors, calls): +def gen_filled(accessors, calls, sticky): call_names = set(c.Name for c in calls) + sticky_map = dict(sticky) out = [banner("PipeFilled.inc", "G5: PipeInputs field ids and the per-verb poison generations.", "Coverage.def and PipeCalls.def")] out.append("""// One field id per GLContext accessor the backends actually read (plan B section 6.2: @@ -458,8 +474,8 @@ def gen_filled(accessors, calls): // field stamps it with that serial, and reading a non-sticky field whose stamp is older is // Fatal{UnmigratedPipeInput} (section 6.2.2). // -// P0 is the skeleton: the enum, the tables and the assertion helper exist, PipeInputs -// itself lands in P1. +// PipeInputs itself is MG_Backend/MGPipe/PipeInputs.h (P1); the verb enum and the +// per-class fill masks are G5b, generated/PipeFillPoints.inc. """) out.append("enum class MGPipeInputField : Uint16 {") for name, _ in accessors: @@ -475,13 +491,17 @@ def gen_filled(accessors, calls): out.append(" \"%s\"," % name) out.append("};") out.append("") - out.append("// Fields whose value is valid ACROSS verbs. Every entry is false in P0 and each") - out.append("// true has to be argued for in P1 when the fillers land: a sticky field is a field") - out.append("// the poison cannot protect.") + out.append("// Fields whose value is valid ACROSS verbs: a sticky field is a field the poison") + out.append("// cannot protect, so every true is argued for in Coverage.def's") + out.append("// MGP_COVERAGE_STICKY_LIST (the seven forwarded, argument-keyed accessors).") out.append("inline constexpr Bool kMGPipeInputFieldSticky[kMGPipeInputFieldCount] = {") for name, _ in accessors: - out.append(" false, // %s" % name) + if name in sticky_map: + out.append(" true, // %s: %s" % (name, sticky_map[name])) + else: + out.append(" false, // %s" % name) out.append("};") + out.append("inline constexpr SizeT kMGPipeInputStickyFieldCount = %d;" % len(sticky)) out.append("") out.append("// Which call is expected to have filled a field by the time a verb reads it. Names") out.append("// come from Coverage.def, so this table and the coverage table cannot disagree.") @@ -510,6 +530,153 @@ def gen_filled(accessors, calls): return "\n".join(out) + "\n" +FUNCTION_POINTER_MEMBER_RE = re.compile(r"\(\s*\*\s*(\w+)\s*\)\s*\(") + + +def parse_function_table(): + """The function-pointer members of MG_Backend::GLFunctionsTable, in declaration order. + Data members (PrefersCpuXfbPrimitiveAccounting) are not verbs and are skipped; comments + are masked line by line.""" + text = read(FUNCTION_TABLE_HEADER) + start = text.find("struct GLFunctionsTable {") + if start < 0: + sys.exit("%s: struct GLFunctionsTable is missing" % FUNCTION_TABLE_HEADER) + members = [] + for line in text[start:].splitlines()[1:]: + if re.match(r"^\s*};", line): + break + code = line.split("//", 1)[0] + for name in FUNCTION_POINTER_MEMBER_RE.findall(code): + members.append(name) + if not members: + sys.exit("%s: GLFunctionsTable has no function-pointer members" % FUNCTION_TABLE_HEADER) + return members + + +def parse_fill_points(accessors, table_members=None): + """FillPoints.def -> (verbs, classes, fields): verbs is [(verb, class)] in file order, + classes is [class], fields is {class: [field]}. Refuses a verb set that is not exactly + GLFunctionsTable's function-pointer members in declaration order, a verb in two + classes, a class with no verbs, a field that is not an accessor, a duplicate + (class, field) row, and a class row that names no class.""" + text = read(os.path.join(PIPE_DIR, "FillPoints.def")) + if table_members is None: + table_members = parse_function_table() + + def block(macro): + match = re.search(r"#define %s\(X\)(.*?)\n\n" % macro, text, re.S) + if not match: + sys.exit("FillPoints.def: %s is missing (or not followed by a blank line)" % macro) + return match.group(1) + + classes = re.findall(r"X\((\w+)\)", block("MGP_FILL_CLASS_LIST")) + if len(classes) != len(set(classes)): + sys.exit("FillPoints.def: a class is listed twice in MGP_FILL_CLASS_LIST") + verbs = re.findall(r"X\((\w+)\s*,\s*(\w+)\)", block("MGP_FILL_VERB_LIST")) + seen = set() + for verb, cls in verbs: + if verb in seen: + sys.exit("FillPoints.def: verb %s is in two classes" % verb) + seen.add(verb) + if cls not in classes: + sys.exit("FillPoints.def: verb %s names unknown class %s" % (verb, cls)) + verb_names = [verb for verb, _ in verbs] + missing = [m for m in table_members if m not in seen] + if missing: + sys.exit("FillPoints.def: GLFunctionsTable member(s) without a verb row: %s" % ", ".join(missing)) + extra = [v for v in verb_names if v not in table_members] + if extra: + sys.exit("FillPoints.def: verb(s) that are not GLFunctionsTable members: %s" % ", ".join(extra)) + if verb_names != table_members: + sys.exit("FillPoints.def: verb rows are not in GLFunctionsTable declaration order " + "(first difference at %s)" % next(a for a, b in zip(verb_names, table_members) if a != b)) + for cls in classes: + if not any(c == cls for _, c in verbs): + sys.exit("FillPoints.def: class %s has no verbs" % cls) + accessor_names = set(name for name, _ in accessors) + fields = {cls: [] for cls in classes} + for cls, field in re.findall(r"X\((\w+)\s*,\s*(\w+)\)", block("MGP_FILL_FIELD_LIST")): + if cls not in fields: + sys.exit("FillPoints.def: field row names unknown class %s" % cls) + if field not in accessor_names: + sys.exit("FillPoints.def: %s is not an accessor in Coverage.def" % field) + if field in fields[cls]: + sys.exit("FillPoints.def: duplicate row (%s, %s)" % (cls, field)) + fields[cls].append(field) + return verbs, classes, fields + + +def gen_fill_points(accessors, sticky, verbs, classes, fields): + field_index = {name: i for i, (name, _) in enumerate(accessors)} + # Two words minimum (the P1 contract shape, headroom for the 64th field); grows on demand. + words = max(2, (len(accessors) + 63) // 64) + sticky_names = [name for name, _ in sticky] + out = [banner("PipeFillPoints.inc", "G5b: the verb enum, the verb classes and their may-read field masks.", + "FillPoints.def, Coverage.def and MG_Backend/BackendObject.h")] + out.append("""// One verb per function-pointer member of MG_Backend::GLFunctionsTable, in declaration +// order, so the enum IS the table's member list. MG_Impl spells MGP_FILL(Verb) before every +// call through the table; MGPipeFillForVerb fills exactly the fields of the verb's class +// (plus the sticky fields, OR'ed into every mask) and stamps them with the new serial. A +// read of any other field is Fatal{UnmigratedPipeInput, \"Field@Verb\"} in a poison build. +""") + out.append("enum class MGPipeVerb : Uint8 {") + for verb, _ in verbs: + out.append(" %s," % verb) + out.append(" kVerbCount,") + out.append("};") + out.append("") + out.append("inline constexpr SizeT kMGPipeVerbCount = static_cast(MGPipeVerb::kVerbCount);") + out.append("static_assert(kMGPipeVerbCount == %d, \"the GLFunctionsTable verb set moved\");" % len(verbs)) + out.append("") + out.append("inline constexpr const char* kMGPipeVerbNames[kMGPipeVerbCount] = {") + for verb, _ in verbs: + out.append(" \"%s\"," % verb) + out.append("};") + out.append("") + out.append("enum class MGPipeVerbClass : Uint8 {") + for cls in classes: + out.append(" %s," % cls) + out.append(" kClassCount,") + out.append("};") + out.append("") + out.append("inline constexpr SizeT kMGPipeVerbClassCount = static_cast(MGPipeVerbClass::kClassCount);") + out.append("static_assert(kMGPipeVerbClassCount == %d, \"the verb class set moved\");" % len(classes)) + out.append("") + out.append("inline constexpr const char* kMGPipeVerbClassNames[kMGPipeVerbClassCount] = {") + for cls in classes: + out.append(" \"%s\"," % cls) + out.append("};") + out.append("") + out.append("inline constexpr MGPipeVerbClass kMGPipeVerbClass[kMGPipeVerbCount] = {") + for verb, cls in verbs: + out.append(" MGPipeVerbClass::%s, // %s" % (cls, verb)) + out.append("};") + out.append("") + out.append("// One bit per MGPipeInputField. The %d sticky fields are OR'ed into every class." % len(sticky)) + out.append("struct MGPipeFieldMask {") + out.append(" Uint64 Words[%d];" % words) + out.append("};") + out.append("") + out.append("inline constexpr Bool MGPipeFieldMaskHas(const MGPipeFieldMask& mask, MGPipeInputField field) {") + out.append(" const SizeT index = static_cast(field);") + out.append(" return (mask.Words[index / 64] >> (index % 64)) & 1u;") + out.append("}") + out.append("") + out.append("inline constexpr MGPipeFieldMask kMGPipeClassFieldMask[kMGPipeVerbClassCount] = {") + for cls in classes: + bits = [0] * words + names = fields[cls] + [n for n in sticky_names if n not in fields[cls]] + for name in names: + index = field_index[name] + bits[index // 64] |= 1 << (index % 64) + out.append(" // %s: %d fields (%d own + %d sticky)" % (cls, len(names), len(fields[cls]), len(names) - len(fields[cls]))) + out.append(" {{%s}}," % ", ".join("0x%016xull" % b for b in bits)) + out.append("};") + out.append("") + out.append("static_assert(kMGPipeInputFieldCount <= %d * 64, \"MGPipeFieldMask needs another word\");" % words) + return "\n".join(out) + "\n" + + def gen_coverage(accessors, deltas, rows, calls): call_names = set(c.Name for c in calls) pseudo = {"kClientResolved", "kReverseChannel", "kStructuralHandle"} @@ -635,7 +802,8 @@ def main(): calls = parse_calls() payloads = parse_verify_payloads() check_call_payloads_have_field_lists(calls, payloads) - accessors, deltas = parse_coverage() + accessors, deltas, sticky = parse_coverage() + verbs, classes, fields = parse_fill_points(accessors) rows = parse_inventory() if not os.path.isdir(GENERATED_DIR): @@ -647,13 +815,17 @@ def main(): write(os.path.join(GENERATED_DIR, "PipeThunks.inc"), gen_thunks(calls), args.check, changed) write(os.path.join(GENERATED_DIR, "PipeWire.inc"), gen_wire(calls), args.check, changed) write(os.path.join(GENERATED_DIR, "PipeVerify.inc"), gen_verify(payloads), args.check, changed) - write(os.path.join(GENERATED_DIR, "PipeFilled.inc"), gen_filled(accessors, calls), args.check, changed) + write(os.path.join(GENERATED_DIR, "PipeFilled.inc"), gen_filled(accessors, calls, sticky), args.check, changed) + write(os.path.join(GENERATED_DIR, "PipeFillPoints.inc"), + gen_fill_points(accessors, sticky, verbs, classes, fields), args.check, changed) write(os.path.join(GENERATED_DIR, "PipeCoverage.inc"), coverage_text, args.check, changed) write(os.path.join(GENERATED_DIR, "PipeSpanTable.inc"), gen_span_table(), args.check, changed) screen = sum(1 for c in calls if c.IsScreen) - print("gen_pipe: %d calls (%d screen, %d context), %d verify payloads, %d PipeInputs fields" - % (len(calls), screen, len(calls) - screen, len(payloads), len(accessors))) + print("gen_pipe: %d calls (%d screen, %d context), %d verify payloads, %d PipeInputs fields " + "(%d sticky), %d verbs, %d classes" + % (len(calls), screen, len(calls) - screen, len(payloads), len(accessors), len(sticky), + len(verbs), len(classes))) print("gen_pipe: inventory %d rows: %d -> call, %d client-resolved, %d reverse-channel, " "%d structural handle, %d UNMAPPED" % (len(rows), mapped, pseudo["kClientResolved"], pseudo["kReverseChannel"], From 3aa4d8af1ff2ca6f95c9ab3e047e70bc0e0cf65f Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 01:48:23 -0400 Subject: [PATCH 043/529] [Feat] (Pipe): fill PipeInputs per verb class from GLContext and stamp per-verb generations - a read of a field the verb did not fill is Fatal{UnmigratedPipeInput} - MGPipeFillForVerb now walks kMGPipeClassFieldMask[kMGPipeVerbClass[verb]] and copies every field in it by calling the GLContext accessor of the same name (MGPipeFillAccess::CopyField, one switch over the 56 stored fields; the seven forwarded fields copy nothing), stamping each with the new serial; the sticky seven get FilledGen = 1 on the first live fill through the same mask walk, since every class mask carries them. - MOBILEGL_PIPE_POISON_OMIT (:) is parsed once on the first fill and MGPipeSetPoisonOmission is the programmatic form for the unit tests; the omitted pair keeps its value copy and loses only its stamp, so the omission is indistinguishable from a forgotten FillPoints.def row. An unknown name is Fatal{PipeVerifyBadKnob}; a non-poison push build acknowledges the knob with one warning because no stamp exists to omit. - PipeInputs names one friend, struct MGPipeFillAccess, instead of two friend functions, and VisitStorage gains a const overload for the comparator that follows. --- MobileGL/MG_Backend/MGPipe/PipeInputs.h | 17 +- MobileGL/MG_Impl/Pipe/PipeFill.cpp | 365 ++++++++++++++++++++++-- MobileGL/MG_Impl/Pipe/PipeFill.h | 15 +- 3 files changed, 369 insertions(+), 28 deletions(-) diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.h b/MobileGL/MG_Backend/MGPipe/PipeInputs.h index ab21f4f28..67b048c55 100644 --- a/MobileGL/MG_Backend/MGPipe/PipeInputs.h +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.h @@ -567,6 +567,18 @@ namespace MobileGL::MG_Pipe { case MGPipeInputField::Field: \ return fn(a.Member, b.Member); MGP_INPUT_STORAGE_LIST(MGP_INPUT_VISIT) +#undef MGP_INPUT_VISIT + default: + return false; + } + } + template + static Bool VisitStorage(MGPipeInputField field, const PipeInputs& a, const PipeInputs& b, Fn&& fn) { + switch (field) { +#define MGP_INPUT_VISIT(Field, Member) \ + case MGPipeInputField::Field: \ + return fn(a.Member, b.Member); + MGP_INPUT_STORAGE_LIST(MGP_INPUT_VISIT) #undef MGP_INPUT_VISIT default: return false; @@ -574,8 +586,9 @@ namespace MobileGL::MG_Pipe { } private: - friend void MGPipeFillForVerb(MGPipeVerb verb); - friend void SnapshotFromGLContext(PipeInputs& snapshot, const MGPipeFieldMask& mask); + // The one door into the storage from the client side (MG_Impl/Pipe/PipeFill.cpp): + // the filler's per-field copies and stamps, and the verify snapshot. + friend struct MGPipeFillAccess; // ---- identity ---- const void* m_contextIdentity = nullptr; diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 9c73aec40..750239f8e 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -8,27 +8,338 @@ // The client side of the PipeInputs block (ARCHITECTURE.md 9.2 phase A): the only place in // the push arm that reads MG_State::pGLContext. Holds the per-verb filler, the F-class -// forwarders and IsLive. Compiled only under MOBILEGL_PIPE_PUSH (CMakeLists.txt appends it -// to SOURCE_FILES there). -// -// Contract commit (P1 c1): the filler bumps the verb serial, records the verb and the -// context identity, and stamps the seven sticky fields once; the per-class field copies and -// stamps land in c2, the verify snapshot and comparator in c4. +// forwarders, IsLive, the MOBILEGL_PIPE_POISON_OMIT knob and - in a verify build - the +// second arm (SnapshotFromGLContext), the entry compare, the compare-at-read hook and the +// MOBILEGL_PIPE_VERIFY_CORRUPT / _FATAL knobs. Compiled only under MOBILEGL_PIPE_PUSH +// (CMakeLists.txt appends it to SOURCE_FILES there). #include +#include #include +#include #include +#include +#include +#include + namespace MobileGL::MG_Pipe { + using GLContext = MG_State::GLState::GLContext; + + // The one door into PipeInputs' storage on the client side. A struct rather than a + // list of friend functions so the header names exactly one friend. + struct MGPipeFillAccess { + // Copies ONE field's storage out of the live context by calling the GLContext + // accessor of the same name (P1 brief D4: no derivation logic is re-implemented + // here, which is what keeps the copy semantically identical by construction). + // A forwarded field has no storage and copies nothing. + static void CopyField(PipeInputs& dst, GLContext& ctx, MGPipeInputField field) { + using F = MGPipeInputField; + using MG_State::GLState::BufferBindPointTargets; + using MG_State::GLState::GlobalBufferTargets; + switch (field) { + case F::GetActiveTextureUnit: + dst.m_activeTextureUnit = ctx.GetActiveTextureUnit(); + break; + case F::GetBlendColor: + dst.m_blendColor = ctx.GetBlendColor(); + break; + case F::GetBlendEquationIndexed: + for (Uint i = 0; i < kMGMaxDrawBuffers; ++i) { + ctx.GetBlendEquationIndexed(i, dst.m_blendEquation[i][0], dst.m_blendEquation[i][1]); + } + break; + case F::GetBlendFuncIndexed: + for (Uint i = 0; i < kMGMaxDrawBuffers; ++i) { + ctx.GetBlendFuncIndexed(i, dst.m_blendFunc[i][0], dst.m_blendFunc[i][1], dst.m_blendFunc[i][2], + dst.m_blendFunc[i][3]); + } + break; + case F::GetBoundTransformFeedbackName: + dst.m_boundTransformFeedbackName = ctx.GetBoundTransformFeedbackName(); + break; + case F::GetBoundVertexArray: + dst.m_boundVertexArray = ctx.GetBoundVertexArray(); + break; + case F::GetBufferBindingSlot: + // Every global target has a slot; the others (Index) stay null and a read + // of one is the poison Fatal in the accessor. + for (const auto target : GlobalBufferTargets) { + dst.m_bufferBindingSlot[static_cast(target)] = &ctx.GetBufferBindingSlot(target); + } + break; + case F::GetBufferBindingPoint: + // The live storage is Array, N> + // (BufferState.h), so the address of point 0 is the base of that target's row. + for (const auto target : BufferBindPointTargets) { + dst.m_bufferBindingPointBase[static_cast(target)] = &ctx.GetBufferBindingPoint(target, 0); + } + break; + case F::GetTouchedBufferBindingPointCount: + for (const auto target : BufferBindPointTargets) { + dst.m_touchedBindingPointCount[static_cast(target)] = + ctx.GetTouchedBufferBindingPointCount(target); + } + break; + case F::GetClampReadColor: + dst.m_clampReadColor = ctx.GetClampReadColor(); + break; + case F::GetClearColor: + dst.m_clearColor = ctx.GetClearColor(); + break; + case F::GetClearDepth: + dst.m_clearDepth = ctx.GetClearDepth(); + break; + case F::GetClearStencil: + dst.m_clearStencil = ctx.GetClearStencil(); + break; + case F::GetColorMaskIndexed: + for (Uint i = 0; i < kMGMaxDrawBuffers; ++i) { + dst.m_colorMask[i] = ctx.GetColorMaskIndexed(i); + } + break; + case F::GetCullFaceMode: + dst.m_cullFaceMode = ctx.GetCullFaceMode(); + break; + case F::GetCurrentVertexAttribute: + for (Uint i = 0; i < PipeInputs::kMaxVertexAttribs; ++i) { + dst.m_currentVertexAttribute[i] = ctx.GetCurrentVertexAttribute(i); + } + break; + case F::GetDepthFunc: + dst.m_depthFunc = ctx.GetDepthFunc(); + break; + case F::GetDepthMask: + dst.m_depthMask = ctx.GetDepthMask(); + break; + case F::GetDepthRangeIndexed: + for (Uint i = 0; i < PipeInputs::kMaxViewports; ++i) { + dst.m_depthRange[i] = ctx.GetDepthRangeIndexed(i); + } + break; + case F::GetFramebufferBindingSlot: + for (SizeT i = 0; i < PipeInputs::kFramebufferTargetCount; ++i) { + dst.m_framebufferBindingSlot[i] = + &ctx.GetFramebufferBindingSlot(static_cast(i)); + } + break; + case F::GetImageTextureBinding: + // Array (TextureState.h): unit 0's + // address is the base. + dst.m_imageTextureBindingBase = &ctx.GetImageTextureBinding(0); + break; + case F::GetLineWidth: + dst.m_lineWidth = ctx.GetLineWidth(); + break; + case F::GetLogicOp: + dst.m_logicOp = ctx.GetLogicOp(); + break; + case F::GetMaxTouchedTextureUnit: + dst.m_maxTouchedTextureUnit = ctx.GetMaxTouchedTextureUnit(); + break; + case F::GetMinSampleShadingValue: + dst.m_minSampleShadingValue = ctx.GetMinSampleShadingValue(); + break; + case F::GetPatchDefaultInnerLevel: + dst.m_patchDefaultInnerLevel = ctx.GetPatchDefaultInnerLevel(); + break; + case F::GetPatchDefaultOuterLevel: + dst.m_patchDefaultOuterLevel = ctx.GetPatchDefaultOuterLevel(); + break; + case F::GetPatchVertices: + dst.m_patchVertices = ctx.GetPatchVertices(); + break; + case F::GetPipelineStateVersion: + dst.m_pipelineStateVersion = ctx.GetPipelineStateVersion(); + break; + case F::GetPixelStoreParameters: + dst.m_pixelStore[0] = ctx.GetPixelStoreParameters(false); + dst.m_pixelStore[1] = ctx.GetPixelStoreParameters(true); + break; + case F::GetPolygonModeFront: + dst.m_polygonModeFront = ctx.GetPolygonModeFront(); + break; + case F::GetPolygonOffsetFactor: + dst.m_polygonOffsetFactor = ctx.GetPolygonOffsetFactor(); + break; + case F::GetPolygonOffsetUnits: + dst.m_polygonOffsetUnits = ctx.GetPolygonOffsetUnits(); + break; + case F::GetPrimitiveRestartIndex: + dst.m_primitiveRestartIndex = ctx.GetPrimitiveRestartIndex(); + break; + case F::GetProgramForDispatch: + dst.m_programForDispatch = ctx.GetProgramForDispatch(); + break; + case F::GetProgramForDraw: + dst.m_programForDraw = ctx.GetProgramForDraw(); + break; + case F::GetProvokingVertexMode: + dst.m_provokingVertexMode = ctx.GetProvokingVertexMode(); + break; + case F::GetRenderStateParameters: + dst.m_renderState = ctx.GetRenderStateParameters(); + break; + case F::GetRenderStateParametersVersion: + dst.m_renderStateParametersVersion = ctx.GetRenderStateParametersVersion(); + break; + case F::GetSamplingResolutionGeneration: + dst.m_samplingResolutionGeneration = ctx.GetSamplingResolutionGeneration(); + break; + case F::GetScissorBox: + dst.m_scissorBox = ctx.GetScissorBox(); + break; + case F::GetStencilState: + dst.m_stencil[0] = ctx.GetStencilState(StencilFace::Front); + dst.m_stencil[1] = ctx.GetStencilState(StencilFace::Back); + break; + case F::GetTextureBindGeneration: + dst.m_textureBindGeneration = ctx.GetTextureBindGeneration(); + break; + case F::GetTextureContextId: + dst.m_textureContextId = ctx.GetTextureContextId(); + break; + case F::GetTextureUnitObject: + // Array (TextureState.h): unit 0 is the base. + dst.m_textureUnitBase = &ctx.GetTextureUnitObject(0); + break; + case F::GetTransformFeedbackCapturedVertices: + dst.m_transformFeedbackCapturedVertices = ctx.GetTransformFeedbackCapturedVertices(); + break; + case F::GetTransformFeedbackGeneration: + dst.m_transformFeedbackGeneration = ctx.GetTransformFeedbackGeneration(); + break; + case F::GetTransformFeedbackPausedPrimitiveCounter: + dst.m_transformFeedbackPausedPrimitiveCounter = ctx.GetTransformFeedbackPausedPrimitiveCounter(); + break; + case F::GetTransformFeedbackProgram: + dst.m_transformFeedbackProgram = ctx.GetTransformFeedbackProgram(); + break; + case F::GetViewport: + dst.m_viewport = ctx.GetViewport(); + break; + case F::GetViewportIndexed: + for (Uint i = 0; i < PipeInputs::kMaxViewports; ++i) { + dst.m_viewportIndexed[i] = ctx.GetViewportIndexed(i); + } + break; + case F::IsCapabilityEnabled: + // Every capability, FramebufferSrgb included: it copies today's constant false + // (MEASUREMENTS.md), so no value changes. + for (SizeT i = 0; i < PipeInputs::kCapabilityCount; ++i) { + dst.m_capability[i] = ctx.IsCapabilityEnabled(static_cast(i)); + } + break; + case F::IsCapabilityEnabledIndexed: + // The only two indexed capabilities GLContext keeps (RenderState). + for (Uint i = 0; i < kMGMaxDrawBuffers; ++i) { + dst.m_capabilityIndexed.Blend[i] = ctx.IsCapabilityEnabledIndexed(CapabilityInput::Blend, i); + } + for (Uint i = 0; i < PipeInputs::kMaxViewports; ++i) { + dst.m_capabilityIndexed.ScissorTest[i] = + ctx.IsCapabilityEnabledIndexed(CapabilityInput::ScissorTest, i); + } + break; + case F::IsTransformFeedbackActive: + dst.m_transformFeedbackActive = ctx.IsTransformFeedbackActive(); + break; + case F::IsTransformFeedbackPaused: + dst.m_transformFeedbackPaused = ctx.IsTransformFeedbackPaused(); + break; + case F::GetBoundTransformFeedbackLifetimeId: + dst.m_boundTransformFeedbackLifetimeId = ctx.GetBoundTransformFeedbackLifetimeId(); + break; + // The seven forwarded fields: nothing to copy. + case F::GetBufferBindingPointCount: + case F::GetProgramObject: + case F::GetTextureObject: + case F::HasOpenTransformFeedbackSpan: + case F::InvalidateCompileEnv: + case F::ValidateProgramName: + case F::RecordError: + case F::kFieldCount: + break; + } + } + + static void SetIdentity(PipeInputs& inputs, GLContext* ctx) { + inputs.m_live = ctx != nullptr; + inputs.m_contextIdentity = ctx; + } + static void SetVerb(PipeInputs& inputs, MGPipeVerb verb) { inputs.m_currentVerb = verb; } +#if MOBILEGL_PIPE_POISON + static MGPipeFilledState& Filled(PipeInputs& inputs) { return inputs.m_filled; } +#endif + }; + namespace { - MG_State::GLState::GLContext* LiveContext() { return MG_State::pGLContext.get(); } + GLContext* LiveContext() { return MG_State::pGLContext.get(); } template const SharedPtr& NullShared() { static const SharedPtr null; return null; } + + [[noreturn]] void BadKnob(const char* knob, const char* value, const char* why) { + MGLOG_F("MGPipe: Fatal{PipeVerifyBadKnob, \"%s=%s\": %s}", knob, value, why); + std::abort(); + } + + // ---- MOBILEGL_PIPE_POISON_OMIT (negative control B, P1 brief D6) ---- + // The filler skips the STAMP (never the value) of one (verb, field) pair: an omission + // indistinguishable from a forgotten FillPoints.def row, so that verb's read of the + // field is Fatal{UnmigratedPipeInput, "Field@Verb"} and no other verb is affected. + struct PoisonOmission { + Bool Armed = false; + MGPipeVerb Verb = MGPipeVerb::kVerbCount; + MGPipeInputField Field = MGPipeInputField::kFieldCount; + }; + PoisonOmission g_omission; + Bool g_omissionKnobParsed = false; + + void ParsePoisonOmissionKnob() { + if (g_omissionKnobParsed) return; + g_omissionKnobParsed = true; + const String& knob = MG_Config::Features.PipePoisonOmit; + if (knob.empty()) return; + const auto colon = knob.find(':'); + if (colon == String::npos || colon == 0 || colon + 1 >= knob.size()) { + BadKnob("MOBILEGL_PIPE_POISON_OMIT", knob.c_str(), "expected :"); + } + const String verbName = knob.substr(0, colon); + const String fieldName = knob.substr(colon + 1); + const auto verb = MGPipeFindVerb(verbName.c_str()); + if (!verb) BadKnob("MOBILEGL_PIPE_POISON_OMIT", knob.c_str(), "no such verb in kMGPipeVerbNames"); + const auto field = MGPipeFindInputField(fieldName.c_str()); + if (!field) BadKnob("MOBILEGL_PIPE_POISON_OMIT", knob.c_str(), "no such field in kMGPipeInputFieldNames"); + MGPipeSetPoisonOmission(verbName.c_str(), fieldName.c_str()); + } + + [[maybe_unused]] Bool IsOmitted(MGPipeVerb verb, MGPipeInputField field) { + return g_omission.Armed && g_omission.Verb == verb && g_omission.Field == field; + } } // namespace + void MGPipeSetPoisonOmission(const char* verb, const char* field) { + if (verb == nullptr || field == nullptr) { + g_omission = PoisonOmission{}; + return; + } + const auto v = MGPipeFindVerb(verb); + const auto f = MGPipeFindInputField(field); + if (!v || !f) BadKnob("MOBILEGL_PIPE_POISON_OMIT", verb, "unknown verb or field"); + g_omission.Armed = true; + g_omission.Verb = *v; + g_omission.Field = *f; +#if MOBILEGL_PIPE_POISON + MGLOG_I("MGPipe: poison omission armed - %s@%s", field, verb); +#else + MGLOG_W_ONCE("MGPipe: poison omission %s@%s requested but the poison is not compiled in " + "(MOBILEGL_PIPE_POISON=0): no stamp exists to omit", + field, verb); +#endif + } + // ---- liveness ---- Bool PipeInputs::IsLive() const { return LiveContext() != nullptr; } @@ -74,27 +385,35 @@ namespace MobileGL::MG_Pipe { // ---- the filler ---- void MGPipeFillForVerb(MGPipeVerb verb) { PipeInputs& inputs = gPipeInputs; + ParsePoisonOmissionKnob(); #if MOBILEGL_PIPE_POISON + MGPipeFilledState& filled = MGPipeFillAccess::Filled(inputs); // Starts at 1, so FilledGen == 0 means "never filled". - ++inputs.m_filled.CurrentVerbSerial; + ++filled.CurrentVerbSerial; #endif - inputs.m_currentVerb = verb; + MGPipeFillAccess::SetVerb(inputs, verb); auto* ctx = LiveContext(); - if (ctx == nullptr) { - inputs.m_live = false; - inputs.m_contextIdentity = nullptr; - return; - } - inputs.m_live = true; - inputs.m_contextIdentity = ctx; -#if MOBILEGL_PIPE_POISON - // The sticky (forwarded) fields are stamped once by the first fill that sees a live - // context and stay fresh through the Sticky -> FilledGen != 0 branch of - // MGPipeInputFieldIsFresh. + MGPipeFillAccess::SetIdentity(inputs, ctx); + if (ctx == nullptr) return; + const MGPipeFieldMask& mask = kMGPipeClassFieldMask[static_cast(kMGPipeVerbClass[static_cast(verb)])]; for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { - if (kMGPipeInputFieldSticky[i] && inputs.m_filled.FilledGen[i] == 0) inputs.m_filled.FilledGen[i] = 1; - } + const auto field = static_cast(i); + if (!MGPipeFieldMaskHas(mask, field)) continue; +#if MOBILEGL_PIPE_POISON + if (kMGPipeInputFieldSticky[i]) { + // Stamped once by the first fill that sees a live context; fresh through the + // Sticky -> FilledGen != 0 branch of MGPipeInputFieldIsFresh from then on. + if (filled.FilledGen[i] == 0) filled.FilledGen[i] = 1; + continue; + } +#else + if (kMGPipeInputFieldSticky[i]) continue; #endif - // c2: copy and stamp every field in kMGPipeClassFieldMask[kMGPipeVerbClass[verb]]. + MGPipeFillAccess::CopyField(inputs, *ctx, field); +#if MOBILEGL_PIPE_POISON + // The value is copied either way; only the stamp is withheld for the omitted pair. + if (!IsOmitted(verb, field)) filled.FilledGen[i] = filled.CurrentVerbSerial; +#endif + } } } // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.h b/MobileGL/MG_Impl/Pipe/PipeFill.h index 73cea5eb2..f433da9fc 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.h +++ b/MobileGL/MG_Impl/Pipe/PipeFill.h @@ -16,10 +16,19 @@ #if MOBILEGL_PIPE_PUSH #include namespace MobileGL::MG_Pipe { - // PipeFill.cpp. Bumps the per-verb serial, records the verb, and (from c2 on) copies - // every field in the verb class's may-read mask out of the live GLContext, stamping each - // with the new serial. + // PipeFill.cpp. Bumps the per-verb serial, records the verb and the context identity, + // and copies every field in the verb class's may-read mask (kMGPipeClassFieldMask) out + // of the live GLContext, stamping each with the new serial. In a verify build it then + // runs the entry compare against a second snapshot (P1 brief D8). void MGPipeFillForVerb(MGPipeVerb verb); + + // PipeFill.cpp. Negative control B (P1 brief D6): the filler withholds the STAMP - never + // the value - of `field` at `verb`, so that verb's read of it is + // Fatal{UnmigratedPipeInput, "Field@Verb"} while every other verb is unaffected. The + // MOBILEGL_PIPE_POISON_OMIT knob (":") calls this once, on the first + // fill; tests call it directly. Both null clears the omission. An unknown name is + // Fatal{PipeVerifyBadKnob}. + void MGPipeSetPoisonOmission(const char* verb, const char* field); } // namespace MobileGL::MG_Pipe #define MGP_FILL(Verb) ::MobileGL::MG_Pipe::MGPipeFillForVerb(::MobileGL::MG_Pipe::MGPipeVerb::Verb) #else From a196ada4c11b59f23587596486d265124ea06a6f Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 01:51:16 -0400 Subject: [PATCH 044/529] [Feat] (Impl): call MGP_FILL before every GLFunctionsTable entry - 83 statements over 69 verbs, a no-op in the pull build - Every call through gBackendFunctionsTable.GL in the seven MG_Impl TUs (Drawing 36, Framebuffer 11, Texture 8, Getter 3, Program 1, Query 18, Sync 6) is preceded by MGP_FILL(); placed after every early return the call sits behind - the conditional-render check, the null-entry guards, the loop bodies - so a verb whose entry is null on this backend never bumps the serial. - Seven calls sit on a continuation line of a guarded expression (GetQueryResult64 x2, BeginXfbPrimitivesQuery, BeginTimeElapsedQuery, QueryCounterTimestamp, IsTimerQuerySupported, GetSyncStatus) and two more fold the null guard into the same expression (IsQueryResultAvailable, ClientWaitSync's guarded return); there the fill precedes the statement, so a null entry bumps the serial once with nothing to read it - harmless for the poison, recorded for the record. - Each TU includes after its last MG_State/MG_Backend include; under MOBILEGL_PIPE_PUSH=OFF the macro is ((void)0) and the pull library is symbol-identical with a .text delta of zero. --- .../MG_Impl/GLImpl/Drawing/GL_Drawing.cpp | 37 +++++++++++++++++++ .../GLImpl/Framebuffer/GL_Framebuffer.cpp | 12 ++++++ MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp | 4 ++ .../MG_Impl/GLImpl/Program/GL_Program.cpp | 2 + MobileGL/MG_Impl/GLImpl/Query/GL_Query.cpp | 19 ++++++++++ MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp | 7 ++++ .../MG_Impl/GLImpl/Texture/GL_Texture.cpp | 9 +++++ 7 files changed, 90 insertions(+) diff --git a/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp b/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp index d76e80190..0d24554bb 100644 --- a/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp +++ b/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include "../Getter/GL_Getter.h" namespace MobileGL::MG_Impl::GLImpl { @@ -527,6 +528,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(Clear); MG_Backend::gBackendFunctionsTable.GL.Clear(mask); } @@ -535,6 +537,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawElements); MG_Backend::gBackendFunctionsTable.GL.DrawElements(mode, count, type, indices); } @@ -544,6 +547,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(MultiDrawElements); MG_Backend::gBackendFunctionsTable.GL.MultiDrawElements(mode, count, type, indices, drawcount); } @@ -553,6 +557,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(MultiDrawElementsBaseVertex); MG_Backend::gBackendFunctionsTable.GL.MultiDrawElementsBaseVertex(mode, count, type, indices, drawcount, basevertex); } @@ -562,6 +567,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawArrays); MG_Backend::gBackendFunctionsTable.GL.DrawArrays(mode, first, count); } @@ -570,6 +576,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(MultiDrawArrays); MG_Backend::gBackendFunctionsTable.GL.MultiDrawArrays(mode, first, count, drawcount); } @@ -579,6 +586,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawElementsBaseVertex); MG_Backend::gBackendFunctionsTable.GL.DrawElementsBaseVertex(mode, count, type, indices, basevertex); } @@ -588,6 +596,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(MultiDrawElementsIndirect); MG_Backend::gBackendFunctionsTable.GL.MultiDrawElementsIndirect(mode, type, indirect, drawcount, stride); } @@ -596,6 +605,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(MultiDrawArraysIndirect); MG_Backend::gBackendFunctionsTable.GL.MultiDrawArraysIndirect(mode, indirect, drawcount, stride); } @@ -605,6 +615,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(MultiDrawElementsIndirectCount); MG_Backend::gBackendFunctionsTable.GL.MultiDrawElementsIndirectCount(mode, type, indirect, drawcount, maxdrawcount, stride); } @@ -615,6 +626,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(MultiDrawArraysIndirectCount); MG_Backend::gBackendFunctionsTable.GL.MultiDrawArraysIndirectCount(mode, indirect, drawcount, maxdrawcount, stride); } @@ -625,6 +637,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawRangeElementsBaseVertex); MG_Backend::gBackendFunctionsTable.GL.DrawRangeElementsBaseVertex(mode, start, end, count, type, indices, basevertex); } @@ -635,6 +648,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawRangeElements); MG_Backend::gBackendFunctionsTable.GL.DrawRangeElements(mode, start, end, count, type, indices); } @@ -645,6 +659,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawElementsInstancedBaseVertexBaseInstance); MG_Backend::gBackendFunctionsTable.GL.DrawElementsInstancedBaseVertexBaseInstance( mode, count, type, indices, instancecount, basevertex, baseinstance); } @@ -655,6 +670,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawElementsInstancedBaseVertex); MG_Backend::gBackendFunctionsTable.GL.DrawElementsInstancedBaseVertex(mode, count, type, indices, instancecount, basevertex); } @@ -665,6 +681,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawElementsInstancedBaseInstance); MG_Backend::gBackendFunctionsTable.GL.DrawElementsInstancedBaseInstance(mode, count, type, indices, instancecount, baseinstance); } @@ -675,6 +692,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawElementsInstanced); MG_Backend::gBackendFunctionsTable.GL.DrawElementsInstanced(mode, count, type, indices, instancecount); } @@ -683,6 +701,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawElementsIndirect); MG_Backend::gBackendFunctionsTable.GL.DrawElementsIndirect(mode, type, indirect); } void DrawArraysInstancedBaseInstance_Backend(GLenum mode, GLint first, GLsizei count, GLsizei instancecount, @@ -691,6 +710,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawArraysInstancedBaseInstance); MG_Backend::gBackendFunctionsTable.GL.DrawArraysInstancedBaseInstance(mode, first, count, instancecount, baseinstance); } @@ -700,6 +720,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawArraysInstanced); MG_Backend::gBackendFunctionsTable.GL.DrawArraysInstanced(mode, first, count, instancecount); } @@ -708,6 +729,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawArraysIndirect); MG_Backend::gBackendFunctionsTable.GL.DrawArraysIndirect(mode, indirect); } @@ -739,6 +761,7 @@ namespace MobileGL::MG_Impl::GLImpl { // GL 4.3 added both dispatches to the conditional-render set (GL 4.6 core 10.9), which is // exactly what KHR-GL43.compute_shader.conditional-dispatching checks. if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DispatchCompute); dispatchCompute(numGroupsX, numGroupsY, numGroupsZ); } @@ -791,6 +814,7 @@ namespace MobileGL::MG_Impl::GLImpl { } if (!ValidateCurrentProgramForCompute(__func__)) return; if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DispatchComputeIndirect); dispatchComputeIndirect(indirect); } @@ -812,6 +836,7 @@ namespace MobileGL::MG_Impl::GLImpl { } MG_State::pGLContext->SetPatchVertices(static_cast(value)); if (const auto patchParameteri = MG_Backend::gBackendFunctionsTable.GL.PatchParameteri) { + MGP_FILL(PatchParameteri); patchParameteri(pname, value); } } @@ -882,6 +907,7 @@ namespace MobileGL::MG_Impl::GLImpl { MakeUnique("MG_Impl/GLImpl", __func__, "Backend does not support memory barriers.")); return; } + MGP_FILL(MemoryBarrier); memoryBarrier(barriers); } @@ -903,6 +929,7 @@ namespace MobileGL::MG_Impl::GLImpl { MakeUnique("MG_Impl/GLImpl", __func__, "Backend does not support memory barriers.")); return; } + MGP_FILL(MemoryBarrier); memoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT | GL_FRAMEBUFFER_BARRIER_BIT); } @@ -916,6 +943,7 @@ namespace MobileGL::MG_Impl::GLImpl { "Backend does not support regional memory barriers.")); return; } + MGP_FILL(MemoryBarrierByRegion); memoryBarrierByRegion(barriers); } @@ -1238,6 +1266,7 @@ namespace MobileGL::MG_Impl::GLImpl { } MG_State::pGLContext->BeginTransformFeedback(primitiveMode, program); if (const auto beginXfb = MG_Backend::gBackendFunctionsTable.GL.BeginTransformFeedback) { + MGP_FILL(BeginTransformFeedback); beginXfb(primitiveMode); } } @@ -1320,6 +1349,7 @@ namespace MobileGL::MG_Impl::GLImpl { // Closed while the capture state is still active: a backend that captures // through its own driver reads the capture program and buffer bindings here. if (const auto endXfb = MG_Backend::gBackendFunctionsTable.GL.EndTransformFeedback) { + MGP_FILL(EndTransformFeedback); endXfb(); } MG_State::pGLContext->EndTransformFeedback(); @@ -1328,9 +1358,12 @@ namespace MobileGL::MG_Impl::GLImpl { // the GPU work is all that is required. auto& backendGL = MG_Backend::gBackendFunctionsTable.GL; if (backendGL.FenceSync && backendGL.ClientWaitSync) { + MGP_FILL(FenceSync); if (auto sync = backendGL.FenceSync()) { + MGP_FILL(ClientWaitSync); backendGL.ClientWaitSync(sync, GL_SYNC_FLUSH_COMMANDS_BIT, ~0ull); if (backendGL.DeleteSync) { + MGP_FILL(DeleteSync); backendGL.DeleteSync(sync); } } @@ -1349,6 +1382,7 @@ namespace MobileGL::MG_Impl::GLImpl { } MG_State::pGLContext->SetTransformFeedbackPaused(true); if (const auto pauseXfb = MG_Backend::gBackendFunctionsTable.GL.PauseTransformFeedback) { + MGP_FILL(PauseTransformFeedback); pauseXfb(); } } @@ -1363,6 +1397,7 @@ namespace MobileGL::MG_Impl::GLImpl { } MG_State::pGLContext->SetTransformFeedbackPaused(false); if (const auto resumeXfb = MG_Backend::gBackendFunctionsTable.GL.ResumeTransformFeedback) { + MGP_FILL(ResumeTransformFeedback); resumeXfb(); } } @@ -1568,6 +1603,7 @@ namespace MobileGL::MG_Impl::GLImpl { continue; } if (const auto deleteXfb = MG_Backend::gBackendFunctionsTable.GL.DeleteTransformFeedback) { + MGP_FILL(DeleteTransformFeedback); deleteXfb(id); } MG_State::pGLContext->MarkTransformFeedbackObjectForDeletion(id); @@ -1599,6 +1635,7 @@ namespace MobileGL::MG_Impl::GLImpl { } MG_State::pGLContext->BindTransformFeedbackObject(id); if (const auto bindXfb = MG_Backend::gBackendFunctionsTable.GL.BindTransformFeedback) { + MGP_FILL(BindTransformFeedback); bindXfb(id); } } diff --git a/MobileGL/MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp b/MobileGL/MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp index 6a6b7bb1c..394c6cd31 100644 --- a/MobileGL/MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp +++ b/MobileGL/MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -616,6 +617,7 @@ namespace MobileGL::MG_Impl::GLImpl { void BlitFramebuffer_Backend(GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1, GLint dstX0, GLint dstY0, GLint dstX1, GLint dstY1, GLbitfield mask, GLenum filter) { + MGP_FILL(BlitFramebuffer); MG_Backend::gBackendFunctionsTable.GL.BlitFramebuffer(srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask, filter); } @@ -629,6 +631,7 @@ namespace MobileGL::MG_Impl::GLImpl { MGLOG_E_ONCE("glBlitNamedFramebuffer skipped: backend does not implement explicit framebuffer blit."); return; } + MGP_FILL(BlitNamedFramebuffer); blitNamedFramebuffer(readFramebuffer, drawFramebuffer, srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask, filter); } @@ -640,6 +643,7 @@ namespace MobileGL::MG_Impl::GLImpl { MGLOG_E_ONCE("glClearNamedFramebufferfv skipped: backend does not implement explicit framebuffer clear."); return; } + MGP_FILL(ClearNamedFramebufferfv); clearNamedFramebufferfv(framebuffer, buffer, drawbuffer, value); } @@ -650,6 +654,7 @@ namespace MobileGL::MG_Impl::GLImpl { MGLOG_E_ONCE("glClearNamedFramebufferfi skipped: backend does not implement explicit framebuffer clear."); return; } + MGP_FILL(ClearNamedFramebufferfi); clearNamedFramebufferfi(framebuffer, buffer, drawbuffer, depth, stencil); } @@ -660,6 +665,7 @@ namespace MobileGL::MG_Impl::GLImpl { MGLOG_E_ONCE("glClearNamedFramebufferiv skipped: backend does not implement explicit framebuffer clear."); return; } + MGP_FILL(ClearNamedFramebufferiv); clearNamedFramebufferiv(framebuffer, buffer, drawbuffer, value); } @@ -670,6 +676,7 @@ namespace MobileGL::MG_Impl::GLImpl { MGLOG_E_ONCE("glClearNamedFramebufferuiv skipped: backend does not implement explicit framebuffer clear."); return; } + MGP_FILL(ClearNamedFramebufferuiv); clearNamedFramebufferuiv(framebuffer, buffer, drawbuffer, value); } @@ -2729,24 +2736,28 @@ namespace MobileGL::MG_Impl::GLImpl { void ClearBufferfi_Backend(GLenum buffer, GLint drawbuffer, GLfloat depth, GLint stencil) { // GL 4.6 core 10.9 makes ClearBuffer* conditional alongside the drawing commands. if (MG_State::pGLContext->ConditionalRenderDiscardsCommands()) return; + MGP_FILL(ClearBufferfi); MG_Backend::gBackendFunctionsTable.GL.ClearBufferfi(buffer, drawbuffer, depth, stencil); } void ClearBufferfv_Backend(GLenum buffer, GLint drawbuffer, const GLfloat* value) { // GL 4.6 core 10.9 makes ClearBuffer* conditional alongside the drawing commands. if (MG_State::pGLContext->ConditionalRenderDiscardsCommands()) return; + MGP_FILL(ClearBufferfv); MG_Backend::gBackendFunctionsTable.GL.ClearBufferfv(buffer, drawbuffer, value); } void ClearBufferuiv_Backend(GLenum buffer, GLint drawbuffer, const GLuint* value) { // GL 4.6 core 10.9 makes ClearBuffer* conditional alongside the drawing commands. if (MG_State::pGLContext->ConditionalRenderDiscardsCommands()) return; + MGP_FILL(ClearBufferuiv); MG_Backend::gBackendFunctionsTable.GL.ClearBufferuiv(buffer, drawbuffer, value); } void ClearBufferiv_Backend(GLenum buffer, GLint drawbuffer, const GLint* value) { // GL 4.6 core 10.9 makes ClearBuffer* conditional alongside the drawing commands. if (MG_State::pGLContext->ConditionalRenderDiscardsCommands()) return; + MGP_FILL(ClearBufferiv); MG_Backend::gBackendFunctionsTable.GL.ClearBufferiv(buffer, drawbuffer, value); } @@ -2994,6 +3005,7 @@ namespace MobileGL::MG_Impl::GLImpl { } void ReadPixels_Backend(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels) { + MGP_FILL(ReadPixels); MG_Backend::gBackendFunctionsTable.GL.ReadPixels(x, y, width, height, format, type, pixels); } diff --git a/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp b/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp index 211f7141a..7a96b6a0f 100644 --- a/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp +++ b/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp @@ -30,6 +30,7 @@ #include #include #include +#include namespace MobileGL::MG_Impl::GLImpl { // Declared rather than #included from GL_RenderState.h on purpose: that header also declares @@ -1173,6 +1174,7 @@ namespace MobileGL::MG_Impl::GLImpl { : GetMinComputeWorkGroupSize(index); GLint backendValue = 0; if (getIntegeri) { + MGP_FILL(GetIntegeri_v); getIntegeri(target, index, &backendValue); } *data = std::max(backendValue, minimum); @@ -1353,6 +1355,7 @@ namespace MobileGL::MG_Impl::GLImpl { Int64 timestamp = 0; if (!MG_Config::Features.DisableTimerQuery) { if (const auto getGpuTimestampNs = MG_Backend::gBackendFunctionsTable.GL.GetGpuTimestampNs) { + MGP_FILL(GetGpuTimestampNs); timestamp = getGpuTimestampNs(); } } @@ -2263,6 +2266,7 @@ namespace MobileGL::MG_Impl::GLImpl { Int64 timestamp = 0; if (!MG_Config::Features.DisableTimerQuery) { if (const auto getGpuTimestampNs = MG_Backend::gBackendFunctionsTable.GL.GetGpuTimestampNs) { + MGP_FILL(GetGpuTimestampNs); timestamp = getGpuTimestampNs(); } } diff --git a/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp b/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp index e3bee89bb..dfbe2ca4d 100644 --- a/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp +++ b/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp @@ -21,6 +21,7 @@ #include #include #include +#include namespace MobileGL::MG_Impl::GLImpl { // The flattened uniform type these helpers used to take as a raw glslang::TType* @@ -3398,6 +3399,7 @@ namespace MobileGL::MG_Impl::GLImpl { "Backend does not support shader storage block binding.")); return; } + MGP_FILL(ShaderStorageBlockBinding); shaderStorageBlockBinding(program, blockName.c_str(), storageBlockBinding); } diff --git a/MobileGL/MG_Impl/GLImpl/Query/GL_Query.cpp b/MobileGL/MG_Impl/GLImpl/Query/GL_Query.cpp index bcdc199a7..dd21c0e38 100644 --- a/MobileGL/MG_Impl/GLImpl/Query/GL_Query.cpp +++ b/MobileGL/MG_Impl/GLImpl/Query/GL_Query.cpp @@ -12,6 +12,7 @@ #include #include #include +#include namespace MobileGL::MG_Impl::GLImpl { namespace { @@ -164,6 +165,7 @@ namespace MobileGL::MG_Impl::GLImpl { void ResetQueryObjectLocked(QueryObject* queryObject) { if (queryObject->backendHandle) { if (const auto deleteBackendQuery = MG_Backend::gBackendFunctionsTable.GL.DeleteBackendQuery) { + MGP_FILL(DeleteBackendQuery); deleteBackendQuery(queryObject->backendHandle); } queryObject->backendHandle = nullptr; @@ -178,6 +180,7 @@ namespace MobileGL::MG_Impl::GLImpl { void EndTimeElapsedQueryLocked(QueryObject* queryObject) { const auto endTimeElapsedQuery = MG_Backend::gBackendFunctionsTable.GL.EndTimeElapsedQuery; if (endTimeElapsedQuery && queryObject->backendHandle) { + MGP_FILL(EndTimeElapsedQuery); endTimeElapsedQuery(queryObject->backendHandle); } queryObject->active = false; @@ -257,6 +260,7 @@ namespace MobileGL::MG_Impl::GLImpl { } Uint64 result = 0; const auto getQueryResult64 = MG_Backend::gBackendFunctionsTable.GL.GetQueryResult64; + MGP_FILL(GetQueryResult64); if (queryObject->backendHandle && getQueryResult64 && !getQueryResult64(queryObject->backendHandle, /*wait=*/false, &result)) { // Not ready. The whole point of the no-wait form is that the caller's @@ -271,6 +275,7 @@ namespace MobileGL::MG_Impl::GLImpl { } if (queryObject->backendHandle) { if (const auto deleteBackendQuery = MG_Backend::gBackendFunctionsTable.GL.DeleteBackendQuery) { + MGP_FILL(DeleteBackendQuery); deleteBackendQuery(queryObject->backendHandle); } queryObject->backendHandle = nullptr; @@ -286,6 +291,7 @@ namespace MobileGL::MG_Impl::GLImpl { return true; } const auto isQueryResultAvailable = MG_Backend::gBackendFunctionsTable.GL.IsQueryResultAvailable; + MGP_FILL(IsQueryResultAvailable); outValue = (!isQueryResultAvailable || isQueryResultAvailable(queryObject->backendHandle)) ? 1 : 0; return true; } @@ -297,6 +303,7 @@ namespace MobileGL::MG_Impl::GLImpl { Uint64 result = 0; if (queryObject->backendHandle) { const auto getQueryResult64 = MG_Backend::gBackendFunctionsTable.GL.GetQueryResult64; + MGP_FILL(GetQueryResult64); if (getQueryResult64 && !getQueryResult64(queryObject->backendHandle, /*wait=*/true, &result)) { // The backend could not produce the result YET (e.g. a @@ -317,6 +324,7 @@ namespace MobileGL::MG_Impl::GLImpl { // query degrades to a zero result); the backend handle is // consumed and the value cached for later reads. if (const auto deleteBackendQuery = MG_Backend::gBackendFunctionsTable.GL.DeleteBackendQuery) { + MGP_FILL(DeleteBackendQuery); deleteBackendQuery(queryObject->backendHandle); } queryObject->backendHandle = nullptr; @@ -422,6 +430,7 @@ namespace MobileGL::MG_Impl::GLImpl { queryObject->target == GL_ANY_SAMPLES_PASSED_CONSERVATIVE) { if (const auto endOcclusionQuery = MG_Backend::gBackendFunctionsTable.GL.EndOcclusionQuery; endOcclusionQuery && queryObject->backendHandle) { + MGP_FILL(EndOcclusionQuery); endOcclusionQuery(queryObject->backendHandle); } queryObject->active = false; @@ -441,6 +450,7 @@ namespace MobileGL::MG_Impl::GLImpl { } if (queryObject->backendHandle) { if (const auto deleteBackendQuery = MG_Backend::gBackendFunctionsTable.GL.DeleteBackendQuery) { + MGP_FILL(DeleteBackendQuery); deleteBackendQuery(queryObject->backendHandle); } queryObject->backendHandle = nullptr; @@ -519,6 +529,7 @@ namespace MobileGL::MG_Impl::GLImpl { // Prefer real GPU transform-feedback queries (exact with geometry shaders); // the CPU accounting delta stays as the fallback when the backend lacks them. const auto beginXfbPrimitivesQuery = MG_Backend::gBackendFunctionsTable.GL.BeginXfbPrimitivesQuery; + MGP_FILL(BeginXfbPrimitivesQuery); queryObject->backendHandle = beginXfbPrimitivesQuery ? beginXfbPrimitivesQuery(target == GL_PRIMITIVES_GENERATED) : nullptr; queryObject->counterSnapshot = TransformFeedbackCounterForTarget(target); @@ -527,9 +538,11 @@ namespace MobileGL::MG_Impl::GLImpl { queryObject->geometryCaptureDrawSnapshot = MG_State::pGLContext->GetTransformFeedbackGeometryCaptureDraws(); } else if (isOcclusionQuery) { + MGP_FILL(BeginOcclusionQuery); queryObject->backendHandle = MG_Backend::gBackendFunctionsTable.GL.BeginOcclusionQuery(); } else { const auto beginTimeElapsedQuery = MG_Backend::gBackendFunctionsTable.GL.BeginTimeElapsedQuery; + MGP_FILL(BeginTimeElapsedQuery); queryObject->backendHandle = (!TimerQueryDisabled() && beginTimeElapsedQuery) ? beginTimeElapsedQuery() : nullptr; } @@ -579,6 +592,7 @@ namespace MobileGL::MG_Impl::GLImpl { if (isTransformFeedbackQuery) { if (queryObject->backendHandle) { if (const auto endXfbPrimitivesQuery = MG_Backend::gBackendFunctionsTable.GL.EndXfbPrimitivesQuery) { + MGP_FILL(EndXfbPrimitivesQuery); endXfbPrimitivesQuery(queryObject->backendHandle); } } @@ -588,6 +602,7 @@ namespace MobileGL::MG_Impl::GLImpl { if (!queryObject->backendHandle || PrefersCpuTransformFeedbackResult(queryObject)) { if (queryObject->backendHandle) { if (const auto deleteBackendQuery = MG_Backend::gBackendFunctionsTable.GL.DeleteBackendQuery) { + MGP_FILL(DeleteBackendQuery); deleteBackendQuery(queryObject->backendHandle); } queryObject->backendHandle = nullptr; @@ -604,6 +619,7 @@ namespace MobileGL::MG_Impl::GLImpl { if (isOcclusionQuery) { if (const auto endOcclusionQuery = MG_Backend::gBackendFunctionsTable.GL.EndOcclusionQuery; endOcclusionQuery && queryObject->backendHandle) { + MGP_FILL(EndOcclusionQuery); endOcclusionQuery(queryObject->backendHandle); } queryObject->active = false; @@ -642,6 +658,7 @@ namespace MobileGL::MG_Impl::GLImpl { ResetQueryObjectLocked(queryObject); // discard any previous result queryObject->target = target; const auto queryCounterTimestamp = MG_Backend::gBackendFunctionsTable.GL.QueryCounterTimestamp; + MGP_FILL(QueryCounterTimestamp); queryObject->backendHandle = (!TimerQueryDisabled() && queryCounterTimestamp) ? queryCounterTimestamp() : nullptr; queryObject->ended = true; @@ -771,6 +788,7 @@ namespace MobileGL::MG_Impl::GLImpl { } const Bool timerTarget = target == GL_TIME_ELAPSED || target == GL_TIMESTAMP; const auto isTimerQuerySupported = MG_Backend::gBackendFunctionsTable.GL.IsTimerQuerySupported; + MGP_FILL(IsTimerQuerySupported); const Bool supported = timerTarget && !TimerQueryDisabled() && isTimerQuerySupported && isTimerQuerySupported(); *params = supported ? 64 : 0; @@ -912,6 +930,7 @@ namespace MobileGL::MG_Impl::GLImpl { const auto deleteBackendQuery = MG_Backend::gBackendFunctionsTable.GL.DeleteBackendQuery; for (const auto& [_, queryObject] : orphans) { if (deleteBackendQuery && queryObject->backendHandle) { + MGP_FILL(DeleteBackendQuery); deleteBackendQuery(queryObject->backendHandle); } delete queryObject; diff --git a/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp b/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp index 39da96d5c..6a818aad1 100644 --- a/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp +++ b/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp @@ -9,6 +9,7 @@ #include "GL_Sync.h" #include #include +#include namespace MobileGL::MG_Impl::GLImpl { namespace { @@ -56,6 +57,7 @@ namespace MobileGL::MG_Impl::GLImpl { syncObject->condition = condition; syncObject->flags = flags; if (const auto backendFenceSync = MG_Backend::gBackendFunctionsTable.GL.FenceSync) { + MGP_FILL(FenceSync); syncObject->backendHandle = backendFenceSync(); } const GLsync handle = reinterpret_cast(syncObject); @@ -94,6 +96,7 @@ namespace MobileGL::MG_Impl::GLImpl { if (!backendClientWaitSync || !syncObject->backendHandle) { return GL_ALREADY_SIGNALED; // legacy always-signaled fallback } + MGP_FILL(ClientWaitSync); return backendClientWaitSync(syncObject->backendHandle, flags, timeout); } @@ -119,6 +122,7 @@ namespace MobileGL::MG_Impl::GLImpl { } const auto backendWaitSync = MG_Backend::gBackendFunctionsTable.GL.WaitSync; if (backendWaitSync && syncObject->backendHandle) { + MGP_FILL(WaitSync); backendWaitSync(syncObject->backendHandle, flags, timeout); } } @@ -139,6 +143,7 @@ namespace MobileGL::MG_Impl::GLImpl { } const auto backendDeleteSync = MG_Backend::gBackendFunctionsTable.GL.DeleteSync; if (backendDeleteSync && syncObject->backendHandle) { + MGP_FILL(DeleteSync); backendDeleteSync(syncObject->backendHandle); } delete syncObject; @@ -174,6 +179,7 @@ namespace MobileGL::MG_Impl::GLImpl { break; case GL_SYNC_STATUS: { const auto backendGetSyncStatus = MG_Backend::gBackendFunctionsTable.GL.GetSyncStatus; + MGP_FILL(GetSyncStatus); const Bool signaled = !backendGetSyncStatus || !syncObject->backendHandle || backendGetSyncStatus(syncObject->backendHandle); value = signaled ? GL_SIGNALED : GL_UNSIGNALED; @@ -226,6 +232,7 @@ namespace MobileGL::MG_Impl::GLImpl { // the function table itself is cleared. const auto backendDeleteSync = MG_Backend::gBackendFunctionsTable.GL.DeleteSync; for (const auto& [_, syncObject] : orphans) { + MGP_FILL(DeleteSync); if (backendDeleteSync && syncObject->backendHandle) { backendDeleteSync(syncObject->backendHandle); } diff --git a/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp b/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp index 647f2c07b..4ed5cbc7c 100644 --- a/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp +++ b/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp @@ -30,6 +30,7 @@ #include #include #include +#include namespace MobileGL::MG_Impl::GLImpl { static SharedPtr nullTextureObject; @@ -1076,6 +1077,7 @@ namespace MobileGL::MG_Impl::GLImpl { Vector scratch(static_cast(width) * static_cast(height) * bytesPerTexel); { ScopedNeutralPackState neutralPack; + MGP_FILL(ReadPixels); MG_Backend::gBackendFunctionsTable.GL.ReadPixels(x, y, width, height, format, type, scratch.data()); } @@ -1619,6 +1621,7 @@ namespace MobileGL::MG_Impl::GLImpl { } void GenerateMipmap_Backend(GLenum target) { + MGP_FILL(GenerateMipmap); MG_Backend::gBackendFunctionsTable.GL.GenerateMipmap(target); } @@ -4024,6 +4027,7 @@ namespace MobileGL::MG_Impl::GLImpl { void CopyTexSubImage2D_Backend(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint x, GLint y, GLsizei width, GLsizei height) { + MGP_FILL(CopyTexSubImage2D); MG_Backend::gBackendFunctionsTable.GL.CopyTexSubImage2D(target, level, xoffset, yoffset, x, y, width, height); } @@ -4040,6 +4044,7 @@ namespace MobileGL::MG_Impl::GLImpl { "Backend does not support image-to-image copies.")); return; } + MGP_FILL(CopyImageSubData); copyImageSubData(src, srcTarget, srcLevel, srcX, srcY, srcZ, dst, dstTarget, dstLevel, dstX, dstY, dstZ, srcWidth, srcHeight, srcDepth); } @@ -4461,6 +4466,7 @@ namespace MobileGL::MG_Impl::GLImpl { void CopyTexImage2D_Backend(GLenum target, GLint level, GLenum internalformat, GLint x, GLint y, GLsizei width, GLsizei height, GLint border) { + MGP_FILL(CopyTexImage2D); MG_Backend::gBackendFunctionsTable.GL.CopyTexImage2D(target, level, internalformat, x, y, width, height, border); } @@ -5071,6 +5077,7 @@ namespace MobileGL::MG_Impl::GLImpl { } void GetTexImage_Backend(GLenum target, GLint level, GLenum format, GLenum type, GLvoid* pixels) { + MGP_FILL(GetTexImage); MG_Backend::gBackendFunctionsTable.GL.GetTexImage(target, level, format, type, pixels); } @@ -6453,6 +6460,7 @@ namespace MobileGL::MG_Impl::GLImpl { if (MG_Backend::pActiveBackendObject != nullptr && MG_Backend::pActiveBackendObject->GetBackendType() == BackendType::DirectVulkan && MG_Backend::gBackendFunctionsTable.GL.GetTextureImage != nullptr) { + MGP_FILL(GetTextureImage); MG_Backend::gBackendFunctionsTable.GL.GetTextureImage(textureObject, uploadTarget, level, format, type, bufSize, pixels); return; @@ -6657,6 +6665,7 @@ namespace MobileGL::MG_Impl::GLImpl { MG_State::pGLContext->GetImageTextureBinding(static_cast(unit)) .Bind(textureObject, level, layered, layer, access, format); MG_State::pGLContext->NoteTextureUnitTouched(static_cast(unit)); + MGP_FILL(BindImageTexture); bindImageTexture(unit, texture, level, layered, layer, access, format); } From 275dd3edb4909fe2a23af0f9a2667fb8215d24be Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 01:56:44 -0400 Subject: [PATCH 045/529] [Feat] (Pipe): the MOBILEGL_PIPE_VERIFY shadow comparator - a per-verb entry compare over the fill set and a compare-at-read in every accessor, first differing field and verb serial, fatal by default - Entry compare: at the end of MGPipeFillForVerb a file-static second PipeInputs is filled by SnapshotFromGLContext (the branch that survives P13) over the same class mask, MOBILEGL_PIPE_VERIFY_CORRUPT perturbs one field of that snapshot arm, and MGPipeVerifyInputs compares every field in the mask through MGPipeInputsFieldEqual (V by G4's MGPipeFieldEqual, O by identity, F equal by definition). Both arms come from the same context at the same instant, so this arm is tautological until P2 - the CORRUPT knob keeps it falsifiable. - Compare-at-read: MGP_INPUT_VERIFY_READ now calls MGPipeVerifyReadHook(*this, field, i0, i1), which re-reads the whole field from the live context into a scratch block and compares it against the stored value on the live block only - a superset of "the same indices"; the indices decorate the report. This is the arm that is real in P1. - Reporting per D8: MGLOG_F("MGPipe: Fatal{PipeVerifyDiffer, \"Field@Verb\", verb=, where=entry|read}") then abort unless MOBILEGL_PIPE_VERIFY_FATAL=0, which counts and summarises at teardown with MGLOG_E; arming logs "MGPipe: verify armed - 63 fields, 69 verbs, fatal=N" once, the knobs acknowledge themselves, an unknown field name is Fatal{PipeVerifyBadKnob}; a push build without the comparator answers MOBILEGL_PIPE_VERIFY=1 with one MGLOG_W_ONCE. - MGPipeVerifyInputs carries default visibility so the retrace-verify job's nm -D probe can prove the verify library was the one swapped in; pointer corruption flips low bits instead of nulling (a null pointer already null was invisible to the compare), a SharedPtr becomes an aliasing pointer with no control block; CurrentVertexAttributeValue gets its own bitwise equality. --- MobileGL/MG_Backend/MGPipe/PipeInputs.cpp | 48 +++++++-- MobileGL/MG_Backend/MGPipe/PipeInputs.h | 32 ++++-- MobileGL/MG_Impl/Pipe/PipeFill.cpp | 119 ++++++++++++++++++++++ MobileGL/MG_Impl/Pipe/PipeFill.h | 10 ++ 4 files changed, 196 insertions(+), 13 deletions(-) diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp b/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp index 0a9b57730..583479c0c 100644 --- a/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp @@ -7,12 +7,13 @@ // End of Source File Header // The backend-side half of the PipeInputs block: the poison Fatal with its verb name, the -// name lookups the runtime knobs need, and - in a verify build - the per-field equality and -// the corruption injector the comparator uses. Compiled only under MOBILEGL_PIPE_PUSH +// name lookups the runtime knobs need, and - in a verify build - the per-field equality, +// the entry comparator and the corruption injector. Compiled only under MOBILEGL_PIPE_PUSH // (CMakeLists.txt appends it to SOURCE_FILES there), so the pull build never sees it. Spells // no MG_State global: everything that reads the live context lives in MG_Impl/Pipe/PipeFill.cpp. #include +#include #include namespace MobileGL::MG_Pipe { @@ -43,6 +44,8 @@ namespace MobileGL::MG_Pipe { #if MOBILEGL_PIPE_VERIFY namespace { + using CurrentVertexAttributeValue = PipeInputs::CurrentVertexAttributeValue; + // Every overload is declared up front: the array overloads recurse into their element // type, and a call inside a template only sees what was declared before the template. template @@ -54,6 +57,7 @@ namespace MobileGL::MG_Pipe { template Bool StorageEqual(const T (&a)[N], const T (&b)[N]); Bool StorageEqual(const PipeInputs::IndexedCapabilities& a, const PipeInputs::IndexedCapabilities& b); + Bool StorageEqual(const CurrentVertexAttributeValue& a, const CurrentVertexAttributeValue& b); template void CorruptStorage(T& v); template @@ -63,6 +67,7 @@ namespace MobileGL::MG_Pipe { template void CorruptStorage(T (&a)[N]); void CorruptStorage(PipeInputs::IndexedCapabilities& c); + void CorruptStorage(CurrentVertexAttributeValue& v); // ---- equality over one field's storage ---- // O-class storage compares by identity: a raw pointer into the context, or the object a @@ -86,6 +91,14 @@ namespace MobileGL::MG_Pipe { Bool StorageEqual(const PipeInputs::IndexedCapabilities& a, const PipeInputs::IndexedCapabilities& b) { return StorageEqual(a.Blend, b.Blend) && StorageEqual(a.ScissorTest, b.ScissorTest); } + // Three scalar arrays and nothing else (Core.h), so a bitwise compare has no padding to + // false-differ on and keeps a NaN float attribute equal to itself. The size assertion is + // what turns a fourth member into a build break rather than a blind spot. + Bool StorageEqual(const CurrentVertexAttributeValue& a, const CurrentVertexAttributeValue& b) { + static_assert(sizeof(CurrentVertexAttributeValue) == 3 * 4 * 4, + "CurrentVertexAttributeValue grew a member; update the comparator"); + return std::memcmp(&a, &b, sizeof(CurrentVertexAttributeValue)) == 0; + } template Bool StorageEqual(const T& a, const T& b) { return MGPipeFieldEqual(a, b); @@ -93,15 +106,21 @@ namespace MobileGL::MG_Pipe { // ---- corruption of one field's storage ---- // Every shape is perturbed in a way the comparator above must see: a Bool flips, a - // scalar or enum moves by one, a pointer becomes null, a SharedPtr is dropped, an array - // corrupts its first element, and any other struct has its first byte XOR'ed with 0x5A. + // scalar or enum moves by one, a pointer's low bits are flipped (never dereferenced: + // the snapshot is only ever compared), a SharedPtr becomes an aliasing pointer to a + // flipped address with no control block, an array corrupts its first element, and any + // other struct has its first byte XOR'ed with 0x5A. + template + T* FlipPointer(T* p) { + return reinterpret_cast(reinterpret_cast(p) ^ 0x5A); + } template void CorruptStorage(T*& p) { - p = nullptr; + p = FlipPointer(p); } template void CorruptStorage(SharedPtr& p) { - p.reset(); + p = SharedPtr(SharedPtr(), FlipPointer(p.get())); } template void CorruptStorage(T (&a)[N]) { @@ -110,6 +129,9 @@ namespace MobileGL::MG_Pipe { void CorruptStorage(PipeInputs::IndexedCapabilities& c) { CorruptStorage(c.Blend); } + void CorruptStorage(CurrentVertexAttributeValue& v) { + v.floatValue[0] += 1.f; + } template void CorruptStorage(T& v) { if constexpr (std::is_same_v) { @@ -128,13 +150,25 @@ namespace MobileGL::MG_Pipe { } } // namespace - Bool MGPipeInputsFieldEqual(MGPipeInputField field, PipeInputs& a, PipeInputs& b) { + Bool MGPipeInputsFieldEqual(MGPipeInputField field, const PipeInputs& a, const PipeInputs& b) { // A forwarded field has no storage and is equal by definition; VisitStorage answers // false for it, hence the explicit sticky test first. if (kMGPipeInputFieldSticky[static_cast(field)]) return true; return PipeInputs::VisitStorage(field, a, b, [](const auto& x, const auto& y) { return StorageEqual(x, y); }); } + Bool MGPipeVerifyInputs(const PipeInputs& pushed, const PipeInputs& snapshot, const MGPipeFieldMask& mask, + MGPipeInputField* outField) { + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + const auto field = static_cast(i); + if (!MGPipeFieldMaskHas(mask, field)) continue; + if (MGPipeInputsFieldEqual(field, pushed, snapshot)) continue; + if (outField != nullptr) *outField = field; + return false; + } + return true; + } + Bool MGPipeApplyVerifyCorruption(PipeInputs& snapshot, MGPipeInputField field) { return PipeInputs::VisitStorage(field, snapshot, snapshot, [](auto& x, auto&) { CorruptStorage(x); diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.h b/MobileGL/MG_Backend/MGPipe/PipeInputs.h index 67b048c55..3b51f7d23 100644 --- a/MobileGL/MG_Backend/MGPipe/PipeInputs.h +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.h @@ -50,10 +50,20 @@ namespace MobileGL::MG_Pipe { #else #define MGP_INPUT_CHECK(Field) ((void)0) #endif - // The compare-at-read hook of the MOBILEGL_PIPE_VERIFY comparator (P1 brief D8): re-reads - // the same accessor with the same indices from the live context and compares. Armed by - // the comparator commit; until then every build's accessor is a load. + // The compare-at-read hook of the MOBILEGL_PIPE_VERIFY comparator (P1 brief D8), defined + // in MG_Impl/Pipe/PipeFill.cpp: re-reads the field from the live context and compares it + // against the stored value, and reports the FIRST divergence as + // Fatal{PipeVerifyDiffer, "Field@Verb", verb=, where=read} (the indices go in a + // preceding MGLOG_E). Only the live block (gPipeInputs) is verified; a snapshot's own + // accessors are plain loads. Off in every other build. + struct PipeInputs; +#if MOBILEGL_PIPE_VERIFY + void MGPipeVerifyReadHook(const PipeInputs& self, MGPipeInputField field, Uint index0, Uint index1); +#define MGP_INPUT_VERIFY_READ(Field, Index0, Index1) \ + ::MobileGL::MG_Pipe::MGPipeVerifyReadHook(*this, (Field), static_cast(Index0), static_cast(Index1)) +#else #define MGP_INPUT_VERIFY_READ(Field, Index0, Index1) ((void)0) +#endif // The V/O storage of every field that has storage, by field id. The seven F-class // (forwarded) fields have none. PipeInputs::VisitStorage dispatches on this list, which @@ -675,10 +685,20 @@ namespace MobileGL::MG_Pipe { // PipeInputs.cpp. Per-field equality for the entry compare (P1 brief D8): V by value // through G4's MGPipeFieldEqual (bitwise floats, field-wise structs), O by identity, F // always equal (no storage). - Bool MGPipeInputsFieldEqual(MGPipeInputField field, PipeInputs& a, PipeInputs& b); + Bool MGPipeInputsFieldEqual(MGPipeInputField field, const PipeInputs& a, const PipeInputs& b); + // PipeInputs.cpp. The entry compare: every field in `mask` of the pushed block against the + // snapshot, first differing field out. Exported from the shared library on purpose - the + // retrace-verify CI job proves it swapped in a verify build by finding this symbol with + // nm -D, so a "green" run against a library without the comparator cannot happen. +#if defined(__GNUC__) || defined(__clang__) + __attribute__((visibility("default"))) +#endif + Bool MGPipeVerifyInputs(const PipeInputs& pushed, const PipeInputs& snapshot, const MGPipeFieldMask& mask, + MGPipeInputField* outField); // PipeInputs.cpp. Negative control A: perturbs one field's storage (flip a Bool, +1 a - // scalar, ^0x5A the first byte of a struct, null a pointer). Returns false for a forwarded - // field, which has nothing to corrupt. + // scalar, ^0x5A the first byte of a struct, flip a pointer's low bits - never + // dereferenced, the snapshot is only ever compared). Returns false for a forwarded field, + // which has nothing to corrupt. Bool MGPipeApplyVerifyCorruption(PipeInputs& snapshot, MGPipeInputField field); #endif } // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 750239f8e..33891ec1a 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -318,8 +318,114 @@ namespace MobileGL::MG_Pipe { [[maybe_unused]] Bool IsOmitted(MGPipeVerb verb, MGPipeInputField field) { return g_omission.Armed && g_omission.Verb == verb && g_omission.Field == field; } + +#if MOBILEGL_PIPE_VERIFY + // ---- the MOBILEGL_PIPE_VERIFY comparator (P1 brief D8) ---- + // Two mechanisms, both active only when Features.PipeVerify is set: the ENTRY compare + // once per verb (the pushed block against a second snapshot of the live context, + // taken at the same instant - tautological until P2 gives the first arm a real + // filler, and kept falsifiable by MOBILEGL_PIPE_VERIFY_CORRUPT), and the + // COMPARE-AT-READ in every accessor (the stored value against a fresh read of the + // live context at the moment the backend reads it - the arm that is real in P1: it + // catches a value that changed between the verb boundary and the read). + PipeInputs g_snapshot{}; // the second arm + PipeInputs g_readScratch{}; // where the compare-at-read re-read lands + + struct VerifyState { + Bool Parsed = false; + Bool Enabled = false; + Bool Fatal = true; + Bool InHook = false; // a re-read that re-enters an accessor is not re-verified + Optional Corrupt; + std::atomic Divergences{0}; + ~VerifyState() { + const Uint64 count = Divergences.load(std::memory_order_relaxed); + if (count != 0) { + MGLOG_E("MGPipe: verify summary - %llu divergence(s) survived MOBILEGL_PIPE_VERIFY_FATAL=0", + static_cast(count)); + } + } + }; + VerifyState g_verify; + + void ArmVerify() { + if (g_verify.Parsed) return; + g_verify.Parsed = true; + g_verify.Enabled = MG_Config::Features.PipeVerify; + if (!g_verify.Enabled) return; + g_verify.Fatal = MG_Config::Features.PipeVerifyFatal; + const String& corrupt = MG_Config::Features.PipeVerifyCorrupt; + if (!corrupt.empty()) { + const auto field = MGPipeFindInputField(corrupt.c_str()); + if (!field) { + BadKnob("MOBILEGL_PIPE_VERIFY_CORRUPT", corrupt.c_str(), "no such field in kMGPipeInputFieldNames"); + } + g_verify.Corrupt = field; + } + // The lanes grep for this line: a verify run whose log lacks it never armed. + MGLOG_I("MGPipe: verify armed - %u fields, %u verbs, fatal=%d", static_cast(kMGPipeInputFieldCount), + static_cast(kMGPipeVerbCount), g_verify.Fatal ? 1 : 0); + if (g_verify.Corrupt) { + MGLOG_I("MGPipe: verify corruption armed - %s", kMGPipeInputFieldNames[static_cast(*g_verify.Corrupt)]); + } + } + + void ReportDivergence(MGPipeInputField field, const char* where) { + const Uint64 serial = MGPipeFillAccess::Filled(gPipeInputs).CurrentVerbSerial; + MGLOG_F("MGPipe: Fatal{PipeVerifyDiffer, \"%s@%s\", verb=%llu, where=%s}", + kMGPipeInputFieldNames[static_cast(field)], MGPipeVerbName(gPipeInputs.CurrentVerb()), + static_cast(serial), where); + if (g_verify.Fatal) std::abort(); + g_verify.Divergences.fetch_add(1, std::memory_order_relaxed); + } + + void EntryCompare(PipeInputs& inputs, const MGPipeFieldMask& mask) { + if (!g_verify.Enabled) return; + SnapshotFromGLContext(g_snapshot, mask); + // Negative control A: perturb the SNAPSHOT arm, so a green run goes red naming the + // field. A field outside this verb's mask is not compared and stays untouched. + if (g_verify.Corrupt && MGPipeFieldMaskHas(mask, *g_verify.Corrupt)) { + MGPipeApplyVerifyCorruption(g_snapshot, *g_verify.Corrupt); + } + MGPipeInputField differing = MGPipeInputField::kFieldCount; + if (!MGPipeVerifyInputs(inputs, g_snapshot, mask, &differing)) ReportDivergence(differing, "entry"); + } +#endif // MOBILEGL_PIPE_VERIFY } // namespace +#if MOBILEGL_PIPE_VERIFY + void SnapshotFromGLContext(PipeInputs& snapshot, const MGPipeFieldMask& mask) { + auto* ctx = LiveContext(); + MGPipeFillAccess::SetIdentity(snapshot, ctx); + MGPipeFillAccess::SetVerb(snapshot, gPipeInputs.CurrentVerb()); + if (ctx == nullptr) return; + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + const auto field = static_cast(i); + if (!MGPipeFieldMaskHas(mask, field) || kMGPipeInputFieldSticky[i]) continue; + MGPipeFillAccess::CopyField(snapshot, *ctx, field); + } + } + + void MGPipeVerifyReadHook(const PipeInputs& self, MGPipeInputField field, Uint index0, Uint index1) { + if (&self != &gPipeInputs || !g_verify.Enabled || g_verify.InHook) return; + const auto index = static_cast(field); + if (kMGPipeInputFieldSticky[index]) return; + auto* ctx = LiveContext(); + if (ctx == nullptr) return; + // The whole field is re-read and compared - a superset of "the same indices", so a + // divergence in an index the backend did not ask for is still a divergence between + // the boundary value and the live value. The indices only decorate the report. + g_verify.InHook = true; + MGPipeFillAccess::CopyField(g_readScratch, *ctx, field); + const Bool equal = MGPipeInputsFieldEqual(field, self, g_readScratch); + g_verify.InHook = false; + if (equal) return; + MGLOG_E("MGPipe: verify read of %s (index %u, %u) differs from the live context", kMGPipeInputFieldNames[index], + index0, index1); + ReportDivergence(field, "read"); + } +#endif // MOBILEGL_PIPE_VERIFY + void MGPipeSetPoisonOmission(const char* verb, const char* field) { if (verb == nullptr || field == nullptr) { g_omission = PoisonOmission{}; @@ -386,6 +492,16 @@ namespace MobileGL::MG_Pipe { void MGPipeFillForVerb(MGPipeVerb verb) { PipeInputs& inputs = gPipeInputs; ParsePoisonOmissionKnob(); +#if MOBILEGL_PIPE_VERIFY + ArmVerify(); +#else + // The runtime knob without the compiled comparator is a no-op that would look green; + // this warning is what a lane's arming assertion turns into red. + if (MG_Config::Features.PipeVerify) { + MGLOG_W_ONCE("MGPipe: MOBILEGL_PIPE_VERIFY=1 requested but the comparator is not compiled in " + "(configure with -DMOBILEGL_PIPE_VERIFY=ON)"); + } +#endif #if MOBILEGL_PIPE_POISON MGPipeFilledState& filled = MGPipeFillAccess::Filled(inputs); // Starts at 1, so FilledGen == 0 means "never filled". @@ -415,5 +531,8 @@ namespace MobileGL::MG_Pipe { if (!IsOmitted(verb, field)) filled.FilledGen[i] = filled.CurrentVerbSerial; #endif } +#if MOBILEGL_PIPE_VERIFY + EntryCompare(inputs, mask); +#endif } } // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.h b/MobileGL/MG_Impl/Pipe/PipeFill.h index f433da9fc..deda59914 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.h +++ b/MobileGL/MG_Impl/Pipe/PipeFill.h @@ -16,6 +16,8 @@ #if MOBILEGL_PIPE_PUSH #include namespace MobileGL::MG_Pipe { + struct PipeInputs; + // PipeFill.cpp. Bumps the per-verb serial, records the verb and the context identity, // and copies every field in the verb class's may-read mask (kMGPipeClassFieldMask) out // of the live GLContext, stamping each with the new serial. In a verify build it then @@ -29,6 +31,14 @@ namespace MobileGL::MG_Pipe { // fill; tests call it directly. Both null clears the omission. An unknown name is // Fatal{PipeVerifyBadKnob}. void MGPipeSetPoisonOmission(const char* verb, const char* field); + +#if MOBILEGL_PIPE_VERIFY + // PipeFill.cpp. The second arm of the comparator (P1 brief D8, ARCHITECTURE.md 13.2-2): + // fills `snapshot` from the live GLContext the old way, for every field in `mask`. This + // is the branch that survives P13, which is why it is its own function rather than the + // filler's loop. + void SnapshotFromGLContext(PipeInputs& snapshot, const MGPipeFieldMask& mask); +#endif } // namespace MobileGL::MG_Pipe #define MGP_FILL(Verb) ::MobileGL::MG_Pipe::MGPipeFillForVerb(::MobileGL::MG_Pipe::MGPipeVerb::Verb) #else From 83b16561c20428dd15995ed3b2b4ad5496a97bab Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 02:02:07 -0400 Subject: [PATCH 046/529] [Feat] (Pipe): give the six value structs G4 field lists and assert the lists cover their members - the memcmp fallback is now a compile error, floats in vector types compare bitwise - PipeFields.def gains MGP_FIELDS_RenderStateParameters (65 members), PixelStoreParameters (8), PerBufferBlendState (7), StencilFaceState (7), DynamicBackendParameters (85) and MGHostSpan (Ptr, Seg, Size, Offset), all appended to MGP_VERIFY_PAYLOAD_LIST: kMGPipeVerifiedPayloadCount 63 -> 69, and ResidualValueBlock / MGPPixelPackState / MGPCaps are now compared field by field all the way down. - gen_verify: MEMCMP_FALLBACK_TYPES is empty and the generic MGPipeFieldEqual's last branch is static_assert(sizeof(T) == 0) - a struct without a field list is a compile error, not a padding false positive; Array gets an element-wise overload, and a VecBase-derived vector (FloatVec4, IntVec4, BoolVec4...) is detected by a probe and compared bitwise over its data, because VecBase::operator== is IEEE == and a derived-to-base overload would lose resolution to the exact-match generic template. - check_field_lists_cover_struct_members(): for every payload in MGP_VERIFY_PAYLOAD_LIST, parse `struct {` out of MGPipeTypes.h / MGPipeValueTypes.h / MGPipeHostSpan.h / BackendObject.h (comments and strings masked, statics, functions, nested types and Pad members excluded) and refuse a member without an F(...) or an F(...) that is not a member; runs in both modes, so it is a pipe-gates gate. - scan_live_accessors(): every MGB_CTX-> / pGLContext-> read under MG_Backend must have a Coverage.def row (rows nobody reads are printed: today only the dead GetBoundTransformFeedbackName). - --self-test: six negative controls (struct member without F, F without member, payload without struct, verb missing from FillPoints.def, verb outside GLFunctionsTable, field row naming a non-accessor) that must each trip, plus a positive control; zero trips is itself an error. --- MobileGL/MG_Pipe/PipeFields.def | 83 ++++- MobileGL/MG_Pipe/generated/PipeVerify.inc | 89 ++++- scripts/gen_pipe.py | 382 ++++++++++++++++++++-- 3 files changed, 522 insertions(+), 32 deletions(-) diff --git a/MobileGL/MG_Pipe/PipeFields.def b/MobileGL/MG_Pipe/PipeFields.def index d142ac804..30dd14979 100644 --- a/MobileGL/MG_Pipe/PipeFields.def +++ b/MobileGL/MG_Pipe/PipeFields.def @@ -12,9 +12,11 @@ // exactly what makes a memcmp of RenderStateParameters false-DIFFER // (DirectGLES.cpp documents that behaviour where it does the same comparison itself). // -// Hand maintained alongside MGPipeTypes.h. Adding a field to a payload without adding it -// here makes the comparator blind to it; that gap closes in P1, when the verify harness -// goes live and the comparator's coverage is itself asserted. +// Hand maintained alongside MGPipeTypes.h, MGPipeValueTypes.h, MGPipeHostSpan.h and +// MG_Backend/BackendObject.h. Adding a member to one of these structs without adding it here +// would make the comparator blind to it, so gen_pipe.py asserts - in both modes, hence in +// pipe-gates - that every list below names exactly the direct data members of its struct +// (P1 brief D8; a member named Pad is padding and is not listed). // // clang-format off @@ -218,6 +220,77 @@ #define MGP_FIELDS_MGPSurfaceInfo(F) \ F(Width) F(Height) F(InternalFormat) F(Samples) F(Layers) F(IsDefault) +// ---- the value structs and the host span (P1 brief D8). Not call payloads themselves, but +// members of ones (ResidualValueBlock, MGPPixelPackState, MGPCaps) and of PipeInputs, so the +// comparator has to see INTO them: with these lists the memcmp fallback of MGPipeFieldEqual is +// gone (a struct without a list is a compile error), and gen_pipe.py asserts every list names +// every direct data member of its struct - Pad-named members are padding and excluded - so a +// member added to RenderStateParameters without a row here fails pipe-gates. + +#define MGP_FIELDS_RenderStateParameters(F) \ + F(Viewports) F(LineWidth) F(PointSize) F(PatchVertices) F(PatchDefaultOuterLevel) \ + F(PatchDefaultInnerLevel) F(PolygonOffsetFactor) F(PolygonOffsetUnits) F(PolygonOffsetClamp) \ + F(ClipOrigin) F(ClipDepthMode) F(BlendStates) F(LogicOp) F(DepthTestEnabled) F(DepthFunc) \ + F(DepthMask) F(ColorMasks) F(ClearColor) F(ClearDepth) F(ClearStencil) F(BlendColor) \ + F(DepthRanges) F(SampleCoverageValue) F(SampleCoverageInvert) F(SampleMaskValue) \ + F(MinSampleShadingValue) F(StencilStates) F(CullFaceEnabled) F(CullFaceModeSetting) \ + F(FrontFaceModeSetting) F(ProvokingVertexModeSetting) F(LineSmoothHint) F(PolygonSmoothHint) \ + F(TextureCompressionHint) F(FragmentShaderDerivativeHint) F(PointFadeThresholdSize) \ + F(PointSpriteCoordOrigin) F(ClampReadColor) F(PolygonModeFront) F(PolygonModeBack) \ + F(PrimitiveRestartIndex) F(ColorLogicOpEnabled) F(DebugOutputEnabled) \ + F(DebugOutputSynchronousEnabled) F(DitherEnabled) F(LineSmoothEnabled) F(MultisampleEnabled) \ + F(PolygonOffsetFillEnabled) F(PolygonOffsetLineEnabled) F(PolygonOffsetPointEnabled) \ + F(PolygonSmoothEnabled) F(PrimitiveRestartEnabled) F(PrimitiveRestartFixedIndexEnabled) \ + F(RasterizerDiscardEnabled) F(SampleAlphaToCoverageEnabled) F(SampleAlphaToOneEnabled) \ + F(SampleCoverageEnabled) F(SampleMaskEnabled) F(SampleShadingEnabled) F(StencilTestEnabled) \ + F(ProgramPointSizeEnabled) F(ScissorTestEnabledMask) F(ScissorBoxes) F(ScissorBoxWrittenMask) \ + F(ClipDistanceEnabledMask) + +#define MGP_FIELDS_PixelStoreParameters(F) \ + F(SwapBytes) F(LSBFirst) F(RowLength) F(ImageHeight) F(SkipPixels) F(SkipRows) F(SkipImages) \ + F(Alignment) + +#define MGP_FIELDS_PerBufferBlendState(F) \ + F(Enabled) F(SrcFactorRGB) F(DstFactorRGB) F(SrcFactorAlpha) F(DstFactorAlpha) F(ColorEquation) \ + F(AlphaEquation) + +#define MGP_FIELDS_StencilFaceState(F) \ + F(Func) F(Ref) F(ValueMask) F(WriteMask) F(FailOp) F(PassDepthFailOp) F(PassDepthPassOp) + +#define MGP_FIELDS_DynamicBackendParameters(F) \ + F(UniformBufferOffsetAlignment) F(ShaderStorageBufferOffsetAlignment) F(MaxTextureMaxAnisotropy) \ + F(AliasedLineWidthRangeMin) F(AliasedLineWidthRangeMax) F(SmoothLineWidthRangeMin) \ + F(SmoothLineWidthRangeMax) F(SmoothLineWidthGranularity) F(PointSizeRangeMin) \ + F(PointSizeRangeMax) F(PointSizeGranularity) F(Max3DTextureSize) F(MaxArrayTextureLayers) \ + F(MaxCubeMapTextureSize) F(MaxFramebufferWidth) F(MaxFramebufferHeight) F(MaxFramebufferLayers) \ + F(MaxRenderbufferSize) F(MaxTextureSize) F(MaxColorTextureSamples) F(MaxDepthTextureSamples) \ + F(MaxFramebufferSamples) F(MaxIntegerSamples) F(MaxSamples) F(MaxSampleMaskWords) \ + F(MaxPatchVertices) F(MaxTessGenLevel) F(MinProgramTextureGatherOffset) \ + F(MaxProgramTextureGatherOffset) F(MaxTextureImageUnits) F(MaxVertexTextureImageUnits) \ + F(MaxComputeTextureImageUnits) F(MaxCombinedTextureImageUnits) F(MaxVertexAttribs) \ + F(MaxComputeShaderStorageBlocks) F(MaxCombinedShaderStorageBlocks) \ + F(MaxVertexShaderStorageBlocks) F(MaxTessControlShaderStorageBlocks) \ + F(MaxTessEvaluationShaderStorageBlocks) F(MaxGeometryShaderStorageBlocks) \ + F(MaxFragmentShaderStorageBlocks) F(MaxComputeUniformBlocks) F(MaxComputeWorkGroupInvocations) \ + F(MaxComputeWorkGroupCount) F(MaxComputeWorkGroupSize) F(MaxShaderStorageBufferBindings) \ + F(MaxTextureBufferSize) F(TextureBufferOffsetAlignment) F(MaxUniformBufferBindings) \ + F(MaxUniformBlockSize) F(MaxImageUnits) F(MaxCombinedImageUniforms) F(MaxVertexImageUniforms) \ + F(MaxGeometryImageUniforms) F(MaxFragmentImageUniforms) F(MaxComputeImageUniforms) \ + F(MaxDrawBuffers) F(MaxColorAttachments) F(MaxClipDistances) F(MaxCullDistances) \ + F(MaxCombinedClipAndCullDistances) F(MaxViewports) F(LayerProvokingVertex) \ + F(ViewportIndexProvokingVertex) F(MaxViewportWidth) F(MaxViewportHeight) \ + F(ViewportBoundsRangeMin) F(ViewportBoundsRangeMax) F(ViewportSubpixelBits) \ + F(MinFragmentInterpolationOffset) F(MaxFragmentInterpolationOffset) \ + F(FragmentInterpolationOffsetBits) F(SupportsWideLines) \ + F(SupportsDistinctDepthStencilAttachments) F(PerLayerFramebufferAttachmentTargets) \ + F(SupportsShaderFloat64) F(SupportsFloat64VertexAttributes) F(SupportsTessellationPointSize) \ + F(SupportsGeometryPointSize) F(MaxShaderStorageBlockSize) F(SubgroupSize) \ + F(SubgroupSupportedStages) F(SubgroupSupportedFeatures) F(SubgroupQuadOperationsInAllStages) \ + F(GpuVendor) + +#define MGP_FIELDS_MGHostSpan(F) \ + F(Ptr) F(Seg) F(Size) F(Offset) + // Every payload above, in the order the comparator is generated. Keep in sync with the // macros; gen_pipe.py reads THIS list to know what to emit. #define MGP_VERIFY_PAYLOAD_LIST(P) \ @@ -233,6 +306,8 @@ P(MGPCopyRegion) P(MGPBlit) P(MGPClear) P(MGPMipPlan) P(MGPReadbackInfo) P(MGPDrawInfo) \ P(MGPDrawRange) P(MGPDrawIndirect) P(MGPGridInfo) P(MGPMemoryBarrier) P(MGPStreamOutputBegin) \ P(MGPXfbAccounting) P(MGPStreamOutputControl) P(MGPFlush) P(MGPPresent) P(MGPSwapInterval) \ - P(MGPSurfaceInfo) + P(MGPSurfaceInfo) \ + P(RenderStateParameters) P(PixelStoreParameters) P(PerBufferBlendState) P(StencilFaceState) \ + P(DynamicBackendParameters) P(MGHostSpan) // clang-format on diff --git a/MobileGL/MG_Pipe/generated/PipeVerify.inc b/MobileGL/MG_Pipe/generated/PipeVerify.inc index e6f676ec9..c4c4661ae 100644 --- a/MobileGL/MG_Pipe/generated/PipeVerify.inc +++ b/MobileGL/MG_Pipe/generated/PipeVerify.inc @@ -27,6 +27,23 @@ template struct MGPipeHasFieldVerifier : std::false_type {}; +// A vector type (FloatVec4, IntVec4, BoolVec4...) is detected through its VecBase and +// compared BITWISE over its data: VecBase::operator== is IEEE ==, under which a NaN patch +// level would differ from itself. The probe rather than an overload because a +// derived-to-base conversion loses overload resolution to the exact-match generic template. +template +std::true_type MGPipeVecBaseProbe(const VecBase*); +std::false_type MGPipeVecBaseProbe(const void*); +template +inline constexpr Bool kMGPipeIsVecBase = decltype(MGPipeVecBaseProbe(static_cast(nullptr)))::value; + +template +inline Bool MGPipeFieldEqual(const T& a, const T& b); +template +inline Bool MGPipeFieldEqual(const Array& a, const Array& b); +template +inline Bool MGPipeFieldEqual(const T (&a)[N], const T (&b)[N]); + inline Bool MGPipeVerify(const MGPBlobRef& a, const MGPBlobRef& b, const char** outField); inline Bool MGPipeVerify(const MGPRange& a, const MGPRange& b, const char** outField); inline Bool MGPipeVerify(const MGPBox& a, const MGPBox& b, const char** outField); @@ -90,6 +107,12 @@ inline Bool MGPipeVerify(const MGPFlush& a, const MGPFlush& b, const char** outF inline Bool MGPipeVerify(const MGPPresent& a, const MGPPresent& b, const char** outField); inline Bool MGPipeVerify(const MGPSwapInterval& a, const MGPSwapInterval& b, const char** outField); inline Bool MGPipeVerify(const MGPSurfaceInfo& a, const MGPSurfaceInfo& b, const char** outField); +inline Bool MGPipeVerify(const RenderStateParameters& a, const RenderStateParameters& b, const char** outField); +inline Bool MGPipeVerify(const PixelStoreParameters& a, const PixelStoreParameters& b, const char** outField); +inline Bool MGPipeVerify(const PerBufferBlendState& a, const PerBufferBlendState& b, const char** outField); +inline Bool MGPipeVerify(const StencilFaceState& a, const StencilFaceState& b, const char** outField); +inline Bool MGPipeVerify(const DynamicBackendParameters& a, const DynamicBackendParameters& b, const char** outField); +inline Bool MGPipeVerify(const MGHostSpan& a, const MGHostSpan& b, const char** outField); template <> struct MGPipeHasFieldVerifier : std::true_type {}; @@ -217,12 +240,26 @@ template <> struct MGPipeHasFieldVerifier : std::true_type {}; template <> struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> +struct MGPipeHasFieldVerifier : std::true_type {}; template inline Bool MGPipeFieldEqual(const T& a, const T& b) { if constexpr (MGPipeHasFieldVerifier::value) { const char* unusedField = nullptr; return MGPipeVerify(a, b, &unusedField); + } else if constexpr (kMGPipeIsVecBase) { + return std::memcmp(a.data.data(), b.data.data(), sizeof(a.data)) == 0; } else if constexpr (std::is_floating_point_v) { return std::memcmp(&a, &b, sizeof(T)) == 0; } else if constexpr (std::is_scalar_v || std::is_enum_v) { @@ -230,13 +267,21 @@ inline Bool MGPipeFieldEqual(const T& a, const T& b) { } else if constexpr (requires(const T& x, const T& y) { x == y; }) { return a == b; } else { - // MEMCMP FALLBACK. Only reached by the payload members that are still value structs - // without a field list (RenderStateParameters, PixelStoreParameters, - // DynamicBackendParameters) and by MGHostSpan. P0.5 moved the first two into - // MGPipeValueTypes.h; P1 gives them field lists of their own, at which point this - // branch stops being reachable from any payload. - return std::memcmp(&a, &b, sizeof(T)) == 0; + // NO MEMCMP FALLBACK. Every value struct has a field list in PipeFields.def since P1 + // (and gen_pipe.py asserts each list covers its struct's members); a type reaching + // this branch is one nobody gave a field list, and a memcmp would false-differ on + // its padding. A compile error is the honest answer. + static_assert(sizeof(T) == 0, "no field list in PipeFields.def for this type"); + return false; + } +} + +template +inline Bool MGPipeFieldEqual(const Array& a, const Array& b) { + for (SizeT i = 0; i < N; ++i) { + if (!MGPipeFieldEqual(a[i], b[i])) return false; } + return true; } template @@ -568,6 +613,36 @@ inline Bool MGPipeVerify(const MGPSurfaceInfo& a, const MGPSurfaceInfo& b, const return true; } +inline Bool MGPipeVerify(const RenderStateParameters& a, const RenderStateParameters& b, const char** outField) { + MGP_FIELDS_RenderStateParameters(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const PixelStoreParameters& a, const PixelStoreParameters& b, const char** outField) { + MGP_FIELDS_PixelStoreParameters(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const PerBufferBlendState& a, const PerBufferBlendState& b, const char** outField) { + MGP_FIELDS_PerBufferBlendState(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const StencilFaceState& a, const StencilFaceState& b, const char** outField) { + MGP_FIELDS_StencilFaceState(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const DynamicBackendParameters& a, const DynamicBackendParameters& b, const char** outField) { + MGP_FIELDS_DynamicBackendParameters(MGP_VERIFY_FIELD) + return true; +} + +inline Bool MGPipeVerify(const MGHostSpan& a, const MGHostSpan& b, const char** outField) { + MGP_FIELDS_MGHostSpan(MGP_VERIFY_FIELD) + return true; +} + #undef MGP_VERIFY_FIELD -inline constexpr SizeT kMGPipeVerifiedPayloadCount = 63; +inline constexpr SizeT kMGPipeVerifiedPayloadCount = 69; diff --git a/scripts/gen_pipe.py b/scripts/gen_pipe.py index 2a644d804..a26d1ad94 100644 --- a/scripts/gen_pipe.py +++ b/scripts/gen_pipe.py @@ -25,11 +25,15 @@ them and fails on a diff, which is what keeps the seven generators from drifting apart from the catalogue (they all consume the same .def). - python3 scripts/gen_pipe.py # write the generated files, print the summary - python3 scripts/gen_pipe.py --check # fail if regenerating would change anything + python3 scripts/gen_pipe.py # write the generated files, print the summary + python3 scripts/gen_pipe.py --check # fail if regenerating would change anything + python3 scripts/gen_pipe.py --self-test # the negative controls: each gate below must trip Both modes refuse a catalogue whose call payload has no field list in PipeFields.def: a -payload the G4 comparator cannot see is a payload MOBILEGL_PIPE_VERIFY is blind to. +payload the G4 comparator cannot see is a payload MOBILEGL_PIPE_VERIFY is blind to. Both +also refuse a field list that does not name every data member of its struct (or names one +that is not a member), and a backend accessor read through MGB_CTX-> / pGLContext-> that +has no Coverage.def row (P1 brief D8, D12). """ import argparse @@ -164,16 +168,23 @@ def parse_calls(): return calls -# The member types the G4 comparator falls back to memcmp for (see gen_verify): the -# MG_Pipe / MG_Backend value structs and MGHostSpan. They are not call payloads and get -# field lists of their own in P1 (P0.5 moved the types). Nothing else may be missing from -# PipeFields.def. -MEMCMP_FALLBACK_TYPES = { - "RenderStateParameters", - "PixelStoreParameters", - "DynamicBackendParameters", - "MGHostSpan", -} +# Types the G4 comparator may fall back to memcmp for: NONE since P1. The value structs and +# MGHostSpan have field lists of their own, and the fallback branch of MGPipeFieldEqual is a +# static_assert, so a future struct without a field list is a compile error rather than a +# padding false positive. Kept as a (deliberately empty) set so the check below keeps its +# shape. +MEMCMP_FALLBACK_TYPES = set() + +# Where the structs named in PipeFields.def are declared: the payload header, the value +# header, the host-span header and - for DynamicBackendParameters, the caps block - the +# backend object header. +FIELD_LIST_STRUCT_HEADERS = [ + os.path.join(PIPE_DIR, "MGPipeTypes.h"), + os.path.join(PIPE_DIR, "MGPipeValueTypes.h"), + os.path.join(PIPE_DIR, "MGPipeHostSpan.h"), + FUNCTION_TABLE_HEADER, +] +BACKEND_DIR = os.path.join(REPO_ROOT, "MobileGL", "MG_Backend") def parse_verify_payloads(): @@ -188,6 +199,253 @@ def parse_verify_payloads(): return payloads +FIELD_LIST_RE = re.compile(r"#define MGP_FIELDS_(\w+)\(F\)") + + +def parse_field_lists(text=None): + """PipeFields.def -> {payload: [field, ...]} for every MGP_FIELDS_(F) macro; the + macro ends at the first line without a continuation backslash.""" + if text is None: + text = read(os.path.join(PIPE_DIR, "PipeFields.def")) + lists = {} + lines = text.splitlines() + i = 0 + while i < len(lines): + match = FIELD_LIST_RE.match(lines[i]) + if not match: + i += 1 + continue + name = match.group(1) + body = [] + while i < len(lines): + body.append(lines[i]) + if not lines[i].rstrip().endswith("\\"): + break + i += 1 + i += 1 + fields = re.findall(r"\bF\((\w+)\)", "\n".join(body)) + if name in lists: + sys.exit("PipeFields.def: MGP_FIELDS_%s is defined twice" % name) + lists[name] = fields + return lists + + +def mask_comments_and_strings(text): + """Replace comment and string-literal bodies with spaces, keeping every offset and + newline, so the regexes below cannot match inside a comment or a literal + (gen_pipe_dirty_surface.py's shape).""" + out = list(text) + i = 0 + n = len(text) + while i < n: + c = text[i] + if c == "/" and i + 1 < n and text[i + 1] == "/": + while i < n and text[i] != "\n": + out[i] = " " + i += 1 + elif c == "/" and i + 1 < n and text[i + 1] == "*": + out[i] = out[i + 1] = " " + i += 2 + while i < n and not (text[i] == "*" and i + 1 < n and text[i + 1] == "/"): + if text[i] != "\n": + out[i] = " " + i += 1 + if i < n: + out[i] = " " + if i + 1 < n: + out[i + 1] = " " + i += 2 + elif c in "\"'": + quote = c + i += 1 + while i < n and text[i] != quote: + if text[i] == "\\": + out[i] = " " + i += 1 + if i < n and text[i] != "\n": + out[i] = " " + i += 1 + if i < n: + out[i] = " " + i += 1 + else: + i += 1 + return "".join(out) + + +PADDING_MEMBER_RE = re.compile(r"^Pad\d*$") +NESTED_TYPE_RE = re.compile(r"^\s*(?:struct|class|union|enum)\b") +FUNCTION_HEAD_RE = re.compile(r"\)\s*(?:const\s*)?(?:noexcept\s*)?(?:override\s*)?(?:=\s*(?:default|delete|0)\s*)?$") +NON_MEMBER_RE = re.compile(r"^\s*(?:static|using|typedef|friend|template|explicit|virtual|operator)\b") + + +def find_struct_body(masked, name): + """The text between the braces of `struct {` (an optional base list allowed), or + None. Comments and strings must already be masked.""" + match = re.search(r"\bstruct\s+%s\s*(?::[^{;]*)?\{" % re.escape(name), masked) + if not match: + return None + i = match.end() + depth = 1 + start = i + while i < len(masked) and depth: + if masked[i] == "{": + depth += 1 + elif masked[i] == "}": + depth -= 1 + i += 1 + return masked[start:i - 1] + + +def matching_brace(text, open_index): + depth = 0 + j = open_index + while j < len(text): + if text[j] == "{": + depth += 1 + elif text[j] == "}": + depth -= 1 + if depth == 0: + return j + j += 1 + return len(text) - 1 + + +def strip_balanced(text, open_char, close_char): + out = [] + depth = 0 + for c in text: + if c == open_char: + depth += 1 + elif c == close_char: + depth -= 1 + elif depth == 0: + out.append(c) + return "".join(out) + + +def member_names(statement): + """The data-member names declared by one struct-body statement, or [] for anything + that is not a data member (a function, a static, a using, an access label...).""" + statement = re.sub(r"^\s*(?:public|private|protected)\s*:", "", statement).strip() + if not statement or NON_MEMBER_RE.match(statement): + return [] + # The declarator part is what precedes the default initializer. + left = re.split(r"=|\{\.\.\.\}", statement, maxsplit=1)[0] + if "(" in left: + return [] # a function declaration + left = strip_balanced(left, "<", ">") + left = strip_balanced(left, "[", "]") + names = [] + for k, chunk in enumerate(left.split(",")): + tokens = re.findall(r"[A-Za-z_]\w*", chunk) + if k == 0 and len(tokens) < 2: + return [] # no type: not a declaration + if not tokens: + return [] + names.append(tokens[-1]) + return [n for n in names if not PADDING_MEMBER_RE.match(n)] + + +def struct_data_members(body): + """Direct data members of a struct body, in declaration order: statics, member + functions, nested types and Pad-named members excluded.""" + members = [] + statement = [] + i = 0 + n = len(body) + while i < n: + c = body[i] + if c == "{": + head = "".join(statement) + close = matching_brace(body, i) + if NESTED_TYPE_RE.match(head.strip()) or FUNCTION_HEAD_RE.search(head.rstrip()): + # A nested type or a member-function body: not a data member. Swallow the + # nested type's trailing semicolon too. + statement = [] + i = close + 1 + if NESTED_TYPE_RE.match(head.strip()): + while i < n and body[i] in " \t\n": + i += 1 + if i < n and body[i] == ";": + i += 1 + continue + statement.append("{...}") # a brace default initializer + i = close + 1 + continue + if c == ";": + members.extend(member_names("".join(statement))) + statement = [] + i += 1 + continue + statement.append(c) + i += 1 + return members + + +def check_field_lists_cover_struct_members(field_lists, payloads, header_texts=None): + """Every payload in MGP_VERIFY_PAYLOAD_LIST: its MGP_FIELDS_ list must name every direct + data member of `struct {` (Pad-named members are padding and excluded) and + nothing that is not a member. A member without an F(...) is a field MOBILEGL_PIPE_VERIFY + is blind to; an F(...) that is not a member is a list that stopped describing its + struct. Runs in both modes, --check included, so it is part of pipe-gates.""" + if header_texts is None: + header_texts = [read(path) for path in FIELD_LIST_STRUCT_HEADERS] + masked = [mask_comments_and_strings(t) for t in header_texts] + problems = [] + for payload in payloads: + body = None + for m in masked: + body = find_struct_body(m, payload) + if body is not None: + break + if body is None: + problems.append("%s: struct not found in %s" % (payload, ", ".join(os.path.basename(p) for p in FIELD_LIST_STRUCT_HEADERS))) + continue + members = struct_data_members(body) + listed = field_lists.get(payload, []) + missing = [m for m in members if m not in listed] + extra = [f for f in listed if f not in members] + if missing: + problems.append("%s: member(s) with no F(...) in PipeFields.def: %s" % (payload, ", ".join(missing))) + if extra: + problems.append("%s: F(...) name(s) that are not members: %s" % (payload, ", ".join(extra))) + if not members: + problems.append("%s: no data members parsed" % payload) + if problems: + sys.exit("PipeFields.def does not cover its structs:\n " + "\n ".join(problems)) + + +ACCESSOR_READ_RE = re.compile(r"\b(?:MGB_CTX|pGLContext)\s*->\s*(\w+)") + + +def scan_live_accessors(accessors, backend_dir=None, verbose=True): + """Every accessor a backend reads through MGB_CTX-> or pGLContext-> (comments and + strings masked) must have a Coverage.def row - a read without a row is a PipeInputs + field that does not exist. Rows no backend reads are printed, not refused (the dead + GetBoundTransformFeedbackName row is deliberate). Returns the set of names read.""" + if backend_dir is None: + backend_dir = BACKEND_DIR + known = set(name for name, _ in accessors) + read_names = {} + for root, _, files in os.walk(backend_dir): + for name in sorted(files): + if not name.endswith((".cpp", ".h")): + continue + path = os.path.join(root, name) + masked = mask_comments_and_strings(read(path)) + for match in ACCESSOR_READ_RE.finditer(masked): + read_names.setdefault(match.group(1), set()).add(os.path.relpath(path, REPO_ROOT)) + unknown = sorted(n for n in read_names if n not in known) + if unknown: + sys.exit("Coverage.def: accessor(s) read by a backend with no row: %s" + % ", ".join("%s (%s)" % (n, ", ".join(sorted(read_names[n]))) for n in unknown)) + unread = sorted(known - set(read_names)) + if verbose and unread: + print("gen_pipe: %d accessor row(s) no backend reads: %s" % (len(unread), ", ".join(unread))) + return set(read_names) + + def check_call_payloads_have_field_lists(calls, payloads): """Every payload PipeCalls.def names must have a G4 field list, or the verify comparator is silently blind to that call. Runs in both modes, --check included.""" @@ -403,6 +661,23 @@ def gen_verify(payloads): """) out.append("template ") out.append("struct MGPipeHasFieldVerifier : std::false_type {};\n") + out.append("""// A vector type (FloatVec4, IntVec4, BoolVec4...) is detected through its VecBase and +// compared BITWISE over its data: VecBase::operator== is IEEE ==, under which a NaN patch +// level would differ from itself. The probe rather than an overload because a +// derived-to-base conversion loses overload resolution to the exact-match generic template. +template +std::true_type MGPipeVecBaseProbe(const VecBase*); +std::false_type MGPipeVecBaseProbe(const void*); +template +inline constexpr Bool kMGPipeIsVecBase = decltype(MGPipeVecBaseProbe(static_cast(nullptr)))::value; + +template +inline Bool MGPipeFieldEqual(const T& a, const T& b); +template +inline Bool MGPipeFieldEqual(const Array& a, const Array& b); +template +inline Bool MGPipeFieldEqual(const T (&a)[N], const T (&b)[N]); +""") for payload in payloads: out.append("inline Bool MGPipeVerify(const %s& a, const %s& b, const char** outField);" % (payload, payload)) @@ -416,6 +691,8 @@ def gen_verify(payloads): if constexpr (MGPipeHasFieldVerifier::value) { const char* unusedField = nullptr; return MGPipeVerify(a, b, &unusedField); + } else if constexpr (kMGPipeIsVecBase) { + return std::memcmp(a.data.data(), b.data.data(), sizeof(a.data)) == 0; } else if constexpr (std::is_floating_point_v) { return std::memcmp(&a, &b, sizeof(T)) == 0; } else if constexpr (std::is_scalar_v || std::is_enum_v) { @@ -423,15 +700,23 @@ def gen_verify(payloads): } else if constexpr (requires(const T& x, const T& y) { x == y; }) { return a == b; } else { - // MEMCMP FALLBACK. Only reached by the payload members that are still value structs - // without a field list (RenderStateParameters, PixelStoreParameters, - // DynamicBackendParameters) and by MGHostSpan. P0.5 moved the first two into - // MGPipeValueTypes.h; P1 gives them field lists of their own, at which point this - // branch stops being reachable from any payload. - return std::memcmp(&a, &b, sizeof(T)) == 0; + // NO MEMCMP FALLBACK. Every value struct has a field list in PipeFields.def since P1 + // (and gen_pipe.py asserts each list covers its struct's members); a type reaching + // this branch is one nobody gave a field list, and a memcmp would false-differ on + // its padding. A compile error is the honest answer. + static_assert(sizeof(T) == 0, "no field list in PipeFields.def for this type"); + return false; } } +template +inline Bool MGPipeFieldEqual(const Array& a, const Array& b) { + for (SizeT i = 0; i < N; ++i) { + if (!MGPipeFieldEqual(a[i], b[i])) return false; + } + return true; +} + template inline Bool MGPipeFieldEqual(const T (&a)[N], const T (&b)[N]) { for (SizeT i = 0; i < N; ++i) { @@ -553,13 +838,14 @@ def parse_function_table(): return members -def parse_fill_points(accessors, table_members=None): +def parse_fill_points(accessors, table_members=None, text=None): """FillPoints.def -> (verbs, classes, fields): verbs is [(verb, class)] in file order, classes is [class], fields is {class: [field]}. Refuses a verb set that is not exactly GLFunctionsTable's function-pointer members in declaration order, a verb in two classes, a class with no verbs, a field that is not an accessor, a duplicate (class, field) row, and a class row that names no class.""" - text = read(os.path.join(PIPE_DIR, "FillPoints.def")) + if text is None: + text = read(os.path.join(PIPE_DIR, "FillPoints.def")) if table_members is None: table_members = parse_function_table() @@ -793,16 +1079,70 @@ def write(path, text, check, changed): handle.write(text) +def expect_trip(name, fn): + """Runs one negative control; a gate that lets it through is the failure.""" + try: + fn() + except SystemExit as trip: + print("gen_pipe: self-test %s: tripped as expected (%s)" % (name, str(trip).splitlines()[0][:100])) + return 1 + print("gen_pipe: self-test %s: DID NOT TRIP" % name, file=sys.stderr) + return 0 + + +def self_test(accessors): + """The negative controls (check_include_closure.py's shape): each gate must go red for + its reason, and zero trips is itself an error.""" + canned_struct = "struct Canned {\n Uint32 A;\n Uint32 B, C;\n Uint8 Pad0[3];\n void F() { return; }\n};\n" + controls = [ + ("struct member without F(...)", + lambda: check_field_lists_cover_struct_members({"Canned": ["A", "B"]}, ["Canned"], [canned_struct])), + ("F(...) that is not a member", + lambda: check_field_lists_cover_struct_members({"Canned": ["A", "B", "C", "D"]}, ["Canned"], [canned_struct])), + ("payload with no struct", + lambda: check_field_lists_cover_struct_members({"Nowhere": ["A"]}, ["Nowhere"], [canned_struct])), + ] + fill_text = read(os.path.join(PIPE_DIR, "FillPoints.def")) + verb_row = re.compile(r"X\(\s*DrawArrays\s*,\s*kDraw\s*\)") + field_row = re.compile(r"X\(\s*kDraw\s*,\s*GetBoundVertexArray\s*\)") + if not verb_row.search(fill_text) or not field_row.search(fill_text): + sys.exit("gen_pipe: self-test: FillPoints.def lost the rows the controls edit") + controls.append(("verb missing from FillPoints.def", lambda: parse_fill_points( + accessors, text=verb_row.sub("", fill_text, count=1)))) + controls.append(("verb that is not a GLFunctionsTable member", lambda: parse_fill_points( + accessors, text=verb_row.sub("X(DrawArrays, kDraw) X(NotAVerb, kDraw)", fill_text, count=1)))) + controls.append(("field row naming a non-accessor", lambda: parse_fill_points( + accessors, text=field_row.sub("X(kDraw, NotAnAccessor)", fill_text, count=1)))) + trips = 0 + for name, fn in controls: + trips += expect_trip(name, fn) + # The positive control: the canned struct's exact list passes, and the parser sees the + # padding member as padding and the function as not a member. + check_field_lists_cover_struct_members({"Canned": ["A", "B", "C"]}, ["Canned"], [canned_struct]) + if trips == 0: + sys.exit("gen_pipe: self-test: no negative control tripped - the gates are not checking anything") + if trips != len(controls): + sys.exit("gen_pipe: self-test: %d of %d negative controls did not trip" % (len(controls) - trips, len(controls))) + print("gen_pipe: self-test: %d negative-control trip(s), positive control OK" % trips) + return 0 + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--check", action="store_true", help="do not write; exit 1 if regenerating would change anything") + parser.add_argument("--self-test", action="store_true", + help="run the negative controls (each gate must trip) and exit") args = parser.parse_args() calls = parse_calls() payloads = parse_verify_payloads() check_call_payloads_have_field_lists(calls, payloads) + check_field_lists_cover_struct_members(parse_field_lists(), payloads) accessors, deltas, sticky = parse_coverage() + if args.self_test: + return self_test(accessors) + scan_live_accessors(accessors) verbs, classes, fields = parse_fill_points(accessors) rows = parse_inventory() From 440d3c52534e2c227b8b4d83251d8afcb0849e3f Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 02:06:37 -0400 Subject: [PATCH 047/529] [Test] (Pipe): pin the P1 poison and verify shapes - 63 fields, 69 verbs, the seven sticky fields, an omitted GenerateMipmap field leaves exactly that field stale, a corrupted snapshot names its field, reading an unfilled field aborts with SIGABRT - PipeCatalogueTest (header-only, every build): VerbTableIsTheFunctionTable (69 verbs, 9 non-empty classes, the seven sticky bits in every class mask, D7's edges), StickyFieldsAreExactlyTheSeven, FloatVectorsCompareBitwise (a NaN FloatVec4 equals itself, -0.0f differs from 0.0f, a differing BlendStates[3] names RenderState then BlendStates), SixValueStructsHaveFieldLists (69 payloads, PixelStoreParameters and MGHostSpan compared member-wise with Pad0 ignored). - PipeInputsTest, a new unit target linking the static library with its own main() that points MOBILEGL_LOG_FILE_PATH at a temp file: a fake GLContext, the real filler and accessors. OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale (G5 layer 1), ReadingAnOmittedFieldAbortsNamingTheVerb (G5 layer 2: fork, the child reads the omitted field after a draw and a sibling read that must not abort, the parent expects SIGABRT and the exact Fatal line and no @DrawArrays), ReadingAFilledFieldCompletes (the sibling without the omission: _exit(0), no Fatal), CorruptedSnapshotFieldIsNamedWithItsSerial (G4 at block level: clean compare true, a corrupted GetRenderStateParameters is named, its stamp is the fill serial, a corruption outside the mask is not seen), EveryVerbFillsItsClassAndNothingElse (after each of the 69 fills a field is fresh iff its class bit is set). - Every PipeInputsTest case is a visible GTEST_SKIP in a pull build and the poison/verify cases skip in a push build without them; the ctest name set is the same in all three configurations (additions only: 9 names). --- MobileGL/MG_Test/Pipe/CMakeLists.txt | 30 +++ MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp | 111 ++++++++ MobileGL/MG_Test/Pipe/PipeInputsTest.cpp | 270 ++++++++++++++++++++ 3 files changed, 411 insertions(+) create mode 100755 MobileGL/MG_Test/Pipe/PipeInputsTest.cpp diff --git a/MobileGL/MG_Test/Pipe/CMakeLists.txt b/MobileGL/MG_Test/Pipe/CMakeLists.txt index 19dc5e4b6..1bd8f0efd 100644 --- a/MobileGL/MG_Test/Pipe/CMakeLists.txt +++ b/MobileGL/MG_Test/Pipe/CMakeLists.txt @@ -24,5 +24,35 @@ if (MSVC) target_compile_options(PipeCatalogueTest PRIVATE /Zc:preprocessor) endif() +# The P1 poison and verify shapes at the block level: a fake GLContext, the real filler and +# accessors out of the static library. Links gtest (not gtest_main): the suite needs its own +# main() to point MOBILEGL_LOG_FILE_PATH at a temp file before anything logs, because its +# abort cases read the Fatal line back out of that file. Every case is a visible SKIP in a +# pull build. +add_executable( + PipeInputsTest + PipeInputsTest.cpp +) + +target_include_directories(PipeInputsTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/MobileGL/MG_Pipe + ${MGL_ROOT}/3rdparty/xxHash + ${MGL_ROOT}/3rdparty/Vulkan-Headers/include + ${MGL_ROOT}/3rdparty/SPIRV-Reflect +) + +target_link_libraries( + PipeInputsTest PRIVATE + GTest::gtest + ${LINK_LIBRARIES} +) + +if (MSVC) + target_compile_options(PipeInputsTest PRIVATE /Zc:preprocessor) +endif() + include(GoogleTest) gtest_discover_tests(PipeCatalogueTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +gtest_discover_tests(PipeInputsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp index 42578b3af..c5c720e06 100644 --- a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp +++ b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp @@ -13,6 +13,7 @@ #include #include +#include #include "Includes.h" #include @@ -233,6 +234,116 @@ TEST(PipeCatalogue, PipeInputFieldsStartUnfilled) { EXPECT_FALSE(MGPipeInputFieldIsFresh(state, MGPipeInputField::GetRenderStateParameters)); } +// G5b: the verb enum is GLFunctionsTable's member list (69 entries), every class has verbs, +// and the seven sticky fields ride in every class mask (P1 brief D7). +TEST(PipeCatalogue, VerbTableIsTheFunctionTable) { + EXPECT_EQ(kMGPipeVerbCount, 69u); + EXPECT_EQ(kMGPipeVerbClassCount, 9u); + SizeT perClass[kMGPipeVerbClassCount] = {}; + for (SizeT v = 0; v < kMGPipeVerbCount; ++v) { + ++perClass[static_cast(kMGPipeVerbClass[v])]; + } + for (SizeT c = 0; c < kMGPipeVerbClassCount; ++c) { + EXPECT_GT(perClass[c], 0u) << kMGPipeVerbClassNames[c]; + for (SizeT f = 0; f < kMGPipeInputFieldCount; ++f) { + if (kMGPipeInputFieldSticky[f]) { + EXPECT_TRUE(MGPipeFieldMaskHas(kMGPipeClassFieldMask[c], static_cast(f))) + << kMGPipeInputFieldNames[f] << " in " << kMGPipeVerbClassNames[c]; + } + } + } + // The class table of D7, spot-checked at its edges: a draw reads the render state, a + // query reads only the paused-primitive counter, and GenerateMipmap is a texture op. + const auto& draw = kMGPipeClassFieldMask[static_cast(MGPipeVerbClass::kDraw)]; + const auto& query = kMGPipeClassFieldMask[static_cast(MGPipeVerbClass::kQuery)]; + EXPECT_TRUE(MGPipeFieldMaskHas(draw, MGPipeInputField::GetRenderStateParameters)); + EXPECT_FALSE(MGPipeFieldMaskHas(query, MGPipeInputField::GetRenderStateParameters)); + EXPECT_TRUE(MGPipeFieldMaskHas(query, MGPipeInputField::GetTransformFeedbackPausedPrimitiveCounter)); + EXPECT_EQ(kMGPipeVerbClass[static_cast(MGPipeVerb::GenerateMipmap)], MGPipeVerbClass::kTextureOp); + EXPECT_STREQ(kMGPipeVerbNames[static_cast(MGPipeVerb::GetGpuTimestampNs)], "GetGpuTimestampNs"); +} + +// The sticky set is exactly the seven forwarded, argument-keyed accessors (P1 brief D6); no +// version or generation accessor is among them. +TEST(PipeCatalogue, StickyFieldsAreExactlyTheSeven) { + const char* const expected[] = {"GetBufferBindingPointCount", "GetProgramObject", "GetTextureObject", + "HasOpenTransformFeedbackSpan", "InvalidateCompileEnv", "ValidateProgramName", + "RecordError"}; + SizeT count = 0; + for (SizeT f = 0; f < kMGPipeInputFieldCount; ++f) { + Bool listed = false; + for (const char* name : expected) { + if (std::strcmp(kMGPipeInputFieldNames[f], name) == 0) listed = true; + } + EXPECT_EQ(kMGPipeInputFieldSticky[f], listed) << kMGPipeInputFieldNames[f]; + if (kMGPipeInputFieldSticky[f]) ++count; + } + EXPECT_EQ(count, 7u); + EXPECT_EQ(kMGPipeInputStickyFieldCount, 7u); + EXPECT_FALSE(kMGPipeInputFieldSticky[static_cast(MGPipeInputField::GetTextureContextId)]); + EXPECT_FALSE(kMGPipeInputFieldSticky[static_cast(MGPipeInputField::GetSamplingResolutionGeneration)]); + EXPECT_FALSE(kMGPipeInputFieldSticky[static_cast(MGPipeInputField::GetPipelineStateVersion)]); +} + +// G4 compares floating point BY BITS (P1 brief D8): a NaN equals itself, a negative zero +// does not equal a positive one, and a vector type inside an Array inside a value struct is +// reached field by field - the differing member of the residual block is named. +TEST(PipeCatalogue, FloatVectorsCompareBitwise) { + const Float nan = std::numeric_limits::quiet_NaN(); + const FloatVec4 a{nan, 1.f, 2.f, 3.f}; + const FloatVec4 b{nan, 1.f, 2.f, 3.f}; + EXPECT_TRUE(MGPipeFieldEqual(a, b)); + EXPECT_FALSE(a == b); // IEEE ==, the comparison the comparator must NOT use + const FloatVec4 zero{0.f, 0.f, 0.f, 0.f}; + const FloatVec4 negativeZero{-0.f, 0.f, 0.f, 0.f}; + EXPECT_FALSE(MGPipeFieldEqual(zero, negativeZero)); + EXPECT_TRUE(zero == negativeZero); + EXPECT_TRUE(MGPipeFieldEqual(1.5f, 1.5f)); + EXPECT_FALSE(MGPipeFieldEqual(-0.f, 0.f)); + + ResidualValueBlock left{}; + ResidualValueBlock right{}; + const char* field = nullptr; + EXPECT_TRUE(MGPipeVerify(left, right, &field)); + right.RenderState.BlendStates[3].SrcFactorRGB = BlendFactor::DstColor; + EXPECT_FALSE(MGPipeVerify(left, right, &field)); + EXPECT_STREQ(field, "RenderState"); + const char* inner = nullptr; + EXPECT_FALSE(MGPipeVerify(left.RenderState, right.RenderState, &inner)); + EXPECT_STREQ(inner, "BlendStates"); + // A NaN patch level in the render state equals itself too. + right = left; + left.RenderState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f}; + right.RenderState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f}; + EXPECT_TRUE(MGPipeVerify(left, right, &field)); +} + +// The six value structs have field lists of their own (P1 brief D8): 63 + 6 payloads, and +// the struct that used to memcmp is compared member by member. +TEST(PipeCatalogue, SixValueStructsHaveFieldLists) { + EXPECT_EQ(kMGPipeVerifiedPayloadCount, 69u); + static_assert(MGPipeHasFieldVerifier::value); + static_assert(MGPipeHasFieldVerifier::value); + static_assert(MGPipeHasFieldVerifier::value); + static_assert(MGPipeHasFieldVerifier::value); + static_assert(MGPipeHasFieldVerifier::value); + static_assert(MGPipeHasFieldVerifier::value); + PixelStoreParameters p{}; + PixelStoreParameters q{}; + const char* field = nullptr; + EXPECT_TRUE(MGPipeVerify(p, q, &field)); + q.SkipRows = 2; + EXPECT_FALSE(MGPipeVerify(p, q, &field)); + EXPECT_STREQ(field, "SkipRows"); + MGHostSpan s{}; + MGHostSpan t{}; + t.Pad0 = 0x5A; // padding is not a field + EXPECT_TRUE(MGPipeVerify(s, t, &field)); + t.Offset = 8; + EXPECT_FALSE(MGPipeVerify(s, t, &field)); + EXPECT_STREQ(field, "Offset"); +} + // G7 pins the member list the pipeline/dynamic split is derived from. TEST(PipeCatalogue, PipelineSubsetMembersArePinned) { EXPECT_EQ(kMGPipePipelineStateMemberCount, 24u); diff --git a/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp b/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp new file mode 100755 index 000000000..2d6266eb7 --- /dev/null +++ b/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp @@ -0,0 +1,270 @@ +// MobileGL - MobileGL/MG_Test/Pipe/PipeInputsTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The P1 poison and verify shapes at the block level (P1 brief C.1): a fake GLContext, the +// real filler, the real accessors. Needs the push sources, so every case is a visible SKIP +// in a pull build rather than a vanishing test. The abort cases fork (HeadlessGL.cpp's +// pre-flight shape): the child performs the read that must be Fatal{UnmigratedPipeInput} +// and the parent reads SIGABRT out of waitpid and the exact line out of the log file that +// main() below points MOBILEGL_LOG_FILE_PATH at (LogLevelTest's shape). Never EXPECT_DEATH. + +#include + +#include +#include +#include +#include +#include +#include + +#include "Includes.h" +#include + +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#endif + +#if !defined(_WIN32) +#include +#include +#include +#define MGTEST_HAVE_FORK 1 +#else +#define MGTEST_HAVE_FORK 0 +#endif + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + std::string g_logPath; + + std::string ReadLog() { + std::ifstream in(g_logPath, std::ios::binary); + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); + } + +#if MOBILEGL_PIPE_PUSH + using GLContext = MG_State::GLState::GLContext; + + // A live frontend context for the filler to read (SanityTest's idiom), restored on the + // way out so the cases stay independent. + class PipeInputsTest : public ::testing::Test { + protected: + void SetUp() override { + m_previous = Move(MG_State::pGLContext); + MG_State::pGLContext = MakeUnique(); + MGPipeSetPoisonOmission(nullptr, nullptr); + } + void TearDown() override { + MGPipeSetPoisonOmission(nullptr, nullptr); + MG_State::pGLContext = Move(m_previous); + } + UniquePtr m_previous; + }; + +#if MOBILEGL_PIPE_POISON + Bool Fresh(MGPipeInputField field) { return MGPipeInputFieldIsFresh(gPipeInputs.FilledState(), field); } +#endif + + [[maybe_unused]] constexpr const char* kOmittedFatal ="Fatal{UnmigratedPipeInput, \"GetActiveTextureUnit@GenerateMipmap\"}"; +#endif // MOBILEGL_PIPE_PUSH +} // namespace + +#if !MOBILEGL_PIPE_PUSH + +TEST(PipeInputsTest,OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(PipeInputsTest,ReadingAnOmittedFieldAbortsNamingTheVerb) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(PipeInputsTest,ReadingAFilledFieldCompletes) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(PipeInputsTest,CorruptedSnapshotFieldIsNamedWithItsSerial) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(PipeInputsTest,EveryVerbFillsItsClassAndNothingElse) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} + +#else // MOBILEGL_PIPE_PUSH + +// Negative control B, layer 1 (P1 brief D6 / G5): the omitted (verb, field) pair is the +// ONLY thing that goes stale - a sibling field of the same verb is fresh, and the verb after +// it neither heals the field (not in kDraw's mask) nor loses one of its own. +TEST_F(PipeInputsTest, OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale) { +#if !MOBILEGL_PIPE_POISON + GTEST_SKIP() << "poison not compiled in (MOBILEGL_PIPE_POISON=0)"; +#else + MGPipeFillForVerb(MGPipeVerb::GenerateMipmap); + EXPECT_TRUE(Fresh(MGPipeInputField::GetActiveTextureUnit)); + EXPECT_TRUE(Fresh(MGPipeInputField::GetTextureUnitObject)); + + MGPipeSetPoisonOmission("GenerateMipmap", "GetActiveTextureUnit"); + MGPipeFillForVerb(MGPipeVerb::GenerateMipmap); + EXPECT_TRUE(Fresh(MGPipeInputField::GetTextureUnitObject)); + EXPECT_FALSE(Fresh(MGPipeInputField::GetActiveTextureUnit)); + // The value was still copied: only the stamp is withheld. + EXPECT_EQ(gPipeInputs.CurrentVerb(), MGPipeVerb::GenerateMipmap); + + MGPipeFillForVerb(MGPipeVerb::DrawArrays); + EXPECT_FALSE(Fresh(MGPipeInputField::GetActiveTextureUnit)); + EXPECT_TRUE(Fresh(MGPipeInputField::GetBoundVertexArray)); + EXPECT_TRUE(Fresh(MGPipeInputField::GetRenderStateParameters)); + // A sticky field stays fresh across every verb. + EXPECT_TRUE(Fresh(MGPipeInputField::RecordError)); + + // And the omission is scoped to its verb: a different verb of the same class keeps it. + MGPipeFillForVerb(MGPipeVerb::BindImageTexture); + EXPECT_TRUE(Fresh(MGPipeInputField::GetActiveTextureUnit)); +#endif +} + +// Negative control B, layer 2 (G5): the read itself. The child fills GenerateMipmap with the +// omission and reads gPipeInputs.GetActiveTextureUnit(); the parent expects SIGABRT and the +// exact Fatal line, and that nothing else was fatal. +TEST_F(PipeInputsTest, ReadingAnOmittedFieldAbortsNamingTheVerb) { +#if !MOBILEGL_PIPE_POISON + GTEST_SKIP() << "poison not compiled in (MOBILEGL_PIPE_POISON=0)"; +#elif !MGTEST_HAVE_FORK + GTEST_SKIP() << "no fork() on this platform"; +#else + ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; + const std::string before = ReadLog(); + std::fflush(nullptr); + const pid_t pid = ::fork(); + ASSERT_GE(pid, 0); + if (pid == 0) { + // Child: no gtest assertions, _exit never exit. + MGPipeSetPoisonOmission("GenerateMipmap", "GetActiveTextureUnit"); + MGPipeFillForVerb(MGPipeVerb::DrawArrays); + (void)gPipeInputs.GetRenderStateParameters(); // a filled field of the preceding draw: must not abort + MGPipeFillForVerb(MGPipeVerb::GenerateMipmap); + (void)gPipeInputs.GetTextureUnitObject(0); // the sibling field: filled, must not abort + (void)gPipeInputs.GetActiveTextureUnit(); // the omitted field: Fatal + ::_exit(3); // reached only if the poison failed + } + int status = 0; + ASSERT_EQ(::waitpid(pid, &status, 0), pid); + ASSERT_TRUE(WIFSIGNALED(status)) << "child exited normally with " << (WIFEXITED(status) ? WEXITSTATUS(status) : -1); + EXPECT_EQ(WTERMSIG(status), SIGABRT); + const std::string log = ReadLog().substr(before.size()); + EXPECT_NE(log.find(kOmittedFatal), std::string::npos) << log; + EXPECT_EQ(log.find("@DrawArrays"), std::string::npos) << log; + EXPECT_EQ(log.find("GetTextureUnitObject@"), std::string::npos) << log; +#endif +} + +// The sibling: the same reads without the omission complete, and no Fatal is logged. +TEST_F(PipeInputsTest, ReadingAFilledFieldCompletes) { +#if !MGTEST_HAVE_FORK + GTEST_SKIP() << "no fork() on this platform"; +#else + ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; + const std::string before = ReadLog(); + std::fflush(nullptr); + const pid_t pid = ::fork(); + ASSERT_GE(pid, 0); + if (pid == 0) { + MGPipeFillForVerb(MGPipeVerb::DrawArrays); + (void)gPipeInputs.GetRenderStateParameters(); + MGPipeFillForVerb(MGPipeVerb::GenerateMipmap); + (void)gPipeInputs.GetTextureUnitObject(0); + (void)gPipeInputs.GetActiveTextureUnit(); + ::_exit(0); + } + int status = 0; + ASSERT_EQ(::waitpid(pid, &status, 0), pid); + ASSERT_TRUE(WIFEXITED(status)) << "child died on signal " << (WIFSIGNALED(status) ? WTERMSIG(status) : -1); + EXPECT_EQ(WEXITSTATUS(status), 0); + const std::string log = ReadLog().substr(before.size()); + EXPECT_EQ(log.find("Fatal{"), std::string::npos) << log; +#endif +} + +// Negative control A at the block level (G4): a clean snapshot compares equal to the pushed +// block over the verb's mask; corrupting one field of the snapshot makes the compare name +// exactly that field, at the serial of the fill it belongs to. +TEST_F(PipeInputsTest, CorruptedSnapshotFieldIsNamedWithItsSerial) { +#if !MOBILEGL_PIPE_VERIFY + GTEST_SKIP() << "verify not compiled in (MOBILEGL_PIPE_VERIFY=OFF)"; +#else + const Uint64 serialBefore = gPipeInputs.FilledState().CurrentVerbSerial; + MGPipeFillForVerb(MGPipeVerb::DrawArrays); + const Uint64 serial = gPipeInputs.FilledState().CurrentVerbSerial; + EXPECT_EQ(serial, serialBefore + 1); + const MGPipeFieldMask& mask = kMGPipeClassFieldMask[static_cast(MGPipeVerbClass::kDraw)]; + + static PipeInputs snapshot{}; + SnapshotFromGLContext(snapshot, mask); + MGPipeInputField field = MGPipeInputField::kFieldCount; + EXPECT_TRUE(MGPipeVerifyInputs(gPipeInputs, snapshot, mask, &field)); + EXPECT_EQ(field, MGPipeInputField::kFieldCount); + + ASSERT_TRUE(MGPipeApplyVerifyCorruption(snapshot, MGPipeInputField::GetRenderStateParameters)); + EXPECT_FALSE(MGPipeVerifyInputs(gPipeInputs, snapshot, mask, &field)); + EXPECT_EQ(field, MGPipeInputField::GetRenderStateParameters); + EXPECT_STREQ(kMGPipeInputFieldNames[static_cast(field)], "GetRenderStateParameters"); + // The serial the report would print is the fill's, and it stamped that field. + EXPECT_EQ(gPipeInputs.FilledState().FilledGen[static_cast(field)], serial); + + // A forwarded field has nothing to corrupt, and the corruption of a field outside the + // mask is not seen by a compare over that mask. + EXPECT_FALSE(MGPipeApplyVerifyCorruption(snapshot, MGPipeInputField::RecordError)); + SnapshotFromGLContext(snapshot, mask); + ASSERT_TRUE(MGPipeApplyVerifyCorruption(snapshot, MGPipeInputField::GetPixelStoreParameters)); + EXPECT_FALSE(MGPipeFieldMaskHas(mask, MGPipeInputField::GetPixelStoreParameters)); + EXPECT_TRUE(MGPipeVerifyInputs(gPipeInputs, snapshot, mask, &field)); +#endif +} + +// Every verb fills exactly its class mask: after a fill, a field is fresh iff its bit is set +// (sticky fields included, since every class mask carries them). +TEST_F(PipeInputsTest, EveryVerbFillsItsClassAndNothingElse) { +#if !MOBILEGL_PIPE_POISON + GTEST_SKIP() << "poison not compiled in (MOBILEGL_PIPE_POISON=0)"; +#else + for (SizeT v = 0; v < kMGPipeVerbCount; ++v) { + const auto verb = static_cast(v); + MGPipeFillForVerb(verb); + const MGPipeFieldMask& mask = kMGPipeClassFieldMask[static_cast(kMGPipeVerbClass[v])]; + for (SizeT f = 0; f < kMGPipeInputFieldCount; ++f) { + const auto field = static_cast(f); + EXPECT_EQ(Fresh(field), MGPipeFieldMaskHas(mask, field)) + << kMGPipeInputFieldNames[f] << " after " << kMGPipeVerbNames[v]; + } + } + EXPECT_EQ(gPipeInputs.ContextIdentity(), static_cast(MG_State::pGLContext.get())); + EXPECT_TRUE(gPipeInputs.IsLive()); +#endif +} + +#endif // MOBILEGL_PIPE_PUSH + +int main(int argc, char** argv) { + // Before anything logs: MG_Util::Debug::InitFile() reads the variable once, on the first + // write, and caches the FILE*. + namespace fs = std::filesystem; + const fs::path path = fs::temp_directory_path() / "mobilegl-pipeinputs-test.log"; + std::error_code ec; + fs::remove(path, ec); + g_logPath = path.string(); +#if defined(_WIN32) + _putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str()); +#else + setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); +#endif + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 510ecd9293760078a412b6b1cf90b3acf2a88e30 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 02:31:56 -0400 Subject: [PATCH 048/529] [Fix] (Impl): move the DeleteSync fill of the orphan sweep inside its null-entry guard - D7 says a verb whose table entry is null never bumps the serial; the sweep's fill sat before `if (backendDeleteSync && syncObject->backendHandle)`, so a backend without DeleteSync, or a sync without a backend handle, bumped once per orphan with nothing reading the fill. - The sibling sweep in GL_Query.cpp (DeleteBackendQuery) already fills inside its guard; this makes the two the same shape and leaves the declared list of guarded-expression sites at nine. - Pull build unchanged: MGP_FILL is ((void)0) there. --- MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp b/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp index 6a818aad1..608139768 100644 --- a/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp +++ b/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp @@ -232,8 +232,8 @@ namespace MobileGL::MG_Impl::GLImpl { // the function table itself is cleared. const auto backendDeleteSync = MG_Backend::gBackendFunctionsTable.GL.DeleteSync; for (const auto& [_, syncObject] : orphans) { - MGP_FILL(DeleteSync); if (backendDeleteSync && syncObject->backendHandle) { + MGP_FILL(DeleteSync); backendDeleteSync(syncObject->backendHandle); } delete syncObject; From 77ecde1524989a39c4e96a7856888e4c6565b802 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 02:31:56 -0400 Subject: [PATCH 049/529] [Fix] (Pipe): refuse an eighth sticky row at compile time - the forwarded count equals the generated sticky count - kMGPipeForwardedFieldCount = 7 was a hand-written twin of the generated kMGPipeInputStickyFieldCount, tied only by PipeCatalogue.StickyFieldsAreExactlyTheSeven; a static_assert in the header makes a PipeFields.def sticky row without a forwarder a build error instead of a test failure. --- MobileGL/MG_Backend/MGPipe/PipeInputs.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.h b/MobileGL/MG_Backend/MGPipe/PipeInputs.h index 3b51f7d23..5f143642d 100644 --- a/MobileGL/MG_Backend/MGPipe/PipeInputs.h +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.h @@ -130,7 +130,11 @@ namespace MobileGL::MG_Pipe { // clang-format on // The seven F-class fields, for the arithmetic below and for the sticky table's proof. + // The forwarded set IS the sticky set (PipeFields.def marks the same seven rows F and + // sticky), so an eighth sticky row without a forwarder is refused here, not by a test. inline constexpr SizeT kMGPipeForwardedFieldCount = 7; + static_assert(kMGPipeForwardedFieldCount == kMGPipeInputStickyFieldCount, + "the forwarded (F-class) fields and the sticky fields of PipeFields.def are the same seven rows"); // The block the backends read instead of GLContext (ARCHITECTURE.md 9.2 phase A, P1 brief // D4). One struct, three storage classes, and every accessor keeps the NAME, PARAMETERS From 878db2c405c2164fbc39c5b196c8c135b5036182 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 02:31:56 -0400 Subject: [PATCH 050/529] [Fix] (Pipe): re-parse the POISON_OMIT and VERIFY knobs when their Features value changes, not once per process - The two parsers (ParsePoisonOmissionKnob, ArmVerify) latched on the first fill, so the only way to reach them was a lane that loads MG_Config::Features before any fill; no unit test could exercise the parse, the arming lines or Fatal{PipeVerifyBadKnob}. - The latch now keys on the value: a lane still parses once (Features is loaded before the first fill), while a forked test child that sets Features after its parent filled gets its own parse. An empty omission value never clears an omission armed through MGPipeSetPoisonOmission. - Re-arming resets the CORRUPT field so a stale corruption cannot outlive the knob that named it. --- MobileGL/MG_Impl/Pipe/PipeFill.cpp | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 33891ec1a..6537838b8 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -296,11 +296,18 @@ namespace MobileGL::MG_Pipe { }; PoisonOmission g_omission; Bool g_omissionKnobParsed = false; + String g_omissionKnobValue; // the value the last parse saw + // Parsed on the first fill and again only when the value changes. A lane loads + // Features once, before any fill, so that is one parse per process there; a forked + // test child that sets Features after its parent already filled gets its own parse, + // which is what puts the parser and its Fatal{PipeVerifyBadKnob} under a unit test. + // An empty value never clears an omission a test armed through MGPipeSetPoisonOmission. void ParsePoisonOmissionKnob() { - if (g_omissionKnobParsed) return; - g_omissionKnobParsed = true; const String& knob = MG_Config::Features.PipePoisonOmit; + if (g_omissionKnobParsed && knob == g_omissionKnobValue) return; + g_omissionKnobParsed = true; + g_omissionKnobValue = knob; if (knob.empty()) return; const auto colon = knob.find(':'); if (colon == String::npos || colon == 0 || colon + 1 >= knob.size()) { @@ -348,10 +355,14 @@ namespace MobileGL::MG_Pipe { }; VerifyState g_verify; + // Armed on the first fill and re-armed only when Features.PipeVerify changes (the + // same reason as ParsePoisonOmissionKnob: one arm per lane process, a fresh arm for a + // forked test child that turns the knob on after its parent filled unarmed). void ArmVerify() { - if (g_verify.Parsed) return; + if (g_verify.Parsed && g_verify.Enabled == MG_Config::Features.PipeVerify) return; g_verify.Parsed = true; g_verify.Enabled = MG_Config::Features.PipeVerify; + g_verify.Corrupt = Optional{}; if (!g_verify.Enabled) return; g_verify.Fatal = MG_Config::Features.PipeVerifyFatal; const String& corrupt = MG_Config::Features.PipeVerifyCorrupt; From d9f4698d98c0be36de4d123333eae74e4728a8d1 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 02:31:56 -0400 Subject: [PATCH 051/529] [Test] (Pipe): give every PipeInputsTest process its own log file, and cover the compare-at-read arm, the three knob parsers and VERIFY_FATAL=0 with forked children - gtest_discover_tests runs each case as its own process; under ctest -j the five shared one fixed log path, each main() unlinked it and each fork parent re-read it by path, so a sibling's Fatal line or unlink landed in another case's assertion (red 40/40 at -j 8, for a reason unrelated to the poison). The name now carries the pid and the file is removed on the way out; a forked child inherits the path on purpose. - MutatedFieldIsNamedAtRead: the first falsifier of MGPipeVerifyReadHook - the child arms verify, fills DrawArrays, reads GetLineWidth (completes), mutates the live context's line width and reads again, and the parent expects SIGABRT with Fatal{PipeVerifyDiffer, "GetLineWidth@DrawArrays", verb=, where=read} and no where=entry. - PoisonOmitKnobArmsTheOmission / BadPoisonOmitKnobIsFatalNamingTheKnob / VerifyCorruptKnobNamesTheFieldAtEntry / BadVerifyCorruptKnobIsFatalNamingTheKnob / VerifyFatalOffLogsTheDivergenceAndContinues: the knob parsers through MG_Config::Features, their arming lines, the exact Fatal{PipeVerifyBadKnob} text, and a FATAL=0 run that logs two divergences at consecutive serials and exits 0. - OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale now loops every field: fresh iff in kTextureOp's mask and not the omitted one, so the name is true. - The fork/waitpid/log-delta shape is one RunInChild helper; every case is still a visible skip where its switch is off. --- MobileGL/MG_Test/Pipe/PipeInputsTest.cpp | 297 ++++++++++++++++++++--- 1 file changed, 264 insertions(+), 33 deletions(-) mode change 100755 => 100644 MobileGL/MG_Test/Pipe/PipeInputsTest.cpp diff --git a/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp b/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp old mode 100755 new mode 100644 index 2d6266eb7..8b3075359 --- a/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp +++ b/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp @@ -12,6 +12,10 @@ // pre-flight shape): the child performs the read that must be Fatal{UnmigratedPipeInput} // and the parent reads SIGABRT out of waitpid and the exact line out of the log file that // main() below points MOBILEGL_LOG_FILE_PATH at (LogLevelTest's shape). Never EXPECT_DEATH. +// +// The log file is per PROCESS: gtest_discover_tests runs every case as its own process, in +// parallel under ctest -j, and a name shared between them let a sibling's unlink or Fatal +// line land in this process's read. A forked child inherits its parent's path on purpose. #include @@ -26,6 +30,7 @@ #include #if MOBILEGL_PIPE_PUSH +#include #include #include #include @@ -37,6 +42,7 @@ #include #define MGTEST_HAVE_FORK 1 #else +#include #define MGTEST_HAVE_FORK 0 #endif @@ -53,6 +59,14 @@ namespace { return ss.str(); } + long ProcessId() { +#if defined(_WIN32) + return static_cast(::_getpid()); +#else + return static_cast(::getpid()); +#endif + } + #if MOBILEGL_PIPE_PUSH using GLContext = MG_State::GLState::GLContext; @@ -77,6 +91,44 @@ namespace { #endif [[maybe_unused]] constexpr const char* kOmittedFatal ="Fatal{UnmigratedPipeInput, \"GetActiveTextureUnit@GenerateMipmap\"}"; + [[maybe_unused]] constexpr const char* kOmissionKnob = "GenerateMipmap:GetActiveTextureUnit"; + +#if MGTEST_HAVE_FORK + struct ChildResult { + int Status = -1; // waitpid's status; -1 when fork or waitpid failed + std::string Log; // the lines the child appended to the log file + }; + + // Runs `body` in a forked child and returns its wait status and log delta. The child + // must not use gtest assertions; it _exit(0)s when `body` returns, so a body that is + // expected to die must be asserted dead by the parent (WIFSIGNALED), never assumed. + template + ChildResult RunInChild(Body body) { + ChildResult result; + const std::string before = ReadLog(); + std::fflush(nullptr); + const pid_t pid = ::fork(); + if (pid < 0) return result; + if (pid == 0) { + body(); + ::_exit(0); + } + int status = 0; + if (::waitpid(pid, &status, 0) != pid) return result; + result.Status = status; + result.Log = ReadLog().substr(before.size()); + return result; + } + + Bool DiedOfAbort(const ChildResult& r) { return WIFSIGNALED(r.Status) && WTERMSIG(r.Status) == SIGABRT; } + Bool ExitedWith(const ChildResult& r, int code) { return WIFEXITED(r.Status) && WEXITSTATUS(r.Status) == code; } + std::string DescribeStatus(const ChildResult& r) { + if (r.Status < 0) return "fork/waitpid failed"; + if (WIFEXITED(r.Status)) return "exited " + std::to_string(WEXITSTATUS(r.Status)); + if (WIFSIGNALED(r.Status)) return "signal " + std::to_string(WTERMSIG(r.Status)); + return "status " + std::to_string(r.Status); + } +#endif // MGTEST_HAVE_FORK #endif // MOBILEGL_PIPE_PUSH } // namespace @@ -97,12 +149,31 @@ TEST(PipeInputsTest,CorruptedSnapshotFieldIsNamedWithItsSerial) { TEST(PipeInputsTest,EveryVerbFillsItsClassAndNothingElse) { GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; } +TEST(PipeInputsTest,MutatedFieldIsNamedAtRead) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(PipeInputsTest,PoisonOmitKnobArmsTheOmission) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(PipeInputsTest,BadPoisonOmitKnobIsFatalNamingTheKnob) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(PipeInputsTest,VerifyCorruptKnobNamesTheFieldAtEntry) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(PipeInputsTest,BadVerifyCorruptKnobIsFatalNamingTheKnob) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(PipeInputsTest,VerifyFatalOffLogsTheDivergenceAndContinues) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} #else // MOBILEGL_PIPE_PUSH // Negative control B, layer 1 (P1 brief D6 / G5): the omitted (verb, field) pair is the -// ONLY thing that goes stale - a sibling field of the same verb is fresh, and the verb after -// it neither heals the field (not in kDraw's mask) nor loses one of its own. +// ONLY thing that goes stale - every other bit of the verb class's mask is fresh, every +// field outside it is not, and the verb after it neither heals the field (not in kDraw's +// mask) nor loses one of its own. TEST_F(PipeInputsTest, OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale) { #if !MOBILEGL_PIPE_POISON GTEST_SKIP() << "poison not compiled in (MOBILEGL_PIPE_POISON=0)"; @@ -117,6 +188,17 @@ TEST_F(PipeInputsTest, OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale) { EXPECT_FALSE(Fresh(MGPipeInputField::GetActiveTextureUnit)); // The value was still copied: only the stamp is withheld. EXPECT_EQ(gPipeInputs.CurrentVerb(), MGPipeVerb::GenerateMipmap); + // Exactly that field: a field is fresh iff its bit is in kTextureOp's mask and it is not + // the omitted one. + { + const auto cls = kMGPipeVerbClass[static_cast(MGPipeVerb::GenerateMipmap)]; + const MGPipeFieldMask& mask = kMGPipeClassFieldMask[static_cast(cls)]; + for (SizeT f = 0; f < kMGPipeInputFieldCount; ++f) { + const auto field = static_cast(f); + const Bool expected = MGPipeFieldMaskHas(mask, field) && field != MGPipeInputField::GetActiveTextureUnit; + EXPECT_EQ(Fresh(field), expected) << kMGPipeInputFieldNames[f] << " after the omitted GenerateMipmap fill"; + } + } MGPipeFillForVerb(MGPipeVerb::DrawArrays); EXPECT_FALSE(Fresh(MGPipeInputField::GetActiveTextureUnit)); @@ -141,12 +223,7 @@ TEST_F(PipeInputsTest, ReadingAnOmittedFieldAbortsNamingTheVerb) { GTEST_SKIP() << "no fork() on this platform"; #else ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; - const std::string before = ReadLog(); - std::fflush(nullptr); - const pid_t pid = ::fork(); - ASSERT_GE(pid, 0); - if (pid == 0) { - // Child: no gtest assertions, _exit never exit. + const ChildResult r = RunInChild([] { MGPipeSetPoisonOmission("GenerateMipmap", "GetActiveTextureUnit"); MGPipeFillForVerb(MGPipeVerb::DrawArrays); (void)gPipeInputs.GetRenderStateParameters(); // a filled field of the preceding draw: must not abort @@ -154,15 +231,11 @@ TEST_F(PipeInputsTest, ReadingAnOmittedFieldAbortsNamingTheVerb) { (void)gPipeInputs.GetTextureUnitObject(0); // the sibling field: filled, must not abort (void)gPipeInputs.GetActiveTextureUnit(); // the omitted field: Fatal ::_exit(3); // reached only if the poison failed - } - int status = 0; - ASSERT_EQ(::waitpid(pid, &status, 0), pid); - ASSERT_TRUE(WIFSIGNALED(status)) << "child exited normally with " << (WIFEXITED(status) ? WEXITSTATUS(status) : -1); - EXPECT_EQ(WTERMSIG(status), SIGABRT); - const std::string log = ReadLog().substr(before.size()); - EXPECT_NE(log.find(kOmittedFatal), std::string::npos) << log; - EXPECT_EQ(log.find("@DrawArrays"), std::string::npos) << log; - EXPECT_EQ(log.find("GetTextureUnitObject@"), std::string::npos) << log; + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find(kOmittedFatal), std::string::npos) << r.Log; + EXPECT_EQ(r.Log.find("@DrawArrays"), std::string::npos) << r.Log; + EXPECT_EQ(r.Log.find("GetTextureUnitObject@"), std::string::npos) << r.Log; #endif } @@ -172,24 +245,62 @@ TEST_F(PipeInputsTest, ReadingAFilledFieldCompletes) { GTEST_SKIP() << "no fork() on this platform"; #else ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; - const std::string before = ReadLog(); - std::fflush(nullptr); - const pid_t pid = ::fork(); - ASSERT_GE(pid, 0); - if (pid == 0) { + const ChildResult r = RunInChild([] { MGPipeFillForVerb(MGPipeVerb::DrawArrays); (void)gPipeInputs.GetRenderStateParameters(); MGPipeFillForVerb(MGPipeVerb::GenerateMipmap); (void)gPipeInputs.GetTextureUnitObject(0); (void)gPipeInputs.GetActiveTextureUnit(); - ::_exit(0); - } - int status = 0; - ASSERT_EQ(::waitpid(pid, &status, 0), pid); - ASSERT_TRUE(WIFEXITED(status)) << "child died on signal " << (WIFSIGNALED(status) ? WTERMSIG(status) : -1); - EXPECT_EQ(WEXITSTATUS(status), 0); - const std::string log = ReadLog().substr(before.size()); - EXPECT_EQ(log.find("Fatal{"), std::string::npos) << log; + }); + ASSERT_TRUE(ExitedWith(r, 0)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_EQ(r.Log.find("Fatal{"), std::string::npos) << r.Log; +#endif +} + +// The MOBILEGL_PIPE_POISON_OMIT parser, through the Features field the loader fills: the +// child sets the knob after its parent filled without it, and the first fill after that +// parses it, logs the arming line and withholds the stamp exactly as the programmatic form. +TEST_F(PipeInputsTest, PoisonOmitKnobArmsTheOmission) { +#if !MOBILEGL_PIPE_POISON + GTEST_SKIP() << "poison not compiled in (MOBILEGL_PIPE_POISON=0)"; +#elif !MGTEST_HAVE_FORK + GTEST_SKIP() << "no fork() on this platform"; +#else + ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; + MGPipeFillForVerb(MGPipeVerb::DrawArrays); // the parent's parse saw an empty knob + const ChildResult r = RunInChild([] { + MG_Config::Features.PipePoisonOmit = kOmissionKnob; + MGPipeFillForVerb(MGPipeVerb::GenerateMipmap); + (void)gPipeInputs.GetTextureUnitObject(0); // the sibling field: filled, must not abort + (void)gPipeInputs.GetActiveTextureUnit(); // the omitted field: Fatal + ::_exit(3); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("MGPipe: poison omission armed - GetActiveTextureUnit@GenerateMipmap"), std::string::npos) + << r.Log; + EXPECT_NE(r.Log.find(kOmittedFatal), std::string::npos) << r.Log; + EXPECT_EQ(r.Log.find("GetTextureUnitObject@"), std::string::npos) << r.Log; +#endif +} + +// An unknown verb in the knob is Fatal{PipeVerifyBadKnob} naming the knob and the value, +// before any stamp is withheld. +TEST_F(PipeInputsTest, BadPoisonOmitKnobIsFatalNamingTheKnob) { +#if !MGTEST_HAVE_FORK + GTEST_SKIP() << "no fork() on this platform"; +#else + ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; + const ChildResult r = RunInChild([] { + MG_Config::Features.PipePoisonOmit = "NoSuchVerb:GetActiveTextureUnit"; + MGPipeFillForVerb(MGPipeVerb::GenerateMipmap); + ::_exit(3); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{PipeVerifyBadKnob, \"MOBILEGL_PIPE_POISON_OMIT=NoSuchVerb:GetActiveTextureUnit\": " + "no such verb in kMGPipeVerbNames}"), + std::string::npos) + << r.Log; + EXPECT_EQ(r.Log.find("poison omission armed"), std::string::npos) << r.Log; #endif } @@ -229,6 +340,122 @@ TEST_F(PipeInputsTest, CorruptedSnapshotFieldIsNamedWithItsSerial) { #endif } +// The compare-at-read arm (P1 brief D8, the arm that is real in P1): a value that changes in +// the live context between the verb boundary and the read is Fatal{PipeVerifyDiffer, +// "Field@Verb", verb=, where=read}; the same read before the mutation completes. +TEST_F(PipeInputsTest, MutatedFieldIsNamedAtRead) { +#if !MOBILEGL_PIPE_VERIFY + GTEST_SKIP() << "verify not compiled in (MOBILEGL_PIPE_VERIFY=OFF)"; +#elif !MGTEST_HAVE_FORK + GTEST_SKIP() << "no fork() on this platform"; +#else + ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; + MGPipeFillForVerb(MGPipeVerb::Clear); // the parent armed nothing: Features.PipeVerify is false here + const Uint64 serial = gPipeInputs.FilledState().CurrentVerbSerial + 1; // the child's DrawArrays fill + const ChildResult r = RunInChild([] { + MG_Config::Features.PipeVerify = true; + MGPipeFillForVerb(MGPipeVerb::DrawArrays); + const Float boundary = gPipeInputs.GetLineWidth(); // boundary == live: completes + (void)gPipeInputs.GetRenderStateParameters(); + MG_State::pGLContext->SetLineWidth(boundary + 1.0f); + if (MG_State::pGLContext->GetLineWidth() == boundary) ::_exit(7); // the mutation did not take + (void)gPipeInputs.GetLineWidth(); // the stored value is stale against the live one: Fatal + ::_exit(3); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("MGPipe: verify armed - 63 fields, 69 verbs, fatal=1"), std::string::npos) << r.Log; + EXPECT_NE(r.Log.find("MGPipe: verify read of GetLineWidth (index 0, 0) differs from the live context"), + std::string::npos) + << r.Log; + const std::string expected = "Fatal{PipeVerifyDiffer, \"GetLineWidth@DrawArrays\", verb=" + std::to_string(serial) + + ", where=read}"; + EXPECT_NE(r.Log.find(expected), std::string::npos) << "expected " << expected << " in\n" << r.Log; + EXPECT_EQ(r.Log.find("where=entry"), std::string::npos) << r.Log; +#endif +} + +// The MOBILEGL_PIPE_VERIFY_CORRUPT parser through Features: the fill after the child arms it +// logs both arming lines and the entry compare names the corrupted field at that fill's +// serial, where=entry. +TEST_F(PipeInputsTest, VerifyCorruptKnobNamesTheFieldAtEntry) { +#if !MOBILEGL_PIPE_VERIFY + GTEST_SKIP() << "verify not compiled in (MOBILEGL_PIPE_VERIFY=OFF)"; +#elif !MGTEST_HAVE_FORK + GTEST_SKIP() << "no fork() on this platform"; +#else + ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; + MGPipeFillForVerb(MGPipeVerb::Clear); + const Uint64 serial = gPipeInputs.FilledState().CurrentVerbSerial + 1; + const ChildResult r = RunInChild([] { + MG_Config::Features.PipeVerify = true; + MG_Config::Features.PipeVerifyCorrupt = "GetRenderStateParameters"; + MGPipeFillForVerb(MGPipeVerb::DrawArrays); + ::_exit(3); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("MGPipe: verify armed - 63 fields, 69 verbs, fatal=1"), std::string::npos) << r.Log; + EXPECT_NE(r.Log.find("MGPipe: verify corruption armed - GetRenderStateParameters"), std::string::npos) << r.Log; + const std::string expected = "Fatal{PipeVerifyDiffer, \"GetRenderStateParameters@DrawArrays\", verb=" + + std::to_string(serial) + ", where=entry}"; + EXPECT_NE(r.Log.find(expected), std::string::npos) << "expected " << expected << " in\n" << r.Log; +#endif +} + +// An unknown field name in MOBILEGL_PIPE_VERIFY_CORRUPT is Fatal{PipeVerifyBadKnob} at +// arming, before the comparator reports anything. +TEST_F(PipeInputsTest, BadVerifyCorruptKnobIsFatalNamingTheKnob) { +#if !MOBILEGL_PIPE_VERIFY + GTEST_SKIP() << "verify not compiled in (MOBILEGL_PIPE_VERIFY=OFF)"; +#elif !MGTEST_HAVE_FORK + GTEST_SKIP() << "no fork() on this platform"; +#else + ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; + const ChildResult r = RunInChild([] { + MG_Config::Features.PipeVerify = true; + MG_Config::Features.PipeVerifyCorrupt = "NoSuchField"; + MGPipeFillForVerb(MGPipeVerb::DrawArrays); + ::_exit(3); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{PipeVerifyBadKnob, \"MOBILEGL_PIPE_VERIFY_CORRUPT=NoSuchField\": " + "no such field in kMGPipeInputFieldNames}"), + std::string::npos) + << r.Log; + EXPECT_EQ(r.Log.find("verify armed"), std::string::npos) << r.Log; + EXPECT_EQ(r.Log.find("PipeVerifyDiffer"), std::string::npos) << r.Log; +#endif +} + +// MOBILEGL_PIPE_VERIFY_FATAL=0: the divergence is logged with its field and serial and the +// process goes on (the fill returns, the next fill's compare runs again). +TEST_F(PipeInputsTest, VerifyFatalOffLogsTheDivergenceAndContinues) { +#if !MOBILEGL_PIPE_VERIFY + GTEST_SKIP() << "verify not compiled in (MOBILEGL_PIPE_VERIFY=OFF)"; +#elif !MGTEST_HAVE_FORK + GTEST_SKIP() << "no fork() on this platform"; +#else + ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; + MGPipeFillForVerb(MGPipeVerb::Clear); + const Uint64 serial = gPipeInputs.FilledState().CurrentVerbSerial + 1; + const ChildResult r = RunInChild([] { + MG_Config::Features.PipeVerify = true; + MG_Config::Features.PipeVerifyFatal = false; + MG_Config::Features.PipeVerifyCorrupt = "GetRenderStateParameters"; + MGPipeFillForVerb(MGPipeVerb::DrawArrays); + MGPipeFillForVerb(MGPipeVerb::DrawElements); + ::_exit(0); + }); + ASSERT_TRUE(ExitedWith(r, 0)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("MGPipe: verify armed - 63 fields, 69 verbs, fatal=0"), std::string::npos) << r.Log; + const std::string first = "Fatal{PipeVerifyDiffer, \"GetRenderStateParameters@DrawArrays\", verb=" + + std::to_string(serial) + ", where=entry}"; + const std::string second = "Fatal{PipeVerifyDiffer, \"GetRenderStateParameters@DrawElements\", verb=" + + std::to_string(serial + 1) + ", where=entry}"; + EXPECT_NE(r.Log.find(first), std::string::npos) << "expected " << first << " in\n" << r.Log; + EXPECT_NE(r.Log.find(second), std::string::npos) << "expected " << second << " in\n" << r.Log; +#endif +} + // Every verb fills exactly its class mask: after a fill, a field is fresh iff its bit is set // (sticky fields included, since every class mask carries them). TEST_F(PipeInputsTest, EveryVerbFillsItsClassAndNothingElse) { @@ -254,9 +481,11 @@ TEST_F(PipeInputsTest, EveryVerbFillsItsClassAndNothingElse) { int main(int argc, char** argv) { // Before anything logs: MG_Util::Debug::InitFile() reads the variable once, on the first - // write, and caches the FILE*. + // write, and caches the FILE*. The name carries this process's pid (see the file header), + // and the file is removed on the way out; a forked child that aborts leaves it to us. namespace fs = std::filesystem; - const fs::path path = fs::temp_directory_path() / "mobilegl-pipeinputs-test.log"; + const fs::path path = + fs::temp_directory_path() / ("mobilegl-pipeinputs-test-" + std::to_string(ProcessId()) + ".log"); std::error_code ec; fs::remove(path, ec); g_logPath = path.string(); @@ -266,5 +495,7 @@ int main(int argc, char** argv) { setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); #endif ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); + const int rc = RUN_ALL_TESTS(); + fs::remove(path, ec); + return rc; } From 30d72c5b4e623685928cc7c119f386c25d3d35c7 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 02:55:17 -0400 Subject: [PATCH 052/529] [Fix] (Pipe): refuse a stamp of 0 as fresh on both branches - a read before the first fill is Fatal{UnmigratedPipeInput, "@"}, not default storage - MGPipeInputFieldIsFresh (gen_pipe.py gen_filled, emitted into PipeFilled.inc) compared FilledGen == CurrentVerbSerial for a non-sticky field; before the first MGPipeFillForVerb both are 0, so 55 of the 63 fields read as fresh and served their default-constructed storage silently, with no log line and no abort. Only the four raw-pointer O-class accessors tripped, through their null-base checks. - D6 says the serial starts at 1 so that FilledGen == 0 means never filled, and names the window "@"; the predicate now refuses a stamp of 0 before consulting the sticky branch or the serial, so the window is the poison's case as documented. This is the window E's risk table expects the verify lane to find (an init-time read, the first link, anything reached from eglMakeCurrent). - Reproduced with the reviewer's pre-fill program (a live context with line width 7, no fill, gPipeInputs.GetLineWidth()): stored=0, rc=0 before; rc=134 with exactly Fatal{UnmigratedPipeInput, "GetLineWidth@"} after, with and without Features.PipeVerify set first. - The compare-at-read hook arms at the first fill and cannot see this window either; a static_assert next to it pins that a verify build always carries the poison, which is what covers the reads before arming. --- MobileGL/MG_Impl/Pipe/PipeFill.cpp | 11 ++++++++++- MobileGL/MG_Pipe/generated/PipeFilled.inc | 8 ++++++-- scripts/gen_pipe.py | 8 ++++++-- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 6537838b8..e298c8f19 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -338,6 +338,13 @@ namespace MobileGL::MG_Pipe { PipeInputs g_snapshot{}; // the second arm PipeInputs g_readScratch{}; // where the compare-at-read re-read lands + // The read hook arms at the first fill (ArmVerify below), so it cannot see a read + // made before that. That window is covered by the poison instead: MGP_INPUT_CHECK + // precedes MGP_INPUT_VERIFY_READ in every accessor and a stamp of 0 is never fresh, + // so such a read is Fatal{UnmigratedPipeInput, "@"} before the hook + // could matter - which holds only while a verify build always carries the poison. + static_assert(MOBILEGL_PIPE_POISON, "the compare-at-read hook relies on the poison for reads before the first fill"); + struct VerifyState { Bool Parsed = false; Bool Enabled = false; @@ -515,7 +522,9 @@ namespace MobileGL::MG_Pipe { #endif #if MOBILEGL_PIPE_POISON MGPipeFilledState& filled = MGPipeFillAccess::Filled(inputs); - // Starts at 1, so FilledGen == 0 means "never filled". + // Starts at 1: FilledGen == 0 is "never filled", and MGPipeInputFieldIsFresh refuses + // it on both branches, so a read before this first bump is + // Fatal{UnmigratedPipeInput, "@"} rather than default storage. ++filled.CurrentVerbSerial; #endif MGPipeFillAccess::SetVerb(inputs, verb); diff --git a/MobileGL/MG_Pipe/generated/PipeFilled.inc b/MobileGL/MG_Pipe/generated/PipeFilled.inc index 91e82514b..4c485cfd5 100644 --- a/MobileGL/MG_Pipe/generated/PipeFilled.inc +++ b/MobileGL/MG_Pipe/generated/PipeFilled.inc @@ -310,8 +310,12 @@ struct MGPipeFilledState { std::abort(); } +// FilledGen == 0 is "never filled" on BOTH branches: before the first MGPipeFillForVerb the +// serial is 0 as well, and a read in that window is the poison's "@" case +// (P1 brief D6), never a fresh read of default-constructed storage. inline Bool MGPipeInputFieldIsFresh(const MGPipeFilledState& state, MGPipeInputField field) { const SizeT index = static_cast(field); - return kMGPipeInputFieldSticky[index] ? state.FilledGen[index] != 0 - : state.FilledGen[index] == state.CurrentVerbSerial; + const Uint64 gen = state.FilledGen[index]; + if (gen == 0) return false; + return kMGPipeInputFieldSticky[index] || gen == state.CurrentVerbSerial; } diff --git a/scripts/gen_pipe.py b/scripts/gen_pipe.py index a26d1ad94..7ae0fe566 100644 --- a/scripts/gen_pipe.py +++ b/scripts/gen_pipe.py @@ -807,10 +807,14 @@ def gen_filled(accessors, calls, sticky): std::abort(); } +// FilledGen == 0 is "never filled" on BOTH branches: before the first MGPipeFillForVerb the +// serial is 0 as well, and a read in that window is the poison's "@" case +// (P1 brief D6), never a fresh read of default-constructed storage. inline Bool MGPipeInputFieldIsFresh(const MGPipeFilledState& state, MGPipeInputField field) { const SizeT index = static_cast(field); - return kMGPipeInputFieldSticky[index] ? state.FilledGen[index] != 0 - : state.FilledGen[index] == state.CurrentVerbSerial; + const Uint64 gen = state.FilledGen[index]; + if (gen == 0) return false; + return kMGPipeInputFieldSticky[index] || gen == state.CurrentVerbSerial; }""") return "\n".join(out) + "\n" From d0ff647581d6ab07e8816117b95ee83e04e4bc6d Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 02:55:17 -0400 Subject: [PATCH 053/529] [Fix] (Pipe): re-arm the verify comparator when any of its three knobs changes, not only when PipeVerify does - ArmVerify latched PipeVerifyFatal and PipeVerifyCorrupt at the moment PipeVerify changed; a later change to either without a PipeVerify toggle was not seen, so 878db2c4's "re-parse when their value changes" held for one knob of three. - The latch now keys on all three Features values. A lane loads Features before its first fill, so it still arms once per process; cost is two Bool compares and one String compare per fill, verify builds only. --- MobileGL/MG_Impl/Pipe/PipeFill.cpp | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index e298c8f19..551678cc6 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -351,6 +351,7 @@ namespace MobileGL::MG_Pipe { Bool Fatal = true; Bool InHook = false; // a re-read that re-enters an accessor is not re-verified Optional Corrupt; + String CorruptKnob; // the MOBILEGL_PIPE_VERIFY_CORRUPT value the last arm saw std::atomic Divergences{0}; ~VerifyState() { const Uint64 count = Divergences.load(std::memory_order_relaxed); @@ -362,17 +363,24 @@ namespace MobileGL::MG_Pipe { }; VerifyState g_verify; - // Armed on the first fill and re-armed only when Features.PipeVerify changes (the - // same reason as ParsePoisonOmissionKnob: one arm per lane process, a fresh arm for a - // forked test child that turns the knob on after its parent filled unarmed). + // Armed on the first fill and re-armed when any of the three verify knobs' + // Features value (PipeVerify, PipeVerifyFatal, PipeVerifyCorrupt) differs from what + // the last arm latched (the same reason as ParsePoisonOmissionKnob: one arm per lane + // process, a fresh arm for a forked test child that turns a knob after its parent + // filled). Cost: two Bool compares and one String compare per fill, verify builds only. void ArmVerify() { - if (g_verify.Parsed && g_verify.Enabled == MG_Config::Features.PipeVerify) return; + const auto& features = MG_Config::Features; + if (g_verify.Parsed && g_verify.Enabled == features.PipeVerify && g_verify.Fatal == features.PipeVerifyFatal && + g_verify.CorruptKnob == features.PipeVerifyCorrupt) { + return; + } g_verify.Parsed = true; - g_verify.Enabled = MG_Config::Features.PipeVerify; + g_verify.Enabled = features.PipeVerify; + g_verify.Fatal = features.PipeVerifyFatal; + g_verify.CorruptKnob = features.PipeVerifyCorrupt; g_verify.Corrupt = Optional{}; if (!g_verify.Enabled) return; - g_verify.Fatal = MG_Config::Features.PipeVerifyFatal; - const String& corrupt = MG_Config::Features.PipeVerifyCorrupt; + const String& corrupt = g_verify.CorruptKnob; if (!corrupt.empty()) { const auto field = MGPipeFindInputField(corrupt.c_str()); if (!field) { From 12b57055b7c908fb4670c57f7e7e1e8edea01fdb Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 02:55:17 -0400 Subject: [PATCH 054/529] [Docs] (Pipe): record why the forwarders carry no poison check, the InHook re-entry guard, and the Index slot no FillPoints.def row can fill - The seven F-class forwarders are the declared exception to D4's "every accessor body": a forward is a live call, not a stored value, and InvalidateCompileEnv is reached from backend initialisation before any verb has filled, where a check would be Fatal{...@} on every start. Their sticky stamp is consulted by no accessor; the tests pin it through MGPipeInputFieldIsFresh directly. Written down so P2's tracker does not "fix" the missing check. - MGPipeVerifyReadHook: InHook is what keeps a GetProgramForDraw-triggered backend re-entry from recursing into a second hook, and the whole-field re-read is a per-read cost to keep in mind when reading the verify lane's wall time. - GetBufferBindingSlot(Index) is polymorphic on GLContext (the bound VAO's element-buffer slot) and null in the block by design: a push Fatal there is not a missing row. No backend reads it today. --- MobileGL/MG_Backend/MGPipe/PipeInputs.h | 6 ++++++ MobileGL/MG_Impl/Pipe/PipeFill.cpp | 16 +++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.h b/MobileGL/MG_Backend/MGPipe/PipeInputs.h index 5f143642d..6e237de13 100644 --- a/MobileGL/MG_Backend/MGPipe/PipeInputs.h +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.h @@ -560,6 +560,12 @@ namespace MobileGL::MG_Pipe { // i.e. it is a lookup or a reverse-channel write, not a state read; there is no value // the filler could copy and no verb whose fill could make it stale. Phase C replaces // them with handle tables and callbacks. + // They carry no MGP_INPUT_CHECK / MGP_INPUT_VERIFY_READ (the declared exception to + // P1 brief D4's "every accessor body"): a forward is a live call, not a stored value, + // and InvalidateCompileEnv is reached from backend initialisation before any verb has + // filled, where a check would be Fatal{...@} on every start. Their sticky stamp + // is therefore consulted by no accessor; the tests pin it through + // MGPipeInputFieldIsFresh directly. SizeT GetBufferBindingPointCount(BufferTarget target) const; const SharedPtr& GetProgramObject(Uint index); const SharedPtr& GetTextureObject(Uint index); diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 551678cc6..9768d6725 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -61,8 +61,11 @@ namespace MobileGL::MG_Pipe { dst.m_boundVertexArray = ctx.GetBoundVertexArray(); break; case F::GetBufferBindingSlot: - // Every global target has a slot; the others (Index) stay null and a read - // of one is the poison Fatal in the accessor. + // Every global target has a slot; Index stays null - GLContext resolves it + // through the bound VAO's element-buffer slot (Core.cpp), a derivation no + // FillPoints.def row can copy - and a read of it is the poison Fatal in the + // accessor. No backend reads it today (every slot read is DrawIndirect, + // DispatchIndirect, Parameter or PixelPack). for (const auto target : GlobalBufferTargets) { dst.m_bufferBindingSlot[static_cast(target)] = &ctx.GetBufferBindingSlot(target); } @@ -440,7 +443,14 @@ namespace MobileGL::MG_Pipe { if (ctx == nullptr) return; // The whole field is re-read and compared - a superset of "the same indices", so a // divergence in an index the backend did not ask for is still a divergence between - // the boundary value and the live value. The indices only decorate the report. + // the boundary value and the live value. The indices only decorate the report. The + // cost is per backend read (GetRenderStateParameters re-copies and compares the whole + // struct; GetProgramForDraw re-joins the pending link), inside the verify budget and + // to be kept in mind when reading the verify lane's wall time. + // InHook: the re-read calls the same GLContext accessor the filler calls, and + // GetProgramForDraw's join can re-enter a backend and with it another gPipeInputs + // accessor; that inner read is a plain load rather than a second hook, so the hook + // never recurses (and never reports the inner read against a half-copied scratch). g_verify.InHook = true; MGPipeFillAccess::CopyField(g_readScratch, *ctx, field); const Bool equal = MGPipeInputsFieldEqual(field, self, g_readScratch); From 44ffafb2dc10a7976fd3895a8c6bd4d3588ee922 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 02:55:17 -0400 Subject: [PATCH 055/529] [Test] (Pipe): pin the pre-fill window - every stamp of 0 is stale at serial 0, a read before any fill aborts naming "", and the FATAL=0 child leaves through std::exit so the teardown summary is observed - PipeCatalogue.PipeInputFieldsStartUnfilled asserted "unfilled" only after setting the serial to 1 by hand, stepping around the serial-0 window; it now asserts every field stale on a value-initialised state first (sticky fields included). - PipeInputsTest.ReadingBeforeAnyFillAbortsNamingNoVerb: a live context, no fill, gPipeInputs.GetLineWidth() in a forked child; the parent expects SIGABRT, exactly Fatal{UnmigratedPipeInput, "GetLineWidth@"}, no PipeVerifyDiffer and no arming line. In a verify build the child sets Features.PipeVerify first, the lane's shape. - VerifyFatalOffLogsTheDivergenceAndContinues: the child _exit(0)ed, so VerifyState's destructor never ran and the "verify summary" line was covered only by a lane run; std::exit(0) runs it, and the parent now asserts "2 divergence(s) survived MOBILEGL_PIPE_VERIFY_FATAL=0". --- MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp | 6 +++ MobileGL/MG_Test/Pipe/PipeInputsTest.cpp | 42 ++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp index c5c720e06..9af675200 100644 --- a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp +++ b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp @@ -225,6 +225,12 @@ TEST(PipeCatalogue, CoverageAccountsForEveryInventoryRow) { TEST(PipeCatalogue, PipeInputFieldsStartUnfilled) { EXPECT_EQ(kMGPipeInputFieldCount, 63u); MGPipeFilledState state{}; + // Before the first fill the serial is 0 as well: 0 == 0 must not read as fresh, on the + // sticky branch either (the window D6 names "@"). + EXPECT_EQ(state.CurrentVerbSerial, 0u); + for (SizeT f = 0; f < kMGPipeInputFieldCount; ++f) { + EXPECT_FALSE(MGPipeInputFieldIsFresh(state, static_cast(f))) << kMGPipeInputFieldNames[f]; + } state.CurrentVerbSerial = 1; EXPECT_FALSE(MGPipeInputFieldIsFresh(state, MGPipeInputField::GetRenderStateParameters)); state.FilledGen[static_cast(MGPipeInputField::GetRenderStateParameters)] = 1; diff --git a/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp b/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp index 8b3075359..69be8437b 100644 --- a/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp +++ b/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp @@ -143,6 +143,9 @@ TEST(PipeInputsTest,ReadingAnOmittedFieldAbortsNamingTheVerb) { TEST(PipeInputsTest,ReadingAFilledFieldCompletes) { GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; } +TEST(PipeInputsTest,ReadingBeforeAnyFillAbortsNamingNoVerb) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} TEST(PipeInputsTest,CorruptedSnapshotFieldIsNamedWithItsSerial) { GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; } @@ -257,6 +260,36 @@ TEST_F(PipeInputsTest, ReadingAFilledFieldCompletes) { #endif } +// The window before the first verb (P1 brief D6: "@"). Nothing has filled this +// process's block, so a backend-style read of a value field is Fatal naming no verb: the +// serial and every stamp are 0, and 0 == 0 is not fresh. In a verify build the child also +// sets the lane's knob first, the shape of a read reached from initialisation: the poison +// fires at MGP_INPUT_CHECK, before the read hook (armed only by the first fill) could matter. +TEST_F(PipeInputsTest, ReadingBeforeAnyFillAbortsNamingNoVerb) { +#if !MOBILEGL_PIPE_POISON + GTEST_SKIP() << "poison not compiled in (MOBILEGL_PIPE_POISON=0)"; +#elif !MGTEST_HAVE_FORK + GTEST_SKIP() << "no fork() on this platform"; +#else + ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; + if (gPipeInputs.FilledState().CurrentVerbSerial != 0) { + GTEST_SKIP() << "another case already filled in this process; gtest_discover_tests runs each case alone"; + } + MG_State::pGLContext->SetLineWidth(7.0f); // a live value the default storage (0) does not hold + const ChildResult r = RunInChild([] { +#if MOBILEGL_PIPE_VERIFY + MG_Config::Features.PipeVerify = true; +#endif + (void)gPipeInputs.GetLineWidth(); // Fatal{UnmigratedPipeInput, "GetLineWidth@"} + ::_exit(3); // reached only if the pre-fill window read as fresh + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{UnmigratedPipeInput, \"GetLineWidth@\"}"), std::string::npos) << r.Log; + EXPECT_EQ(r.Log.find("PipeVerifyDiffer"), std::string::npos) << r.Log; + EXPECT_EQ(r.Log.find("verify armed"), std::string::npos) << r.Log; +#endif +} + // The MOBILEGL_PIPE_POISON_OMIT parser, through the Features field the loader fills: the // child sets the knob after its parent filled without it, and the first fill after that // parses it, logs the arming line and withholds the stamp exactly as the programmatic form. @@ -427,7 +460,9 @@ TEST_F(PipeInputsTest, BadVerifyCorruptKnobIsFatalNamingTheKnob) { } // MOBILEGL_PIPE_VERIFY_FATAL=0: the divergence is logged with its field and serial and the -// process goes on (the fill returns, the next fill's compare runs again). +// process goes on (the fill returns, the next fill's compare runs again), and the teardown +// summary counts both. The child leaves through std::exit so the comparator's static +// destructor runs (a _exit would skip the summary line). TEST_F(PipeInputsTest, VerifyFatalOffLogsTheDivergenceAndContinues) { #if !MOBILEGL_PIPE_VERIFY GTEST_SKIP() << "verify not compiled in (MOBILEGL_PIPE_VERIFY=OFF)"; @@ -443,10 +478,13 @@ TEST_F(PipeInputsTest, VerifyFatalOffLogsTheDivergenceAndContinues) { MG_Config::Features.PipeVerifyCorrupt = "GetRenderStateParameters"; MGPipeFillForVerb(MGPipeVerb::DrawArrays); MGPipeFillForVerb(MGPipeVerb::DrawElements); - ::_exit(0); + std::exit(0); }); ASSERT_TRUE(ExitedWith(r, 0)) << DescribeStatus(r) << "\n" << r.Log; EXPECT_NE(r.Log.find("MGPipe: verify armed - 63 fields, 69 verbs, fatal=0"), std::string::npos) << r.Log; + EXPECT_NE(r.Log.find("MGPipe: verify summary - 2 divergence(s) survived MOBILEGL_PIPE_VERIFY_FATAL=0"), + std::string::npos) + << r.Log; const std::string first = "Fatal{PipeVerifyDiffer, \"GetRenderStateParameters@DrawArrays\", verb=" + std::to_string(serial) + ", where=entry}"; const std::string second = "Fatal{PipeVerifyDiffer, \"GetRenderStateParameters@DrawElements\", verb=" + From 9087f13308e28ca841c2dbbcd32f249d2db5358b Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 04:41:15 -0400 Subject: [PATCH 056/529] [Fix] (Pipe): let a readback fill the transform-feedback state its emulation reads - the depth/stencil read emulation opens a ScopedEmulationDrawState that pauses an active capture around its own draw, so ReadPixels/GetTexImage read IsTransformFeedbackActive and IsTransformFeedbackPaused; without the two kReadback rows every emulated readback aborts with Fatal{UnmigratedPipeInput, "IsTransformFeedbackActive@ReadPixels"} once the Espryt sites are converted --- MobileGL/MG_Pipe/FillPoints.def | 2 +- MobileGL/MG_Pipe/generated/PipeFillPoints.inc | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/MobileGL/MG_Pipe/FillPoints.def b/MobileGL/MG_Pipe/FillPoints.def index ab15bbea4..e66bff85c 100644 --- a/MobileGL/MG_Pipe/FillPoints.def +++ b/MobileGL/MG_Pipe/FillPoints.def @@ -237,7 +237,7 @@ X(kReadback, GetSamplingResolutionGeneration) \ X(kReadback, GetTextureBindGeneration) \ X(kReadback, GetMaxTouchedTextureUnit) \ - X(kReadback, GetImageTextureBinding) \ + X(kReadback, GetImageTextureBinding) /* The depth/stencil read emulation draws (ScopedEmulationDrawState, */ /* DirectGLES.cpp:5224) and pauses an active capture around its own draw, so */ /* a readback reads the transform-feedback state exactly as a draw does. */ X(kReadback, IsTransformFeedbackActive) X(kReadback, IsTransformFeedbackPaused) \ /* kXfbSpan */ \ X(kXfbSpan, GetTransformFeedbackProgram) \ X(kXfbSpan, GetBufferBindingPoint) \ diff --git a/MobileGL/MG_Pipe/generated/PipeFillPoints.inc b/MobileGL/MG_Pipe/generated/PipeFillPoints.inc index 1afd3e511..41c6c2f0f 100644 --- a/MobileGL/MG_Pipe/generated/PipeFillPoints.inc +++ b/MobileGL/MG_Pipe/generated/PipeFillPoints.inc @@ -287,8 +287,8 @@ inline constexpr MGPipeFieldMask kMGPipeClassFieldMask[kMGPipeVerbClassCount] = {{0x5f50ffa001344101ull, 0x0000000000000000ull}}, // kTextureOp: 14 fields (7 own + 7 sticky) {{0x5c00f22001200101ull, 0x0000000000000000ull}}, - // kReadback: 22 fields (15 own + 7 sticky) - {{0x5c50f3a041300541ull, 0x0000000000000000ull}}, + // kReadback: 24 fields (17 own + 7 sticky) + {{0x5f50f3a041300541ull, 0x0000000000000000ull}}, // kXfbSpan: 15 fields (8 own + 7 sticky) {{0x7f0b402000000380ull, 0x0000000000000000ull}}, // kProgramOp: 18 fields (11 own + 7 sticky) From d4504e30f856db020117980371661d65ea5c2493 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 01:51:40 -0400 Subject: [PATCH 057/529] [Refactor] (Espryt): route every frontend read through MGB_CTX - 113 arrow sites sed'd, 9 non-arrow lines converted (the fb-slot cache keys on MGB_CTX_IDENTITY); pull build byte-identical - P1 package B (BRIEF-P1 C.2): the four DirectGLES TUs include right after their MG_State/GLState/Core.h include (Managers.cpp after its Managers.h include) and spell every frontend read MGB_CTX->Accessor(...). In the pull build MGB_CTX is MG_State::pGLContext, so the code is the tree before this commit token for token; in the push build it is &gPipeInputs, the block the frontend fills at every verb boundary. - 113 arrow occurrences on 113 lines went through the mechanical sed (DirectGLES.cpp 91, Managers.cpp 15, MultiDraw.cpp 5, Utils.cpp 2). The 9 non-arrow lines follow D9: Managers.cpp's five bare/compound guards and three ternary conditions become MGB_CTX_LIVE (UniquePtr::operator bool spelled out, so the pull build does not move); DirectGLES.cpp's GetFramebufferBindingSlotFast keys its static cache on MGB_CTX_IDENTITY (a const void* compare) instead of pGLContext.get(). - The cache refill loop dereferences MGB_CTX once into a local reference and reads the slots through it, instead of &MGB_CTX->GetFramebufferBindingSlot(i) per iteration as D9 spells it: a store into g_fbSlotCache (a pointer) may alias the unique_ptr's own pointer under clang's TBAA, so the per-iteration spelling re-reads pGLContext inside the loop and grows the three functions the loop is inlined into (SyncCurrentProgram +16, ForceBindCurrentFBO +9, BlitNamedFramebuffer +1; .text +32). Hoisting the dereference restores the single load and a zero .text delta. - grep -rc pGLContext MobileGL/MG_Backend/DirectGLES reports 0 in every file; symbol_report --threshold 0 against the 087685d1 baseline: 0 added / 0 removed / 0 resized / 0 renamed, .text +0, nm --defined-only set identical. The only bytes that move are __LINE__ immediates in RecordError sites after the inserted include line. The 8 SyncPersistentMappedRange and 3 SyncGpuWrites sites and the PipeStats AddCalls literals are untouched. --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 190 +++++++++--------- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 47 ++--- MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp | 11 +- MobileGL/MG_Backend/DirectGLES/Utils.cpp | 5 +- 4 files changed, 129 insertions(+), 124 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 85a10e00a..feb59a1be 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -140,14 +141,15 @@ namespace MobileGL::MG_Backend::DirectGLES { // addresses again - the cached pointers cannot go stale. Invalidation is // exactly the pointer compare below. using FbBindingSlot = - std::remove_reference_tGetFramebufferBindingSlot(FramebufferTarget::Draw))>; - static const MG_State::GLState::GLContext* g_fbSlotCacheContext = nullptr; + std::remove_reference_tGetFramebufferBindingSlot(FramebufferTarget::Draw))>; + static const void* g_fbSlotCacheContext = nullptr; static Array g_fbSlotCache = {}; static inline FbBindingSlot& GetFramebufferBindingSlotFast(FramebufferTarget target) { - MG_State::GLState::GLContext* ctx = MG_State::pGLContext.get(); + const void* ctx = MGB_CTX_IDENTITY; if (ctx != g_fbSlotCacheContext) { + auto& live = *MGB_CTX; for (SizeT i = 0; i < g_fbSlotCache.size(); ++i) { - g_fbSlotCache[i] = &ctx->GetFramebufferBindingSlot(static_cast(i)); + g_fbSlotCache[i] = &live.GetFramebufferBindingSlot(static_cast(i)); } g_fbSlotCacheContext = ctx; } @@ -258,7 +260,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } const Uint8* ResolveIndirectCommandBytes(const void* indirect, SizeT requiredBytes, const char* label) { - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (drawBuffer) { drawBuffer->SyncPersistentMappedRange(); const SizeT commandOffset = reinterpret_cast(indirect); @@ -354,7 +356,7 @@ namespace MobileGL::MG_Backend::DirectGLES { #endif // Only sync up to the high-water mark of app-touched points; the fixed array is 84 // deep but apps bind a handful, so the never-touched tail is already at GL default 0. - auto bindingPointCnt = MG_State::pGLContext->GetTouchedBufferBindingPointCount(target); + auto bindingPointCnt = MGB_CTX->GetTouchedBufferBindingPointCount(target); // ...and never past what the ES driver itself can hold. MobileGL advertises the GL 4.5 // minimum of 84 uniform binding points while the ES 3.2 minimum is 72, so a frontend // index in that gap would reach glBindBufferBase as GL_INVALID_VALUE. Nothing is lost @@ -367,7 +369,7 @@ namespace MobileGL::MG_Backend::DirectGLES { static_cast(g_GLESCapabilities.MaxUniformBufferBindings)); } for (SizeT i = 0; i < bindingPointCnt; ++i) { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(target, i); + auto& point = MGB_CTX->GetBufferBindingPoint(target, i); auto& obj = point.GetBoundObject(); if (!obj) { BindBufferBaseCached(glTarget, static_cast(i), 0); @@ -426,7 +428,7 @@ namespace MobileGL::MG_Backend::DirectGLES { const SizeT pointCount = std::min( bufferCount, MG_State::GLState::GLContext::MAX_TRANSFORM_FEEDBACK_BUFFERS); for (SizeT i = 0; i < pointCount; ++i) { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::TransformFeedback, i); + auto& point = MGB_CTX->GetBufferBindingPoint(BufferTarget::TransformFeedback, i); const auto& obj = point.GetBoundObject(); // A stride-0 slot (two consecutive gl_NextBuffer entries) captures nothing and // needs no binding; anything else with no buffer never got past the frontend. @@ -459,10 +461,10 @@ namespace MobileGL::MG_Backend::DirectGLES { // pull the real contents back (BufferObject::SyncGpuWrites). void MarkShaderStorageBuffersGpuWritten() { const SizeT bindingPointCnt = - MG_State::pGLContext->GetTouchedBufferBindingPointCount(BufferTarget::ShaderStorage); + MGB_CTX->GetTouchedBufferBindingPointCount(BufferTarget::ShaderStorage); for (SizeT i = 0; i < bindingPointCnt; ++i) { const auto& obj = - MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, i).GetBoundObject(); + MGB_CTX->GetBufferBindingPoint(BufferTarget::ShaderStorage, i).GetBoundObject(); if (obj) obj->MarkGpuWritten(); } } @@ -471,14 +473,14 @@ namespace MobileGL::MG_Backend::DirectGLES { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - const SizeT pointCount = MG_State::pGLContext->GetBufferBindingPointCount(BufferTarget::AtomicCounter); + const SizeT pointCount = MGB_CTX->GetBufferBindingPointCount(BufferTarget::AtomicCounter); for (const Int glBinding : glBindings) { if (glBinding < 0 || static_cast(glBinding) >= pointCount) continue; const Int esslBinding = esslBindingTop - glBinding; // Already diagnosed once when the block was transpiled; nothing was bound to it // there either, so there is nothing to unbind here. if (esslBinding < 0) continue; - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::AtomicCounter, + auto& point = MGB_CTX->GetBufferBindingPoint(BufferTarget::AtomicCounter, static_cast(glBinding)); auto& obj = point.GetBoundObject(); if (!obj) { @@ -515,7 +517,7 @@ namespace MobileGL::MG_Backend::DirectGLES { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - auto& bufferObject = MG_State::pGLContext->GetBufferBindingSlot(target).GetBoundObject(); + auto& bufferObject = MGB_CTX->GetBufferBindingSlot(target).GetBoundObject(); if (!bufferObject) { g_GLESFuncs.glBindBuffer(glTarget, 0); return; @@ -659,7 +661,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // context since indirect draws now execute natively on the GPU. if (includeIndirectBuffer) { auto& possibleIndirectBuffer = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (possibleIndirectBuffer) { SyncBoundBuffer(BufferTarget::DrawIndirect, GL_DRAW_INDIRECT_BUFFER); } @@ -889,7 +891,7 @@ namespace MobileGL::MG_Backend::DirectGLES { const SizeT packedStride = program->GetTransformFeedbackPackedStride(); const SizeT modelledVertices = - static_cast(MG_State::pGLContext->GetTransformFeedbackCapturedVertices()); + static_cast(MGB_CTX->GetTransformFeedbackCapturedVertices()); const SizeT vertices = std::min(modelledVertices, xfb.scatterCapacityVertices); if (packedStride == 0 || vertices == 0) { // The scatter path redirected the DRIVER's capture into the scratch buffer, @@ -984,7 +986,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // not captured, and opening the span would also subject it to the capture // primitive-mode rule the paused draw is exempt from. if (!xfb.pending || xfb.paused) return; - const auto& program = MG_State::pGLContext->GetTransformFeedbackProgram(); + const auto& program = MGB_CTX->GetTransformFeedbackProgram(); if (!program) { // The pending flag is deliberately NOT consumed here. It used to be cleared // before this check, so a single draw that could not see the capture program @@ -1004,7 +1006,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // recording it here keeps End independent of the frontend capture state. const SizeT bufferCount = program->GetTransformFeedbackBufferCount(); for (SizeT i = 0; i < bufferCount; ++i) { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::TransformFeedback, + auto& point = MGB_CTX->GetBufferBindingPoint(BufferTarget::TransformFeedback, static_cast(i)); const auto& bufferObject = point.GetBoundObject(); if (!bufferObject) continue; @@ -1241,7 +1243,7 @@ namespace MobileGL::MG_Backend::DirectGLES { #endif if (!program) return; - const auto& vao = MG_State::pGLContext->GetBoundVertexArray(); + const auto& vao = MGB_CTX->GetBoundVertexArray(); if (!vao || !vaoTwin) return; const Uint32 activeAttribMask = program->GetActiveAttributeLocationMask(); @@ -1272,7 +1274,7 @@ namespace MobileGL::MG_Backend::DirectGLES { for (Uint32 remaining = memo.pendingMask; remaining != 0; remaining &= remaining - 1) { const Uint32 location = static_cast(std::countr_zero(remaining)); - const auto& currentValue = MG_State::pGLContext->GetCurrentVertexAttribute(location); + const auto& currentValue = MGB_CTX->GetCurrentVertexAttribute(location); const auto typeInfo = MG_State::GLState::ClassifyVertexAttribType(program->GetAttribType(location)); switch (typeInfo.baseType) { case MG_State::GLState::VertexAttribBaseType::Float: @@ -1370,7 +1372,7 @@ namespace MobileGL::MG_Backend::DirectGLES { static void CaptureUnitBindings(Int maxTouchedUnit, Vector& out) { out.resize(static_cast(maxTouchedUnit + 1)); for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); auto& snapshot = out[static_cast(unit)]; const auto& slots = textureUnit.GetAllBindingSlots(); for (SizeT i = 0; i < slots.size(); ++i) { @@ -1383,7 +1385,7 @@ namespace MobileGL::MG_Backend::DirectGLES { static Bool UnitBindingsUnchanged(Int maxTouchedUnit, const Vector& snapshots) { if (snapshots.size() != static_cast(maxTouchedUnit + 1)) return false; for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); const auto& snapshot = snapshots[static_cast(unit)]; const auto& slots = textureUnit.GetAllBindingSlots(); for (SizeT i = 0; i < slots.size(); ++i) { @@ -1411,8 +1413,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // bump - so epoch equality alone proves the bindings a consumer resolved against // are the bindings on the units now. static Uint64 CurrentUnitBindingsEpoch(Int maxTouchedUnit) { - const Uint64 contextId = MG_State::pGLContext->GetTextureContextId(); - const Uint64 bindGeneration = MG_State::pGLContext->GetTextureBindGeneration(); + const Uint64 contextId = MGB_CTX->GetTextureContextId(); + const Uint64 bindGeneration = MGB_CTX->GetTextureBindGeneration(); if (MG_Util::PipeStats::Enabled()) { // Two accessor calls whichever way the shutter goes; only the unit WALK is // gated, and that walk reads no GLContext accessor of its own. @@ -1512,10 +1514,10 @@ namespace MobileGL::MG_Backend::DirectGLES { DrawTextureSyncKeys CaptureDrawTextureSyncKeys() { DrawTextureSyncKeys keys; - keys.contextId = MG_State::pGLContext->GetTextureContextId(); + keys.contextId = MGB_CTX->GetTextureContextId(); // Units past the frontend's high-water mark have provably-empty slots. - keys.maxTouchedUnit = MG_State::pGLContext->GetMaxTouchedTextureUnit(); - keys.samplingGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + keys.maxTouchedUnit = MGB_CTX->GetMaxTouchedTextureUnit(); + keys.samplingGeneration = MGB_CTX->GetSamplingResolutionGeneration(); keys.unitBindingsEpoch = CurrentUnitBindingsEpoch(keys.maxTouchedUnit); if (MG_Util::PipeStats::Enabled()) { // The three reads above; CurrentUnitBindingsEpoch counts its own two. @@ -1577,7 +1579,7 @@ namespace MobileGL::MG_Backend::DirectGLES { g_unitTextureSyncListValid = false; g_unitTextureSyncList.clear(); for (Int index = 0; index <= maxTouchedUnit; ++index) { - auto& unit = MG_State::pGLContext->GetTextureUnitObject(index); + auto& unit = MGB_CTX->GetTextureUnitObject(index); for (const auto& bindingSlot : unit.GetAllBindingSlots()) { auto& textureObject = bindingSlot.GetBoundObject(); // An image-less default texture (name 0) is the slot's initial / "unbound" @@ -1725,7 +1727,7 @@ namespace MobileGL::MG_Backend::DirectGLES { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(unit)); + auto& imageBinding = MGB_CTX->GetImageTextureBinding(static_cast(unit)); TrackWritableImageBufferUnit(unit, IsWritableImageBufferTexture(imageBinding)); if (imageBinding.Texture && unit + 1 > g_imageUnitHighWaterMark) { g_imageUnitHighWaterMark = unit + 1; @@ -1817,7 +1819,7 @@ namespace MobileGL::MG_Backend::DirectGLES { if (g_writableImageBufferUnitCount == 0) return; for (Uint unit = 0; unit < g_writableImageBufferUnits.size(); ++unit) { if (!g_writableImageBufferUnits[unit]) continue; - const auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(unit)); + const auto& imageBinding = MGB_CTX->GetImageTextureBinding(static_cast(unit)); if (!IsWritableImageBufferTexture(imageBinding)) { TrackWritableImageBufferUnit(unit, false); continue; @@ -2023,7 +2025,7 @@ namespace MobileGL::MG_Backend::DirectGLES { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - Uint16 currentRenderStateVersion = MG_State::pGLContext->GetRenderStateParametersVersion(); + Uint16 currentRenderStateVersion = MGB_CTX->GetRenderStateParametersVersion(); const Bool forceFullPush = g_forceFullRenderStateResync; g_forceFullRenderStateResync = false; // The alpha discipline for widened colour attachments (see the header comment on @@ -2051,7 +2053,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 3); } - const auto& parameters = MG_State::pGLContext->GetRenderStateParameters(); + const auto& parameters = MGB_CTX->GetRenderStateParameters(); // The frontend has ONE version for the whole parameter block, so a per-draw blend // toggle used to re-diff all ~40 pieces of state field by field on every draw @@ -2080,7 +2082,7 @@ namespace MobileGL::MG_Backend::DirectGLES { !g_hasSyncedRenderState || std::memcmp(currentBytes + kBlendSpanEnd, syncedBytes + kBlendSpanEnd, sizeof(RenderStateParameters) - kBlendSpanEnd) != 0; - IntVec4 backendViewport = MG_State::pGLContext->GetViewport(); + IntVec4 backendViewport = MGB_CTX->GetViewport(); if (backendViewport.z() <= 0 || backendViewport.w() <= 0) { Int surfaceWidth = 0; Int surfaceHeight = 0; @@ -2163,7 +2165,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // never turns it on, so the driver has to be told to write raw. Without this a render // into an sRGB colour buffer comes back encoded once too often (the shader's own // decode on the next fetch then leaves the value one conversion short). - const Bool srgbWrites = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::FramebufferSrgb); + const Bool srgbWrites = MGB_CTX->IsCapabilityEnabled(CapabilityInput::FramebufferSrgb); if (g_GLESCapabilities.SupportsSrgbWriteControl && (forceFullPush || srgbWrites != g_syncedSrgbFramebufferWrites)) { srgbWrites ? g_GLESFuncs.glEnable(GL_FRAMEBUFFER_SRGB) @@ -2839,11 +2841,11 @@ namespace MobileGL::MG_Backend::DirectGLES { // member set it has to be told. (twin->GetPassthroughTessControlPatchVertices() >= 0 && (twin->GetPassthroughTessControlPatchVertices() != - static_cast(MG_State::pGLContext->GetPatchVertices()) || + static_cast(MGB_CTX->GetPatchVertices()) || !BitwiseEqual(twin->GetPassthroughTessControlOuterLevel(), - MG_State::pGLContext->GetPatchDefaultOuterLevel()) || + MGB_CTX->GetPatchDefaultOuterLevel()) || !BitwiseEqual(twin->GetPassthroughTessControlInnerLevel(), - MG_State::pGLContext->GetPatchDefaultInnerLevel())))) { + MGB_CTX->GetPatchDefaultInnerLevel())))) { twin->SyncToBackend(currentProgram); } g_currentDrawFrontendProgram = currentProgram.get(); @@ -2954,7 +2956,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // resolved-buffers memo on the twin), the VAO sync and the draw-time bind // below. Nothing in between can invalidate it — the bound VAO is pinned by // the context, and no step here erases or replaces a live VAO's twin. - const auto& currentVAO = MG_State::pGLContext->GetBoundVertexArray(); + const auto& currentVAO = MGB_CTX->GetBoundVertexArray(); VertexArrayImpl::BackendVertexArrayObject* vaoTwin = currentVAO ? VertexArrayImpl::ResolveVaoTwin(currentVAO) : nullptr; // Early config-version read: see the note on SyncNeccessaryBuffers - issuing @@ -2965,7 +2967,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // either, and none can run inside this preparation. GetProgramForDraw is a // cross-TU call with a guarded static inside - repeating it per stage showed // up in draw-loop profiles. - const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw(); + const auto& currentProgram = MGB_CTX->GetProgramForDraw(); if (MG_Util::PipeStats::Enabled()) { // THE per-draw denominator for Espryt, plus this function's own two accessor // calls (the VAO and the draw program). Everything the callees below read is @@ -3045,7 +3047,7 @@ namespace MobileGL::MG_Backend::DirectGLES { }; for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); Array boundBackendTargets{}; Array claimedByFrontendTarget{}; claimedByFrontendTarget.fill(TextureTarget::Unknown); @@ -3209,7 +3211,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } g_unitSamplerWalkValid = false; for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { - const auto& samplerObject = MG_State::pGLContext->GetTextureUnitObject(unit).GetSamplerObject(); + const auto& samplerObject = MGB_CTX->GetTextureUnitObject(unit).GetSamplerObject(); if (samplerObject) { if (auto* backendSampler = ResolveUnitSamplerBackend(unit, samplerObject)) { backendSampler->Bind(unit); @@ -3359,7 +3361,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } void BindCurrentTextures() { - BindCurrentTextures(TextureImpl::CaptureDrawTextureSyncKeys(), MG_State::pGLContext->GetProgramForDraw()); + BindCurrentTextures(TextureImpl::CaptureDrawTextureSyncKeys(), MGB_CTX->GetProgramForDraw()); } // Binds the current program's backend object and re-establishes its per-program @@ -3479,7 +3481,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // Connect buffer to backend binding point auto binding = currentProgram->GetUniformBlockBinding(i); - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::Uniform, binding); + auto& point = MGB_CTX->GetBufferBindingPoint(BufferTarget::Uniform, binding); auto& bufferObj = point.GetBoundObject(); auto range = point.GetRange(); @@ -3577,7 +3579,7 @@ namespace MobileGL::MG_Backend::DirectGLES { samplerBinding.lastAssignedUnit = unit; } - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); auto& samplerObject = textureUnit.GetSamplerObject(); const auto& texture2D = textureUnit.GetBindingSlot(TextureTarget::Texture2D).GetBoundObject(); @@ -3659,7 +3661,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // PrepareForCompute, where the current program (and therefore its registry twin) // is pinned for the duration. Prefers the per-draw stash those preparations wrote. static PrgramImpl::BackendProgramObjectImpl* GetCurrentBackendProgram() { - const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw(); + const auto& currentProgram = MGB_CTX->GetProgramForDraw(); if (!currentProgram || !currentProgram->GetLinkStatus() || !currentProgram->GetSpirvStatus()) { return nullptr; } @@ -3709,7 +3711,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // flattening a batch that turns out to need per-sub-draw values is unrecoverable - // so an unanswerable program counts as needing them. Bool CurrentProgramMayNeedPerSubDrawBuiltins(Bool batchCarriesBaseVertices) { - const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw(); + const auto& currentProgram = MGB_CTX->GetProgramForDraw(); const auto program = GetCurrentBackendProgram(); if (!currentProgram || program == nullptr || program->GetSyncedLinkVersion() != currentProgram->GetLinkVersion()) { @@ -3818,12 +3820,12 @@ namespace MobileGL::MG_Backend::DirectGLES { // times; rasterizer discard means there are no fragments to gate at all, so replaying // would be pure cost with nothing to show for it. Both fall back to a single pass with an // open gate, i.e. to the pre-emulation behaviour, rather than to wrong data. - if (MG_State::pGLContext->IsTransformFeedbackActive() || - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) { + if (MGB_CTX->IsTransformFeedbackActive() || + MGB_CTX->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) { return 1; } - const auto& parameters = MG_State::pGLContext->GetRenderStateParameters(); + const auto& parameters = MGB_CTX->GetRenderStateParameters(); Int surfaceWidth = 0; Int surfaceHeight = 0; if (!QueryCurrentSurfaceSize(surfaceWidth, surfaceHeight)) { @@ -4059,7 +4061,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // PrepareForDraw (nothing below can move either). The DISPATCH accessor: with a // pipeline bound this is its compute stage program, which is a whole program on its // own - the graphics composite a draw builds carries no compute stage. - const auto& currentProgram = MG_State::pGLContext->GetProgramForDispatch(); + const auto& currentProgram = MGB_CTX->GetProgramForDispatch(); const TextureImpl::DrawTextureSyncKeys textureKeys = TextureImpl::CaptureDrawTextureSyncKeys(); BufferImpl::SyncComputeBuffers(includeDispatchIndirectBuffer); @@ -4085,12 +4087,12 @@ namespace MobileGL::MG_Backend::DirectGLES { } GLuint GetBackendProgramId(GLuint program) { - if (!MG_State::pGLContext->ValidateProgramName(program)) { + if (!MGB_CTX->ValidateProgramName(program)) { MGLOG_E_ONCE("Invalid frontend program object: %u", program); return 0; } - auto& programObject = MG_State::pGLContext->GetProgramObject(program); + auto& programObject = MGB_CTX->GetProgramObject(program); if (!programObject) { MGLOG_E_ONCE("Program object %u is null.", program); return 0; @@ -4153,7 +4155,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // color must go through glClearBufferfv, which GLES does not clamp. GLbitfield remainingMask = mask; if ((mask & GL_COLOR_BUFFER_BIT) != 0) { - const FloatVec4& cc = MG_State::pGLContext->GetRenderStateParameters().ClearColor; + const FloatVec4& cc = MGB_CTX->GetRenderStateParameters().ClearColor; const Bool outOfRange = cc.x() < 0.f || cc.x() > 1.f || cc.y() < 0.f || cc.y() > 1.f || cc.z() < 0.f || cc.z() > 1.f || cc.w() < 0.f || cc.w() > 1.f; // A widened attachment's stored alpha has to end up 1.0, and glClear applies ONE @@ -4212,7 +4214,7 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glReadPixels(100, 100, 1, 1, GL_RGBA, GL_FLOAT, rb); const GLenum rbErr = g_GLESFuncs.glGetError(); const auto& feFbo = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); int feDb0 = -1, feDb1 = -1; Uint feIdx = 0, feVer = 0; if (feFbo) { @@ -4377,7 +4379,7 @@ namespace MobileGL::MG_Backend::DirectGLES { const SharedPtr& BoundElementArrayBuffer() { static const SharedPtr none; - const auto& vao = MG_State::pGLContext->GetBoundVertexArray(); + const auto& vao = MGB_CTX->GetBoundVertexArray(); if (!vao) return none; return vao->GetIndexBufferBindingSlot().GetBoundObject(); } @@ -4393,13 +4395,13 @@ namespace MobileGL::MG_Backend::DirectGLES { } // namespace RestartSubstitutionKind ResolveRestartSubstitution(GLenum indexType) { - if (!MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) || - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex)) { + if (!MGB_CTX->IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) || + MGB_CTX->IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex)) { return RestartSubstitutionKind::None; } const Uint32 fixedMax = MG_Util::FixedRestartIndexForGLType(indexType); if (fixedMax == 0) return RestartSubstitutionKind::None; - const Uint32 restartIndex = MG_State::pGLContext->GetPrimitiveRestartIndex(); + const Uint32 restartIndex = MGB_CTX->GetPrimitiveRestartIndex(); if (restartIndex == fixedMax) return RestartSubstitutionKind::None; // Strictly greater, never truncated. GL 4.6 core 10.3.6 compares the fetched index // zero-extended against the full 32-bit state, so an index this type cannot hold matches @@ -4439,7 +4441,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } const SizeT sourceIndexSize = MG_Util::GetGLTypeSize(indexType); const Uint32 fixedMax = MG_Util::FixedRestartIndexForGLType(indexType); - const Uint32 applicationRestartIndex = MG_State::pGLContext->GetPrimitiveRestartIndex(); + const Uint32 applicationRestartIndex = MGB_CTX->GetPrimitiveRestartIndex(); const auto& indexBuffer = BoundElementArrayBuffer(); const Uint8* source = nullptr; @@ -4567,7 +4569,7 @@ namespace MobileGL::MG_Backend::DirectGLES { #endif DrawSyncFlags syncBit = DrawSyncBit::None; PrepareForDraw(syncBit); - const auto& currentVAO = MG_State::pGLContext->GetBoundVertexArray(); + const auto& currentVAO = MGB_CTX->GetBoundVertexArray(); if (currentVAO) { auto* backendVAOSlot = VertexArrayImpl::g_backendVertexArrayObjects.Find(currentVAO.get()); if (backendVAOSlot && *backendVAOSlot) { @@ -4606,7 +4608,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // ladder and the indirect executors do. Without it every sub-draw of a // glMultiDrawArrays read draw index 0. const Bool feedDrawID = CurrentProgramReadsDrawID(); - const auto& currentVAO = MG_State::pGLContext->GetBoundVertexArray(); + const auto& currentVAO = MGB_CTX->GetBoundVertexArray(); for (GLsizei i = 0; i < drawcount; ++i) { // Client-side arrays are uploaded per sub-draw range, like the single DrawArrays path. if (currentVAO) { @@ -4677,7 +4679,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } const auto& drawIndirectBuffer = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); ExecuteIndexedIndirectCommands(mode, type, indexSize, commandBytes, reinterpret_cast(indirect), drawIndirectBuffer, drawcount, stride, "MultiDrawElementsIndirect"); } @@ -4708,8 +4710,8 @@ namespace MobileGL::MG_Backend::DirectGLES { return; } - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); - auto parameterBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject(); + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto parameterBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject(); if (!drawBuffer) { MGLOG_E_ONCE("MultiDrawElementsIndirectCount skipped: no GL_DRAW_INDIRECT_BUFFER is bound"); return; @@ -4779,7 +4781,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } const auto& drawIndirectBuffer = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); ExecuteArraysIndirectCommands(mode, commandBytes, reinterpret_cast(indirect), drawIndirectBuffer, drawcount, stride, "MultiDrawArraysIndirect"); } @@ -4810,8 +4812,8 @@ namespace MobileGL::MG_Backend::DirectGLES { DrawSyncFlags syncBit = DrawSyncBit::IndirectBuffer | DrawSyncBit::Instancing; PrepareForDraw(syncBit); - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); - auto parameterBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject(); + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto parameterBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject(); if (!drawBuffer) { MGLOG_E_ONCE("MultiDrawArraysIndirectCount skipped: no GL_DRAW_INDIRECT_BUFFER is bound"); return; @@ -4970,7 +4972,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } const auto& drawIndirectBuffer = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); ExecuteIndexedIndirectCommands(mode, type, indexSize, commandBytes, reinterpret_cast(indirect), drawIndirectBuffer, 1, sizeof(DrawElementsIndirectCommand), "DrawElementsIndirect"); @@ -5011,7 +5013,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } const auto& drawIndirectBuffer = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); ExecuteArraysIndirectCommands(mode, commandBytes, reinterpret_cast(indirect), drawIndirectBuffer, 1, sizeof(DrawArraysIndirectCommand), "DrawArraysIndirect"); } @@ -5256,8 +5258,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // restore. Drop the flag so it is not misattributed to the emulation's own work. DrainBlitErrors(); - if (MG_State::pGLContext->IsTransformFeedbackActive() && - !MG_State::pGLContext->IsTransformFeedbackPaused() && g_GLESFuncs.glPauseTransformFeedback) { + if (MGB_CTX->IsTransformFeedbackActive() && + !MGB_CTX->IsTransformFeedbackPaused() && g_GLESFuncs.glPauseTransformFeedback) { g_GLESFuncs.glPauseTransformFeedback(); m_pausedTransformFeedback = true; DrainBlitErrors(); @@ -6041,8 +6043,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // A no-op on every driver that honours a non-zero destination array layer, which is all // of them but the probed one. Whatever it performs itself is taken out of the mask. mask &= ~BlitLayeredDestinationAspects( - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(), - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(), srcX0, srcY0, + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(), + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(), srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask); if (mask != 0) { IssueBlitWithResolveFallback(srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask, filter); @@ -6104,8 +6106,8 @@ namespace MobileGL::MG_Backend::DirectGLES { #ifdef TRACY_ENABLE ZoneScopedNC(__func__, TRACY_ZONECOLOR_BACKEND); #endif - auto unit = MG_State::pGLContext->GetActiveTextureUnit(); - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto unit = MGB_CTX->GetActiveTextureUnit(); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); auto textureTarget = MG_Util::ConvertGLEnumToTextureTarget(target); if (!TextureImpl::IsSupportedTextureTarget(textureTarget)) { @@ -6182,7 +6184,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // The frontend's current PACK parameters, for readbacks the ES driver serves // directly with the client's layout. static PixelStoreImpl::PackState PackStateFromContext() { - const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false); + const auto packParams = MGB_CTX->GetPixelStoreParameters(false); return {static_cast(packParams.Alignment), static_cast(packParams.RowLength), static_cast(packParams.SkipRows), static_cast(packParams.SkipPixels)}; } @@ -6372,7 +6374,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MG_Util::ConvertGLEnumToString(err).c_str(), MG_Util::ConvertGLEnumToString(target).c_str(), MG_Util::ConvertTextureInternalFormatToString(format).c_str()); - MG_State::pGLContext->RecordError( + MGB_CTX->RecordError( ConvertGLESErrorToErrorCode(err), MakeUnique("DirectGLES", operation, MG_Util::ConvertGLEnumToString(err))); @@ -6696,8 +6698,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // Bind necessary FBO and texture BindCurrentFBO(FramebufferTarget::Read); - Uint activeTextureUnit = MG_State::pGLContext->GetActiveTextureUnit(); - const auto& textureObject = MG_State::pGLContext->GetTextureUnitObject((Int)activeTextureUnit) + Uint activeTextureUnit = MGB_CTX->GetActiveTextureUnit(); + const auto& textureObject = MGB_CTX->GetTextureUnitObject((Int)activeTextureUnit) .GetBindingSlot(MG_Util::ConvertGLEnumToTextureTarget(target)) .GetBoundObject(); auto* backendTextureSlot = TextureImpl::g_backendTextureObjects.Find(textureObject.get()); @@ -6791,8 +6793,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // Bind necessary FBO and texture BindCurrentFBO(FramebufferTarget::Read); - auto activeTextureUnit = MG_State::pGLContext->GetActiveTextureUnit(); - const auto& textureObject = MG_State::pGLContext->GetTextureUnitObject(activeTextureUnit) + auto activeTextureUnit = MGB_CTX->GetActiveTextureUnit(); + const auto& textureObject = MGB_CTX->GetTextureUnitObject(activeTextureUnit) .GetBindingSlot(MG_Util::ConvertGLEnumToTextureTarget(target)) .GetBoundObject(); auto* backendTextureSlot = TextureImpl::g_backendTextureObjects.Find(textureObject.get()); @@ -6929,8 +6931,8 @@ namespace MobileGL::MG_Backend::DirectGLES { #if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG && MOBILEGL_ENABLE_SCOPE_MARKER DebugImpl::OpenGLScopeMarker marker(__func__); #endif - auto unitIndex = MG_State::pGLContext->GetActiveTextureUnit(); - auto& unit = MG_State::pGLContext->GetTextureUnitObject(unitIndex); + auto unitIndex = MGB_CTX->GetActiveTextureUnit(); + auto& unit = MGB_CTX->GetTextureUnitObject(unitIndex); auto& slot = unit.GetBindingSlot(MG_Util::ConvertGLEnumToTextureTarget(target)); auto& texture = slot.GetBoundObject(); MOBILEGL_ASSERT(texture != nullptr, "GenerateMipmap requires a bound texture."); @@ -7354,8 +7356,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // effect by the block's next use. void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding) { if (!storageBlockName) return; - if (!MG_State::pGLContext->ValidateProgramName(program)) return; - auto& programObject = MG_State::pGLContext->GetProgramObject(program); + if (!MGB_CTX->ValidateProgramName(program)) return; + auto& programObject = MGB_CTX->GetProgramObject(program); if (!programObject) return; auto* backendProgramSlot = PrgramImpl::g_backendProgramObjects.Find(programObject.get()); @@ -7551,7 +7553,7 @@ namespace MobileGL::MG_Backend::DirectGLES { template static Bool StoreReadbackRowsToClient(GLsizei width, GLsizei height, SizeT dstPixelBytes, void* pixels, const char* what, FillRow&& fillRow) { - const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false); + const auto packParams = MGB_CTX->GetPixelStoreParameters(false); const SizeT rowPixels = static_cast(packParams.RowLength > 0 ? packParams.RowLength : width); const SizeT dstRowStride = AlignPixelRow(rowPixels * dstPixelBytes, packParams.Alignment); const SizeT dstOffset = static_cast(std::max(packParams.SkipRows, 0)) * dstRowStride + @@ -7559,7 +7561,7 @@ namespace MobileGL::MG_Backend::DirectGLES { const SizeT rowBytes = static_cast(width) * dstPixelBytes; const SizeT packedSize = dstOffset + static_cast(height - 1) * dstRowStride + rowBytes; const auto& pixelPackBufferObject = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); const SizeT pboOffset = reinterpret_cast(pixels); if (pixelPackBufferObject && pboOffset + packedSize > pixelPackBufferObject->GetSize()) { MGLOG_E_ONCE("ReadPixels: %s readback PBO is too small", what); @@ -8541,7 +8543,7 @@ namespace MobileGL::MG_Backend::DirectGLES { return true; } const auto& pixelPackBufferObject = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); if (!pixelPackBufferObject && pixels == nullptr) { return true; } @@ -8771,7 +8773,7 @@ namespace MobileGL::MG_Backend::DirectGLES { return true; } const auto& pixelPackBufferObject = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); if (!pixelPackBufferObject && pixels == nullptr) { return true; } @@ -9038,7 +9040,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // and legacy GL_RED reads) goes through the wide-format conversion, which picks a wide type // the driver accepts for the current attachment. GL_PACK_SWAP_BYTES has no ES equivalent, so // it always takes the conversion path (which swaps on the CPU). - const Bool packSwapBytes = MG_State::pGLContext->GetPixelStoreParameters(false).SwapBytes; + const Bool packSwapBytes = MGB_CTX->GetPixelStoreParameters(false).SwapBytes; // The read buffer is what glReadPixels reads, so the frontend's READ binding is exactly // the right thing to ask here. const Bool forceOpaqueAlpha = FramebufferImpl::IsAlphaWidenedFallbackReadAttachment(); @@ -9081,7 +9083,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // (the driver-level binding used to stay on the user PBO after this call, // capturing subsequent client-memory readbacks into it). auto& pixelPackBufferObject = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); Bool usePBO = false; GLuint packBufferId = 0; if (pixelPackBufferObject) { @@ -9191,10 +9193,10 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("GetTexImage: SyncCurrentFBO()"); FramebufferImpl::SyncCurrentFBO(); - auto activeTextureUnit = MG_State::pGLContext->GetActiveTextureUnit(); + auto activeTextureUnit = MGB_CTX->GetActiveTextureUnit(); MGLOG_D("GetTexImage: active texture unit = %u", activeTextureUnit); - const auto& textureObject = MG_State::pGLContext->GetTextureUnitObject(activeTextureUnit) + const auto& textureObject = MGB_CTX->GetTextureUnitObject(activeTextureUnit) .GetBindingSlot(MG_Util::ConvertGLEnumToTextureTarget(target)) .GetBoundObject(); @@ -9417,7 +9419,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // Each slice is packed as its own 2D image, so the per-slice call must not apply // GL_PACK_SKIP_IMAGES / GL_PACK_IMAGE_HEIGHT itself - this walks the destination // over them, using the same layout StoreWideRowsToClient computes. - const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false); + const auto packParams = MGB_CTX->GetPixelStoreParameters(false); const SizeT dstPixelBytes = GetReadbackDstPixelSize(conversionMapping, type); const SizeT rowPixels = static_cast(packParams.RowLength > 0 ? packParams.RowLength : size.x()); @@ -9507,7 +9509,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // Handle PBO. The pack binding is scoped: it returns to the resting 0 state // on every exit path, so a later readback can never land in a stale PBO. auto& pixelPackBufferObject = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); Bool usePBO = false; GLuint packBufferId = 0; if (pixelPackBufferObject) { diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 56eb9298c..81e4ca642 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -7,6 +7,7 @@ // End of Source File Header #include "Managers.h" +#include #include "Utils.h" #include "DirectGLES.h" #include "BackendObject_DirectGLES.h" @@ -3641,9 +3642,9 @@ namespace MobileGL::MG_Backend::DirectGLES { // that needs no work costs the same nothing per draw that any other synced texture does. void BackendTextureObject::StampViewSyncKeys( const SharedPtr& stateTextureObject) { - if (MG_State::pGLContext) { - m_syncedShapeContextId = MG_State::pGLContext->GetTextureContextId(); - m_syncedShapeGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + if (MGB_CTX_LIVE) { + m_syncedShapeContextId = MGB_CTX->GetTextureContextId(); + m_syncedShapeGeneration = MGB_CTX->GetSamplingResolutionGeneration(); m_syncedShapeParamsVersion = stateTextureObject->GetTextureParamsVersion(); } m_syncedContentVersion = stateTextureObject->GetContentVersion(); @@ -3770,9 +3771,9 @@ namespace MobileGL::MG_Backend::DirectGLES { // version - and backend-side storage resets clear m_isInitialized. Restricted to // Mipmap storage like the probe fast path: a buffer texture's backing store can move // without any of these keys noticing. - if (m_isInitialized && m_syncedShapeContextId != 0 && MG_State::pGLContext && - m_syncedShapeContextId == MG_State::pGLContext->GetTextureContextId() && - m_syncedShapeGeneration == MG_State::pGLContext->GetSamplingResolutionGeneration() && + if (m_isInitialized && m_syncedShapeContextId != 0 && MGB_CTX_LIVE && + m_syncedShapeContextId == MGB_CTX->GetTextureContextId() && + m_syncedShapeGeneration == MGB_CTX->GetSamplingResolutionGeneration() && m_syncedContentVersion == stateTextureObject->GetContentVersion() && m_syncedShapeParamsVersion == stateTextureObject->GetTextureParamsVersion() && stateTextureObject->GetStorageType() == TextureStorageType::Mipmap) { @@ -3841,9 +3842,9 @@ namespace MobileGL::MG_Backend::DirectGLES { // The probe just proved "fully synced" from the real state, so the cheap // gate may be (re)stamped here: the coarse generation only ever goes stale // from OTHER textures' churn, and this draw re-validated this one. - if (MG_State::pGLContext) { - m_syncedShapeContextId = MG_State::pGLContext->GetTextureContextId(); - m_syncedShapeGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + if (MGB_CTX_LIVE) { + m_syncedShapeContextId = MGB_CTX->GetTextureContextId(); + m_syncedShapeGeneration = MGB_CTX->GetSamplingResolutionGeneration(); m_syncedShapeParamsVersion = stateTextureObject->GetTextureParamsVersion(); } return; @@ -4732,9 +4733,9 @@ namespace MobileGL::MG_Backend::DirectGLES { // Same instant, so the cheap gate's keys describe exactly this synced state. // Only Mipmap storage may arm it - the gate refuses other storage types anyway, // but a stale trio must not linger on an object that later switches type. - if (MG_State::pGLContext && stateTextureObject->GetStorageType() == TextureStorageType::Mipmap) { - m_syncedShapeContextId = MG_State::pGLContext->GetTextureContextId(); - m_syncedShapeGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + if (MGB_CTX_LIVE && stateTextureObject->GetStorageType() == TextureStorageType::Mipmap) { + m_syncedShapeContextId = MGB_CTX->GetTextureContextId(); + m_syncedShapeGeneration = MGB_CTX->GetSamplingResolutionGeneration(); m_syncedShapeParamsVersion = stateTextureObject->GetTextureParamsVersion(); } else { m_syncedShapeContextId = 0; @@ -5352,7 +5353,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // read buffer names no colour attachment at all. static const MG_State::GLState::FramebufferAttachmentObject* GetReadColorAttachment() { const auto& readFBO = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); if (!readFBO) { return nullptr; } @@ -5457,7 +5458,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Bool IsFixedPointFallbackReadAttachment() { const auto& readFBO = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); if (!readFBO) { return false; } @@ -6227,7 +6228,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // outside the frontend's array, which cannot be addressed at all. Uint BoundImageUnitFormat(Int unit) { if (unit < 0 || unit >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) return 0; - return static_cast(MG_State::pGLContext->GetImageTextureBinding(unit).Format); + return static_cast(MGB_CTX->GetImageTextureBinding(unit).Format); } // Combines one (unit, format) pair into a running digest. Commutative, so the order @@ -7189,19 +7190,19 @@ namespace MobileGL::MG_Backend::DirectGLES { // patch size - so a program built for one value is stale for another. Recorded here // and compared on the draw path (SyncCurrentProgram), the same shape as the // storage-block and image-format signatures next to it. - const Uint patchVertices = MG_State::pGLContext != nullptr - ? MG_State::pGLContext->GetPatchVertices() + const Uint patchVertices = MGB_CTX_LIVE + ? MGB_CTX->GetPatchVertices() : 3u; m_passthroughTessControlPatchVertices = static_cast(patchVertices); // PATCH_DEFAULT_{OUTER,INNER}_LEVEL are the same kind of dynamic state and are baked // into the same stage (ES has no such state and no entry point to forward them to), so // they are recorded and compared alongside the patch size - the two move together, as // BuildPassthroughTessControlEssl's contract says. - m_passthroughTessControlOuterLevel = MG_State::pGLContext != nullptr - ? MG_State::pGLContext->GetPatchDefaultOuterLevel() + m_passthroughTessControlOuterLevel = MGB_CTX_LIVE + ? MGB_CTX->GetPatchDefaultOuterLevel() : FloatVec4(1.0f, 1.0f, 1.0f, 1.0f); - m_passthroughTessControlInnerLevel = MG_State::pGLContext != nullptr - ? MG_State::pGLContext->GetPatchDefaultInnerLevel() + m_passthroughTessControlInnerLevel = MGB_CTX_LIVE + ? MGB_CTX->GetPatchDefaultInnerLevel() : FloatVec2(1.0f, 1.0f); if (tessEvalShaderIndex < 0 || @@ -8747,8 +8748,8 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_E_ONCE("Renderbuffer %u storage allocation ran out of memory: %dx%d, samples=%d, format=%s", stateRBOObject->GetExternalIndex(), width, height, samples, MG_Util::ConvertGLEnumToString(glInternalFormat).c_str()); - if (MG_State::pGLContext) { - MG_State::pGLContext->RecordError( + if (MGB_CTX_LIVE) { + MGB_CTX->RecordError( ErrorCode::OutOfMemory, MakeUnique("DirectGLES", "BackendRenderbufferObject::SyncToBackend", "The ES driver could not allocate the renderbuffer storage.")); diff --git a/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp b/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp index 9d9f012d4..7c993c752 100644 --- a/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp +++ b/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp @@ -9,6 +9,7 @@ #include "MultiDraw.h" #include "Managers.h" #include +#include #include #include #include @@ -42,14 +43,14 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { // verbatim is already "this batch restarts nowhere". Uint32 RestartSentinelFor(GLenum type) { if (ResolveRestartSubstitution(type) != RestartSubstitutionKind::None) { - return MG_State::pGLContext->GetPrimitiveRestartIndex(); + return MGB_CTX->GetPrimitiveRestartIndex(); } return MG_Util::FixedRestartIndexForGLType(type); } Bool RestartActive() { - return MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) || - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex); + return MGB_CTX->IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) || + MGB_CTX->IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex); } // Vertices per primitive for the modes whose sub-draws may be concatenated into a @@ -84,7 +85,7 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { Uint BoundDrawIndirectBufferId() { const auto& indirect = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (!indirect) return 0; const auto* resource = BufferImpl::EnsureBufferResource(indirect); return resource ? resource->id : 0; @@ -92,7 +93,7 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { const SharedPtr& BoundIndexBuffer() { static const SharedPtr none; - const auto& vao = MG_State::pGLContext->GetBoundVertexArray(); + const auto& vao = MGB_CTX->GetBoundVertexArray(); if (!vao) return none; return vao->GetIndexBufferBindingSlot().GetBoundObject(); } diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.cpp b/MobileGL/MG_Backend/DirectGLES/Utils.cpp index 4239930f1..fa6458906 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Utils.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -2294,11 +2295,11 @@ namespace MobileGL::MG_Backend::DirectGLES { static Bool StoreClientRows(SizeT dstPixelBytes, SizeT swapGroupSize, GLsizei width, GLsizei sliceHeight, GLsizei sliceCount, void* pixels, Bool applyPackImageParams, FillRow&& fillRow) { const auto& pixelPackBufferObject = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); // Destination layout is computed from the client-side PACK parameters; only the actual pixel // rows are written so skip regions of the destination stay untouched. - const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false); + const auto packParams = MGB_CTX->GetPixelStoreParameters(false); const SizeT rowPixels = static_cast(packParams.RowLength > 0 ? packParams.RowLength : width); const SizeT dstRowStride = AlignReadbackRow(rowPixels * dstPixelBytes, packParams.Alignment); const SizeT imageRows = From bf8b39a86745c89dcda9f23464056e2775ec151e Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 01:46:50 -0400 Subject: [PATCH 058/529] [Refactor] (Magma): route every frontend read through MGB_CTX - 164 arrow sites sed'd, 49 non-arrow lines converted (43 asserts keep their meaning as MGB_CTX_LIVE); pull build byte-identical - Every MG_State::pGLContext-> in the seven DirectVulkan TUs becomes MGB_CTX-> (DirectVulkan.cpp 12, BackendObject_DirectVulkan.cpp 2, UniformManager.cpp 14, VkClearManager.cpp 1, VkRenderPassManager.cpp 3, VkTextureManager.cpp 2, VulkanRenderer.cpp 130 occurrences on 127 lines); each TU includes right after its MG_State/GLState/Core.h include (VkRenderPassManager.cpp after its include block, it never included Core.h). - The 43 MOBILEGL_ASSERT(pGLContext) / (pGLContext != nullptr) lines become MOBILEGL_ASSERT(MGB_CTX_LIVE, ...): identical in every INFO build (the macro is empty there) and still a null-context assert in a DEBUG build. - The six code-bearing guards are like-for-like pointer tests in the pull arm: if (MGB_CTX_LIVE) at the two InvalidateCompileEnv sites, MGB_CTX_LIVE in the XFB query counter condition and the ternary that snapshots the paused-primitive counter, !MGB_CTX_LIVE in BeginXfbCaptureForDraw, MGB_CTX_LIVE && in the provoking-vertex resolve. No semantic rewrite: under push MGB_CTX_LIVE is simply "a context exists", the real meaning of these guards is P2's business. - VertexInputStateFactory.h's comment stops naming pGLContext so purity gate C (grep -rc pGLContext MobileGL/MG_Backend/DirectVulkan) reads 0 in every file. - The 12 SyncPersistentMappedRange and 3 SyncGpuWrites sites of the D10 table are untouched; PipeStats AddCalls literals unchanged. - Proof on the pull build (Release/INFO, LTO off, vs feat/disaggregated@087685d1): symbol_report --threshold 0 -> 27799 symbols, 0 added / 0 removed / 0 resized / 0 renamed, .text 10792579 -> 10792579 (+0); nm --defined-only name set identical; ctest -N name set identical (2334); unit 1466/1466; DirectVulkan integration lane 427/427 on lavapipe (two ArmedWhenTheEnvironmentPinsItOn entries flake under -j 4 exactly as on the baseline and pass serially). The push build compiles and links. --- .../BackendObject_DirectVulkan.cpp | 9 +- .../MG_Backend/DirectVulkan/DirectVulkan.cpp | 95 +++---- .../DirectVulkan/Renderer/UniformManager.cpp | 47 ++-- .../Renderer/VertexInputStateFactory.h | 2 +- .../DirectVulkan/Renderer/VkClearManager.cpp | 3 +- .../Renderer/VkRenderPassManager.cpp | 7 +- .../Renderer/VkTextureManager.cpp | 5 +- .../DirectVulkan/Renderer/VulkanRenderer.cpp | 259 +++++++++--------- 8 files changed, 217 insertions(+), 210 deletions(-) diff --git a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp index 3a3bc0d14..450cb48c0 100644 --- a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp @@ -12,6 +12,7 @@ #include "SubgroupSupportPolicy.h" #include "MG_State/GLState/FramebufferState/FramebufferObject.h" #include "MG_State/GLState/Core.h" +#include #include "MG_State/GLState/TextureState/TextureState.h" #include "MG_Util/Classifiers/TextureEnumClassifier.h" #include "MG_Util/Converters/MGToGL/TextureEnumConverter.h" @@ -385,8 +386,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { } UpdateDynamicBackendParameters(); UpdateAdvertisedExtensions(); - if (MG_State::pGLContext) { - MG_State::pGLContext->InvalidateCompileEnv(); + if (MGB_CTX_LIVE) { + MGB_CTX->InvalidateCompileEnv(); } PopulateFormatCapabilities(physicalDevice.handle, vkGetPhysicalDeviceFormatProperties, m_vulkanCaps, MutableFormatCapabilities()); @@ -783,8 +784,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { m_vulkanCaps = capabilities; UpdateDynamicBackendParameters(); UpdateAdvertisedExtensions(); - if (MG_State::pGLContext) { - MG_State::pGLContext->InvalidateCompileEnv(); + if (MGB_CTX_LIVE) { + MGB_CTX->InvalidateCompileEnv(); } MutableFormatCapabilities().Clear(); } diff --git a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp index bb778cc2d..0d5760b39 100644 --- a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp @@ -10,6 +10,7 @@ #include "DirectVulkanResourceState.h" #include "MG_Backend/BackendObjects.h" #include "MG_State/GLState/Core.h" +#include #include "MG_State/GLState/ErrorState/ErrorInfo.h" #include "MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h" #include "MG_Util/Converters/GLToMG/TextureEnumConverter.h" @@ -267,15 +268,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { } MG_State::GLState::ProgramObject* TryGetDirectVulkanProgram(GLuint program) { - if (!MG_State::pGLContext->ValidateProgramName(program)) { + if (!MGB_CTX->ValidateProgramName(program)) { return nullptr; } - auto& programObject = MG_State::pGLContext->GetProgramObject(program); + auto& programObject = MGB_CTX->GetProgramObject(program); return programObject.get(); } const Uint8* ResolveIndirectCommandBytes(const void* indirect, SizeT requiredBytes, const char* label) { - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (drawBuffer) { drawBuffer->SyncPersistentMappedRange(); const SizeT commandOffset = reinterpret_cast(indirect); @@ -334,64 +335,64 @@ namespace MobileGL::MG_Backend::DirectVulkan { void ClearBufferfi(GLenum buffer, GLint drawbuffer, GLfloat depth, GLint stencil) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ClearBufferfi called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ClearBufferfi called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ClearBufferfi called with null GL context"); pVulkanRenderer->ClearBufferfi(buffer, drawbuffer, depth, stencil); } void ClearBufferfv(GLenum buffer, GLint drawbuffer, const GLfloat* value) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ClearBufferfv called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ClearBufferfv called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ClearBufferfv called with null GL context"); pVulkanRenderer->ClearBufferfv(buffer, drawbuffer, value); } void ClearBufferuiv(GLenum buffer, GLint drawbuffer, const GLuint* value) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ClearBufferuiv called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ClearBufferuiv called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ClearBufferuiv called with null GL context"); pVulkanRenderer->ClearBufferuiv(buffer, drawbuffer, value); } void ClearBufferiv(GLenum buffer, GLint drawbuffer, const GLint* value) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ClearBufferiv called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ClearBufferiv called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ClearBufferiv called with null GL context"); pVulkanRenderer->ClearBufferiv(buffer, drawbuffer, value); } void ClearNamedFramebufferfv(const SharedPtr& framebuffer, GLenum buffer, GLint drawbuffer, const GLfloat* value) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ClearNamedFramebufferfv called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ClearNamedFramebufferfv called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ClearNamedFramebufferfv called with null GL context"); pVulkanRenderer->ClearNamedFramebufferfv(framebuffer, buffer, drawbuffer, value); } void ClearNamedFramebufferiv(const SharedPtr& framebuffer, GLenum buffer, GLint drawbuffer, const GLint* value) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ClearNamedFramebufferiv called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ClearNamedFramebufferiv called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ClearNamedFramebufferiv called with null GL context"); pVulkanRenderer->ClearNamedFramebufferiv(framebuffer, buffer, drawbuffer, value); } void ClearNamedFramebufferuiv(const SharedPtr& framebuffer, GLenum buffer, GLint drawbuffer, const GLuint* value) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ClearNamedFramebufferuiv called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ClearNamedFramebufferuiv called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ClearNamedFramebufferuiv called with null GL context"); pVulkanRenderer->ClearNamedFramebufferuiv(framebuffer, buffer, drawbuffer, value); } void ClearNamedFramebufferfi(const SharedPtr& framebuffer, GLenum buffer, GLint drawbuffer, GLfloat depth, GLint stencil) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ClearNamedFramebufferfi called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ClearNamedFramebufferfi called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ClearNamedFramebufferfi called with null GL context"); pVulkanRenderer->ClearNamedFramebufferfi(framebuffer, buffer, drawbuffer, depth, stencil); } void MultiDrawElementsIndirect(GLenum mode, GLenum type, const void* indirect, GLsizei drawcount, GLsizei stride) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawElementsIndirect called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawElementsIndirect called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::MultiDrawElementsIndirect called with null GL context"); pVulkanRenderer->MultiDrawElementsIndirect(mode, type, indirect, drawcount, stride); } void MultiDrawArraysIndirect(GLenum mode, const void* indirect, GLsizei drawcount, GLsizei stride) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawArraysIndirect called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawArraysIndirect called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::MultiDrawArraysIndirect called with null GL context"); if (drawcount <= 0) { return; @@ -399,7 +400,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // With a bound GL_DRAW_INDIRECT_BUFFER the command parameters may be GPU-written // (e.g. by a compute shader), so consume them natively on the GPU. - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (drawBuffer) { pVulkanRenderer->MultiDrawArraysIndirect(mode, indirect, drawcount, stride); return; @@ -442,13 +443,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { void MultiDrawElementsIndirectCount(GLenum mode, GLenum type, const void* indirect, GLintptr drawcount, GLsizei maxdrawcount, GLsizei stride) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawElementsIndirectCount called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawElementsIndirectCount called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::MultiDrawElementsIndirectCount called with null GL context"); pVulkanRenderer->MultiDrawElementsIndirectCount(mode, type, indirect, drawcount, maxdrawcount, stride); } void MultiDrawArraysIndirectCount(GLenum mode, const void* indirect, GLintptr drawcount, GLsizei maxdrawcount, GLsizei stride) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawArraysIndirectCount called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawArraysIndirectCount called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::MultiDrawArraysIndirectCount called with null GL context"); if (maxdrawcount <= 0) { return; @@ -462,7 +463,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { return; } - auto parameterBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject(); + auto parameterBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject(); if (!parameterBuffer || drawcount < 0 || static_cast(drawcount) + sizeof(Uint32) > parameterBuffer->GetSize()) { MGLOG_E_ONCE("MultiDrawArraysIndirectCount skipped: invalid GL_PARAMETER_BUFFER binding or range"); return; @@ -493,7 +494,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { void DrawElementsInstancedBaseVertexBaseInstance(GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLint basevertex, GLuint baseinstance) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DrawElementsInstancedBaseVertexBaseInstance called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DrawElementsInstancedBaseVertexBaseInstance called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DrawElementsInstancedBaseVertexBaseInstance called with null GL context"); DrawIndexedCmd payload{}; payload.mode = mode; @@ -520,7 +521,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { } void DrawElementsIndirect(GLenum mode, GLenum type, const void* indirect) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DrawElementsIndirect called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DrawElementsIndirect called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DrawElementsIndirect called with null GL context"); const SizeT indexSize = MG_Util::GetGLTypeSize(type); if (indexSize == 0) { @@ -530,7 +531,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // With a bound GL_DRAW_INDIRECT_BUFFER the command parameters may be GPU-written // (e.g. by a compute shader), so consume them natively on the GPU. - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (drawBuffer) { pVulkanRenderer->MultiDrawElementsIndirect(mode, type, indirect, 1, 0); return; @@ -564,7 +565,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { void DrawArraysInstancedBaseInstance(GLenum mode, GLint first, GLsizei count, GLsizei instancecount, GLuint baseinstance) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DrawArraysInstancedBaseInstance called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DrawArraysInstancedBaseInstance called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DrawArraysInstancedBaseInstance called with null GL context"); DrawCmd payload{}; payload.mode = mode; @@ -579,11 +580,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { } void DrawArraysIndirect(GLenum mode, const void* indirect) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DrawArraysIndirect called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DrawArraysIndirect called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DrawArraysIndirect called with null GL context"); // With a bound GL_DRAW_INDIRECT_BUFFER the command parameters may be GPU-written // (e.g. by a compute shader), so consume them natively on the GPU. - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (drawBuffer) { pVulkanRenderer->MultiDrawArraysIndirect(mode, indirect, 1, 0); return; @@ -613,13 +614,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { void CopyTexImage2D(GLenum target, GLint level, GLenum internalformat, GLint x, GLint y, GLsizei width, GLsizei height, GLint border) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::CopyTexImage2D called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::CopyTexImage2D called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::CopyTexImage2D called with null GL context"); pVulkanRenderer->CopyTexSubImage2D(target, level, 0, 0, x, y, width, height); } void CopyTexSubImage2D(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint x, GLint y, GLsizei width, GLsizei height) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::CopyTexSubImage2D called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::CopyTexSubImage2D called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::CopyTexSubImage2D called with null GL context"); pVulkanRenderer->CopyTexSubImage2D(target, level, xoffset, yoffset, x, y, width, height); } void CopyImageSubData(const CopyImageEndpoint& src, @@ -628,32 +629,32 @@ namespace MobileGL::MG_Backend::DirectVulkan { GLenum dstTarget, GLint dstLevel, GLint dstX, GLint dstY, GLint dstZ, GLsizei srcWidth, GLsizei srcHeight, GLsizei srcDepth) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::CopyImageSubData called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::CopyImageSubData called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::CopyImageSubData called with null GL context"); pVulkanRenderer->CopyImageSubData(src, srcTarget, srcLevel, srcX, srcY, srcZ, dst, dstTarget, dstLevel, dstX, dstY, dstZ, srcWidth, srcHeight, srcDepth); } void GenerateMipmap(GLenum target) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::GenerateMipmap called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::GenerateMipmap called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::GenerateMipmap called with null GL context"); pVulkanRenderer->GenerateMipmap(target); } void DispatchCompute(GLuint numGroupsX, GLuint numGroupsY, GLuint numGroupsZ) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DispatchCompute called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DispatchCompute called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DispatchCompute called with null GL context"); pVulkanRenderer->DispatchCompute(numGroupsX, numGroupsY, numGroupsZ); } void DispatchComputeIndirect(GLintptr indirect) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DispatchComputeIndirect called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DispatchComputeIndirect called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DispatchComputeIndirect called with null GL context"); pVulkanRenderer->DispatchComputeIndirect(indirect); } void MemoryBarrier(GLbitfield barriers) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MemoryBarrier called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MemoryBarrier called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::MemoryBarrier called with null GL context"); pVulkanRenderer->MemoryBarrier(barriers); } @@ -713,7 +714,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { ? pActiveBackendObject->GetDynamicParameters().MaxShaderStorageBufferBindings : 0; if (storageBlockBinding >= static_cast(maxBindings)) { - MG_State::pGLContext->RecordError( + MGB_CTX->RecordError( ErrorCode::InvalidValue, MakeUnique("DirectVulkan", __func__, "Shader storage binding is out of range.")); return; @@ -738,24 +739,24 @@ namespace MobileGL::MG_Backend::DirectVulkan { } void ReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ReadPixels called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ReadPixels called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ReadPixels called with null GL context"); pVulkanRenderer->ReadPixels(x, y, width, height, format, type, pixels); } void GetTexImage(GLenum target, GLint level, GLenum format, GLenum type, GLvoid* pixels) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::GetTexImage called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::GetTexImage called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::GetTexImage called with null GL context"); pVulkanRenderer->GetTexImage(target, level, format, type, pixels); } void GetTextureImage(const SharedPtr& texture, TextureUploadTarget uploadTarget, GLint level, GLenum format, GLenum type, GLsizei bufSize, GLvoid* pixels) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::GetTextureImage called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::GetTextureImage called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::GetTextureImage called with null GL context"); pVulkanRenderer->GetTextureImage(texture, uploadTarget, level, format, type, bufSize, pixels); } void Clear(GLbitfield mask) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::Clear called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::Clear called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::Clear called with null GL context"); pVulkanRenderer->Clear(mask); } @@ -783,7 +784,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { return false; } const Uint8* indexBytes = nullptr; - const auto& vao = *MG_State::pGLContext->GetBoundVertexArray(); + const auto& vao = *MGB_CTX->GetBoundVertexArray(); const auto& indexBufferShared = vao.GetIndexBufferBindingSlot().GetBoundObject(); if (indexBufferShared != nullptr) { const SizeT offset = reinterpret_cast(indices); @@ -814,7 +815,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { void DrawArrays(GLenum mode, GLint first, GLsizei count) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DrawArrays called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DrawArrays called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DrawArrays called with null GL context"); if (mode == GL_LINE_LOOP) { if (count < 2) { @@ -839,7 +840,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { void DrawElements(GLenum mode, GLsizei count, GLenum type, const void* indices) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DrawElements called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DrawElements called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DrawElements called with null GL context"); if (mode == GL_LINE_LOOP) { Vector closedIndices; @@ -862,7 +863,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { void MultiDrawArrays(GLenum mode, const GLint* first, const GLsizei* count, GLsizei drawcount) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawArrays called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawArrays called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::MultiDrawArrays called with null GL context"); if (drawcount <= 0) { return; } @@ -907,7 +908,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // MultiDrawIndexedCmd left the client-memory shape addressing a view whose byte // offset is a hardcoded 0, so UploadAndBindIndexBuffer saw a null client pointer, // declined the whole batch and painted nothing.) - const auto& vao = *MG_State::pGLContext->GetBoundVertexArray(); + const auto& vao = *MGB_CTX->GetBoundVertexArray(); if (vao.GetIndexBufferBindingSlot().GetBoundObject() == nullptr) { for (GLsizei i = 0; i < drawcount; ++i) { if (count[i] <= 0) { @@ -968,13 +969,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { void MultiDrawElements(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices, GLsizei drawcount) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawElements called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawElements called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::MultiDrawElements called with null GL context"); MultiDrawElementsImpl(mode, count, type, indices, drawcount, nullptr); } void DrawElementsBaseVertex(GLenum mode, GLsizei count, GLenum type, const GLvoid* indices, GLint basevertex) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DrawElementsBaseVertex called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DrawElementsBaseVertex called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DrawElementsBaseVertex called with null GL context"); if (mode == GL_LINE_LOOP) { Vector closedIndices; if (BuildClosedLineLoopIndices(count, type, indices, closedIndices)) { @@ -998,14 +999,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { void MultiDrawElementsBaseVertex(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices, GLsizei drawcount, const GLint* basevertex) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawElementsBaseVertex called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawElementsBaseVertex called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::MultiDrawElementsBaseVertex called with null GL context"); MultiDrawElementsImpl(mode, count, type, indices, drawcount, basevertex); } void BlitFramebuffer(GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1, GLint dstX0, GLint dstY0, GLint dstX1, GLint dstY1, GLbitfield mask, GLenum filter) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::BlitFramebuffer called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::BlitFramebuffer called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::BlitFramebuffer called with null GL context"); pVulkanRenderer->BlitFramebuffer(srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask, filter); } @@ -1233,8 +1234,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { return false; } if (query->kind == VulkanTimerQuery::Kind::XfbGenerated && - !query->pausedPrimitivesCountedByGpu && MG_State::pGLContext != nullptr) { - primitives += MG_State::pGLContext->GetTransformFeedbackPausedPrimitiveCounter() - + !query->pausedPrimitivesCountedByGpu && MGB_CTX_LIVE) { + primitives += MGB_CTX->GetTransformFeedbackPausedPrimitiveCounter() - query->pausedPrimitiveSnapshot; } *outNanoseconds = primitives; @@ -1281,7 +1282,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { query->kind = generated ? VulkanTimerQuery::Kind::XfbGenerated : VulkanTimerQuery::Kind::XfbWritten; query->rendererGeneration = GetRendererGeneration(); query->pausedPrimitiveSnapshot = - MG_State::pGLContext ? MG_State::pGLContext->GetTransformFeedbackPausedPrimitiveCounter() : 0; + MGB_CTX_LIVE ? MGB_CTX->GetTransformFeedbackPausedPrimitiveCounter() : 0; // Read AFTER StartXfbQueryCapture, which is where a failed reroute-pool creation // disarms: the answer is then what this span will actually do for every draw. query->pausedPrimitivesCountedByGpu = generated && pVulkanRenderer->ArePausedDrawsGpuCounted(); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp index de8163012..19094359c 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp @@ -10,6 +10,7 @@ #include "MG_Backend/DirectVulkan/DirectVulkanResourceState.h" #include "MG_State/GLState/Core.h" +#include #include "MG_State/GLState/ProgramState/ProgramObject.h" #include "MG_State/GLState/TextureState/TextureObject1D.h" #include "MG_State/GLState/TextureState/TextureObject2D.h" @@ -503,7 +504,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // alive through the draw via GL binding state. Only the fallback path needs a SharedPtr to // keep the fallback texture alive for the rest of this call. MG_State::GLState::ITextureObject* texture = ResolveSamplerTextureRaw(program, programObj, binding, element); - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); const auto& samplerOverride = textureUnit.GetSamplerObject(); const auto preferredTarget = programObj.samplerTextureTargetByBinding[binding]; SharedPtr fallbackHolder; @@ -552,7 +553,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { return false; } if (!IsValidSampledImageLayout(resource->layout)) { - auto drawFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); + auto drawFbo = MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); FramebufferAttachmentType attachmentType = FramebufferAttachmentType::None; Int attachmentLevel = 0; if (drawFbo && @@ -779,7 +780,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // filtering - which a single-level view can still have. Resolve the sampler exactly // the way ResolveSamplerDescriptor does and bail if anisotropy would apply. const Int unit = ResolveSamplerUnitIndex(program, location, binding); - const auto& samplerOverride = MG_State::pGLContext->GetTextureUnitObject(unit).GetSamplerObject(); + const auto& samplerOverride = MGB_CTX->GetTextureUnitObject(unit).GetSamplerObject(); const auto* effectiveSampler = samplerOverride ? samplerOverride.get() : texture->GetSamplerObject().get(); if (effectiveSampler == nullptr) return false; @@ -809,7 +810,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { const ProgramFactory::VkProgramObject& programObj, Uint32 binding, SharedPtr& outTexture) { outTexture.reset(); - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveSamplerTexture: GL context is null"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "ResolveSamplerTexture: GL context is null"); MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(), "ResolveSamplerTexture: sampler location binding %u out of range", binding); MOBILEGL_ASSERT(binding < programObj.samplerTextureTargetByBinding.size(), @@ -818,7 +819,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { const Int location = programObj.samplerUniformLocationByBinding[binding]; const Int unit = ResolveSamplerUnitIndex(program, location, binding); - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); const TextureTarget preferredTarget = programObj.samplerTextureTargetByBinding[binding]; outTexture = textureUnit.GetBindingSlot(preferredTarget).GetBoundObject(); // The slot always holds at least the target's default texture (name 0). While that @@ -834,7 +835,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { MG_State::GLState::ITextureObject* UniformManager::ResolveSamplerTextureRaw( const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, Uint32 binding, Uint32 element) { - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveSamplerTextureRaw: GL context is null"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "ResolveSamplerTextureRaw: GL context is null"); MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(), "ResolveSamplerTextureRaw: sampler location binding %u out of range", binding); MOBILEGL_ASSERT(binding < programObj.samplerTextureTargetByBinding.size(), @@ -844,7 +845,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { ResolveDescriptorElementLocation(program, programObj.samplerUniformLocationByBinding[binding], element); const Int unit = ResolveSamplerUnitIndex(program, location, binding); - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); const TextureTarget preferredTarget = programObj.samplerTextureTargetByBinding[binding]; // GetBoundObject() returns the SharedPtr by const ref; .get() reads the pointer without // touching the refcount (no atomic inc/dec per binding per draw). @@ -989,7 +990,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkBufferView& outBufferView) { outBufferView = VK_NULL_HANDLE; MOBILEGL_ASSERT(m_bufferManager != nullptr, "ResolveStorageTexelBufferDescriptor: buffer manager is null"); - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveStorageTexelBufferDescriptor: GL context is null"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "ResolveStorageTexelBufferDescriptor: GL context is null"); MOBILEGL_ASSERT(frameIndex < m_frames.size(), "ResolveStorageTexelBufferDescriptor: frame index out of range"); MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(), @@ -1013,7 +1014,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { MOBILEGL_ASSERT(binding < programObj.samplerNumericDomainByBinding.size(), "ResolveStorageTexelBufferDescriptor: numeric domain binding %u out of range", binding); - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(imageUnit); + auto& imageBinding = MGB_CTX->GetImageTextureBinding(imageUnit); const auto& texture = imageBinding.Texture; if (texture == nullptr) { // An image unit with no texture on it is legal GL (4.6 core 8.26): loads return zero @@ -1149,7 +1150,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkDescriptorBufferInfo& outBufferInfo) const { outBufferInfo = {}; MOBILEGL_ASSERT(m_bufferManager != nullptr, "ResolveStorageBufferDescriptor: buffer manager is null"); - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveStorageBufferDescriptor: GL context is null"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "ResolveStorageBufferDescriptor: GL context is null"); MOBILEGL_ASSERT(binding < programObj.storageBlockIndexByBinding.size(), "ResolveStorageBufferDescriptor: binding %u out of range", binding); @@ -1186,12 +1187,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { ? static_cast(atomicCounterBinding) : GetShaderStorageBlockBinding(program, static_cast(blockIndex)) + element; const Uint32 bindingPointCount = - static_cast(MG_State::pGLContext->GetBufferBindingPointCount(bufferTarget)); + static_cast(MGB_CTX->GetBufferBindingPointCount(bufferTarget)); MOBILEGL_ASSERT(frontendBinding < bindingPointCount, "ResolveStorageBufferDescriptor: frontend binding %u out of range for block '%s'", frontendBinding, blockName.c_str()); - auto& bindingPoint = MG_State::pGLContext->GetBufferBindingPoint(bufferTarget, frontendBinding); + auto& bindingPoint = MGB_CTX->GetBufferBindingPoint(bufferTarget, frontendBinding); const auto& bufferObject = bindingPoint.GetBoundObject(); if (bufferObject == nullptr) { // NOT an error, and above all not a reason to lose the draw. GL 4.6 core 7.8 lets a @@ -1263,7 +1264,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkDescriptorImageInfo& outImageInfo) const { outImageInfo = {}; MOBILEGL_ASSERT(m_textureManager != nullptr, "ResolveStorageImageDescriptor: texture manager is null"); - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveStorageImageDescriptor: GL context is null"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "ResolveStorageImageDescriptor: GL context is null"); MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(), "ResolveStorageImageDescriptor: binding %u out of range", binding); @@ -1292,7 +1293,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { return false; } - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(imageUnit); + auto& imageBinding = MGB_CTX->GetImageTextureBinding(imageUnit); if (imageBinding.Texture == nullptr) { // Legal GL: an image unit with no texture bound makes loads return zero and discards // stores (4.6 core 8.26). It is not a reason to lose the draw, which is what returning @@ -1648,7 +1649,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Open-coded ResolveSamplerTextureRaw so the unit is resolved once for both the // texture and the sampler override - this runs per binding per full-path draw, // and program-alternating draw streams take the full path on every draw. - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveSampledBinding: GL context is null"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "ResolveSampledBinding: GL context is null"); MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(), "ResolveSampledBinding: sampler location binding %u out of range", binding); MOBILEGL_ASSERT(binding < programObj.samplerTextureTargetByBinding.size(), @@ -1659,7 +1660,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { return false; } const Int unit = ResolveSamplerUnitIndex(program, location, binding); - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); const TextureTarget preferredTarget = programObj.samplerTextureTargetByBinding[binding]; MG_State::GLState::ITextureObject* texture = textureUnit.GetBindingSlot(preferredTarget).GetBoundObject().get(); @@ -1803,7 +1804,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { const ProgramFactory::VkProgramObject& programObj, Vector& outTextures) const { outTextures.clear(); - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, + MOBILEGL_ASSERT(MGB_CTX_LIVE, "CollectStorageImageTextures: GL context is null"); // Same as the sampled walk: a declined program is refused at bind time, and its declined // binding has no uniform location to reach an image unit through. @@ -1847,7 +1848,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { return false; } - auto* texture = MG_State::pGLContext->GetImageTextureBinding(imageUnit).Texture.get(); + auto* texture = MGB_CTX->GetImageTextureBinding(imageUnit).Texture.get(); if (texture == nullptr) { // ResolveStorageImageDescriptor will substitute the placeholder image for this // binding; include it here for the same reason the sampled walk includes the @@ -1885,7 +1886,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { const ProgramFactory::VkProgramObject& programObj, Vector& outBindings) const { outBindings.clear(); - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, + MOBILEGL_ASSERT(MGB_CTX_LIVE, "CollectSamplerImageFeedback: GL context is null"); if (programObj.declinedDescriptors) return true; @@ -1935,7 +1936,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { if (imageUnit < 0 || imageUnit >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { return false; } - const auto& image = MG_State::pGLContext->GetImageTextureBinding(imageUnit); + const auto& image = MGB_CTX->GetImageTextureBinding(imageUnit); // A sampler view exposes all layers of its target; equal texture plus an // overlapping mip therefore aliases the writable image subresource. if (image.Texture.get() == sampledTexture && @@ -1965,7 +1966,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { const void* outData = nullptr; VkDeviceSize outSize = 0; - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveUniformBufferPayload: GL context is null"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "ResolveUniformBufferPayload: GL context is null"); MOBILEGL_ASSERT(binding < programObj.bindingKinds.size(), "ResolveUniformBufferPayload: binding %u out of range", binding); MOBILEGL_ASSERT(programObj.bindingKinds[binding] == ProgramFactory::DescriptorBindingKind::UniformBufferDynamic, @@ -2010,12 +2011,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { const Uint32 frontendBinding = program.GetUniformBlockBinding(static_cast(blockIndex)); const Uint32 uniformBindingPointCount = - static_cast(MG_State::pGLContext->GetBufferBindingPointCount(BufferTarget::Uniform)); + static_cast(MGB_CTX->GetBufferBindingPointCount(BufferTarget::Uniform)); MOBILEGL_ASSERT(frontendBinding < uniformBindingPointCount, "ResolveUniformBufferPayload: frontend UBO binding %u out of range for block '%s'", frontendBinding, program.GetUniformBlockName(static_cast(blockIndex)).c_str()); - auto& bindingPoint = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::Uniform, frontendBinding); + auto& bindingPoint = MGB_CTX->GetBufferBindingPoint(BufferTarget::Uniform, frontendBinding); const auto& bufferObject = bindingPoint.GetBoundObject(); MOBILEGL_ASSERT(bufferObject != nullptr, "ResolveUniformBufferPayload: no UBO bound at frontend binding %u for block '%s'", diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h index b14231bbc..66dd69582 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h @@ -130,7 +130,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // stale memo. // // Drawn from a process-wide source, never a per-instance counter: the VAO - // memos outlive this factory (they live on pGLContext's VAOs, the renderer + // memos outlive this factory (they live on the frontend context's VAOs, the renderer // is destroyed and recreated on EGL surface release/re-create), so a fresh // factory restarting at a dead factory's epoch value would honor its // dangling entry pointers. The constructor takes a value strictly greater diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkClearManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkClearManager.cpp index e9191dc04..744f1e85a 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkClearManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkClearManager.cpp @@ -13,6 +13,7 @@ #include "VkTextureManager.h" #include "MG_State/GLState/Core.h" +#include #include "MG_Util/Converters/MGToStr/FramebufferEnumConverter.h" #include "MG_Util/Converters/MGToStr/TextureEnumConverter.h" @@ -54,7 +55,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { if (payload.colorEncoding != ClearColorEncoding::Float) return; // With GL_FRAMEBUFFER_SRGB enabled GL performs the encoding itself, so the driver doing it // is exactly right and there is nothing to undo. - if (MG_State::pGLContext->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb)) return; + if (MGB_CTX->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb)) return; if (ResolveSrgbAttachmentWriteFormat(destinationFormat, false) == destinationFormat) return; // sRGB -> linear (GL 4.6 core 8.24), applied to the colour channels only: alpha is stored diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp index 7396fa2e7..3adaf82c8 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp @@ -13,6 +13,7 @@ #include "MG_Util/Converters/MGToStr/FramebufferEnumConverter.h" #include "MG_Util/Converters/MGToVk/TextureEnumConverter.h" #include "MG_Util/Metrics/TextureMetrics.h" +#include namespace MobileGL::MG_Backend::DirectVulkan { static Bool TryResolveSampleCountFlagBits(Int requestedSamples, VkSampleCountFlagBits& outSampleCount) { @@ -610,7 +611,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // sRGB attachments switch between their sRGB and UNORM-twin views with this // capability (ResolveSrgbAttachmentWriteFormat), changing the render pass formats. const Bool framebufferSrgbEnabled = - MG_State::pGLContext->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb); + MGB_CTX->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb); XXHASH_VERIFY(XXH64_update(m_hashState, &framebufferSrgbEnabled, sizeof(framebufferSrgbEnabled))); auto& drawBuffers = fbo.GetDrawBuffers(); XXHASH_VERIFY(XXH64_update(m_hashState, drawBuffers.data(), drawBuffers.size() * sizeof(drawBuffers[0]))); @@ -962,7 +963,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { const VkImageLayout trackedRbLayout = rbResource->layout; const Bool rbFramebufferSrgb = - MG_State::pGLContext->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb); + MGB_CTX->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb); const VkFormat rbAttachmentFormat = ResolveSrgbAttachmentWriteFormat(rbResource->format, rbFramebufferSrgb); rbDesc.flags = 0; @@ -1108,7 +1109,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { textureResources.emplace_back(textureResource); desc.format = ResolveSrgbAttachmentWriteFormat( textureResource->format, - MG_State::pGLContext->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb)); + MGB_CTX->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb)); attachmentSampleCount = textureResource->sampleCount; trackedColorLayout = textureResource->layout; trackedAttachmentLayouts.emplace_back(TrackedAttachmentLayoutInfo { diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp index 0335f23b3..d64ae924e 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp @@ -11,6 +11,7 @@ #include "ProgramFactory.h" #include "MG_State/GLState/Core.h" +#include #include "MG_Util/Converters/MGToStr/TextureEnumConverter.h" #include "MG_Util/Converters/MGToVk/TextureEnumConverter.h" #include "MG_Util/Metrics/PipeStats.h" @@ -806,7 +807,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // sampled-texture sync scan the entire alive-texture map per draw. if (aliveIt == m_aliveObjects.end()) { WeakPtr aliveTexture; - const auto& liveTexture = MG_State::pGLContext->GetTextureObject(texture.GetExternalIndex()); + const auto& liveTexture = MGB_CTX->GetTextureObject(texture.GetExternalIndex()); if (liveTexture && liveTexture.get() == &texture) { aliveTexture = liveTexture; } else { @@ -949,7 +950,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { } const Bool framebufferSrgbEnabled = - MG_State::pGLContext->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb); + MGB_CTX->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb); const VkFormat baseAttachmentFormat = viewFormatOverride != VK_FORMAT_UNDEFINED ? viewFormatOverride : resource->format; const VkFormat attachmentFormat = diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 289d2c98a..888696299 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -14,6 +14,7 @@ #include "VertexInputStateBuilder.h" #include "MG_State/GLState/Core.h" +#include #include "MG_State/GLState/ProgramState/ProgramObject.h" #include "MG_State/GLState/ProgramState/ShaderObject.h" #include "MG_State/GLState/SamplerState/SamplerObject.h" @@ -460,12 +461,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { // round trip), which is why the honest-but-lossy path was kept over widening every // default-framebuffer Y-flip/pre-transform helper to floats. See the KNOWN INFIDELITY // note in MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp. - const FloatVec4& stored = MG_State::pGLContext->GetViewportIndexed(index); + const FloatVec4& stored = MGB_CTX->GetViewportIndexed(index); const IntVec4 viewportState(static_cast(std::lround(stored.x())), static_cast(std::lround(stored.y())), static_cast(std::lround(stored.z())), static_cast(std::lround(stored.w()))); - const FloatVec2& depthRange = MG_State::pGLContext->GetDepthRangeIndexed(index); + const FloatVec2& depthRange = MGB_CTX->GetDepthRangeIndexed(index); const IntVec2 logicalExtent = isDefaultFramebuffer ? ResolveDefaultFramebufferLogicalExtent(preTransform, framebufferExtent) : framebufferExtent; @@ -520,7 +521,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { } static void ApplyBlendConstants(VkCommandBuffer commandBuffer) { - const FloatVec4& blendColor = MG_State::pGLContext->GetBlendColor(); + const FloatVec4& blendColor = MGB_CTX->GetBlendColor(); const float blendConstants[4] = { blendColor.x(), blendColor.y(), @@ -553,8 +554,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { } static void ApplyPolygonOffsetState(VkCommandBuffer commandBuffer) { - const Float constantFactor = MG_State::pGLContext->GetPolygonOffsetUnits(); - const Float slopeFactor = MG_State::pGLContext->GetPolygonOffsetFactor(); + const Float constantFactor = MGB_CTX->GetPolygonOffsetUnits(); + const Float slopeFactor = MGB_CTX->GetPolygonOffsetFactor(); auto& shadow = g_dynamicStateShadow; if (shadow.depthBiasValid && shadow.depthBiasConstantFactor == constantFactor && shadow.depthBiasSlopeFactor == slopeFactor) { @@ -567,7 +568,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { } static void ApplyLineWidthState(VkCommandBuffer commandBuffer) { - Float lineWidth = MG_State::pGLContext->GetLineWidth(); + Float lineWidth = MGB_CTX->GetLineWidth(); if (MG_Backend::pActiveBackendObject != nullptr) { const auto& dynamicParameters = MG_Backend::pActiveBackendObject->GetDynamicParameters(); const Float minLineWidth = dynamicParameters.AliasedLineWidthRangeMin; @@ -646,8 +647,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { } static void ApplyStencilState(VkCommandBuffer commandBuffer) { - const StencilFaceState& frontStencil = MG_State::pGLContext->GetStencilState(StencilFace::Front); - const StencilFaceState& backStencil = MG_State::pGLContext->GetStencilState(StencilFace::Back); + const StencilFaceState& frontStencil = MGB_CTX->GetStencilState(StencilFace::Front); + const StencilFaceState& backStencil = MGB_CTX->GetStencilState(StencilFace::Back); const Uint32 frontReference = static_cast(std::max(frontStencil.Ref, 0)); const Uint32 backReference = static_cast(std::max(backStencil.Ref, 0)); @@ -1240,11 +1241,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { } static void RecordClearBufferError(const char* func, ErrorCode code, const char* message) { - MG_State::pGLContext->RecordError(code, MakeUnique("DirectVulkan", func, message)); + MGB_CTX->RecordError(code, MakeUnique("DirectVulkan", func, message)); } static void RecordTextureCopyError(const char* func, ErrorCode code, const char* message) { - MG_State::pGLContext->RecordError(code, MakeUnique("DirectVulkan", func, message)); + MGB_CTX->RecordError(code, MakeUnique("DirectVulkan", func, message)); } static Bool HasDistinctCompleteDepthStencilTextureAttachments( @@ -1300,7 +1301,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { } static void RecordUnsupportedFramebufferError(const char* func) { - MG_State::pGLContext->RecordError( + MGB_CTX->RecordError( ErrorCode::InvalidFramebufferOperation, MakeUnique( "DirectVulkan", func, @@ -2768,7 +2769,7 @@ void main() { // glReadPixels final conversion: GL_CLAMP_READ_COLOR defaults to GL_FIXED_ONLY, // clamping fixed-point (normalized) buffers to [0,1] - visible for SNORM reads. if (applyReadColorClamp && wideType == GL_FLOAT) { - const GLenum clampMode = MG_State::pGLContext->GetClampReadColor(); + const GLenum clampMode = MGB_CTX->GetClampReadColor(); const Bool clamp = clampMode == GL_TRUE || (clampMode == GL_FIXED_ONLY && !IsFloatingPointReadbackFormat(srcFormat)); if (clamp) { @@ -2985,7 +2986,7 @@ void main() { inline ProgramFactory::CompileOptionFlags GetShaderTransformFlags(VkSurfaceTransformFlagBitsKHR preTransform) { ProgramFactory::CompileOptionFlags flags = ProgramFactory::CompileOptionBit::PositionZRemap; const auto& currentDrawFBO = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); if (currentDrawFBO != nullptr && currentDrawFBO->IsDefaultFramebuffer()) { flags |= ProgramFactory::CompileOptionBit::PositionYFlip; // gl_FragCoord follows the same rule the default-framebuffer RECTANGLES follow @@ -3445,8 +3446,8 @@ void main() { // with restart off it is a legitimate index and excluding it would truncate the // converted stream by exactly that vertex. const Bool primitiveRestartActive = - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) || - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex); + MGB_CTX->IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) || + MGB_CTX->IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex); const Uint32 restartSentinel = indexSize == 1 ? 0xFFu : indexSize == 2 ? 0xFFFFu : 0xFFFFFFFFu; Uint32 maxIndex = 0; Bool sawIndex = false; @@ -3923,7 +3924,7 @@ void main() { } const auto glType = programObj.vertexInputTypes[location]; - const auto& currentValue = MG_State::pGLContext->GetCurrentVertexAttribute(location); + const auto& currentValue = MGB_CTX->GetCurrentVertexAttribute(location); VkFormat format = VK_FORMAT_UNDEFINED; const void* sourceData = nullptr; VkDeviceSize sourceSize = 0; @@ -4057,7 +4058,7 @@ void main() { Bool substituteRestart = false; // One bulk parameters fetch instead of up to three accessor calls per indexed // draw; all three inputs are pure reads of these fields. - const RenderStateParameters& rsp = MG_State::pGLContext->GetRenderStateParameters(); + const RenderStateParameters& rsp = MGB_CTX->GetRenderStateParameters(); if (rsp.PrimitiveRestartEnabled && !rsp.PrimitiveRestartFixedIndexEnabled) { const Uint32 restartIndex = rsp.PrimitiveRestartIndex; const Uint32 fixedMax = MG_Util::FixedRestartIndexForGLType(pIndexBufferView->indexType); @@ -4812,9 +4813,9 @@ void main() { Uint32 VulkanRenderer::ResolveEffectiveSampleMask(VkSampleCountFlagBits rasterizationSamples) const { constexpr Uint32 kFullCoverage = 0xffffffffu; if (rasterizationSamples == VK_SAMPLE_COUNT_1_BIT) return kFullCoverage; - if (!MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::Multisample)) return kFullCoverage; - if (!MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::SampleMask)) return kFullCoverage; - return MG_State::pGLContext->GetRenderStateParameters().SampleMaskValue; + if (!MGB_CTX->IsCapabilityEnabled(CapabilityInput::Multisample)) return kFullCoverage; + if (!MGB_CTX->IsCapabilityEnabled(CapabilityInput::SampleMask)) return kFullCoverage; + return MGB_CTX->GetRenderStateParameters().SampleMaskValue; } Uint64 VulkanRenderer::ComputePipelineStateHash(Uint32 colorAttachmentCount, @@ -4825,7 +4826,7 @@ void main() { // field (RenderState.cpp), so the hashed values are bit-identical. This runs on // every draw whose pipeline-state version moved (a per-draw GL_BLEND toggle), // where the accessor-call overhead dominated the hash itself. - const RenderStateParameters& p = MG_State::pGLContext->GetRenderStateParameters(); + const RenderStateParameters& p = MGB_CTX->GetRenderStateParameters(); Uint64 capabilityBits = 0; capabilityBits |= p.CullFaceEnabled ? 1ull << 0 : 0; capabilityBits |= p.DepthTestEnabled ? 1ull << 1 : 0; @@ -4937,7 +4938,7 @@ void main() { if (!(aspects & DrawSetupAspect::IndexBuffer) || pIndexBufferView == nullptr) { return false; } - const RenderStateParameters& rsp = MG_State::pGLContext->GetRenderStateParameters(); + const RenderStateParameters& rsp = MGB_CTX->GetRenderStateParameters(); if (rsp.PrimitiveRestartFixedIndexEnabled) { return true; } @@ -4981,7 +4982,7 @@ void main() { // the VALUE hash of that subset, never the version itself: the version is monotonic, so // per-draw state flips (GL_BLEND toggles) would otherwise miss entries the memo holds. // The version only guards recomputing the hash - unchanged version, unchanged bytes. - const Uint renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion(); + const Uint renderStateVersion = MGB_CTX->GetPipelineStateVersion(); if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion || m_pipelineStateHashColorCount != renderPassEntry.colorAttachmentCount || m_pipelineStateHashSampleCount != renderPassEntry.sampleCount) { @@ -5169,22 +5170,22 @@ void main() { syntheticVertexInputState.pNext = vis.state.pNext; pipelineVertexInputState = &syntheticVertexInputState; } - auto cullFaceEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::CullFace); - auto depthTestEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::DepthTest); + auto cullFaceEnabled = MGB_CTX->IsCapabilityEnabled(CapabilityInput::CullFace); + auto depthTestEnabled = MGB_CTX->IsCapabilityEnabled(CapabilityInput::DepthTest); auto polygonOffsetFillEnabled = - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PolygonOffsetFill) && + MGB_CTX->IsCapabilityEnabled(CapabilityInput::PolygonOffsetFill) && DrawModeUsesPolygonFill(mode); auto rasterizerDiscardEnabled = - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard); + MGB_CTX->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard); auto colorLogicOpEnabled = - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::ColorLogicOp) && m_logicOpFeatureEnabled; - auto stencilTestEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::StencilTest); + MGB_CTX->IsCapabilityEnabled(CapabilityInput::ColorLogicOp) && m_logicOpFeatureEnabled; + auto stencilTestEnabled = MGB_CTX->IsCapabilityEnabled(CapabilityInput::StencilTest); // A framebuffer without a depth (stencil) attachment behaves as if the depth // (stencil) test always passes and nothing is written - even when the bound // image is a packed depth-stencil texture attached through only one half. { const auto& gatingFbo = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); if (gatingFbo != nullptr && !gatingFbo->IsDefaultFramebuffer()) { const auto& depthAtt = gatingFbo->GetAttachment(MobileGL::FramebufferAttachmentType::Depth); const auto& stencilAtt = gatingFbo->GetAttachment(MobileGL::FramebufferAttachmentType::Stencil); @@ -5196,10 +5197,10 @@ void main() { } } } - const StencilFaceState& frontStencil = MG_State::pGLContext->GetStencilState(StencilFace::Front); - const StencilFaceState& backStencil = MG_State::pGLContext->GetStencilState(StencilFace::Back); + const StencilFaceState& frontStencil = MGB_CTX->GetStencilState(StencilFace::Front); + const StencilFaceState& backStencil = MGB_CTX->GetStencilState(StencilFace::Back); const VkPolygonMode requestedPolygonMode = - MG_Util::ConvertPolygonModeToVkEnum(MG_State::pGLContext->GetPolygonModeFront()); + MG_Util::ConvertPolygonModeToVkEnum(MGB_CTX->GetPolygonModeFront()); // VK_POLYGON_MODE_LINE/_POINT require the fillModeNonSolid device feature; fall back to // VK_POLYGON_MODE_FILL when the device lacks it. const VkPolygonMode effectivePolygonMode = @@ -5284,18 +5285,18 @@ void main() { // driver's own rate. Both halves move the render state's PIPELINE version, so a cached // pipeline built at the old rate cannot be handed back for the new one. .sampleShadingEnable = m_sampleRateShadingFeatureEnabled && - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::SampleShading), - .minSampleShading = MG_State::pGLContext->GetMinSampleShadingValue(), + MGB_CTX->IsCapabilityEnabled(CapabilityInput::SampleShading), + .minSampleShading = MGB_CTX->GetMinSampleShadingValue(), // Word 1 keeps its all-ones initialiser: GL has no state for samples 32..63. .sampleMask = {ResolveEffectiveSampleMask(renderPassEntry.sampleCount), 0xffffffffu}, .subpass = 0, .topology = vkTopology, .primitiveRestartEnable = primitiveRestartEnabled, - .patchControlPoints = static_cast(MG_State::pGLContext->GetPatchVertices()), + .patchControlPoints = static_cast(MGB_CTX->GetPatchVertices()), .viewportCount = ResolveDrawViewportCount(programObj.writesViewportIndexBuiltin), .polygonMode = effectivePolygonMode, .cullMode = cullFaceEnabled - ? MG_Util::ConvertCullFaceModeToVkEnum(MG_State::pGLContext->GetCullFaceMode(), invertClockwise) + ? MG_Util::ConvertCullFaceModeToVkEnum(MGB_CTX->GetCullFaceMode(), invertClockwise) : VK_CULL_MODE_NONE, .frontFace = VK_FRONT_FACE_CLOCKWISE, // Read the geometry stage off the program's own shader list rather than @@ -5309,13 +5310,13 @@ void main() { .provokingVertexMode = SelectProvokingVertexMode( vkTopology, ProgramCapturesXfbFromGeometryStage(program)), .depthTestEnable = depthTestEnabled, - .depthWriteEnable = depthTestEnabled && MG_State::pGLContext->GetDepthMask(), + .depthWriteEnable = depthTestEnabled && MGB_CTX->GetDepthMask(), .depthBiasEnable = polygonOffsetFillEnabled, .rasterizerDiscardEnable = rasterizerDiscardEnabled, .logicOpEnable = colorLogicOpEnabled, .stencilTestEnable = stencilTestEnabled, - .depthCompareOp = MG_Util::ConvertDepthTestFuncToVkEnum(MG_State::pGLContext->GetDepthFunc()), - .logicOp = MG_Util::ConvertLogicOperationToVkEnum(MG_State::pGLContext->GetLogicOp()), + .depthCompareOp = MG_Util::ConvertDepthTestFuncToVkEnum(MGB_CTX->GetDepthFunc()), + .logicOp = MG_Util::ConvertLogicOperationToVkEnum(MGB_CTX->GetLogicOp()), .frontStencilFailOp = MG_Util::ConvertStencilOperationToVkEnum(frontStencil.FailOp), .frontStencilPassOp = MG_Util::ConvertStencilOperationToVkEnum(frontStencil.PassDepthPassOp), .frontStencilDepthFailOp = MG_Util::ConvertStencilOperationToVkEnum(frontStencil.PassDepthFailOp), @@ -5351,8 +5352,8 @@ void main() { // handed back after the application changed them. if (programObj.needsPassthroughTessControl && programObj.passthroughTessControlEmulatable && vkTopology == VK_PRIMITIVE_TOPOLOGY_PATCH_LIST) { - const FloatVec4& defaultOuterLevel = MG_State::pGLContext->GetPatchDefaultOuterLevel(); - const FloatVec2& defaultInnerLevel = MG_State::pGLContext->GetPatchDefaultInnerLevel(); + const FloatVec4& defaultOuterLevel = MGB_CTX->GetPatchDefaultOuterLevel(); + const FloatVec2& defaultInnerLevel = MGB_CTX->GetPatchDefaultInnerLevel(); payload.passthroughTessControlKey = ProgramFactory::ComputePassthroughTessControlKey( payload.patchControlPoints, defaultOuterLevel, defaultInnerLevel, programObj.passthroughPerVertexMembers); @@ -5404,7 +5405,7 @@ void main() { "GetOrCreatePipeline: colorAttachmentCount=%u exceeds payload capacity", payload.colorAttachmentCount); const auto& drawFboBinding = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); MOBILEGL_ASSERT(drawFboBinding != nullptr, "GetOrCreatePipeline: draw framebuffer is null"); const Bool isDefaultDrawFbo = drawFboBinding->IsDefaultFramebuffer(); const auto& drawBuffers = drawFboBinding->GetDrawBuffers(); @@ -5432,14 +5433,14 @@ void main() { BlendFactor dstAlpha = BlendFactor::Zero; BlendEquation colorEquation = BlendEquation::Add; BlendEquation alphaEquation = BlendEquation::Add; - MG_State::pGLContext->GetBlendFuncIndexed(i, srcRGB, dstRGB, srcAlpha, dstAlpha); - MG_State::pGLContext->GetBlendEquationIndexed(i, colorEquation, alphaEquation); - const Bool blendEnabled = MG_State::pGLContext->IsCapabilityEnabledIndexed(CapabilityInput::Blend, i); + MGB_CTX->GetBlendFuncIndexed(i, srcRGB, dstRGB, srcAlpha, dstAlpha); + MGB_CTX->GetBlendEquationIndexed(i, colorEquation, alphaEquation); + const Bool blendEnabled = MGB_CTX->IsCapabilityEnabledIndexed(CapabilityInput::Blend, i); // Per-draw-buffer color write mask (glColorMaski). Divergent per-attachment masks require // the independentBlend device feature; when it is absent, fall back to draw buffer 0's // mask for every attachment (matching the non-indexed glColorMask broadcast). const BoolVec4 bufferMask = - MG_State::pGLContext->GetColorMaskIndexed(m_independentBlendFeatureEnabled ? i : 0); + MGB_CTX->GetColorMaskIndexed(m_independentBlendFeatureEnabled ? i : 0); VkColorComponentFlags attachmentColorWriteMask = static_cast( (bufferMask.r() ? VK_COLOR_COMPONENT_R_BIT : 0u) | (bufferMask.g() ? VK_COLOR_COMPONENT_G_BIT : 0u) | @@ -5855,7 +5856,7 @@ void main() { VkRect2D VulkanRenderer::ComputeGLScissorRect(Uint32 index, const IntVec2& extent, VkSurfaceTransformFlagBitsKHR preTransform, Bool isDefaultFbo) const { - const auto& parameters = MG_State::pGLContext->GetRenderStateParameters(); + const auto& parameters = MGB_CTX->GetRenderStateParameters(); if ((parameters.ScissorTestEnabledMask & (1u << index)) == 0) { VkRect2D full{}; full.offset = {0, 0}; @@ -5916,7 +5917,7 @@ void main() { // One compare for the whole tail: see the gate's declaration in // DynamicStateShadow for why (version, extent, default-FBO flag) pins every // input the six Apply* below read. - const Uint paramsVersion = MG_State::pGLContext->GetRenderStateParametersVersion(); + const Uint paramsVersion = MGB_CTX->GetRenderStateParametersVersion(); if (shadow.dynamicTailValid && shadow.dynamicTailParamsVersion == paramsVersion && shadow.dynamicTailExtentX == extent.x() && shadow.dynamicTailExtentY == extent.y() && shadow.dynamicTailIsDefaultFbo == isDefaultFbo) { @@ -5940,7 +5941,7 @@ void main() { // re-derive the value its shadow already holds. DynamicStateShadow::DynamicTailKey key; { - const RenderStateParameters& p = MG_State::pGLContext->GetRenderStateParameters(); + const RenderStateParameters& p = MGB_CTX->GetRenderStateParameters(); // Viewport 0 and its depth range: ApplyGLViewportState reads exactly those two // (per-index state for indices > 0 is keyed separately, see multiViewportKey below). key.viewport[0] = p.Viewports[0].x(); @@ -6016,7 +6017,7 @@ void main() { MOBILEGL_ASSERT( [&] { const auto& fbo = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); return isDefaultFbo == (fbo != nullptr && fbo->IsDefaultFramebuffer()); }(), "GetBaseTransformFlagsRaw: isDefaultFbo does not match the bound draw framebuffer"); @@ -6040,7 +6041,7 @@ void main() { // Entry select: by the draw program's lifetime id, MRU first (the id pins // the entry; every other fact is re-guarded below, so probing a stale // entry can only decline, never serve stale state). - const auto& program = *MG_State::pGLContext->GetProgramForDraw(); + const auto& program = *MGB_CTX->GetProgramForDraw(); const Uint64 programLifetimeId = program.GetLifetimeId(); SetupDrawSnapshot* snapPtr = nullptr; { @@ -6088,7 +6089,7 @@ void main() { // captured draw after glBeginTransformFeedback would bind the undecorated // variant and silently capture nothing while the CPU bookkeeping advances. const Bool wantsXfbCapture = m_transformFeedbackFeatureEnabled && - MG_State::pGLContext->IsTransformFeedbackActive() && + MGB_CTX->IsTransformFeedbackActive() && program.GetTransformFeedbackVaryingCount() > 0; const Bool snapHasXfbCapture = static_cast(ProgramFactory::CompileOptionFlags(snap.resolvedTransformFlags) & @@ -6102,12 +6103,12 @@ void main() { // buffer binds (re-run every draw anyway). Declining here would send every // draw of a VAO-cycling stream (Minecraft chunk rendering) through the full // path, re-resolving descriptors and texture layouts nothing invalidated. - const auto& vao = *MG_State::pGLContext->GetBoundVertexArray(); + const auto& vao = *MGB_CTX->GetBoundVertexArray(); const Bool vaoMoved = static_cast(&vao) != snap.vao || vao.GetLifetimeId() != snap.vaoLifetimeId || vao.GetConfigVersion() != snap.vaoConfigVersion; const auto& drawFbo = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); if (static_cast(drawFbo.get()) != snap.drawFbo || drawFbo->GetLifetimeId() != snap.drawFboLifetimeId || drawFbo->GetObjectVersion() != snap.fboVersion) { @@ -6118,8 +6119,8 @@ void main() { // back on what the snapshot already describes (a GL_BLEND toggle between // two draws, a redundant glBindSampler), and declining here sends every // such draw through the full SetupDraw. - const Uint renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion(); - const Uint64 bindGeneration = MG_State::pGLContext->GetTextureBindGeneration(); + const Uint renderStateVersion = MGB_CTX->GetPipelineStateVersion(); + const Uint64 bindGeneration = MGB_CTX->GetTextureBindGeneration(); const Bool renderStateMoved = renderStateVersion != snap.renderStateVersion; const Bool bindsMoved = bindGeneration != snap.bindGeneration; if (renderStateMoved) { @@ -6127,7 +6128,7 @@ void main() { // flavor input (depth/stencil participation); a flip of that must take // the full path's pass selection. One bulk parameters fetch instead of // two capability-accessor calls; both are pure reads of the same fields. - const RenderStateParameters& rsp = MG_State::pGLContext->GetRenderStateParameters(); + const RenderStateParameters& rsp = MGB_CTX->GetRenderStateParameters(); const Bool drawUsesDepthStencil = rsp.DepthTestEnabled || rsp.StencilTestEnabled; if (drawUsesDepthStencil != snap.drawUsesDepthStencil) { return false; @@ -6301,7 +6302,7 @@ void main() { if (contentSum != snap.sampledContentSum || paramsSum != snap.sampledParamsSum) { return false; } - const Uint64 samplingResolutionGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + const Uint64 samplingResolutionGeneration = MGB_CTX->GetSamplingResolutionGeneration(); if (samplingResolutionGeneration != snap.samplingResolutionGeneration) { // Decline, not re-arm: snap.resolvedTransformFlags bakes the // ExplicitLod0Sampling verdict, which reads the effective sampler's @@ -6431,7 +6432,7 @@ void main() { // was cancelled has no usable optimized module - and on an in-place // SanitizeAndOptimizeBinary failure GetGeneratedSpirv() still holds the RAW // glslang words, which must never reach vkCreateShaderModule. Drop the draw. - const auto& drawProgram = *MG_State::pGLContext->GetProgramForDraw(); + const auto& drawProgram = *MGB_CTX->GetProgramForDraw(); if (!drawProgram.GetLinkStatus() || !drawProgram.GetSpirvStatus()) { MGLOG_D("SetupDraw skipped: program=%u is linked=%d spirv=%d", drawProgram.GetExternalIndex(), static_cast(drawProgram.GetLinkStatus()), @@ -6461,15 +6462,15 @@ void main() { MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 3); } const auto& drawFbo = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); if (drawFbo != nullptr && IsUnsupportedFramebufferForDirectVulkan(*drawFbo)) { // Nothing was mutated: other entries' per-probe guards (FBO identity + // version among them) stay authoritative, so none need invalidating. RecordUnsupportedFramebufferError(__func__); return false; } - const auto& vao = *MG_State::pGLContext->GetBoundVertexArray(); - const auto& program = *MG_State::pGLContext->GetProgramForDraw(); + const auto& vao = *MGB_CTX->GetBoundVertexArray(); + const auto& program = *MGB_CTX->GetProgramForDraw(); // The fast path declined (or had no entry for this program): whatever THIS // program's entry saw may be stale, and the full path below mutates state as // it goes, so the entry must not stay matchable if that path fails mid-way. @@ -6509,7 +6510,7 @@ void main() { ProgramFactory::CompileOptionFlags transformFlags = ProgramFactory::CompileOptionFlags(GetBaseTransformFlagsRaw(drawFboIsDefault)); // Captured draws take the xfb-decorated program variant. - if (m_transformFeedbackFeatureEnabled && MG_State::pGLContext->IsTransformFeedbackActive() && + if (m_transformFeedbackFeatureEnabled && MGB_CTX->IsTransformFeedbackActive() && program.GetTransformFeedbackVaryingCount() > 0) { transformFlags |= ProgramFactory::CompileOptionBit::XfbCapture; } @@ -6523,13 +6524,13 @@ void main() { { const Uint64 lodProgramLifetimeId = program.GetLifetimeId(); const Uint32 lodProgramVersion = program.GetBackendStateVersion(); - const Uint64 lodBindGeneration = MG_State::pGLContext->GetTextureBindGeneration(); + const Uint64 lodBindGeneration = MGB_CTX->GetTextureBindGeneration(); // The probe also reads the EFFECTIVE sampler's filters/aniso/LOD range // (ProgramSamplesOnlySingleLevelTextures), and those setters bump ONLY the // sampling-resolution generation - not the texture params version the sum // below covers. Without this key a filter/aniso change would keep serving // the stale verdict. - const Uint64 lodSamplingGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + const Uint64 lodSamplingGeneration = MGB_CTX->GetSamplingResolutionGeneration(); Bool lodMemoHit = false; if (m_lastLodDecisionValid && m_lastSampledSetValid && m_lastLodProgramLifetimeId == lodProgramLifetimeId && @@ -6647,7 +6648,7 @@ void main() { { const Uint64 programLifetimeId = program.GetLifetimeId(); const Uint32 programVersion = program.GetBackendStateVersion(); - const Uint64 bindGeneration = MG_State::pGLContext->GetTextureBindGeneration(); + const Uint64 bindGeneration = MGB_CTX->GetTextureBindGeneration(); // The bind generation alone stopped covering this set the moment ResolveSampledBinding // started asking SamplesAsIncompleteTexture: membership now depends on the effective // sampler PARAMETERS (MIN_FILTER decides whether the mip chain is read at all) and on @@ -6666,7 +6667,7 @@ void main() { // object a unit carries goes through TextureUnit::SetSamplerObject and moves the bind // generation instead. Same term the SetupDrawSnapshot fast path and the LOD memo // already carry. - const Uint64 samplingGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + const Uint64 samplingGeneration = MGB_CTX->GetSamplingResolutionGeneration(); const Bool sampledSetUnchanged = m_lastSampledSetValid && m_lastSampledSetProgramLifetimeId == programLifetimeId && m_lastSampledSetProgramVersion == programVersion && @@ -6827,8 +6828,8 @@ void main() { // pass flavor (GL: a disabled depth/stencil test neither reads nor writes // its buffer). const Bool drawUsesDepthStencil = - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::DepthTest) || - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::StencilTest); + MGB_CTX->IsCapabilityEnabled(CapabilityInput::DepthTest) || + MGB_CTX->IsCapabilityEnabled(CapabilityInput::StencilTest); auto* renderPassEntry = m_renderPassManager->GetOrCreateRenderPass(*drawFbo, m_imageIndexAcquired, drawUsesDepthStencil); // nullptr: the framebuffer has an attachment DirectVulkan cannot represent (a texture the @@ -6969,8 +6970,8 @@ void main() { snap.fboVersion = drawFbo->GetObjectVersion(); snap.drawFboIsDefault = drawFboIsDefault; snap.viewportCount = ResolveDrawViewportCount(programObj.writesViewportIndexBuiltin); - snap.renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion(); - snap.bindGeneration = MG_State::pGLContext->GetTextureBindGeneration(); + snap.renderStateVersion = MGB_CTX->GetPipelineStateVersion(); + snap.bindGeneration = MGB_CTX->GetTextureBindGeneration(); snap.baseTransformFlags = GetBaseTransformFlagsRaw(drawFboIsDefault); snap.resolvedTransformFlags = transformFlags.GetRaw(); snap.renderPassHash = nowActiveRenderPass->hash; @@ -6996,7 +6997,7 @@ void main() { snap.programObj = nullptr; snap.programFactoryEpoch = 0; } - snap.samplingResolutionGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + snap.samplingResolutionGeneration = MGB_CTX->GetSamplingResolutionGeneration(); Uint64 snapContentSum = 0; Uint64 snapParamsSum = 0; // Per-entry copies of this draw's sampled set (the scratch vectors @@ -7033,7 +7034,7 @@ void main() { auto& frame = m_frameContext.GetCurrent(); // The DISPATCH accessor: with a pipeline bound this is its compute stage program // itself, never the graphics composite (which carries no compute stage at all). - const auto& program = *MG_State::pGLContext->GetProgramForDispatch(); + const auto& program = *MGB_CTX->GetProgramForDispatch(); if (!program.GetLinkStatus() || !program.GetSpirvStatus()) { MGLOG_E_ONCE("DispatchCompute skipped: program=%u has no optimized SPIR-V", program.GetExternalIndex()); @@ -7085,7 +7086,7 @@ void main() { m_textureManager->CollectGarbage(); auto& frame = m_frameContext.GetCurrent(); // See DispatchCompute: the dispatch accessor, not the draw one. - const auto& program = *MG_State::pGLContext->GetProgramForDispatch(); + const auto& program = *MGB_CTX->GetProgramForDispatch(); if (!program.GetLinkStatus() || !program.GetSpirvStatus()) { MGLOG_E_ONCE("DispatchComputeIndirect skipped: program=%u has no optimized SPIR-V", program.GetExternalIndex()); @@ -7129,7 +7130,7 @@ void main() { return; } - auto indirectBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DispatchIndirect).GetBoundObject(); + auto indirectBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DispatchIndirect).GetBoundObject(); if (!indirectBuffer) { MGLOG_E_ONCE("DispatchComputeIndirect skipped: GL_DISPATCH_INDIRECT_BUFFER is not bound"); return; @@ -7203,10 +7204,10 @@ void main() { VkClearRect clearRect{}; clearRect.rect = framebuffer.IsDefaultFramebuffer() - ? MakeDefaultFramebufferScissorRect(MG_State::pGLContext->GetScissorBox(), + ? MakeDefaultFramebufferScissorRect(MGB_CTX->GetScissorBox(), renderPassEntry->extent, m_swapchainObject.GetPreTransform()) - : MakeClampedScissorRect(MG_State::pGLContext->GetScissorBox(), renderPassEntry->extent); + : MakeClampedScissorRect(MGB_CTX->GetScissorBox(), renderPassEntry->extent); clearRect.baseArrayLayer = 0; // GL 3.3 §4.4.7: clearing a layered framebuffer clears every layer. clearRect.layerCount = renderPassEntry->layers; @@ -7254,10 +7255,10 @@ void main() { return; } // GL 3.3 §3.1: when RASTERIZER_DISCARD is enabled, Clear and ClearBuffer* are ignored. - if (MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) { + if (MGB_CTX->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) { return; } - auto* fbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject().get(); + auto* fbo = MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject().get(); MOBILEGL_ASSERT(fbo, "VulkanRenderer::Clear: draw framebuffer not found (fbo == nullptr)"); if (IsUnsupportedFramebufferForDirectVulkan(*fbo)) { RecordUnsupportedFramebufferError(__func__); @@ -7265,16 +7266,16 @@ void main() { } ClearFramebufferPayload payload { - .color = MG_State::pGLContext->GetClearColor(), - .depth = MG_State::pGLContext->GetClearDepth(), - .stencil = MG_State::pGLContext->GetClearStencil() + .color = MGB_CTX->GetClearColor(), + .depth = MGB_CTX->GetClearDepth(), + .stencil = MGB_CTX->GetClearStencil() }; // A render-pass loadOp clear always covers the complete attachment, while // OpenGL glClear is clipped by GL_SCISSOR_TEST. Blaze3D relies on this for // GuiItemAtlas: animated items clear only their atlas slot before being // redrawn. Queueing that clear as a loadOp erases every cached static item. - if (MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::ScissorTest)) { + if (MGB_CTX->IsCapabilityEnabled(CapabilityInput::ScissorTest)) { VkClearRect clearRect{}; switch (PrepareScissoredClear(*fbo, clearRect)) { case ScissoredClearPrep::NoOp: @@ -7297,7 +7298,7 @@ void main() { continue; } - const BoolVec4 colorMask = MG_State::pGLContext->GetColorMaskIndexed(drawBufferIndex); + const BoolVec4 colorMask = MGB_CTX->GetColorMaskIndexed(drawBufferIndex); if (!colorMask.r() && !colorMask.g() && !colorMask.b() && !colorMask.a()) { continue; } @@ -7324,7 +7325,7 @@ void main() { } VkImageAspectFlags depthStencilAspects = 0; - if ((mask & GL_DEPTH_BUFFER_BIT) != 0 && MG_State::pGLContext->GetDepthMask()) { + if ((mask & GL_DEPTH_BUFFER_BIT) != 0 && MGB_CTX->GetDepthMask()) { const auto& depthAttachment = fbo->GetAttachment(FramebufferAttachmentType::Depth); if (depthAttachment.IsComplete()) { depthStencilAspects |= VK_IMAGE_ASPECT_DEPTH_BIT; @@ -7337,7 +7338,7 @@ void main() { // vkCmdClearAttachments writes every bit, so only a full (8-bit stencil) or // zero mask can be expressed; treat a partial mask like a partial color mask. const Uint32 stencilWriteMask = - MG_State::pGLContext->GetStencilState(StencilFace::Front).WriteMask; + MGB_CTX->GetStencilState(StencilFace::Front).WriteMask; if ((stencilWriteMask & 0xFFu) == 0xFFu) { depthStencilAspects |= VK_IMAGE_ASPECT_STENCIL_BIT; } else if (stencilWriteMask != 0) { @@ -7366,11 +7367,11 @@ void main() { // gating for the deferred path: drop fully-masked planes, warn on partial // masks vkCmdClear*/loadOp clears cannot express. GLbitfield deferredMask = mask; - if ((deferredMask & GL_DEPTH_BUFFER_BIT) != 0 && !MG_State::pGLContext->GetDepthMask()) { + if ((deferredMask & GL_DEPTH_BUFFER_BIT) != 0 && !MGB_CTX->GetDepthMask()) { deferredMask &= ~static_cast(GL_DEPTH_BUFFER_BIT); } if ((deferredMask & GL_STENCIL_BUFFER_BIT) != 0) { - const Uint32 stencilWriteMask = MG_State::pGLContext->GetStencilState(StencilFace::Front).WriteMask; + const Uint32 stencilWriteMask = MGB_CTX->GetStencilState(StencilFace::Front).WriteMask; if ((stencilWriteMask & 0xFFu) != 0xFFu) { if (stencilWriteMask != 0) { MGLOG_W_ONCE("DirectVulkan: deferred glClear with a partial stencil write mask is not supported"); @@ -7386,7 +7387,7 @@ void main() { if (drawBuffers[drawBufferIndex] == FramebufferAttachmentType::None) { continue; } - const BoolVec4 colorMask = MG_State::pGLContext->GetColorMaskIndexed(drawBufferIndex); + const BoolVec4 colorMask = MGB_CTX->GetColorMaskIndexed(drawBufferIndex); const Bool full = colorMask.r() && colorMask.g() && colorMask.b() && colorMask.a(); if (full) { anyFullMask = true; @@ -7407,7 +7408,7 @@ void main() { if (attachmentType == FramebufferAttachmentType::None) { continue; } - const BoolVec4 colorMask = MG_State::pGLContext->GetColorMaskIndexed(drawBufferIndex); + const BoolVec4 colorMask = MGB_CTX->GetColorMaskIndexed(drawBufferIndex); if (!(colorMask.r() && colorMask.g() && colorMask.b() && colorMask.a())) { continue; } @@ -7436,7 +7437,7 @@ void main() { const ClearAttachmentPayload& clearPayload) { m_clearManager->CollectGarbage(); // GL 3.3 §3.1: when RASTERIZER_DISCARD is enabled, Clear and ClearBuffer* are ignored. - if (MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) { + if (MGB_CTX->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) { return; } if (IsUnsupportedFramebufferForDirectVulkan(framebuffer)) { @@ -7478,7 +7479,7 @@ void main() { } // GL 3.3 §4.2.3: ClearBuffer* is clipped by GL_SCISSOR_TEST exactly like Clear. - if (MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::ScissorTest)) { + if (MGB_CTX->IsCapabilityEnabled(CapabilityInput::ScissorTest)) { VkClearRect clearRect{}; switch (PrepareScissoredClear(framebuffer, clearRect)) { case ScissoredClearPrep::NoOp: @@ -7509,9 +7510,9 @@ void main() { // GL 3.3 §4.2.3: ClearBuffer* honors the write masks like Clear. Deferred // clears cannot express partial masks; warn and skip those. - const auto depthClearAllowed = [&]() -> Bool { return MG_State::pGLContext->GetDepthMask(); }; + const auto depthClearAllowed = [&]() -> Bool { return MGB_CTX->GetDepthMask(); }; const auto stencilClearAllowed = [&]() -> Bool { - const Uint32 stencilWriteMask = MG_State::pGLContext->GetStencilState(StencilFace::Front).WriteMask; + const Uint32 stencilWriteMask = MGB_CTX->GetStencilState(StencilFace::Front).WriteMask; if ((stencilWriteMask & 0xFFu) == 0xFFu) { return true; } @@ -7523,7 +7524,7 @@ void main() { switch (buffer) { case GL_COLOR: { - const BoolVec4 colorMask = MG_State::pGLContext->GetColorMaskIndexed(static_cast(drawbuffer)); + const BoolVec4 colorMask = MGB_CTX->GetColorMaskIndexed(static_cast(drawbuffer)); if (!colorMask.r() && !colorMask.g() && !colorMask.b() && !colorMask.a()) { return; } @@ -7580,7 +7581,7 @@ void main() { if (!attachment.IsComplete()) { return; } - const BoolVec4 colorMask = MG_State::pGLContext->GetColorMaskIndexed(static_cast(drawbuffer)); + const BoolVec4 colorMask = MGB_CTX->GetColorMaskIndexed(static_cast(drawbuffer)); if (!colorMask.r() && !colorMask.g() && !colorMask.b() && !colorMask.a()) { return; } @@ -7598,7 +7599,7 @@ void main() { MakeVkClearColorValue(clearPayload, ColorFormatLacksAlpha(colorTexture)); } else { VkImageAspectFlags aspects = 0; - if ((clearPayload.mask & GL_DEPTH_BUFFER_BIT) != 0 && MG_State::pGLContext->GetDepthMask() && + if ((clearPayload.mask & GL_DEPTH_BUFFER_BIT) != 0 && MGB_CTX->GetDepthMask() && framebuffer.GetAttachment(FramebufferAttachmentType::Depth).IsComplete()) { aspects |= VK_IMAGE_ASPECT_DEPTH_BIT; } @@ -7606,7 +7607,7 @@ void main() { framebuffer.GetAttachment(FramebufferAttachmentType::Stencil).IsComplete()) { // GL 3.3 §4.2.3: the clear is masked by the front stencil write mask (see Clear). const Uint32 stencilWriteMask = - MG_State::pGLContext->GetStencilState(StencilFace::Front).WriteMask; + MGB_CTX->GetStencilState(StencilFace::Front).WriteMask; if ((stencilWriteMask & 0xFFu) == 0xFFu) { aspects |= VK_IMAGE_ASPECT_STENCIL_BIT; } else if (stencilWriteMask != 0) { @@ -7625,7 +7626,7 @@ void main() { void VulkanRenderer::QueueClearBufferPayload(GLenum buffer, GLint drawbuffer, const ClearAttachmentPayload& clearPayload) { - auto* fbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject().get(); + auto* fbo = MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject().get(); if (!fbo) { return; } @@ -8583,8 +8584,8 @@ void main() { void VulkanRenderer::BlitFramebuffer(GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1, GLint dstX0, GLint dstY0, GLint dstX1, GLint dstY1, GLbitfield mask, GLenum filter) { - auto readFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); - auto drawFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); + auto readFbo = MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); + auto drawFbo = MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); BlitNamedFramebuffer(readFbo, drawFbo, srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask, filter); } @@ -8616,8 +8617,8 @@ void main() { // The scissor test clips blit writes: intersect the destination rectangle with // the scissor box and shrink the source proportionally. - if (MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::ScissorTest)) { - const IntVec4& scissor = MG_State::pGLContext->GetScissorBox(); + if (MGB_CTX->IsCapabilityEnabled(CapabilityInput::ScissorTest)) { + const IntVec4& scissor = MGB_CTX->GetScissorBox(); const auto clipAxis = [](GLint& d0, GLint& d1, GLint& s0, GLint& s1, GLint clipLo, GLint clipHi) -> Bool { const Bool dstFlipped = d1 < d0; GLint lo = dstFlipped ? d1 : d0; @@ -9211,7 +9212,7 @@ void main() { return; } - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(MG_State::pGLContext->GetActiveTextureUnit()); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(MGB_CTX->GetActiveTextureUnit()); auto destinationTexture = textureUnit.GetBindingSlot(textureTarget).GetBoundObject(); if (destinationTexture == nullptr) { RecordTextureCopyError(__func__, ErrorCode::InvalidOperation, @@ -9219,7 +9220,7 @@ void main() { return; } - auto readFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); + auto readFbo = MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); if (readFbo == nullptr) { RecordTextureCopyError(__func__, ErrorCode::InvalidOperation, "CopyTexSubImage2D requires a framebuffer bound to GL_READ_FRAMEBUFFER."); @@ -9930,7 +9931,7 @@ void main() { return; } - auto readFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); + auto readFbo = MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); if (readFbo == nullptr) { MGLOG_E_ONCE("DirectVulkan::ReadPixels skipped: no read framebuffer is bound"); return; @@ -10685,8 +10686,8 @@ void main() { // Store honoring the client pack state (single slice). const auto& pixelPackBufferObject = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); - const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false); + MGB_CTX->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); + const auto packParams = MGB_CTX->GetPixelStoreParameters(false); const SizeT rowPixels = static_cast(packParams.RowLength > 0 ? packParams.RowLength : width); const SizeT packAlignment = packParams.Alignment > 0 ? static_cast(packParams.Alignment) : 1; const SizeT dstRowStride = ((rowPixels * dstPixelBytes) + packAlignment - 1) / packAlignment * packAlignment; @@ -10716,7 +10717,7 @@ void main() { void VulkanRenderer::GetTexImage(GLenum target, GLint level, GLenum format, GLenum type, GLvoid* pixels) { const auto textureUploadTarget = MG_Util::ConvertGLEnumToTextureUploadTarget(target); const auto textureTarget = MG_Util::ConvertGLEnumToTextureTarget(target); - auto& activeUnit = MG_State::pGLContext->GetTextureUnitObject(MG_State::pGLContext->GetActiveTextureUnit()); + auto& activeUnit = MGB_CTX->GetTextureUnitObject(MGB_CTX->GetActiveTextureUnit()); auto textureObject = activeUnit.GetBindingSlot(textureTarget).GetBoundObject(); GetTextureImage(textureObject, textureUploadTarget, level, format, type, -1, pixels); } @@ -10960,7 +10961,7 @@ void main() { return; } - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(MG_State::pGLContext->GetActiveTextureUnit()); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(MGB_CTX->GetActiveTextureUnit()); auto texture = textureUnit.GetBindingSlot(textureTarget).GetBoundObject(); MOBILEGL_ASSERT(texture != nullptr, "GenerateMipmap requires a bound texture."); MOBILEGL_ASSERT(texture->IsComplete(), "GenerateMipmap requires a complete texture."); @@ -11216,7 +11217,7 @@ void main() { // resets its counter state, because those bytes describe the previous owner's span. Uint32 VulkanRenderer::CurrentXfbCounterSlot() { constexpr Uint32 kNoSlot = static_cast(kXfbCounterObjectSlots); - const Uint64 identity = MG_State::pGLContext->GetBoundTransformFeedbackLifetimeId(); + const Uint64 identity = MGB_CTX->GetBoundTransformFeedbackLifetimeId(); MOBILEGL_ASSERT(identity != 0, "transform feedback object reported the free-slot sentinel (0) as its identity - " "every slot would then read as 'mine' without ever being claimed"); @@ -11233,7 +11234,7 @@ void main() { Uint32 slot = freeSlot; if (slot == kNoSlot) { for (Uint32 candidate = 0; candidate < kNoSlot; ++candidate) { - if (MG_State::pGLContext->HasOpenTransformFeedbackSpan(m_xfbCounterSlotOwner[candidate])) { + if (MGB_CTX->HasOpenTransformFeedbackSpan(m_xfbCounterSlotOwner[candidate])) { continue; } if (slot == kNoSlot || m_xfbCounterSlotLastUse[candidate] < m_xfbCounterSlotLastUse[slot]) { @@ -11264,17 +11265,17 @@ void main() { } Bool VulkanRenderer::BeginXfbCaptureForDraw(FrameContext::FrameData& frame) { - if (!m_transformFeedbackFeatureEnabled || MG_State::pGLContext == nullptr || - !MG_State::pGLContext->IsTransformFeedbackActive()) { + if (!m_transformFeedbackFeatureEnabled || !MGB_CTX_LIVE || + !MGB_CTX->IsTransformFeedbackActive()) { return false; } // A paused span captures nothing, and the counter buffers keep their values, so the // next resumed draw appends exactly where the last captured one stopped - which is // what pause/resume means (ARB_transform_feedback2). - if (MG_State::pGLContext->IsTransformFeedbackPaused()) { + if (MGB_CTX->IsTransformFeedbackPaused()) { return false; } - const auto& program = MG_State::pGLContext->GetTransformFeedbackProgram(); + const auto& program = MGB_CTX->GetTransformFeedbackProgram(); if (!program || program->GetTransformFeedbackVaryingCount() == 0) { return false; } @@ -11313,7 +11314,7 @@ void main() { VkDeviceSize offsets[4] = {}; VkDeviceSize sizes[4] = {}; for (SizeT i = 0; i < bufferCount; ++i) { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::TransformFeedback, + auto& point = MGB_CTX->GetBufferBindingPoint(BufferTarget::TransformFeedback, static_cast(i)); const auto& bufferObject = point.GetBoundObject(); if (bufferObject == nullptr) { @@ -11344,7 +11345,7 @@ void main() { offsets, sizes); const Uint32 counterSlot = CurrentXfbCounterSlot(); - const Uint64 generation = MG_State::pGLContext->GetTransformFeedbackGeneration(); + const Uint64 generation = MGB_CTX->GetTransformFeedbackGeneration(); const Bool resume = m_xfbCountersValid[counterSlot] && m_xfbLastSeenGeneration[counterSlot] == generation; m_xfbLastSeenGeneration[counterSlot] = generation; @@ -11367,7 +11368,7 @@ void main() { if (!began) { return; } - const auto& program = MG_State::pGLContext->GetTransformFeedbackProgram(); + const auto& program = MGB_CTX->GetTransformFeedbackProgram(); const SizeT bufferCount = program ? std::min(program->GetTransformFeedbackBufferCount(), 4) : 0; const Uint32 counterSlot = CurrentXfbCounterSlot(); VkBuffer counterBuffers[4] = {}; @@ -12015,7 +12016,7 @@ void main() { default: break; } if (mergeGranularity != 0) { - const RenderStateParameters& rsp = MG_State::pGLContext->GetRenderStateParameters(); + const RenderStateParameters& rsp = MGB_CTX->GetRenderStateParameters(); if (rsp.PrimitiveRestartEnabled || rsp.PrimitiveRestartFixedIndexEnabled) { mergeGranularity = 0; } @@ -12093,7 +12094,7 @@ void main() { return; } - const auto& vao = *MG_State::pGLContext->GetBoundVertexArray(); + const auto& vao = *MGB_CTX->GetBoundVertexArray(); const auto* indexBuffer = vao.GetIndexBufferBindingSlot().GetBoundObject().get(); if (!indexBuffer) { MGLOG_E_ONCE("MultiDrawElementsIndirectCount skipped: no element array buffer is bound"); @@ -12103,13 +12104,13 @@ void main() { const SizeT commandOffset = reinterpret_cast(indirect); const SizeT commandBytes = commandOffset + static_cast(stride) * static_cast(maxdrawcount - 1) + kGLDrawElementsIndirectCommandBytes; - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (!drawBuffer || commandBytes > drawBuffer->GetSize()) { MGLOG_E_ONCE("MultiDrawElementsIndirectCount skipped: invalid GL_DRAW_INDIRECT_BUFFER binding or range"); return; } - auto parameterBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject(); + auto parameterBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject(); if (!parameterBuffer || static_cast(drawcount) + sizeof(Uint32) > parameterBuffer->GetSize()) { MGLOG_E_ONCE("MultiDrawElementsIndirectCount skipped: invalid GL_PARAMETER_BUFFER binding or range"); return; @@ -12194,7 +12195,7 @@ void main() { return; } - const auto& vao = *MG_State::pGLContext->GetBoundVertexArray(); + const auto& vao = *MGB_CTX->GetBoundVertexArray(); const auto* indexBuffer = vao.GetIndexBufferBindingSlot().GetBoundObject().get(); if (!indexBuffer) { MGLOG_E_ONCE("MultiDrawElementsIndirect skipped: no element array buffer is bound"); @@ -12204,7 +12205,7 @@ void main() { const SizeT commandOffset = reinterpret_cast(indirect); const SizeT commandBytes = commandOffset + static_cast(stride) * static_cast(drawcount - 1) + kGLDrawElementsIndirectCommandBytes; - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (!drawBuffer || commandBytes > drawBuffer->GetSize()) { MGLOG_E_ONCE("MultiDrawElementsIndirect skipped: invalid GL_DRAW_INDIRECT_BUFFER binding or range"); return; @@ -12274,7 +12275,7 @@ void main() { const SizeT commandOffset = reinterpret_cast(indirect); const SizeT commandBytes = commandOffset + static_cast(stride) * static_cast(drawcount - 1) + kGLDrawArraysIndirectCommandBytes; - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (!drawBuffer || commandBytes > drawBuffer->GetSize()) { MGLOG_E_ONCE("MultiDrawArraysIndirect skipped: invalid GL_DRAW_INDIRECT_BUFFER binding or range"); return; @@ -12763,8 +12764,8 @@ void main() { if (!m_provokingVertexModePerPipeline) { return VK_PROVOKING_VERTEX_MODE_LAST_VERTEX_EXT; } - return (MG_State::pGLContext != nullptr && - MG_State::pGLContext->GetProvokingVertexMode() == ProvokingVertexMode::FirstVertex) + return (MGB_CTX_LIVE && + MGB_CTX->GetProvokingVertexMode() == ProvokingVertexMode::FirstVertex) ? VK_PROVOKING_VERTEX_MODE_FIRST_VERTEX_EXT : VK_PROVOKING_VERTEX_MODE_LAST_VERTEX_EXT; } From bdf05514c348391c2ae84dff29a6cb4490678a59 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 01:57:05 -0400 Subject: [PATCH 059/529] [Test] (Pipe): the integration-verify lanes and their two always-on negative controls - ARCHITECTURE.md 13.2-(2) asks for a third CI mode, and a third mode whose only evidence is "ctest was green" proves nothing: MOBILEGL_PIPE_VERIFY=1 against a library that never compiled the comparator in is a silent no-op that looks exactly like a clean pass. Six registrations, all under if (MOBILEGL_PIPE_VERIFY) and all labelled integration-verify, make both halves falsifiable - a mis-configured build registers nothing and --no-tests=error reds the lane, and PipeVerifyArmingScenario.Armed fails a lane whose library never printed its arming line. - PipeVerifyArmingScenario.CorruptedFieldIsReported is negative control A (G4): its lane pins MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters with MOBILEGL_PIPE_VERIFY_FATAL=0 so the process survives its own divergence and can read the report back; the CI step that exports the same knob against the ambient lane, where FATAL keeps its default, asserts the other half - the abort. - PoisonOmissionScenario is negative control B (G5), in two cases that cannot share a process because the knob is process-wide: the omitted (verb, field) pair must abort the glGenerateMipmap and NOT the draw before it, and the same sequence with the knob unset must complete with no Fatal at all. - The sequence runs in a fork()+execve() of this same binary rather than a bare fork(): the fixture has already brought a context up, and a bare fork of a process holding a live Vulkan device inherits the driver's mutexes with no threads to release them - measured here as a 120s wedge on DirectVulkan against a clean pass on DirectGLES. The child gets its own MOBILEGL_LOG_FILE_PATH because the library opens its log with fopen(path, "w") and would otherwise truncate the file the parent is about to read. - No ambient Verify. entry names MOBILEGL_PIPE_VERIFY_CORRUPT or MOBILEGL_PIPE_POISON_OMIT in its ENVIRONMENT property, because a property entry overrides the job environment for the names it lists: the two CI negative-control steps export those knobs into the job environment and must reach the processes. Every list appends MGL_ITEST_COMMON_ENV / MGL_ITEST_VULKAN_ENV for the same reason, so the vendor and ICD pinning survives. --- MobileGL/MG_IntegrationTest/CMakeLists.txt | 134 ++++++ .../Scenarios/PipeVerifyArmingScenario.cpp | 243 +++++++++++ .../Scenarios/PoisonOmissionScenario.cpp | 385 ++++++++++++++++++ 3 files changed, 762 insertions(+) create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/PipeVerifyArmingScenario.cpp create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 83e93597f..c4bc3d4a5 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -127,6 +127,8 @@ add_executable(MobileGLIntegrationTest Scenarios/ClearTexImageUndefinedLevelZeroScenario.cpp Scenarios/RenderbufferBlendFormatScenario.cpp Scenarios/DualSourceBlendScenario.cpp + Scenarios/PipeVerifyArmingScenario.cpp + Scenarios/PoisonOmissionScenario.cpp ) target_include_directories(MobileGLIntegrationTest PRIVATE @@ -631,3 +633,135 @@ gtest_discover_tests(MobileGLIntegrationTest TIMEOUT ${MGL_ITEST_TIMEOUT} ENVIRONMENT "${MGL_ITEST_VULKAN_POINT_SIZE_DEMOTION_ENVIRONMENT}" ) + +# --- the third CI mode: MOBILEGL_PIPE_VERIFY ----------------------------------- +# +# ARCHITECTURE.md 13.2-(2) asks for a THIRD build mode next to pull and push: two state models in +# one address space, compared field by field at every verb boundary and again at every accessor +# read, 5-10x slower and never shipped. These entries are that mode's lane. They exist only when +# the library was configured with -DMOBILEGL_PIPE_VERIFY=ON, which is deliberate and is half of +# what makes the lane falsifiable: `ctest -L integration-verify --no-tests=error` in a build that +# forgot the option matches NO tests and fails, instead of reporting a green run of nothing. +# +# The other half is PipeVerifyArmingScenario.Armed, which asserts the library's own arming line - +# because MOBILEGL_PIPE_VERIFY=1 in the environment of a library that never compiled the +# comparator in is a silent no-op that looks exactly like a clean pass. +# +# Three things about the ENVIRONMENT properties below, each of which has already gone wrong once +# in this file: +# * every list APPENDS ${MGL_ITEST_COMMON_ENV} / ${MGL_ITEST_VULKAN_ENV}. A ctest ENVIRONMENT +# entry overrides the job environment for the names it lists, so an entry that named only its +# own knobs would lose the EGL vendor and Vulkan ICD pinning and run against whichever driver +# the loader found first. +# * the ambient Verify. entries name NEITHER MOBILEGL_PIPE_VERIFY_CORRUPT NOR +# MOBILEGL_PIPE_POISON_OMIT. That is what lets CI's two always-on negative-control steps +# export those knobs in the JOB environment and have them reach the test processes; a +# property entry of the same name would silently win and the controls would prove nothing. +# * MOBILEGL_LOG_FILE_PATH is per lane. It is the only channel a test process has for reading +# the library's own report (MG_Config is not reachable from this module), and the log is +# opened with fopen(path, "w"), so each process truncates it and the cases can trust it. +if (MOBILEGL_PIPE_VERIFY) + # 900s, not the ambient 120: the comparator re-reads every field of the fill mask at the verb + # boundary and again at every accessor read, which the design budgets at 5-10x. + set(MGL_ITEST_VERIFY_TIMEOUT 900) + + mgl_itest_join_environment(MGL_ITEST_GLES_VERIFY_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-verify-DirectGLES.log" + ${MGL_ITEST_COMMON_ENV}) + mgl_itest_join_environment(MGL_ITEST_VULKAN_VERIFY_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-verify-DirectVulkan.log" + ${MGL_ITEST_VULKAN_ENV}) + + # Negative control A (G4). MOBILEGL_PIPE_VERIFY_FATAL=0 so the process SURVIVES its own + # divergence and the case can read the report back out of the log; the CI step that exports + # the same corruption against the ambient lane, where FATAL keeps its default of 1, asserts + # the other half - that a divergence aborts and reds the entry. + mgl_itest_join_environment(MGL_ITEST_GLES_VERIFY_CORRUPT_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters" "MOBILEGL_PIPE_VERIFY_FATAL=0" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-verify-corrupt-DirectGLES.log" + ${MGL_ITEST_COMMON_ENV}) + mgl_itest_join_environment(MGL_ITEST_VULKAN_VERIFY_CORRUPT_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters" "MOBILEGL_PIPE_VERIFY_FATAL=0" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-verify-corrupt-DirectVulkan.log" + ${MGL_ITEST_VULKAN_ENV}) + + # Negative control B (G5). The omission skips the STAMP of one field for one verb while still + # copying its value, which is indistinguishable from a fill row nobody wrote; the scenario + # forks, so the resulting std::abort() is a datum in waitpid() rather than a dead lane. + mgl_itest_join_environment(MGL_ITEST_GLES_POISON_OMIT_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_PIPE_POISON_OMIT=GenerateMipmap:GetActiveTextureUnit" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-poison-omit-DirectGLES.log" + ${MGL_ITEST_COMMON_ENV}) + mgl_itest_join_environment(MGL_ITEST_VULKAN_POISON_OMIT_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_PIPE_POISON_OMIT=GenerateMipmap:GetActiveTextureUnit" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-poison-omit-DirectVulkan.log" + ${MGL_ITEST_VULKAN_ENV}) + + # The whole suite again, per backend, with the comparator armed. Same scenarios, same + # assertions, but every backend read of frontend state is now checked against a snapshot taken + # from the live context at the verb boundary - which is what "the 742 integration entries + # prove push equals pull" means. Labelled integration-gpu as well so a verify build's + # `ctest -L integration-gpu` still describes the whole registration set. + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Verify." + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_VERIFY_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.Verify." + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_VERIFY_ENVIRONMENT}" + ) + + # One case each: the knobs are process-wide, so a corrupted or poisoned process cannot also be + # running the ambient assertions. These four entries are the ones that assert the RED - they + # pass when the comparator and the poison report, and go red when either stops. + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.VerifyCorrupted." + TEST_FILTER "PipeVerifyArmingScenario.CorruptedFieldIsReported" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_VERIFY_CORRUPT_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.VerifyCorrupted." + TEST_FILTER "PipeVerifyArmingScenario.CorruptedFieldIsReported" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_VERIFY_CORRUPT_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.PoisonOmitted." + TEST_FILTER "PoisonOmissionScenario.OmittedFieldAbortsOnThatVerb" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_POISON_OMIT_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.PoisonOmitted." + TEST_FILTER "PoisonOmissionScenario.OmittedFieldAbortsOnThatVerb" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_POISON_OMIT_ENVIRONMENT}" + ) +endif() diff --git a/MobileGL/MG_IntegrationTest/Scenarios/PipeVerifyArmingScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/PipeVerifyArmingScenario.cpp new file mode 100644 index 000000000..5ce951384 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/PipeVerifyArmingScenario.cpp @@ -0,0 +1,243 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PipeVerifyArmingScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - THE MOBILEGL_PIPE_VERIFY COMPARATOR IS ARMED, AND SAYS SO, AND CAN GO RED. +// +// The third CI mode (ARCHITECTURE.md 13.2-(2)) runs the whole integration suite with two state +// models in one address space: the PipeInputs block the frontend fills at every verb boundary, +// and a SnapshotFromGLContext() taken from the live GLContext. A green run of that mode is only +// worth something if the comparator was actually RUNNING - and "MOBILEGL_PIPE_VERIFY=1 against a +// library that was not built with -DMOBILEGL_PIPE_VERIFY=ON" is a no-op that looks exactly like a +// clean pass. That is the failure mode this scenario exists to make impossible: +// +// Armed - the environment says the comparator is on for this process, so the +// library must SAY it armed. It asserts a library observable against +// the environment, the same shape UnlocatedIoBlockScenario's arming +// case and AsyncCompileScenario::ExtensionStringMatchesTheConfiguration +// use. A lane whose library never armed FAILS here; it never passes. +// CorruptedFieldIsReported - the negative control for the comparator itself (gate G4). With +// MOBILEGL_PIPE_VERIFY_CORRUPT naming a field, the snapshot arm is +// perturbed before the entry compare, so a comparator that works must +// report Fatal{PipeVerifyDiffer, "@"}. A comparator that +// compares nothing stays quiet and this case goes red. +// +// The observable is the library's own log, because MG_Config is not reachable from this module +// (on Android it links the SHIPPING libMobileGL.so, built -fvisibility=hidden) and the arming +// signal is a latched MGLOG_I. The ctest entry sets MOBILEGL_LOG_FILE_PATH; this only reads it. +// +// Note on scope: the log file is opened with fopen(path, "w") at the first log write of a process +// (MG_Util/Debug/Log.cpp, InitFile), so the file holds THIS process's lines and nothing else - a +// whole-file search cannot be satisfied by a sibling ctest entry of the same lane. The arming line +// is latched at the FIRST fill of the process, which may be the harness bring-up rather than this +// test's draw, so the arming search is whole-file on purpose; the divergence search is restricted +// to the bytes this case appended, which is where a differ belongs. + +#include +#include +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // The three strings the comparator contracts to print (the brief's D8 reporting shape). + // They are spelled here once so a rename of either half is one compile-visible edit. + constexpr const char* kArmedLine = "MGPipe: verify armed"; + constexpr const char* kDifferPrefix = "Fatal{PipeVerifyDiffer"; + constexpr const char* kUnmigratedPrefix = "Fatal{UnmigratedPipeInput"; + + constexpr const char* kVS = R"(#version 330 core +in vec2 aPos; +void main() { gl_Position = vec4(aPos, 0.0, 1.0); } +)"; + + constexpr const char* kFS = R"(#version 330 core +out vec4 o_color; +void main() { o_color = vec4(0.25, 0.5, 0.75, 1.0); } +)"; + + // Reads the environment the way MG_ConfigLoader does (ScenarioFixture.h documents the + // rule); a string knob is "set" when it is present and non-empty, which is exactly what + // MG_ConfigLoader's QueryEnvVariable turns into a non-empty Features member. + bool StringKnobIsSet(const char* name) { + const char* value = std::getenv(name); + return value != nullptr && *value != '\0'; + } + + class PipeVerifyArmingScenario : public ScenarioTest { + protected: + // The library log this process is writing, or an empty path when none was configured. + static std::filesystem::path LibraryLogPath() { + const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH"); + return (path != nullptr && *path != '\0') ? std::filesystem::path(path) + : std::filesystem::path(); + } + + static std::uintmax_t LibraryLogSize() { + std::error_code ec; + const std::filesystem::path path = LibraryLogPath(); + if (path.empty()) return 0; + const std::uintmax_t size = std::filesystem::file_size(path, ec); + return ec ? 0 : size; + } + + static std::string LibraryLogSince(std::uintmax_t offset) { + const std::filesystem::path path = LibraryLogPath(); + if (path.empty()) return {}; + std::ifstream file(path, std::ios::binary); + if (!file.good()) return {}; + file.seekg(static_cast(offset)); + return std::string((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + } + + static std::string LibraryLog() { return LibraryLogSince(0); } + + // One frame that crosses several verb boundaries: a clear (kClear), a draw (kDraw) and + // a readback (kReadback). Three of the nine fill classes, so an entry compare that only + // ran for one of them still has something to say. + void DrawOneFrame() { + HeadlessGL& gl = Gl(); + std::string error; + const unsigned int program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(program, 0u) << error; + + static const float kQuad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f}; + GLuint vao = 0; + GLuint vbo = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glGenBuffers(1, &vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(kQuad), kQuad, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr); + + BindDefaultFramebuffer(); + glViewport(0, 0, gl.Width(), gl.Height()); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(program); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + + Rgba8 pixel{}; + glReadPixels(gl.Width() / 2, gl.Height() / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, &pixel); + + glBindVertexArray(0); + glDeleteBuffers(1, &vbo); + glDeleteVertexArrays(1, &vao); + m_centre = pixel; + } + + Rgba8 m_centre{}; + }; + + // THE CASE THAT FAILS A LANE WHOSE LIBRARY NEVER ARMED. + // + // Every other entry in the integration-verify lane renders the same frames it renders in the + // ambient lane and would be just as green against a library with no comparator compiled in - + // which is precisely how a verify lane goes green having verified nothing. This case is the + // one that cannot: the environment pins MOBILEGL_PIPE_VERIFY=1, therefore the library must + // have said "MGPipe: verify armed" in its own log, and if it did not, the mode is not running. + TEST_F(PipeVerifyArmingScenario, Armed) { + if (!Ready()) return; + + if (AmbientQuirkFromEnvironment("MOBILEGL_PIPE_VERIFY") != AmbientQuirk::On) { + GTEST_SKIP() << "this case needs MOBILEGL_PIPE_VERIFY=1 for the whole process, which is " + "what the Verify. ctest entries set; with the variable unset the " + "comparator is dormant even in a build that compiled it in"; + } + if (LibraryLogPath().empty()) { + GTEST_SKIP() << "MOBILEGL_PIPE_VERIFY is pinned on but MOBILEGL_LOG_FILE_PATH is not " + "set, so the library has nowhere to record that it armed; the Verify. " + "ctest entries set both"; + } + if (StringKnobIsSet("MOBILEGL_PIPE_VERIFY_CORRUPT")) { + GTEST_SKIP() << "MOBILEGL_PIPE_VERIFY_CORRUPT is armed in this process, so a divergence " + "is the EXPECTED outcome and asserting on its absence here would be " + "backwards; the VerifyCorrupted. lane owns that half"; + } + + const std::uintmax_t before = LibraryLogSize(); + ASSERT_NO_FATAL_FAILURE(DrawOneFrame()); + EXPECT_EQ(FirstGLError(), 0u); + + // Whole file, not just the appended bytes: the arming line is latched at the FIRST fill + // of the process, which may already have happened during the harness bring-up. The file + // is truncated at this process's first log write, so it still carries nothing else. + const std::string whole = LibraryLog(); + EXPECT_NE(whole.find(kArmedLine), std::string::npos) + << "MOBILEGL_PIPE_VERIFY=1 is set for this process and a frame was cleared, drawn and " + "read back, and the library never reported arming the comparator. Either this " + "library was not built with -DMOBILEGL_PIPE_VERIFY=ON (in which case the whole lane " + "is verifying nothing), or the arming MGLOG_I is gone. Log:\n" + << whole; + + const std::string appended = LibraryLogSince(before); + EXPECT_EQ(appended.find(kDifferPrefix), std::string::npos) + << "the comparator reported a push/pull divergence on an ordinary frame:\n" + << appended; + EXPECT_EQ(appended.find(kUnmigratedPrefix), std::string::npos) + << "a backend read a field the verb's fill table does not list (add the row to " + "MG_Pipe/FillPoints.def, never mark the field sticky):\n" + << appended; + } + + // NEGATIVE CONTROL A (gate G4): a deliberately corrupted snapshot field must turn a green + // verify run red, naming that field and the verb it diverged on. + // + // It runs in its own lane (VerifyCorrupted.) because the knob is process-wide, and with + // MOBILEGL_PIPE_VERIFY_FATAL=0 so the process survives its own divergence and this case can + // read the report back out of the log. The CI step that runs the SAME knob against the + // ambient lane - where FATAL keeps its default - asserts the other half: there, the + // divergence must abort and ctest must go red. + TEST_F(PipeVerifyArmingScenario, CorruptedFieldIsReported) { + if (!Ready()) return; + + if (!StringKnobIsSet("MOBILEGL_PIPE_VERIFY_CORRUPT")) { + GTEST_SKIP() << "this case is the comparator's negative control and needs " + "MOBILEGL_PIPE_VERIFY_CORRUPT= for the whole process, which " + "is what the VerifyCorrupted. ctest entries set"; + } + if (AmbientQuirkFromEnvironment("MOBILEGL_PIPE_VERIFY") != AmbientQuirk::On) { + GTEST_SKIP() << "MOBILEGL_PIPE_VERIFY_CORRUPT is set but MOBILEGL_PIPE_VERIFY is not, so " + "the comparator is dormant and there is nothing to corrupt"; + } + if (LibraryLogPath().empty()) { + GTEST_SKIP() << "MOBILEGL_LOG_FILE_PATH is not set, so the library has nowhere to report " + "the divergence; the VerifyCorrupted. ctest entries set both"; + } + + const std::string knob = std::getenv("MOBILEGL_PIPE_VERIFY_CORRUPT"); + const std::uintmax_t before = LibraryLogSize(); + ASSERT_NO_FATAL_FAILURE(DrawOneFrame()); + + const std::string appended = LibraryLogSince(before); + const std::string expected = std::string(kDifferPrefix) + ", \"" + knob + "@"; + EXPECT_NE(appended.find(expected), std::string::npos) + << "MOBILEGL_PIPE_VERIFY_CORRUPT=" << knob + << " perturbs that field in the snapshot arm before every entry compare, so a working " + "comparator must have reported " << expected << "...\". It reported nothing, which " + "means the comparator is not comparing - and every green entry in this lane is " + "green for no reason. Log appended by this case:\n" + << appended; + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp new file mode 100644 index 000000000..2275ada62 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp @@ -0,0 +1,385 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - NEGATIVE CONTROL B (gate G5): AN OMITTED FILL POINT ABORTS ON THAT VERB, AND ONLY THERE. +// +// The per-verb poison is the half of P1 that makes a forgotten fill row loud instead of silent: the +// filler stamps a generation on every field it copies for a verb, and an accessor whose stamp is not +// this verb's aborts with Fatal{UnmigratedPipeInput, "@"}. A mechanism that can only be +// observed when someone forgets a row is a mechanism nobody can trust, so MOBILEGL_PIPE_POISON_OMIT +// forges the mistake on purpose: it names one (verb, field) pair whose STAMP the filler skips while +// still copying the value, which is indistinguishable from a row that was never written. +// +// The scenario asserts both halves of "on THAT verb, and only there": +// +// OmittedFieldAbortsOnThatVerb - with MOBILEGL_PIPE_POISON_OMIT=GenerateMipmap:GetActiveTextureUnit, +// a draw must still complete (GetActiveTextureUnit is not in kDraw's +// mask, and the draw's own fields are stamped normally) and the +// following glGenerateMipmap must abort naming exactly that pair. +// WithoutOmissionCompletes - the identical sequence with the knob unset runs to completion with +// no Fatal at all. Without this half, "it aborted" would say nothing +// about WHY: a poison that fired on every verb would look just as red. +// +// The knob is process-wide, so the two cases cannot share a lane: the first runs in the PoisonOmitted. +// entries, the second in the ambient Verify. entries (it skips when the knob IS set). +// +// WHY THE SEQUENCE RUNS IN A SEPARATE PROCESS, AND WHY THAT PROCESS IS fork()+execve() AND NOT fork() +// ALONE. The poison reports with MGLOG_F and then std::abort(), in the middle of a GL command - so the +// sequence cannot run in the test process, and the harness's own bring-up pre-flight +// (Harness/HeadlessGL.cpp) already establishes the shape: run it where a SIGABRT is a datum in +// waitpid() instead of a dead lane. But that pre-flight forks BEFORE any context exists, and this case +// cannot: the fixture has already brought one up. A bare fork() of a process holding a live Vulkan +// device inherits the driver's mutexes with no threads to release them, and the child wedges on its +// first submit - measured here as a 120s timeout on DirectVulkan and a clean pass on DirectGLES, which +// is exactly the kind of backend-shaped flake a control must not have. So the child immediately +// execve()s a fresh copy of this same test binary, filtered to the worker case below, which brings up +// its own context from scratch and knows nothing about the parent's. +// +// The child gets its OWN MOBILEGL_LOG_FILE_PATH for the same reason: the library opens its log with +// fopen(path, "w"), so a child sharing the parent's path would truncate the file the parent is about +// to read. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#if !defined(_WIN32) && !defined(__APPLE__) && !defined(__ANDROID__) && __has_include() +#define MGITEST_POISON_HAVE_FORK 1 +#include +#include +#include +#include +#include +extern char** environ; +#else +#define MGITEST_POISON_HAVE_FORK 0 +#endif + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // What the PoisonOmitted. ctest entry and the CI negative-control step name. The pair is + // spelled here so the assertion below is about the exact string the poison contracts to + // print (ARCHITECTURE.md 9.2: Fatal{UnmigratedPipeInput, "@"}). + constexpr const char* kOmittedVerb = "GenerateMipmap"; + constexpr const char* kOmittedField = "GetActiveTextureUnit"; + constexpr const char* kFatalPrefix = "Fatal{UnmigratedPipeInput"; + + // Set only in the re-executed child, so the worker case below runs in that process and skips + // everywhere else (including in the ambient lanes, where it is registered like any other case). + constexpr const char* kChildMarker = "MGITEST_POISON_OMISSION_CHILD"; + constexpr const char* kWorkerFilter = + "--gtest_filter=PoisonOmissionScenario.TheSequenceThePoisonControlsRun"; + + constexpr const char* kVS = R"(#version 330 core +in vec2 aPos; +void main() { gl_Position = vec4(aPos, 0.0, 1.0); } +)"; + + constexpr const char* kFS = R"(#version 330 core +out vec4 o_color; +void main() { o_color = vec4(0.25, 0.5, 0.75, 1.0); } +)"; + + bool StringKnobIsSet(const char* name) { + const char* value = std::getenv(name); + return value != nullptr && *value != '\0'; + } + + std::filesystem::path LibraryLogPath() { + const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH"); + return (path != nullptr && *path != '\0') ? std::filesystem::path(path) + : std::filesystem::path(); + } + + // Where the child is told to write ITS log. Empty when the lane configured no log path at + // all, in which case the signal is the only evidence and the text assertions are skipped. + std::string ChildLogPath() { + const std::filesystem::path parent = LibraryLogPath(); + if (parent.empty()) return {}; + return (parent.string() + ".poison-child"); + } + + std::string ReadWholeFile(const std::string& path) { + if (path.empty()) return {}; + std::ifstream file(path, std::ios::binary); + if (!file.good()) return {}; + return std::string((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + } + + class PoisonOmissionScenario : public ScenarioTest { + protected: + // The sequence under test. Deliberately in this order: the DRAW comes first and must + // survive - if the poison fired there, the "only that verb" half would be false and the + // SIGABRT the parent waits for would prove nothing. + void RunSequence() { + HeadlessGL& gl = Gl(); + + std::string error; + const unsigned int program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(program, 0u) << error; + + // A two-level texture, so glGenerateMipmap has real work to do and cannot be + // short-circuited into a no-op by a backend that inspects the level count first. + GLuint texture = 0; + glGenTextures(1, &texture); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + unsigned char pixels[8 * 8 * 4]; + for (std::size_t i = 0; i < sizeof(pixels); ++i) { + pixels[i] = static_cast(i); + } + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 8, 8, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 3); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + + static const float kQuad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f}; + GLuint vao = 0; + GLuint vbo = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glGenBuffers(1, &vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(kQuad), kQuad, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr); + + BindDefaultFramebuffer(); + glViewport(0, 0, gl.Width(), gl.Height()); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(program); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glFinish(); + std::fprintf(stderr, "[itest] poison worker: the draw completed\n"); + + // The verb the omission names. Under MOBILEGL_PIPE_POISON_OMIT this must abort. + glBindTexture(GL_TEXTURE_2D, texture); + glGenerateMipmap(GL_TEXTURE_2D); + glFinish(); + std::fprintf(stderr, "[itest] poison worker: glGenerateMipmap returned\n"); + + glBindVertexArray(0); + glDeleteBuffers(1, &vbo); + glDeleteVertexArrays(1, &vao); + glDeleteTextures(1, &texture); + } + +#if MGITEST_POISON_HAVE_FORK + // fork() + execve() of this same binary, filtered to the worker case, with the marker and + // the child's own log path added to the environment. Everything that allocates happens + // BEFORE the fork; between fork and execve only async-signal-safe work is done. + static bool RunSequenceInAChildProcess(int& outStatus, std::string& outReason) { + std::vector env; + for (char** entry = environ; entry != nullptr && *entry != nullptr; ++entry) { + const std::string text(*entry); + if (text.rfind("MOBILEGL_LOG_FILE_PATH=", 0) == 0) continue; + if (text.rfind(std::string(kChildMarker) + "=", 0) == 0) continue; + env.push_back(text); + } + env.push_back(std::string(kChildMarker) + "=1"); + const std::string childLog = ChildLogPath(); + if (!childLog.empty()) { + std::error_code ec; + std::filesystem::remove(childLog, ec); + env.push_back("MOBILEGL_LOG_FILE_PATH=" + childLog); + } + + std::vector envp; + envp.reserve(env.size() + 1); + for (std::string& entry : env) envp.push_back(entry.data()); + envp.push_back(nullptr); + + std::string exe = "/proc/self/exe"; + std::string arg0 = "MobileGLIntegrationTest"; + std::string filter = kWorkerFilter; + char* argv[] = {arg0.data(), filter.data(), nullptr}; + + std::fflush(nullptr); + const pid_t child = fork(); + if (child < 0) { + outReason = "fork() failed"; + return false; + } + if (child == 0) { + execve(exe.c_str(), argv, envp.data()); + // execve only returns on failure; _exit, never exit(), because every atexit + // handler in this address space belongs to the parent's copy of the world. + std::fprintf(stderr, "[itest] poison child: execve(/proc/self/exe) failed\n"); + _exit(127); + } + + constexpr int kTimeoutMs = 120000; + int waitedMs = 0; + for (;;) { + const pid_t reaped = waitpid(child, &outStatus, WNOHANG); + if (reaped == child) return true; + if (reaped < 0) { + outReason = "waitpid on the poison worker failed"; + return false; + } + if (waitedMs >= kTimeoutMs) { + kill(child, SIGKILL); + (void)waitpid(child, &outStatus, 0); + outReason = "the poison worker made no progress in 120s and was killed"; + return false; + } + timespec nap{0, 10 * 1000 * 1000}; + nanosleep(&nap, nullptr); + waitedMs += 10; + } + } + + static std::string DescribeStatus(int status) { + if (WIFEXITED(status)) return "exited with status " + std::to_string(WEXITSTATUS(status)); + if (WIFSIGNALED(status)) return "died on signal " + std::to_string(WTERMSIG(status)); + return "ended in an unrecognised way"; + } +#endif + }; + + // The worker. It is a normal registered case so that the re-executed child can be selected + // with nothing but --gtest_filter, and it skips in every process that is not that child. + TEST_F(PoisonOmissionScenario, TheSequenceThePoisonControlsRun) { + if (std::getenv(kChildMarker) == nullptr) { + GTEST_SKIP() << "this case is the body the two poison controls run in a child process; " + "it does nothing unless " << kChildMarker << " is set, which only the " + "re-exec below does"; + } + if (!Ready()) return; + + RunSequence(); + +#if MGITEST_POISON_HAVE_FORK + // _exit, and not a return into gtest's teardown: this process exists to reach the verb + // above and its exit status is the datum the parent reads. A normal teardown of a live + // context could add signals of its own to that answer. + std::fflush(nullptr); + _exit(0); +#endif + } + +#if MGITEST_POISON_HAVE_FORK + + TEST_F(PoisonOmissionScenario, OmittedFieldAbortsOnThatVerb) { + if (!Ready()) return; + + if (!StringKnobIsSet("MOBILEGL_PIPE_POISON_OMIT")) { + GTEST_SKIP() << "this case is the poison's negative control and needs " + "MOBILEGL_PIPE_POISON_OMIT=: for the whole process, which " + "is what the PoisonOmitted. ctest entries set"; + } + const std::string knob = std::getenv("MOBILEGL_PIPE_POISON_OMIT"); + const std::string expectedPair = std::string(kOmittedField) + "@" + kOmittedVerb; + if (knob != std::string(kOmittedVerb) + ":" + kOmittedField) { + GTEST_SKIP() << "MOBILEGL_PIPE_POISON_OMIT is " << knob << ", but this case only knows " + << "how to provoke " << kOmittedVerb << ":" << kOmittedField; + } + + int status = 0; + std::string reason; + ASSERT_TRUE(RunSequenceInAChildProcess(status, reason)) << reason; + + const std::string childLog = ReadWholeFile(ChildLogPath()); + ASSERT_TRUE(WIFSIGNALED(status)) + << "with the stamp of " << expectedPair << " omitted, the glGenerateMipmap in the child " + << "had to read a field its verb never filled and abort. It " << DescribeStatus(status) + << " instead - the poison is not armed (a build without MOBILEGL_PIPE_POISON, a filler " + "that stamps what it was told to skip, or a backend that no longer reads the field " + "through the accessor). Child log:\n" + << childLog; + EXPECT_EQ(WTERMSIG(status), SIGABRT) + << "the child died on signal " << WTERMSIG(status) << " rather than SIGABRT; the poison " + "reports through MGLOG_F + std::abort(), so any other signal is a different crash. " + "Child log:\n" + << childLog; + + if (ChildLogPath().empty()) { + GTEST_SKIP() << "the abort happened, but the lane set no MOBILEGL_LOG_FILE_PATH, so the " + "Fatal's text cannot be read back; the PoisonOmitted. ctest entries set it"; + } + EXPECT_NE(childLog.find(std::string(kFatalPrefix) + ", \"" + expectedPair + "\""), + std::string::npos) + << "the child aborted, but not with Fatal{UnmigratedPipeInput, \"" << expectedPair + << "\"} - that message is the whole diagnostic value of the poison. Child log:\n" + << childLog; + EXPECT_EQ(childLog.find("@DrawArrays"), std::string::npos) + << "the draw that ran BEFORE the omitted verb also tripped the poison, so the omission " + "is not scoped to its verb: the fill classes are wrong, or the stamps are global. " + "Child log:\n" + << childLog; + } + + // The sibling control, in the ambient Verify. lanes: the same sequence with the knob UNSET + // must run to completion and log no Fatal at all. + TEST_F(PoisonOmissionScenario, WithoutOmissionCompletes) { + if (!Ready()) return; + + if (AmbientQuirkFromEnvironment("MOBILEGL_PIPE_VERIFY") != AmbientQuirk::On) { + GTEST_SKIP() << "the poison is only compiled into the push/verify builds; in an ordinary " + "build there is nothing for this control to be a control OF"; + } + if (StringKnobIsSet("MOBILEGL_PIPE_POISON_OMIT")) { + GTEST_SKIP() << "MOBILEGL_PIPE_POISON_OMIT is armed for this process, so the abort is the " + "EXPECTED outcome here; OmittedFieldAbortsOnThatVerb owns that half and " + "runs in the PoisonOmitted. lane"; + } + + int status = 0; + std::string reason; + ASSERT_TRUE(RunSequenceInAChildProcess(status, reason)) << reason; + + const std::string childLog = ReadWholeFile(ChildLogPath()); + ASSERT_TRUE(WIFEXITED(status)) + << "with no omission armed, a draw followed by glGenerateMipmap must complete; the child " + << DescribeStatus(status) + << ". If it aborted, the poison is firing on a field the verb's fill table SHOULD list - " + "add the row to MG_Pipe/FillPoints.def, never mark the field sticky. Child log:\n" + << childLog; + EXPECT_EQ(WEXITSTATUS(status), 0) << "the child " << DescribeStatus(status) + << ". Child log:\n" + << childLog; + EXPECT_EQ(childLog.find("Fatal{"), std::string::npos) + << "an unpoisoned run logged a Fatal:\n" + << childLog; + } + +#else + + TEST_F(PoisonOmissionScenario, OmittedFieldAbortsOnThatVerb) { + GTEST_SKIP() << "the poison control needs fork()/execve()/waitpid() to observe a SIGABRT as " + "a datum; this platform has none of them"; + } + + TEST_F(PoisonOmissionScenario, WithoutOmissionCompletes) { + GTEST_SKIP() << "the poison control needs fork()/execve()/waitpid() to observe a SIGABRT as " + "a datum; this platform has none of them"; + } + +#endif + + } // namespace +} // namespace MGITest From 5f8e8db1b96d1663bfe67155efb5a0c0c370b6dc Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 01:59:39 -0400 Subject: [PATCH 060/529] [Test] (Retrace): make a retrace under MOBILEGL_PIPE_VERIFY prove it armed, and give the lane a label - A retrace that exports MOBILEGL_PIPE_VERIFY=1 at a library configured without -DMOBILEGL_PIPE_VERIFY=ON is a no-op: the frames still match their goldens and the case reports green having compared nothing. run_trace_case.cmake now demands the evidence whenever the variable is set to anything but 0/false - mobilegl.log must exist, must carry "MGPipe: verify armed", and must carry neither Fatal{PipeVerifyDiffer nor Fatal{UnmigratedPipeInput. With the variable unset the script is byte-for-byte the old one. - The Fatal scan is not redundant with the replay's exit status: MOBILEGL_PIPE_VERIFY_FATAL=0 is the supported triage configuration, and there a divergence is logged and counted rather than aborted, so the run would finish 0 with its own report sitting unread in the log. - LABELS retrace on both registrations: the lane was selectable only by regex, so `ctest -L retrace --no-tests=error` - the spelling that reds a lane which registered nothing - could not be written at all. - "verify": true on eight cases (the five 180s cases plus minecraft-1.21.4-in-world, minecraft-1.21.4-fabric-sodium-in-world and improved-transparency-minecraft-26.3), and --format github-verify-matrix over that subset: 16 entries, against the full lane's 77. The verify build compares at every verb boundary and again at every accessor read, which the design budgets at 5-10x, so the per-push job runs the subset and the full sweep is a phase-exit / workflow_dispatch run. - The flag is validated in the manifest loader, not at the matrix, so a non-boolean or a verify case excluded from CI is a loud error in every consumer instead of a subset that is quietly one case short. --- tools/trace_replay/CMakeLists.txt | 6 ++- tools/trace_replay/run_trace_case.cmake | 57 +++++++++++++++++++++++++ tools/trace_replay/trace_cases.json | 8 ++++ tools/trace_replay/trace_cases.py | 27 ++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/tools/trace_replay/CMakeLists.txt b/tools/trace_replay/CMakeLists.txt index c52ca3745..ae1ed03b6 100644 --- a/tools/trace_replay/CMakeLists.txt +++ b/tools/trace_replay/CMakeLists.txt @@ -372,10 +372,12 @@ function(add_trace_replay_test CASE_NAME BACKEND) -P ${MOBILEGL_TRACE_ROOT}/run_trace_case.cmake) if(BACKEND STREQUAL "DirectGLES") set_tests_properties(MobileGLTraceReplay.${CASE_NAME}.${BACKEND} PROPERTIES - ENVIRONMENT "EGL_PLATFORM=surfaceless;LIBGL_ALWAYS_SOFTWARE=1;MESA_GL_VERSION_OVERRIDE=3.3;MESA_GLSL_VERSION_OVERRIDE=330") + ENVIRONMENT "EGL_PLATFORM=surfaceless;LIBGL_ALWAYS_SOFTWARE=1;MESA_GL_VERSION_OVERRIDE=3.3;MESA_GLSL_VERSION_OVERRIDE=330" + LABELS retrace) else() set_tests_properties(MobileGLTraceReplay.${CASE_NAME}.${BACKEND} PROPERTIES - ENVIRONMENT "LIBGL_ALWAYS_SOFTWARE=1;MESA_GL_VERSION_OVERRIDE=3.3;MESA_GLSL_VERSION_OVERRIDE=330") + ENVIRONMENT "LIBGL_ALWAYS_SOFTWARE=1;MESA_GL_VERSION_OVERRIDE=3.3;MESA_GLSL_VERSION_OVERRIDE=330" + LABELS retrace) endif() endfunction() diff --git a/tools/trace_replay/run_trace_case.cmake b/tools/trace_replay/run_trace_case.cmake index ce389b47b..1d76d704b 100644 --- a/tools/trace_replay/run_trace_case.cmake +++ b/tools/trace_replay/run_trace_case.cmake @@ -127,3 +127,60 @@ endif() if(NOT replay_result EQUAL 0) message(FATAL_ERROR "${TRACE_CASE_NAME} ${TRACE_BACKEND} trace replay failed with status ${replay_result}") endif() + +# --- MOBILEGL_PIPE_VERIFY: the third CI mode's own assertions (gates G3 and G8) -------------- +# +# A retrace that exported MOBILEGL_PIPE_VERIFY=1 at a library which was never configured with +# -DMOBILEGL_PIPE_VERIFY=ON is a no-op that looks exactly like a clean pass: the variable steers +# nothing, the frames still match their goldens, and the case reports green having verified +# nothing at all. The mode therefore has to prove it ran, and the only channel a `cmake -P` script +# has for that is the library's own log. +# +# Three demands, all of them silent when MOBILEGL_PIPE_VERIFY is unset or "0", so an ordinary +# retrace is untouched: +# * mobilegl.log exists - the replay wrote one, so the library was loaded and logging; +# * it carries "MGPipe: verify armed" - the comparator armed in THIS process; +# * it carries neither Fatal{PipeVerifyDiffer (a push/pull divergence, the thing the mode +# exists to find) nor Fatal{UnmigratedPipeInput (a backend read of a field the verb's fill +# table does not list - fixed by adding the row to MG_Pipe/FillPoints.def, never by marking +# the field sticky). +# The Fatal check is not redundant with the replay's exit status: MOBILEGL_PIPE_VERIFY_FATAL=0 is +# the supported triage configuration, and there the divergence is logged and counted rather than +# aborted, so the run would otherwise finish 0 with its own report in the log. +if(DEFINED ENV{MOBILEGL_PIPE_VERIFY} AND NOT "$ENV{MOBILEGL_PIPE_VERIFY}" STREQUAL "") + set(pipe_verify_case "${TRACE_CASE_NAME} ${TRACE_BACKEND}") + if("$ENV{MOBILEGL_PIPE_VERIFY}" STREQUAL "0" OR "$ENV{MOBILEGL_PIPE_VERIFY}" STREQUAL "false") + message(STATUS "MGPipe verify: MOBILEGL_PIPE_VERIFY=$ENV{MOBILEGL_PIPE_VERIFY}, no verify assertions for ${pipe_verify_case}") + elseif(NOT EXISTS "${mobilegl_log}") + message(FATAL_ERROR + "MOBILEGL_PIPE_VERIFY is set for ${pipe_verify_case} but the run wrote no ${mobilegl_log}, " + "so there is no evidence the comparator ever armed. A verify retrace with no library log " + "cannot be counted as a verify retrace.") + else() + file(READ "${mobilegl_log}" pipe_verify_log) + string(FIND "${pipe_verify_log}" "MGPipe: verify armed" pipe_verify_armed_at) + if(pipe_verify_armed_at EQUAL -1) + message(FATAL_ERROR + "MOBILEGL_PIPE_VERIFY is set for ${pipe_verify_case} and the library never reported " + "\"MGPipe: verify armed\". Either this libMobileGL.so was not built with " + "-DMOBILEGL_PIPE_VERIFY=ON - in which case the whole verify retrace lane is comparing " + "nothing - or the runtime knob never reached the process. Check that the VERIFY " + "runtime artifact is the one unpacked at ${MOBILEGL_LIBRARY}.") + endif() + file(STRINGS "${mobilegl_log}" pipe_verify_fatals + REGEX "Fatal\\{(PipeVerifyDiffer|UnmigratedPipeInput)") + if(pipe_verify_fatals) + foreach(line IN LISTS pipe_verify_fatals) + message(STATUS "${line}") + endforeach() + list(LENGTH pipe_verify_fatals pipe_verify_fatal_count) + message(FATAL_ERROR + "${pipe_verify_case}: ${pipe_verify_fatal_count} MGPipe Fatal(s) under " + "MOBILEGL_PIPE_VERIFY. Fatal{PipeVerifyDiffer, \"@\"} is a real push/pull " + "divergence and is recorded, not silenced; Fatal{UnmigratedPipeInput, \"@\"} " + "is a missing row in MG_Pipe/FillPoints.def's class table - add it, regenerate, rerun " + "(never mark the field sticky).") + endif() + message(STATUS "MGPipe verify: ${pipe_verify_case} armed, zero divergences, zero unmigrated reads") + endif() +endif() diff --git a/tools/trace_replay/trace_cases.json b/tools/trace_replay/trace_cases.json index 914b9f982..03ef2c0da 100644 --- a/tools/trace_replay/trace_cases.json +++ b/tools/trace_replay/trace_cases.json @@ -13,6 +13,7 @@ "cases": [ { "name": "OpenRA", + "verify": true, "trace_archive": "openra.tgz", "trace_file": "openra.trace", "golden": "openra.0000031249.png", @@ -27,6 +28,7 @@ }, { "name": "minecraft-1.21.4-startup", + "verify": true, "trace_archive": "minecraft-1.21.4-startup.tgz", "golden": "minecraft-1.21.4-startup.0000092195.png", "target_call": 92195, @@ -34,6 +36,7 @@ }, { "name": "minecraft-1.21.4-main-menu", + "verify": true, "trace_archive": "minecraft-1.21.4-main-menu.tgz", "golden": "minecraft-1.21.4-main-menu.0000481787.png", "alternate_golden": "minecraft-1.21.4-main-menu.0000481787-mali.png", @@ -42,6 +45,7 @@ }, { "name": "minecraft-1.21.11-main-menu", + "verify": true, "trace_archive": "minecraft-1.21.11-main-menu.tgz", "golden": "minecraft-1.21.11-main-menu.0000205347.png", "target_call": 205347, @@ -49,6 +53,7 @@ }, { "name": "minecraft-1.17-main-menu-854", + "verify": true, "trace_archive": "minecraft-1.17-main-menu-854.tgz", "golden": "minecraft-1.17-main-menu-854.0000117757.png", "target_call": 117757, @@ -56,6 +61,7 @@ }, { "name": "minecraft-1.21.4-in-world", + "verify": true, "trace_archive": "minecraft-1.21.4-in-world.tgz", "golden": "minecraft-1.21.4-in-world.0000280000.png", "target_call": 280000 @@ -70,6 +76,7 @@ }, { "name": "minecraft-1.21.4-fabric-sodium-in-world", + "verify": true, "trace_archive": "minecraft-1.21.4-fabric-sodium-in-world.tgz", "golden": "minecraft-1.21.4-fabric-sodium-in-world.0000923340.png", "target_call": 923340, @@ -277,6 +284,7 @@ }, { "name": "improved-transparency-minecraft-26.3", + "verify": true, "trace_archive": "improved-transparency-minecraft-26.3.tgz", "golden": "improved-transparency-minecraft-26.3.0002667619.png", "target_call": 2667619, diff --git a/tools/trace_replay/trace_cases.py b/tools/trace_replay/trace_cases.py index 3cdf0ac1e..8203eac5c 100644 --- a/tools/trace_replay/trace_cases.py +++ b/tools/trace_replay/trace_cases.py @@ -26,6 +26,16 @@ def load_trace_case_manifest(path=TRACE_CASES_JSON): for key in ("trace_archive", "trace_file", "golden", "target_call", "width", "height"): if key not in merged: raise ValueError(f"{key} is required for {name}") + # "verify" opts a case into the MOBILEGL_PIPE_VERIFY retrace subset. It is checked here + # rather than where the matrix is built so that a typo is a loud manifest error in every + # consumer (the cmake emitter included) instead of a subset that is quietly one case short. + verify = merged.get("verify", False) + if not isinstance(verify, bool): + raise ValueError(f"verify must be true or false for {name}") + if verify and not merged.get("ci", True): + raise ValueError( + f"{name} is marked verify but excluded from CI, so the verify matrix would drop it" + ) cases.append(merged) return {"defaults": defaults, "cases": cases} @@ -72,6 +82,16 @@ def ci_trace_cases(cases): return [case for case in cases if case.get("ci", True)] +def verify_trace_cases(cases): + """The subset the third CI mode retraces. + + The verify build compares two state models at every verb boundary and again at every accessor + read, which the design budgets at 5-10x, so the per-push lane runs a named subset and the full + 79-case sweep happens at the phase exit and on workflow_dispatch. + """ + return [case for case in cases if case.get("verify", False)] + + def ci_backends(case): backends = case.get("ci_backends") if backends is None: @@ -98,6 +118,10 @@ def github_test_matrix(cases): } +def github_verify_matrix(cases): + return github_test_matrix(verify_trace_cases(cases)) + + def github_apk_matrix(cases): backends = { "DirectGLES": {"name": "DirectGLES", "gpu": "software"}, @@ -158,6 +182,7 @@ def parse_args(): choices=( "names", "github-test-matrix", + "github-verify-matrix", "github-apk", "github-apk-matrix", "fixture-files", @@ -177,6 +202,8 @@ def main(): print(json.dumps([case["name"] for case in cases], separators=(",", ":"))) elif args.format == "github-test-matrix": print(json.dumps(github_test_matrix(cases), separators=(",", ":"))) + elif args.format == "github-verify-matrix": + print(json.dumps(github_verify_matrix(cases), separators=(",", ":"))) elif args.format == "github-apk": print(json.dumps([github_apk_case(case) for case in cases], separators=(",", ":"))) elif args.format == "github-apk-matrix": From 416cd23c28b07ef1a0eb0b270cc835ac06edb16b Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 02:01:22 -0400 Subject: [PATCH 061/529] [Feat] (Tooling): give symbol_report.py the two hard gates G1 needs, with the report written first - --fail-on-added-bytes was a reserved no-op that printed "this run stays informational"; it now exits non-zero when .text grew past the budget, and --fail-on-symbol-set-change joins it for the added/removed buckets. Together they are the spelling of P1's G1 ("the pull build is byte-identical"): --threshold 0 --fail-on-symbol-set-change --fail-on-added-bytes 0. - Default behaviour is unchanged: with no gate flag the tool prints its report and exits 0, which is what every existing caller and the informational monolith-symbol-report job expect. - A gate fires AFTER the Markdown and JSON are written, never before: the report is the diagnosis, and a CI job that failed before uploading its artifact is one nobody can act on. - The decision lives in a pure gate_failures(), so --self-test drives it from the same two canned transcripts as the buckets: each flag fires on the canned add/remove/+100 delta, each stays quiet when it was not asked for, and a tolerated budget is tolerated. A gate whose only test is a real build is a gate nobody re-tests. --- scripts/symbol_report.py | 66 +++++++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/scripts/symbol_report.py b/scripts/symbol_report.py index df65da3cc..1de2ee106 100755 --- a/scripts/symbol_report.py +++ b/scripts/symbol_report.py @@ -30,9 +30,17 @@ The header of every report prints both paths and their byte sizes so a mismatched pair is visible in the output rather than only in the reader's assumptions. -This tool is informational (ARCHITECTURE.md:507, job name `monolith-symbol-report` -ARCHITECTURE.md:568) and always exits 0; `--fail-on-added-bytes` is reserved for the day -it becomes a hard gate. +This tool is informational by default (ARCHITECTURE.md:507, job name `monolith-symbol-report` +ARCHITECTURE.md:568): with no gate flag it prints its report and exits 0, whatever it found. +Two flags turn it into a hard gate, and P1's G1 uses both - the strangler's pull build has to +stay byte-identical, so `--fail-on-symbol-set-change --fail-on-added-bytes 0` is the spelling of +"nothing was added, removed or grown": + + python3 scripts/symbol_report.py --before base.so --after head.so \ + --threshold 0 --fail-on-symbol-set-change --fail-on-added-bytes 0 + +A gate that fires still writes its Markdown and JSON first: the report IS the diagnosis, and a +CI job that failed before uploading its artifact is a job nobody can act on. """ import argparse @@ -233,6 +241,24 @@ def markdown_table(title, rows, columns): """) +def gate_failures(added, removed, text_delta, fail_on_added_bytes, fail_on_symbol_set_change): + """The reasons this run should fail, in the order they are reported. Empty means green. + + Pure, and takes the buckets rather than the file paths, so the self-test can drive it from the + canned transcripts: a gate whose only test is a real build is a gate nobody re-tests. + """ + reasons = [] + if fail_on_symbol_set_change and (added or removed): + reasons.append( + "--fail-on-symbol-set-change: {} symbol(s) added, {} removed. The defined-symbol set " + "is not a property of the build machine, so any change here is a source change - name " + "each one in the commit message or fix it.".format(len(added), len(removed))) + if fail_on_added_bytes is not None and text_delta > fail_on_added_bytes: + reasons.append( + "--fail-on-added-bytes {}: .text grew by {} bytes.".format(fail_on_added_bytes, text_delta)) + return reasons + + def self_test(): strip = ["MobileGL::MG_State::GLState::ProgramObject::"] before, before_sections, before_count = build_side(None, None, None, None, strip, [], @@ -257,9 +283,23 @@ def self_test(): problems.append("unchanged count: {} (expected 2)".format(unchanged)) if before_sections.get(".text") != 1000 or after_sections.get("Total") != 1600: problems.append("size --format=sysv parse: {} / {}".format(before_sections, after_sections)) + # The two gates, driven from the same canned transcripts: the after side adds one symbol, + # removes one and grows .text by 100, so each flag must fire, each must stay quiet when it is + # not asked for, and --fail-on-added-bytes must accept a delta it was told to tolerate. + text_delta = after_sections.get(".text", 0) - before_sections.get(".text", 0) + if gate_failures(added, removed, text_delta, None, False): + problems.append("gates fired with no flag set") + if len(gate_failures(added, removed, text_delta, None, True)) != 1: + problems.append("--fail-on-symbol-set-change did not fire on 1 added + 1 removed") + if len(gate_failures(added, removed, text_delta, 0, False)) != 1: + problems.append("--fail-on-added-bytes 0 did not fire on a +100 .text delta") + if gate_failures(added, removed, text_delta, 100, False): + problems.append("--fail-on-added-bytes 100 fired on a +100 .text delta") + if len(gate_failures([], [], text_delta, 0, True)) != 1: + problems.append("an unchanged symbol set still tripped --fail-on-symbol-set-change") for problem in problems: say("self-test: " + problem) - say("self-test: " + ("OK (2 canned transcripts, 5 buckets)" if not problems else "FAILED")) + say("self-test: " + ("OK (2 canned transcripts, 5 buckets, 2 gates)" if not problems else "FAILED")) return 0 if not problems else 1 @@ -285,7 +325,11 @@ def main(): parser.add_argument("--threshold", type=int, default=0, help="ignore size deltas of at most this many bytes") parser.add_argument("--fail-on-added-bytes", type=int, default=None, - help="reserved for a future hard gate; currently informational only") + help="exit non-zero when .text grew by more than this many bytes " + "(0 = the pull build must not grow at all)") + parser.add_argument("--fail-on-symbol-set-change", action="store_true", + help="exit non-zero when any defined symbol was added or removed " + "(renamed-only folds, see --strip-scope, do not count)") parser.add_argument("--self-test", action="store_true") args = parser.parse_args() @@ -338,8 +382,10 @@ def main(): len(before), len(after), unchanged)) if only_names: say("listing restricted to names containing: " + ", ".join(only_names)) - if args.fail_on_added_bytes is not None: - say("--fail-on-added-bytes is reserved; this run stays informational") + gates = gate_failures(added, removed, delta, args.fail_on_added_bytes, + args.fail_on_symbol_set_change) + if args.fail_on_added_bytes is None and not args.fail_on_symbol_set_change: + say("no gate flag: informational run") lines = ["# MobileGL symbol report", "", "| side | path | file bytes | .text |", @@ -379,7 +425,11 @@ def main(): handle.write("\n") say("json written to " + args.json) - return 0 + # After the report is written, never before: a gate that fired is exactly when someone needs + # the Markdown and the JSON. + for reason in gates: + say("FAIL " + reason) + return 1 if gates else 0 if __name__ == "__main__": From 0fe7bf82d214aa254e1d8b525b289331a9875004 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 02:07:03 -0400 Subject: [PATCH 062/529] [CI] (Pipe): the third CI mode - a verify build, its integration and retrace lanes, and the two negative controls as always-on steps - build-linux-verify is a second Release/INFO build with -DMOBILEGL_PIPE_VERIFY=ON, because the comparator is a compile-time option and does not exist in the shipped library. It refuses to ship an artifact whose libMobileGL.so does not export MGPipeVerifyInputs and MGPipeFillForVerb: a typo'd -D is not an error in CMake, and every lane below would then be green having compared nothing. - integration-verify runs the suite with the comparator armed and then proves it armed twice over: --no-tests=error reds a build whose verify entries were never registered, and a step greps every pipe-verify-*.log for the arming line. - The two negative controls are steps of that job, not a manual exercise: a gate that can only be shown to work by someone remembering to break it has already stopped working. Each passes when ctest FAILS, and each first counts its own selection - an empty selection also exits non-zero under --no-tests=error, and a control that passed because it ran nothing would be worse than no control. - Control B targets PoisonOmissionScenario.WithoutOmissionCompletes, the only integration entry in the tree that calls glGenerateMipmap at all; the case no longer skips itself when the omission knob is set, precisely so that the control has a green entry to turn red. - retrace-verify replays the eight "verify": true cases against the verify library, copied over build-linux/libMobileGL.so because build-retrace freezes that absolute path into every case, with an nm check that the swap happened and an inverted OpenRA step that must go red under MOBILEGL_PIPE_VERIFY_CORRUPT. remove-artifact-clutter now waits for it: it deletes the trace fixtures these jobs download. - monolith-symbol-report is G1 as a job: two pull builds with identical flags and LTO off, the baseline named by a workflow_dispatch input, symbol_report.py with both hard gates, and a refusal of any MG_Remote symbol in the monolith. It is dispatch-only because its answer is about a baseline, not about this push. - pipe-gates gains the two --self-test steps. Regenerating and diffing cannot see a structural check that silently stopped checking; a broken gate and a clean tree produce the same green. Its dirty-surface comment now says P2, which is where ROADMAP.md:18 puts the first mapping round. --- .github/workflows/test.yml | 574 +++++++++++++++++- .../Scenarios/PoisonOmissionScenario.cpp | 22 +- 2 files changed, 588 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 80a43fc0a..471268fd4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,14 @@ on: - Feat/Backend-Direct-GLES - Feat/Backend-Direct-Vulkan workflow_dispatch: + inputs: + baseline_sha: + description: >- + The commit monolith-symbol-report compares this tree against. P1's G1 says the pull + build is byte-identical to feat/disaggregated@087685d1, and that is what the default + names. The trigger set is unchanged: this job runs on workflow_dispatch only. + required: false + default: "087685d1" jobs: build-linux: @@ -295,6 +303,293 @@ jobs: path: /tmp/core.* if-no-files-found: ignore + # THE THIRD CI MODE (ARCHITECTURE.md 13.2-(2)): the same library, built with the PipeInputs + # comparator compiled in, running the integration suite and a trace subset with two state models + # in one address space. It is a second build rather than a flag on the first because + # MOBILEGL_PIPE_VERIFY is a compile-time option - the snapshot, the entry compare and the + # compare-at-read hook do not exist in the shipped library, and are never meant to. + build-linux-verify: + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + actions: write + contents: read + env: + BUILD_DIR: build-verify + CCACHE_BASEDIR: ${{ github.workspace }} + CCACHE_COMPRESS: "true" + CCACHE_DIR: ${{ github.workspace }}/.ccache + CCACHE_MAXSIZE: 4G + CCACHE_NOHASHDIR: "true" + + steps: + - name: Set Swap Space + uses: pierotofy/set-swap-space@v1.0 + with: + swap-size-gb: 32 + + - name: Checkout repo + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Get CMake + uses: lukka/get-cmake@v4.3.3 + + - name: Restore ccache + uses: actions/cache/restore@v5 + with: + path: .ccache + key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1 + restore-keys: | + ${{ runner.os }}-test-${{ github.job }}-ccache- + + - name: Prepare Vulkan SDK + uses: humbletim/setup-vulkan-sdk@v1.2.1 + with: + vulkan-query-version: 1.4.304.1 + vulkan-components: Vulkan-Headers, Vulkan-Loader + vulkan-use-cache: true + + - name: Update glslang external sources + working-directory: 3rdparty/glslang + run: python update_glslang_sources.py + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y ccache clang-20 clang++-20 lld-20 libc++-20-dev libc++abi-20-dev libvulkan-dev libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers ninja-build + + - name: Configure CMake + # Release/INFO like the shipped build on purpose. The poison arms in this configuration + # through MOBILEGL_PIPE_VERIFY (PipeInputs.h derives MOBILEGL_PIPE_POISON from it), so this + # job needs neither a Debug log level nor MOBILEGL_BUILD_DISAGGREGATED - and a Debug build + # would compare a different library from the one the other lanes measure. + run: | + cmake -S . -B "${BUILD_DIR}" -G Ninja \ + -DCMAKE_C_COMPILER=clang-20 \ + -DCMAKE_CXX_COMPILER=clang++-20 \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ + -DCMAKE_BUILD_TYPE=Release \ + -DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO \ + -DMOBILEGL_BUILD_TEST=ON \ + -DMOBILEGL_BUILD_BENCHMARK=OFF \ + -DMOBILEGL_BUILD_INTEGRATION_TEST=ON \ + -DMOBILEGL_ITEST_VK_ICD=/usr/share/vulkan/icd.d/lvp_icd.json \ + -DMOBILEGL_BUILD_TRACE_REPLAY=OFF \ + -DMOBILEGL_PIPE_VERIFY=ON \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + + - name: Build + run: cmake --build "${BUILD_DIR}" --parallel "$(nproc)" + + # The lane is worthless if the option silently did not take, and that is a one-character + # mistake away at all times (a typo'd -D is not an error in CMake). Two checks, both cheap: + # the comparator's entry point must be in the library, and the fill entry point with it. + - name: The verify library really carries the comparator + run: | + test -f "${BUILD_DIR}/libMobileGL.so" + nm -D --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -q MGPipeVerifyInputs + nm -D --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -q MGPipeFillForVerb + echo "libMobileGL.so exports MGPipeVerifyInputs and MGPipeFillForVerb" + + - name: Show ccache stats + if: always() + run: ccache --show-stats + + - name: Release superseded ccache entry + if: github.ref_name == github.event.repository.default_branch + env: + GH_TOKEN: ${{ github.token }} + CACHE_KEY: ${{ runner.os }}-test-${{ github.job }}-ccache-v1 + run: gh cache delete "${CACHE_KEY}" || true + + - name: Save ccache + if: github.ref_name == github.event.repository.default_branch + continue-on-error: true + uses: actions/cache/save@v5 + with: + path: .ccache + key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1 + + - name: Package Linux verify runtime + run: | + mkdir -p ci-artifacts + mapfile -t SHARED_LIBS < <(find "${BUILD_DIR}" -type f \( -name '*.so' -o -name '*.so.*' \) -print | sort) + tar \ + --exclude='*/CMakeFiles' \ + --exclude='*.o' \ + --exclude='*.a' \ + --exclude='*.ninja*' \ + --exclude='build.ninja' \ + --exclude='cmake_install.cmake' \ + -czf ci-artifacts/mobilegl-linux-runtime-verify.tgz \ + "${BUILD_DIR}/CTestTestfile.cmake" \ + "${BUILD_DIR}/MobileGL/MG_Test" \ + "${BUILD_DIR}/MobileGL/MG_IntegrationTest" \ + "${SHARED_LIBS[@]}" + + - name: Upload Linux verify runtime + uses: actions/upload-artifact@v7 + with: + name: mobilegl-linux-runtime-verify + path: ci-artifacts/mobilegl-linux-runtime-verify.tgz + if-no-files-found: error + + # The verify lane itself, plus the two negative controls that keep it falsifiable. The controls + # are ALWAYS-ON steps, not a manual exercise: a gate that can only be shown to work by someone + # remembering to break it on purpose is a gate that has already stopped working. + integration-verify: + runs-on: ubuntu-latest + timeout-minutes: 180 + needs: build-linux-verify + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Get CMake + uses: lukka/get-cmake@v4.3.3 + + - name: Install runtime dependencies + run: | + sudo apt-get update + sudo apt-get install -y libvulkan1 libegl1 libegl-mesa0 libgles2 libgl1-mesa-dri mesa-vulkan-drivers + + - name: Download Linux verify runtime + uses: actions/download-artifact@v8 + with: + name: mobilegl-linux-runtime-verify + path: . + + - name: Unpack Linux verify runtime + run: | + tar -xzf mobilegl-linux-runtime-verify.tgz + test -f build-verify/libMobileGL.so + + - name: Normalize CTest command paths + run: | + python - <<'PY' + from pathlib import Path + import re + + for path in Path('build-verify').rglob('CTestTestfile.cmake'): + text = path.read_text() + text = re.sub(r'"[^"]*/cmake-[^"]*/bin/cmake"', '"cmake"', text) + path.write_text(text) + PY + + - name: Integration scenarios under MOBILEGL_PIPE_VERIFY + working-directory: build-verify + # --no-tests=error is half the gate: the verify entries only exist when the library was + # configured with -DMOBILEGL_PIPE_VERIFY=ON, so a build that lost the option matches no + # tests and reds here instead of reporting a green run of nothing. The other half is + # PipeVerifyArmingScenario.Armed, which fails when the library never printed its arming + # line - the failure mode a bare `MOBILEGL_PIPE_VERIFY=1` cannot detect by itself. + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH: "1" + MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS: "1" + MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER: "1" + run: | + ulimit -c unlimited + sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p' + if [ "${{ secrets.ACTIONS_STEP_DEBUG }}" = "true" ]; then + ctest -V -L integration-verify --no-tests=error + else + ctest --output-on-failure -L integration-verify --no-tests=error + fi + + - name: Every verify process really armed + working-directory: build-verify + run: | + shopt -s nullglob + logs=(MobileGL/MG_IntegrationTest/pipe-verify-*.log) + if [ ${#logs[@]} -eq 0 ]; then + echo "::error::the verify lane wrote no pipe-verify-*.log at all" + exit 1 + fi + for log in "${logs[@]}"; do + if ! grep -q "MGPipe: verify armed" "${log}"; then + echo "::error::${log} carries no arming line: that lane ran without the comparator" + exit 1 + fi + done + echo "arming line present in ${#logs[@]} lane log(s)" + + # NEGATIVE CONTROL A (gate G4). The knob perturbs one field in the snapshot arm before the + # entry compare, so a working comparator must abort the run. This step passes when ctest + # FAILS - `if ctest ...; then error` - which is the only shape that can catch a comparator + # that silently compares nothing. + # + # The knob reaches the test process through the JOB environment: no ctest ENVIRONMENT + # property on the ambient Verify. entries names it (MG_IntegrationTest/CMakeLists.txt says + # so out loud), and a property entry would otherwise override this and the control would + # prove nothing. Same precedent as MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH in `integration`. + - name: Negative control A - a corrupted snapshot field must turn the lane red + working-directory: build-verify + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_PIPE_VERIFY_CORRUPT: GetRenderStateParameters + run: | + FILTER='DirectGLES\.Verify\..*ClearThenReadPixels' + # An empty selection would ALSO make ctest exit non-zero (--no-tests=error), and this + # step reads non-zero as "the control worked" - so the selection is counted first. A + # control that passes because it ran nothing is worse than no control. + matched=$(ctest -N -L integration-verify -R "${FILTER}" | grep -cE '^ *Test *#[0-9]+:') + if [ "${matched}" -lt 1 ]; then + echo "::error::negative control A selected ${matched} tests; its filter no longer matches anything" + exit 1 + fi + if ctest --output-on-failure -L integration-verify -R "${FILTER}" --no-tests=error; then + echo "::error::MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters left ${matched} verify entries GREEN. The comparator is not comparing, so every green entry above is green for no reason." + exit 1 + fi + echo "the corrupted field turned ${matched} selected entries red, as it must" + + # NEGATIVE CONTROL B (gate G5). The omission skips the STAMP of one field for one verb while + # still copying its value - indistinguishable from a fill row nobody wrote - so the poison + # must abort the glGenerateMipmap. Again: this step passes when ctest fails. + # + # The entry it targets is PoisonOmissionScenario.WithoutOmissionCompletes, which is green in + # the ambient lane above and is the ONLY integration entry in the tree that calls + # glGenerateMipmap at all. It deliberately does not skip itself when the knob is set, exactly + # so that this control has something to turn red. + - name: Negative control B - an omitted fill point must turn the lane red on that verb + working-directory: build-verify + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_PIPE_POISON_OMIT: GenerateMipmap:GetActiveTextureUnit + run: | + FILTER='DirectGLES\.Verify\.PoisonOmissionScenario\.WithoutOmissionCompletes' + matched=$(ctest -N -L integration-verify -R "${FILTER}" | grep -cE '^ *Test *#[0-9]+:') + if [ "${matched}" -lt 1 ]; then + echo "::error::negative control B selected ${matched} tests; its filter no longer matches anything" + exit 1 + fi + if ctest --output-on-failure -L integration-verify -R "${FILTER}" --no-tests=error; then + echo "::error::MOBILEGL_PIPE_POISON_OMIT=GenerateMipmap:GetActiveTextureUnit left the verify lane GREEN. The per-verb poison is not armed, so a forgotten fill row would ship silently." + exit 1 + fi + echo "the omitted fill point turned the lane red, as it must" + + - name: Upload verify lane logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: integration-verify-logs + path: build-verify/MobileGL/MG_IntegrationTest/pipe-*.log* + if-no-files-found: warn + + - name: Upload core dumps + if: failure() + uses: actions/upload-artifact@v7 + with: + name: integration-verify-core-dumps + path: /tmp/core.* + if-no-files-found: ignore + # MobileGL/MG_Remote/Protocol/generated/protocol_generated.h is COMMITTED, and # flatc is deliberately absent from the default build graph (a codegen step in # the graph is how the earlier branch ended up cross-compiling an arm64 flatc @@ -562,6 +857,7 @@ jobs: outputs: matrix: ${{ steps.trace-cases.outputs.matrix }} names: ${{ steps.trace-cases.outputs.names }} + verify-matrix: ${{ steps.trace-cases.outputs.verify-matrix }} steps: - name: Checkout repo uses: actions/checkout@v6 @@ -571,6 +867,9 @@ jobs: run: | echo "matrix=$(python3 tools/trace_replay/trace_cases.py --ci --format github-test-matrix)" >> "$GITHUB_OUTPUT" echo "names=$(python3 tools/trace_replay/trace_cases.py --ci --format names)" >> "$GITHUB_OUTPUT" + # The subset the verify build retraces ("verify": true in trace_cases.json). It is a + # SUBSET of the matrix above, so retrace-verify needs no fixtures of its own. + echo "verify-matrix=$(python3 tools/trace_replay/trace_cases.py --ci --format github-verify-matrix)" >> "$GITHUB_OUTPUT" trace-fixtures: name: trace fixture (${{ matrix.case }}) @@ -784,10 +1083,269 @@ jobs: archive: false if-no-files-found: error + # The trace half of the third CI mode. Same replay, same goldens, but the library underneath is + # the verify build and MOBILEGL_PIPE_VERIFY=1 is in the environment, so every backend read of + # frontend state is checked against a snapshot taken at the verb boundary. Eight cases rather + # than the full lane's 40 (tools/trace_replay/trace_cases.json, "verify": true): the comparator + # is budgeted at 5-10x, and the full sweep is a phase-exit / workflow_dispatch run. + retrace-verify: + name: retrace verify (${{ matrix.backend }}, ${{ matrix.case }}) + runs-on: ubuntu-latest + timeout-minutes: 240 + needs: + - build-linux-verify + - build-retrace + - trace-cases + - trace-fixtures + if: ${{ always() && needs.build-linux-verify.result == 'success' && needs.build-retrace.result == 'success' && needs.trace-cases.result == 'success' }} + strategy: + fail-fast: false + max-parallel: 4 + matrix: ${{ fromJSON(needs.trace-cases.outputs.verify-matrix) }} + + steps: + - name: Set Swap Space + uses: pierotofy/set-swap-space@v1.0 + with: + swap-size-gb: 16 + + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Download trace fixture + uses: actions/download-artifact@v8 + with: + name: trace-fixture-${{ matrix.case }} + path: trace-fixture-download + + - name: Install trace fixture + run: | + mkdir -p tools/trace_replay/fixtures + find trace-fixture-download -type f -exec cp {} tools/trace_replay/fixtures/ \; + + - name: Get CMake + uses: lukka/get-cmake@v4.3.3 + + - name: Install runtime dependencies + run: | + sudo apt-get update + sudo apt-get install -y libvulkan1 libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers + test -e /usr/lib/x86_64-linux-gnu/libEGL.so + test -e /usr/lib/x86_64-linux-gnu/libGLESv2.so + + - name: Download Linux verify runtime + uses: actions/download-artifact@v8 + with: + name: mobilegl-linux-runtime-verify + path: . + + - name: Download trace replay + uses: actions/download-artifact@v8 + with: + name: mobilegl-trace-replay + path: . + + - name: Unpack the VERIFY runtime as the library under test + # build-retrace's CTestTestfile.cmake has the absolute path + # /build-linux/libMobileGL.so frozen into every case, so the swap happens here + # rather than through a variable: the verify .so is put where that path points. The nm + # check is what makes the swap falsifiable - a run against the ordinary library would + # export no comparator, ignore MOBILEGL_PIPE_VERIFY entirely, and match its golden. + run: | + tar -xzf mobilegl-linux-runtime-verify.tgz + tar -xzf mobilegl-trace-replay.tgz + test -f build-verify/libMobileGL.so + test -f build-retrace/tools/trace_replay/mobilegl_trace_replay + mkdir -p build-linux + cp build-verify/libMobileGL.so build-linux/libMobileGL.so + nm -D --defined-only build-linux/libMobileGL.so | grep -q MGPipeVerifyInputs + echo "the library at build-linux/libMobileGL.so is the verify build" + + - name: Retrace and validate under MOBILEGL_PIPE_VERIFY + working-directory: build-retrace/tools/trace_replay + # run_trace_case.cmake turns MOBILEGL_PIPE_VERIFY into three assertions of its own (the + # arming line, no Fatal{PipeVerifyDiffer, no Fatal{UnmigratedPipeInput), so a case that + # somehow ran the wrong library reds here instead of passing on its golden. + # --timeout 10800: the 1800s cases run 5-10x slower with both comparator arms live, which + # is well past ctest's 1500s default. + run: | + ulimit -c unlimited + sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p' + export MOBILEGL_PIPE_VERIFY=1 + if [ '${{ matrix.backend }}' = 'DirectVulkan' ]; then + export MOBILEGL_MAGMA_R11G11B10F_FALLBACK=1 + fi + if [ '${{ matrix.backend }}' = 'DirectVulkan' ] \ + && [ '${{ matrix.case }}' = 'improved-transparency-minecraft-26.3' ]; then + export MOBILEGL_MAGMA_DISABLE_BLENDED_DEPTH_WRITE=1 + fi + ctest -V --no-tests=error --timeout 10800 \ + -R '^MobileGLTraceReplay\.${{ matrix.case }}\.${{ matrix.backend }}$' + + # The retrace lane's own always-on negative control, on one case so it costs one short trace: + # with a snapshot field corrupted, the SAME replay must fail. Without it, "40 traces, zero + # divergences" would be a statement about a comparator nobody watched. + - name: Negative control - a corrupted snapshot field must red this retrace + if: ${{ matrix.case == 'OpenRA' && matrix.backend == 'DirectGLES' }} + working-directory: build-retrace/tools/trace_replay + run: | + export MOBILEGL_PIPE_VERIFY=1 + export MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters + if ctest -V --no-tests=error --timeout 10800 \ + -R '^MobileGLTraceReplay\.OpenRA\.DirectGLES$'; then + echo "::error::MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters left the OpenRA retrace GREEN, so the comparator is not comparing and the whole verify retrace lane proves nothing." + exit 1 + fi + echo "the corrupted field turned the retrace red, as it must" + + - name: Upload core dumps + if: failure() + uses: actions/upload-artifact@v7 + with: + name: retrace-verify-core-dumps-${{ matrix.backend }}-${{ matrix.case }} + path: /tmp/core.* + if-no-files-found: ignore + + - name: Upload actual image + if: always() + uses: actions/upload-artifact@v7 + with: + name: retrace-verify-result-${{ matrix.backend }}-${{ matrix.case }} + path: | + build-retrace/tools/trace_replay/${{ matrix.case }}/actual-images/** + build-retrace/tools/trace_replay/${{ matrix.case }}/${{ matrix.backend }}/output/** + if-no-files-found: warn + + # G1's own job: the pull build must be the tree before P1, symbol for symbol and byte for byte. + # workflow_dispatch only - it builds the library twice from scratch, and its answer is about a + # BASELINE rather than about this push, so a per-push run would be measuring the wrong pair. + monolith-symbol-report: + name: monolith symbol report + runs-on: ubuntu-latest + timeout-minutes: 180 + if: ${{ github.event_name == 'workflow_dispatch' }} + env: + CCACHE_BASEDIR: ${{ github.workspace }} + CCACHE_COMPRESS: "true" + CCACHE_DIR: ${{ github.workspace }}/.ccache + CCACHE_MAXSIZE: 4G + CCACHE_NOHASHDIR: "true" + + steps: + - name: Set Swap Space + uses: pierotofy/set-swap-space@v1.0 + with: + swap-size-gb: 32 + + - name: Checkout repo + uses: actions/checkout@v6 + with: + submodules: recursive + fetch-depth: 0 + + - name: Get CMake + uses: lukka/get-cmake@v4.3.3 + + - name: Restore ccache + uses: actions/cache/restore@v5 + with: + path: .ccache + key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1 + restore-keys: | + ${{ runner.os }}-test-${{ github.job }}-ccache- + + - name: Prepare Vulkan SDK + uses: humbletim/setup-vulkan-sdk@v1.2.1 + with: + vulkan-query-version: 1.4.304.1 + vulkan-components: Vulkan-Headers, Vulkan-Loader + vulkan-use-cache: true + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y ccache clang-20 clang++-20 lld-20 libc++-20-dev libc++abi-20-dev libvulkan-dev libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers ninja-build binutils + + # Both sides with IDENTICAL flags, LTO off, the same compiler and the same standard library: + # symbol_report.py's guard rails (scripts/symbol_report.py) say a mismatched pair "adds" + # thousands of symbols and the comparison then means nothing. The library alone - no tests, + # no benchmark, no integration test, no trace replay - because those targets do not ship. + - name: Build the baseline library (${{ inputs.baseline_sha }}) + run: | + git worktree add ../baseline "${{ inputs.baseline_sha }}" + cd ../baseline + git submodule update --init --recursive + (cd 3rdparty/glslang && python update_glslang_sources.py) + cmake -S . -B build-sym-base -G Ninja \ + -DCMAKE_C_COMPILER=clang-20 -DCMAKE_CXX_COMPILER=clang++-20 \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ + -DCMAKE_BUILD_TYPE=Release \ + -DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO \ + -DMOBILEGL_BUILD_TEST=OFF -DMOBILEGL_BUILD_BENCHMARK=OFF \ + -DMOBILEGL_BUILD_INTEGRATION_TEST=OFF -DMOBILEGL_BUILD_TRACE_REPLAY=OFF \ + -DMOBILEGL_BUILD_DISAGGREGATED=OFF \ + -DMOBILEGL_PIPE_PUSH=OFF -DMOBILEGL_PIPE_VERIFY=OFF \ + -DMOBILEGL_ENABLE_LTO=OFF \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + cmake --build build-sym-base --parallel "$(nproc)" + cp build-sym-base/libMobileGL.so "${GITHUB_WORKSPACE}/libMobileGL-baseline.so" + + - name: Build the head library + run: | + (cd 3rdparty/glslang && python update_glslang_sources.py) + cmake -S . -B build-sym-head -G Ninja \ + -DCMAKE_C_COMPILER=clang-20 -DCMAKE_CXX_COMPILER=clang++-20 \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ + -DCMAKE_BUILD_TYPE=Release \ + -DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO \ + -DMOBILEGL_BUILD_TEST=OFF -DMOBILEGL_BUILD_BENCHMARK=OFF \ + -DMOBILEGL_BUILD_INTEGRATION_TEST=OFF -DMOBILEGL_BUILD_TRACE_REPLAY=OFF \ + -DMOBILEGL_BUILD_DISAGGREGATED=OFF \ + -DMOBILEGL_PIPE_PUSH=OFF -DMOBILEGL_PIPE_VERIFY=OFF \ + -DMOBILEGL_ENABLE_LTO=OFF \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + cmake --build build-sym-head --parallel "$(nproc)" + + # The monolith must not have grown a remote half. ARCHITECTURE.md:506: MG_Remote lives behind + # MOBILEGL_BUILD_DISAGGREGATED and nothing of it may reach a shipped pull build. + - name: No MG_Remote in the pull build + run: | + if nm --defined-only build-sym-head/libMobileGL.so | grep -q MG_Remote; then + echo "::error::the pull build defines MG_Remote symbols; the disaggregated half leaked into the monolith" + nm --defined-only build-sym-head/libMobileGL.so | grep MG_Remote | head -20 + exit 1 + fi + echo "no MG_Remote symbols in the pull build" + + - name: Symbol report (G1) + run: | + python3 scripts/symbol_report.py \ + --before libMobileGL-baseline.so \ + --after build-sym-head/libMobileGL.so \ + --threshold 0 \ + --fail-on-symbol-set-change \ + --fail-on-added-bytes 0 \ + --markdown symbol-report.md \ + --json symbol-report.json + + - name: Upload the symbol report + if: always() + uses: actions/upload-artifact@v7 + with: + name: monolith-symbol-report + path: | + symbol-report.md + symbol-report.json + if-no-files-found: error + remove-artifact-clutter: name: remove artifact clutter runs-on: ubuntu-latest - needs: retrace-summary + # (d) retrace-verify too: this job deletes the trace-fixture-* artifacts, and the verify + # retraces download the same ones. + needs: + - retrace-summary + - retrace-verify if: always() permissions: actions: write @@ -855,6 +1413,17 @@ jobs: python3 scripts/gen_pipe.py git diff --exit-code -- MobileGL/MG_Pipe/generated + # The generators' own negative controls: canned inputs that MUST trip each structural check + # (a field list that does not cover its struct's members, a verb set that is not the function + # table's). Regenerating and diffing above cannot see a check that silently stopped + # checking - a broken gate and a clean tree produce the same green. + - name: The MGPipe generators' checks can still fail + run: python3 scripts/gen_pipe.py --self-test + + # The same question for the symbol tool the P1 gate is written in terms of. + - name: The symbol report's buckets and gates can still fail + run: python3 scripts/symbol_report.py --self-test + # Per-draw fprintf/printf instrumentation has repeatedly been committed by accident, # once inside a mutex critical section. Nothing under these two trees prints to a # stdio stream today - MGLOG_D compiles out in INFO builds and is the only channel @@ -872,7 +1441,8 @@ jobs: echo "no fprintf(stderr/stdout / printf( / puts( / std::cout|cerr under MobileGL/MG_Backend or MobileGL/MG_State" # Informational: the frontend mutation surface an MGPipe aggregate generation has to - # cover. It becomes a gate in P1, when the mapping file exists to diff against. + # cover. It becomes a gate in P2, when the mapping file exists to diff against + # (ROADMAP.md:18 puts the first mapping round in P2, not P1). - name: MGPipe dirty-surface report run: python3 scripts/gen_pipe_dirty_surface.py --summary diff --git a/MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp index 2275ada62..79f859d00 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp @@ -335,6 +335,12 @@ void main() { o_color = vec4(0.25, 0.5, 0.75, 1.0); } // The sibling control, in the ambient Verify. lanes: the same sequence with the knob UNSET // must run to completion and log no Fatal at all. + // + // It deliberately does NOT skip when MOBILEGL_PIPE_POISON_OMIT is set. This is the entry + // CI's always-on negative control B exports the knob at: a green entry that the omission + // turns red is the whole proof that the poison is armed, and an entry that politely skipped + // itself would report that green either way. Nothing else in the integration suite calls + // glGenerateMipmap, so this case is also the only possible target for that control. TEST_F(PoisonOmissionScenario, WithoutOmissionCompletes) { if (!Ready()) return; @@ -342,22 +348,26 @@ void main() { o_color = vec4(0.25, 0.5, 0.75, 1.0); } GTEST_SKIP() << "the poison is only compiled into the push/verify builds; in an ordinary " "build there is nothing for this control to be a control OF"; } - if (StringKnobIsSet("MOBILEGL_PIPE_POISON_OMIT")) { - GTEST_SKIP() << "MOBILEGL_PIPE_POISON_OMIT is armed for this process, so the abort is the " - "EXPECTED outcome here; OmittedFieldAbortsOnThatVerb owns that half and " - "runs in the PoisonOmitted. lane"; - } + const bool omissionArmed = StringKnobIsSet("MOBILEGL_PIPE_POISON_OMIT"); int status = 0; std::string reason; ASSERT_TRUE(RunSequenceInAChildProcess(status, reason)) << reason; const std::string childLog = ReadWholeFile(ChildLogPath()); + const std::string note = + omissionArmed + ? std::string( + " NOTE: MOBILEGL_PIPE_POISON_OMIT is set in this process, so this failure is " + "what CI's negative control B is asking for - the poison IS armed, and this " + "entry going red is the proof.") + : std::string(); ASSERT_TRUE(WIFEXITED(status)) << "with no omission armed, a draw followed by glGenerateMipmap must complete; the child " << DescribeStatus(status) << ". If it aborted, the poison is firing on a field the verb's fill table SHOULD list - " - "add the row to MG_Pipe/FillPoints.def, never mark the field sticky. Child log:\n" + "add the row to MG_Pipe/FillPoints.def, never mark the field sticky." + << note << " Child log:\n" << childLog; EXPECT_EQ(WEXITSTATUS(status), 0) << "the child " << DescribeStatus(status) << ". Child log:\n" From 1e3a74686f223b9994959b6c404b37b123872a3e Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 02:38:38 -0400 Subject: [PATCH 063/529] [Test] (Pipe): give the arming assertion a lane and a log of its own, and stop the poison child calling a sequence it never ran a success - the arming case read the ambient lane's MOBILEGL_LOG_FILE_PATH, and that log is opened fopen(path, "w") by every process in the lane: with 406 entries per backend and CI running them -j 4, a whole-file read races a neighbour's bring-up, and the file that survives the lane holds only the LAST writer. Every other log-reading scenario in this suite (UnlocatedIoBlocks, the primgen reroute, the point-size demotion) is registered in a filtered lane with its own log for exactly that reason; PipeVerifyArmingScenario.Armed now follows them, in DirectGLES.VerifyArming. / DirectVulkan.VerifyArming., and skips anywhere MGITEST_PIPE_ARMING_LANE is unset - what that can prove is written down where it is asserted: arming is a property of (this library, this environment) and these two processes share both with their ~400 ambient siblings. A per-process census is not available through a shared log, and a comment that claimed one was the reason CI grepped a file that could not answer - the re-exec'd poison child ran RunSequence() and then _exit(0) unconditionally, so a fatal assertion inside it - the shader failing to compile, say - returned before the draw and the glGenerateMipmap and still reported success: WithoutOmissionCompletes, the one green entry negative control B turns red, passed on a child that ran none of the sequence. It now exits HasFailure() ? 1 : 0, and checks glGetError() after the mipmap so a rejected sequence is part of the answer rather than stderr nobody reads --- MobileGL/MG_IntegrationTest/CMakeLists.txt | 52 +++++++++++++++++-- .../Scenarios/PipeVerifyArmingScenario.cpp | 38 +++++++++++--- .../Scenarios/PoisonOmissionScenario.cpp | 26 ++++++++-- 3 files changed, 103 insertions(+), 13 deletions(-) diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index c4bc3d4a5..c8100d565 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -657,9 +657,14 @@ gtest_discover_tests(MobileGLIntegrationTest # MOBILEGL_PIPE_POISON_OMIT. That is what lets CI's two always-on negative-control steps # export those knobs in the JOB environment and have them reach the test processes; a # property entry of the same name would silently win and the controls would prove nothing. -# * MOBILEGL_LOG_FILE_PATH is per lane. It is the only channel a test process has for reading -# the library's own report (MG_Config is not reachable from this module), and the log is -# opened with fopen(path, "w"), so each process truncates it and the cases can trust it. +# * MOBILEGL_LOG_FILE_PATH is per lane, and "per lane" is the exact limit of what it proves. It +# is the only channel a test process has for reading the library's own report (MG_Config is not +# reachable from this module), but the log is opened fopen(path, "w"), so every process in a +# lane TRUNCATES it: after an ambient lane of 400-odd entries the file holds the LAST process +# and nothing else. Reading it is therefore only sound in a filtered, one-entry lane - which is +# why the arming case has a lane and a log of its own below, and why neither this file nor CI +# may read the ambient logs as evidence about the entries that ran before the last one. The +# ambient path is kept for post-mortems (and to keep library chatter out of ctest's capture). if (MOBILEGL_PIPE_VERIFY) # 900s, not the ambient 120: the comparator re-reads every field of the fill mask at the verb # boundary and again at every accessor read, which the design budgets at 5-10x. @@ -674,6 +679,24 @@ if (MOBILEGL_PIPE_VERIFY) "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-verify-DirectVulkan.log" ${MGL_ITEST_VULKAN_ENV}) + # The arming assertion's own lane, one case per backend, with a log path nothing else writes to. + # + # PipeVerifyArmingScenario.Armed reads the library's log, and the log is a per-LANE resource: it + # is opened fopen(path, "w"), so every process in a lane truncates it. In the ambient Verify. + # lane that is 400-odd processes on one path, run `-j 4` in CI, and a whole-file read there + # races a neighbour's bring-up. Every other log-reading scenario in this file (UnlocatedIoBlocks, + # the primgen reroute, the point-size demotion) is registered exactly like this for the same + # reason. MGITEST_PIPE_ARMING_LANE is a harness marker - the library never reads it - and it is + # what makes the case skip in the ambient lane instead of racing there. + mgl_itest_join_environment(MGL_ITEST_GLES_VERIFY_ARMING_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_VERIFY=1" "MGITEST_PIPE_ARMING_LANE=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-verify-arming-DirectGLES.log" + ${MGL_ITEST_COMMON_ENV}) + mgl_itest_join_environment(MGL_ITEST_VULKAN_VERIFY_ARMING_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_PIPE_VERIFY=1" "MGITEST_PIPE_ARMING_LANE=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-verify-arming-DirectVulkan.log" + ${MGL_ITEST_VULKAN_ENV}) + # Negative control A (G4). MOBILEGL_PIPE_VERIFY_FATAL=0 so the process SURVIVES its own # divergence and the case can read the report back out of the log; the CI step that exports # the same corruption against the ambient lane, where FATAL keeps its default of 1, asserts @@ -725,6 +748,29 @@ if (MOBILEGL_PIPE_VERIFY) ENVIRONMENT "${MGL_ITEST_VULKAN_VERIFY_ENVIRONMENT}" ) + # The arming assertion, one entry per backend. This is the entry that fails a lane whose library + # never armed: it runs the same library and the same MOBILEGL_PIPE_VERIFY=1 as the ambient + # entries above, but unlike them it cannot be green against a library with no comparator + # compiled in. Its log is its own, so `-j 4` cannot make it flake. + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.VerifyArming." + TEST_FILTER "PipeVerifyArmingScenario.Armed" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_VERIFY_ARMING_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.VerifyArming." + TEST_FILTER "PipeVerifyArmingScenario.Armed" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_VERIFY_ARMING_ENVIRONMENT}" + ) + # One case each: the knobs are process-wide, so a corrupted or poisoned process cannot also be # running the ambient assertions. These four entries are the ones that assert the RED - they # pass when the comparator and the poison report, and go red when either stops. diff --git a/MobileGL/MG_IntegrationTest/Scenarios/PipeVerifyArmingScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/PipeVerifyArmingScenario.cpp index 5ce951384..c7dca2536 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/PipeVerifyArmingScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/PipeVerifyArmingScenario.cpp @@ -30,12 +30,25 @@ // (on Android it links the SHIPPING libMobileGL.so, built -fvisibility=hidden) and the arming // signal is a latched MGLOG_I. The ctest entry sets MOBILEGL_LOG_FILE_PATH; this only reads it. // -// Note on scope: the log file is opened with fopen(path, "w") at the first log write of a process -// (MG_Util/Debug/Log.cpp, InitFile), so the file holds THIS process's lines and nothing else - a -// whole-file search cannot be satisfied by a sibling ctest entry of the same lane. The arming line -// is latched at the FIRST fill of the process, which may be the harness bring-up rather than this -// test's draw, so the arming search is whole-file on purpose; the divergence search is restricted -// to the bytes this case appended, which is where a differ belongs. +// Note on scope, and why Armed runs in a lane of its own. The log file is opened with +// fopen(path, "w") at the first log write of a process (MG_Util/Debug/Log.cpp, InitFile), so each +// process TRUNCATES it. That is fine for one process and false for many: in the ambient Verify. +// lane, 400-odd sibling entries share the one MOBILEGL_LOG_FILE_PATH, and CI runs that lane with +// `ctest -j 4`, so a neighbour's bring-up can truncate the file between this case's draw and its +// read. Every existing scenario in this suite that reads the library log (UnlocatedIoBlockScenario, +// the primgen reroute, the point-size demotion) is registered in a FILTERED lane with a log path of +// its own for exactly that reason, and this case now follows them: it runs in the VerifyArming. +// entries, which set MGITEST_PIPE_ARMING_LANE=1 and their own log, and skips everywhere else. +// +// What that proves, stated honestly: the arming line is a property of (this library, this +// environment), not of an individual test body, and the VerifyArming. entry runs the same library +// with the same MOBILEGL_PIPE_VERIFY=1 as its ~400 ambient siblings. One process per backend is +// therefore the whole of the evidence available for "the lane armed" - the per-process claim the +// shared log CANNOT support, because it only ever holds the last writer. +// +// Within the process: the arming line is latched at the FIRST fill, which may be the harness +// bring-up rather than this test's draw, so the arming search is whole-file on purpose; the +// divergence search is restricted to the bytes this case appended, which is where a differ belongs. #include #include @@ -64,6 +77,12 @@ namespace MGITest { constexpr const char* kDifferPrefix = "Fatal{PipeVerifyDiffer"; constexpr const char* kUnmigratedPrefix = "Fatal{UnmigratedPipeInput"; + // Set by the VerifyArming. ctest entries and by nothing else. It is a HARNESS variable, not + // a library knob (hence the MGITEST_ prefix): the library never reads it. It exists because + // this case reads a log file, and a log file is a per-LANE resource - see the note at the + // top of the file. + constexpr const char* kArmingLaneMarker = "MGITEST_PIPE_ARMING_LANE"; + constexpr const char* kVS = R"(#version 330 core in vec2 aPos; void main() { gl_Position = vec4(aPos, 0.0, 1.0); } @@ -158,6 +177,13 @@ void main() { o_color = vec4(0.25, 0.5, 0.75, 1.0); } TEST_F(PipeVerifyArmingScenario, Armed) { if (!Ready()) return; + if (!StringKnobIsSet(kArmingLaneMarker)) { + GTEST_SKIP() << "this case reads the library's log file, so it runs in the VerifyArming. " + "lane, which owns a log path no other entry writes to. In the ambient " + "Verify. lane 400-odd entries share one path and each truncates it " + "(Log.cpp opens it \"w\"), so a whole-file read here would race a " + "neighbour under ctest -j 4. Set by the ctest entry, never by hand."; + } if (AmbientQuirkFromEnvironment("MOBILEGL_PIPE_VERIFY") != AmbientQuirk::On) { GTEST_SKIP() << "this case needs MOBILEGL_PIPE_VERIFY=1 for the whole process, which is " "what the Verify. ctest entries set; with the variable unset the " diff --git a/MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp index 79f859d00..d6b1568ee 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp @@ -182,6 +182,15 @@ void main() { o_color = vec4(0.25, 0.5, 0.75, 1.0); } glFinish(); std::fprintf(stderr, "[itest] poison worker: glGenerateMipmap returned\n"); + // The sequence is the WHOLE datum this child reports, so a GL error in it must be + // part of the answer rather than something only a human reading stderr would see. + // WithoutOmissionCompletes reads the child's exit status, and the status is built + // from HasFailure() below - so this EXPECT is what turns "the mipmap was rejected" + // into a red parent instead of a vacuous "it exited 0, the poison did not fire". + EXPECT_EQ(FirstGLError(), 0u) + << "the draw + glGenerateMipmap sequence the poison controls are about raised a " + "GL error, so neither control is measuring what it claims to measure"; + glBindVertexArray(0); glDeleteBuffers(1, &vbo); glDeleteVertexArrays(1, &vao); @@ -277,8 +286,14 @@ void main() { o_color = vec4(0.25, 0.5, 0.75, 1.0); } // _exit, and not a return into gtest's teardown: this process exists to reach the verb // above and its exit status is the datum the parent reads. A normal teardown of a live // context could add signals of its own to that answer. + // + // HasFailure(), not 0: RunSequence() is full of ASSERT_/EXPECT_ macros, and a fatal one + // (the shader failing to compile, say) RETURNS from RunSequence before the draw and the + // glGenerateMipmap ever happen. Exiting 0 there would have WithoutOmissionCompletes pass + // on a child that ran none of the sequence it is the control for - green because nothing + // happened. The child's assertion text is on its stderr, which ctest captures. std::fflush(nullptr); - _exit(0); + _exit(::testing::Test::HasFailure() ? 1 : 0); #endif } @@ -369,9 +384,12 @@ void main() { o_color = vec4(0.25, 0.5, 0.75, 1.0); } "add the row to MG_Pipe/FillPoints.def, never mark the field sticky." << note << " Child log:\n" << childLog; - EXPECT_EQ(WEXITSTATUS(status), 0) << "the child " << DescribeStatus(status) - << ". Child log:\n" - << childLog; + EXPECT_EQ(WEXITSTATUS(status), 0) + << "the child " << DescribeStatus(status) + << ". Status 1 is the child's OWN assertion failing inside the sequence (it exits " + "HasFailure() ? 1 : 0), so its gtest output on this job's stderr names the line; " + "anything else came from the harness. Child log:\n" + << childLog; EXPECT_EQ(childLog.find("Fatal{"), std::string::npos) << "an unpoisoned run logged a Fatal:\n" << childLog; From 72aa9191b490c2b65ac74a693c5fcd99aa8c7f63 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 02:38:49 -0400 Subject: [PATCH 064/529] [Fix] (Tooling): gate symbol_report on all four buckets and on a .text that moved in either direction - G1 is spelled "added == removed == resized == renamed == 0, .text delta 0", but gate_failures() took only added and removed and fired on text_delta > N: a pull build whose .text SHRANK, or whose functions were resized with zero net delta - the exact shape a null-guard or ternary rewrite produces - walked through `--threshold 0 --fail-on-symbol-set-change --fail-on-added-bytes 0` green - --fail-on-symbol-set-change now covers the four buckets the report prints, and --fail-on-added-bytes 0 means byte-identical rather than "did not grow" (a positive budget keeps the one-sided meaning). --fail-on-text-delta is the explicit spelling for a run that wants no byte budget at all - the gates read the threshold-0 buckets whatever --threshold says: --threshold is a report control, and a gate that read the thresholded resize list would have quietly weakened itself the day someone raised it. The run says so in its output - the self-test drives the three shapes that used to pass (a shrunk .text at budget 0, a resize and a rename at zero net delta) from the same canned transcripts --- scripts/symbol_report.py | 114 ++++++++++++++++++++++++++++++--------- 1 file changed, 88 insertions(+), 26 deletions(-) mode change 100755 => 100644 scripts/symbol_report.py diff --git a/scripts/symbol_report.py b/scripts/symbol_report.py old mode 100755 new mode 100644 index 1de2ee106..c3c1d732d --- a/scripts/symbol_report.py +++ b/scripts/symbol_report.py @@ -32,13 +32,20 @@ This tool is informational by default (ARCHITECTURE.md:507, job name `monolith-symbol-report` ARCHITECTURE.md:568): with no gate flag it prints its report and exits 0, whatever it found. -Two flags turn it into a hard gate, and P1's G1 uses both - the strangler's pull build has to -stay byte-identical, so `--fail-on-symbol-set-change --fail-on-added-bytes 0` is the spelling of -"nothing was added, removed or grown": +Three flags turn it into a hard gate, and P1's G1 uses the first two - the strangler's pull build +has to stay byte-identical, and G1 spells that out as `added == removed == resized == renamed == 0` +with a `.text` delta of zero: python3 scripts/symbol_report.py --before base.so --after head.so \ --threshold 0 --fail-on-symbol-set-change --fail-on-added-bytes 0 + * `--fail-on-symbol-set-change` covers all FOUR buckets, not just added/removed: a resize at zero + net delta and a rename at zero net delta are both source changes, and both are what a guard + rewrite produces. It always compares at threshold 0, whatever `--threshold` is set to. + * `--fail-on-added-bytes 0` means byte-identical, so a SHRUNK .text fails it too; a positive + budget keeps the one-sided "must not grow by more than N" meaning. `--fail-on-text-delta` is + the explicit spelling of the zero case for a run that wants no byte budget at all. + A gate that fires still writes its Markdown and JSON first: the report IS the diagnosis, and a CI job that failed before uploading its artifact is a job nobody can act on. """ @@ -241,21 +248,40 @@ def markdown_table(title, rows, columns): """) -def gate_failures(added, removed, text_delta, fail_on_added_bytes, fail_on_symbol_set_change): +def gate_failures(added, removed, resized, renamed, text_delta, fail_on_added_bytes, + fail_on_symbol_set_change, fail_on_text_delta=False): """The reasons this run should fail, in the order they are reported. Empty means green. Pure, and takes the buckets rather than the file paths, so the self-test can drive it from the canned transcripts: a gate whose only test is a real build is a gate nobody re-tests. + + P1's G1 is spelled `added == removed == resized == renamed == 0` and `.text` delta 0, so all + FOUR buckets are part of --fail-on-symbol-set-change and a budget of 0 bytes means zero + movement in EITHER direction. A shrunk .text and a resize with zero net delta are exactly the + shapes a guard rewrite produces, and both used to walk straight through this function. """ reasons = [] - if fail_on_symbol_set_change and (added or removed): + if fail_on_symbol_set_change and (added or removed or resized or renamed): reasons.append( - "--fail-on-symbol-set-change: {} symbol(s) added, {} removed. The defined-symbol set " - "is not a property of the build machine, so any change here is a source change - name " - "each one in the commit message or fix it.".format(len(added), len(removed))) - if fail_on_added_bytes is not None and text_delta > fail_on_added_bytes: + "--fail-on-symbol-set-change: {} symbol(s) added, {} removed, {} resized, {} renamed. " + "None of that is a property of the build machine, so any change here is a source " + "change - name each one in the commit message or fix it.".format( + len(added), len(removed), len(resized), len(renamed))) + if fail_on_text_delta and text_delta != 0: reasons.append( - "--fail-on-added-bytes {}: .text grew by {} bytes.".format(fail_on_added_bytes, text_delta)) + "--fail-on-text-delta: .text moved by {:+d} bytes.".format(text_delta)) + if fail_on_added_bytes is not None: + # A budget of zero is not "must not grow", it is "must not move": the pull build of a + # strangler that got smaller is just as much a code change as one that got bigger, and G1 + # names the delta, not its sign. A positive budget keeps the older, one-sided meaning. + if fail_on_added_bytes == 0 and text_delta != 0 and not fail_on_text_delta: + reasons.append( + "--fail-on-added-bytes 0: .text moved by {:+d} bytes (a budget of 0 means the " + "section must be byte-identical, in either direction).".format(text_delta)) + elif fail_on_added_bytes > 0 and text_delta > fail_on_added_bytes: + reasons.append( + "--fail-on-added-bytes {}: .text grew by {} bytes.".format( + fail_on_added_bytes, text_delta)) return reasons @@ -283,23 +309,36 @@ def self_test(): problems.append("unchanged count: {} (expected 2)".format(unchanged)) if before_sections.get(".text") != 1000 or after_sections.get("Total") != 1600: problems.append("size --format=sysv parse: {} / {}".format(before_sections, after_sections)) - # The two gates, driven from the same canned transcripts: the after side adds one symbol, - # removes one and grows .text by 100, so each flag must fire, each must stay quiet when it is - # not asked for, and --fail-on-added-bytes must accept a delta it was told to tolerate. + # The three gates, driven from the same canned transcripts: the after side adds one symbol, + # removes one, resizes one, renames one and grows .text by 100, so each flag must fire, each + # must stay quiet when it is not asked for, and --fail-on-added-bytes must accept a delta it + # was told to tolerate. text_delta = after_sections.get(".text", 0) - before_sections.get(".text", 0) - if gate_failures(added, removed, text_delta, None, False): + if gate_failures(added, removed, resized, renamed, text_delta, None, False): problems.append("gates fired with no flag set") - if len(gate_failures(added, removed, text_delta, None, True)) != 1: + if len(gate_failures(added, removed, resized, renamed, text_delta, None, True)) != 1: problems.append("--fail-on-symbol-set-change did not fire on 1 added + 1 removed") - if len(gate_failures(added, removed, text_delta, 0, False)) != 1: + if len(gate_failures(added, removed, resized, renamed, text_delta, 0, False)) != 1: problems.append("--fail-on-added-bytes 0 did not fire on a +100 .text delta") - if gate_failures(added, removed, text_delta, 100, False): + if gate_failures(added, removed, resized, renamed, text_delta, 100, False): problems.append("--fail-on-added-bytes 100 fired on a +100 .text delta") - if len(gate_failures([], [], text_delta, 0, True)) != 1: + if len(gate_failures([], [], [], [], text_delta, 0, True)) != 1: problems.append("an unchanged symbol set still tripped --fail-on-symbol-set-change") + # The three shapes that used to walk through: a SHRUNK .text under a zero budget, a resize with + # no net delta, and a rename with no net delta. G1 forbids all three by name. + if not gate_failures([], [], [], [], -4096, 0, False): + problems.append("--fail-on-added-bytes 0 passed a .text that SHRANK by 4096 bytes") + if not gate_failures([], [], resized, [], 0, None, True): + problems.append("--fail-on-symbol-set-change passed a resized symbol at zero net delta") + if not gate_failures([], [], [], renamed, 0, None, True): + problems.append("--fail-on-symbol-set-change passed a renamed symbol at zero net delta") + if not gate_failures([], [], [], [], -4096, None, False, True): + problems.append("--fail-on-text-delta passed a .text that SHRANK by 4096 bytes") + if gate_failures([], [], [], [], 0, None, False, True): + problems.append("--fail-on-text-delta fired on a zero .text delta") for problem in problems: say("self-test: " + problem) - say("self-test: " + ("OK (2 canned transcripts, 5 buckets, 2 gates)" if not problems else "FAILED")) + say("self-test: " + ("OK (2 canned transcripts, 5 buckets, 3 gates)" if not problems else "FAILED")) return 0 if not problems else 1 @@ -325,11 +364,17 @@ def main(): parser.add_argument("--threshold", type=int, default=0, help="ignore size deltas of at most this many bytes") parser.add_argument("--fail-on-added-bytes", type=int, default=None, - help="exit non-zero when .text grew by more than this many bytes " - "(0 = the pull build must not grow at all)") + help="exit non-zero when .text grew by more than this many bytes; 0 is " + "special and means the section must be BYTE-IDENTICAL, so a shrink " + "fails it too (that is what P1's G1 asks for)") + parser.add_argument("--fail-on-text-delta", action="store_true", + help="exit non-zero when .text moved at all, in either direction. The " + "explicit spelling of what --fail-on-added-bytes 0 also does.") parser.add_argument("--fail-on-symbol-set-change", action="store_true", - help="exit non-zero when any defined symbol was added or removed " - "(renamed-only folds, see --strip-scope, do not count)") + help="exit non-zero when any defined symbol was added, removed, resized or " + "renamed (the four buckets of the report; --strip-scope decides which " + "of added+removed vs renamed a de-nesting lands in, it does not " + "excuse it). Compared at threshold 0 whatever --threshold says.") parser.add_argument("--self-test", action="store_true") args = parser.parse_args() @@ -339,6 +384,9 @@ def main(): if not args.before or not args.after: parser.error("--before and --after are required (or use --self-test)") + if args.fail_on_added_bytes is not None and args.fail_on_added_bytes < 0: + parser.error("--fail-on-added-bytes takes a byte budget of 0 or more") + rename_map = [] for entry in args.rename_map: if "=" not in entry: @@ -353,6 +401,13 @@ def main(): args.after, args.nm, args.size, args.cxxfilt, args.strip_scope, rename_map) removed, added, resized, renamed, unchanged = bucket(before, after, only_names, args.threshold) + # --threshold is a REPORT control - "do not list resizes smaller than this" - and a gate that + # read the thresholded buckets would quietly weaken itself the day someone raised it. The gates + # always see the threshold-0 buckets. + if args.threshold: + gate_removed, gate_added, gate_resized, gate_renamed, _ = bucket(before, after, only_names, 0) + else: + gate_removed, gate_added, gate_resized, gate_renamed = removed, added, resized, renamed before_text = before_sections.get(".text", 0) after_text = after_sections.get(".text", 0) @@ -382,10 +437,17 @@ def main(): len(before), len(after), unchanged)) if only_names: say("listing restricted to names containing: " + ", ".join(only_names)) - gates = gate_failures(added, removed, delta, args.fail_on_added_bytes, - args.fail_on_symbol_set_change) - if args.fail_on_added_bytes is None and not args.fail_on_symbol_set_change: + gates = gate_failures(gate_added, gate_removed, gate_resized, gate_renamed, delta, + args.fail_on_added_bytes, args.fail_on_symbol_set_change, + args.fail_on_text_delta) + if (args.fail_on_added_bytes is None and not args.fail_on_symbol_set_change + and not args.fail_on_text_delta): say("no gate flag: informational run") + elif args.threshold: + say("gates compare at threshold 0 ({} added, {} removed, {} resized, {} renamed there); " + "--threshold {} only shortens the report".format( + len(gate_added), len(gate_removed), len(gate_resized), len(gate_renamed), + args.threshold)) lines = ["# MobileGL symbol report", "", "| side | path | file bytes | .text |", From 7d80c9678eabd3847f3c71afdc3807be8f3f1465 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 02:39:10 -0400 Subject: [PATCH 065/529] [Test] (Retrace): scan a verify retrace's log for the third MGPipe Fatal too - the block looked for Fatal{PipeVerifyDiffer and Fatal{UnmigratedPipeInput only. A misspelt MOBILEGL_PIPE_VERIFY_CORRUPT / MOBILEGL_PIPE_POISON_OMIT reports Fatal{PipeVerifyBadKnob, and it was caught only because D2 makes that one abort the process - which is precisely what MOBILEGL_PIPE_VERIFY_FATAL=0, the supported triage configuration, takes away. A typo'd knob would have left a negative-control run looking healthy - the FATAL_ERROR text now says what each of the three means and where the two vocabularies live, because that message is the whole diagnosis a `cmake -P` step gets --- tools/trace_replay/run_trace_case.cmake | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tools/trace_replay/run_trace_case.cmake b/tools/trace_replay/run_trace_case.cmake index 1d76d704b..6f0110bf7 100644 --- a/tools/trace_replay/run_trace_case.cmake +++ b/tools/trace_replay/run_trace_case.cmake @@ -140,10 +140,13 @@ endif() # retrace is untouched: # * mobilegl.log exists - the replay wrote one, so the library was loaded and logging; # * it carries "MGPipe: verify armed" - the comparator armed in THIS process; -# * it carries neither Fatal{PipeVerifyDiffer (a push/pull divergence, the thing the mode -# exists to find) nor Fatal{UnmigratedPipeInput (a backend read of a field the verb's fill -# table does not list - fixed by adding the row to MG_Pipe/FillPoints.def, never by marking -# the field sticky). +# * it carries none of the three MGPipe Fatals: Fatal{PipeVerifyDiffer (a push/pull divergence, +# the thing the mode exists to find), Fatal{UnmigratedPipeInput (a backend read of a field the +# verb's fill table does not list - fixed by adding the row to MG_Pipe/FillPoints.def, never by +# marking the field sticky), and Fatal{PipeVerifyBadKnob (a misspelt MOBILEGL_PIPE_VERIFY_CORRUPT +# or MOBILEGL_PIPE_POISON_OMIT). The third is in the regex on purpose even though D2 makes it +# abort the process: with MOBILEGL_PIPE_VERIFY_FATAL=0 the abort is exactly what does not +# happen, and a typo'd knob would otherwise leave the negative-control lane looking healthy. # The Fatal check is not redundant with the replay's exit status: MOBILEGL_PIPE_VERIFY_FATAL=0 is # the supported triage configuration, and there the divergence is logged and counted rather than # aborted, so the run would otherwise finish 0 with its own report in the log. @@ -168,7 +171,7 @@ if(DEFINED ENV{MOBILEGL_PIPE_VERIFY} AND NOT "$ENV{MOBILEGL_PIPE_VERIFY}" STREQU "runtime artifact is the one unpacked at ${MOBILEGL_LIBRARY}.") endif() file(STRINGS "${mobilegl_log}" pipe_verify_fatals - REGEX "Fatal\\{(PipeVerifyDiffer|UnmigratedPipeInput)") + REGEX "Fatal\\{(PipeVerifyDiffer|UnmigratedPipeInput|PipeVerifyBadKnob)") if(pipe_verify_fatals) foreach(line IN LISTS pipe_verify_fatals) message(STATUS "${line}") @@ -179,7 +182,9 @@ if(DEFINED ENV{MOBILEGL_PIPE_VERIFY} AND NOT "$ENV{MOBILEGL_PIPE_VERIFY}" STREQU "MOBILEGL_PIPE_VERIFY. Fatal{PipeVerifyDiffer, \"@\"} is a real push/pull " "divergence and is recorded, not silenced; Fatal{UnmigratedPipeInput, \"@\"} " "is a missing row in MG_Pipe/FillPoints.def's class table - add it, regenerate, rerun " - "(never mark the field sticky).") + "(never mark the field sticky); Fatal{PipeVerifyBadKnob, ...} is a misspelt " + "MOBILEGL_PIPE_VERIFY_CORRUPT / MOBILEGL_PIPE_POISON_OMIT - fix the spelling, the " + "vocabularies are kMGPipeInputFieldNames[] and kMGPipeVerbNames[].") endif() message(STATUS "MGPipe verify: ${pipe_verify_case} armed, zero divergences, zero unmigrated reads") endif() From 97b997d5dae8327c5d9ea41ccf8ef03755e209c7 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 02:39:10 -0400 Subject: [PATCH 066/529] [CI] (Pipe): read the verify library's static symtab, grep only the arming lane's log, and stop the two retrace lanes overwriting each other's evidence - both `nm -D --defined-only ... | grep -q MGPipe...` gates could never pass: the library is built CXX_VISIBILITY_PRESET hidden in every non-Debug configuration and the MGPipe entry points carry no export attribute, so the dynamic table holds none of them (0 of 11930 exported symbols on this tree, while `nm --defined-only` finds MGPipeFillForVerb as a local `t`). build-linux-verify, and with it integration-verify and every retrace-verify entry, would have been red forever for a reason unrelated to the comparator. Both now read the static table, name what a miss means, and refuse a stripped artifact instead of reporting its silence as a missing symbol - "Every verify process really armed" grepped one shared per-lane log that holds only the LAST process of 406 - and the last ambient entry is the poison control's parent, which forks, execve()s and waits without ever issuing a verb, so the step would have red a healthy lane while proving nothing about the other 405. It now greps the two VerifyArming. lanes' own logs, requires one per backend, and says what that establishes - remove-artifact-clutter kept fixtures for failed `retrace (` jobs only, so a failed `retrace verify (` case lost the fixture needed to reproduce it; both prefixes now count - the retrace negative control replayed OpenRA into the same case directory, so "Upload actual image" shipped the deliberately corrupted run's output under the good run's name. The verified output is put aside and restored before the verdict - build-linux-verify regains build-linux's "Show installed toolchain" step, and its deliberate divergence (Release even under ACTIONS_STEP_DEBUG - a Debug build would flip visibility and arm the poison through a different #if arm) is written down - the lane's scope is stated where it is run: every integration ENTRY under the comparator, not every configuration - `integration`'s second MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH=1 pass (186 entries here) is not affordable at the 5-10x the comparator costs, so that tier stays covered unverified by `integration` --- .github/workflows/test.yml | 115 +++++++++++++++++++++++++++++++------ 1 file changed, 98 insertions(+), 17 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 471268fd4..3d686f676 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -360,11 +360,22 @@ jobs: sudo apt-get update sudo apt-get install -y ccache clang-20 clang++-20 lld-20 libc++-20-dev libc++abi-20-dev libvulkan-dev libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers ninja-build + - name: Show installed toolchain + run: | + ccache --version + clang-20 --version + clang++-20 --version + ld.lld-20 --version || ld.lld --version || true + dpkg -l 'libc++*' 'libegl*' 'libgles*' 'mesa*' 'vulkan*' || true + - name: Configure CMake # Release/INFO like the shipped build on purpose. The poison arms in this configuration # through MOBILEGL_PIPE_VERIFY (PipeInputs.h derives MOBILEGL_PIPE_POISON from it), so this # job needs neither a Debug log level nor MOBILEGL_BUILD_DISAGGREGATED - and a Debug build - # would compare a different library from the one the other lanes measure. + # would compare a different library from the one the other lanes measure. (build-linux + # switches to Debug under ACTIONS_STEP_DEBUG; this job deliberately does not - a Debug + # build flips CXX_VISIBILITY_PRESET and arms MOBILEGL_PIPE_POISON through a second, unrelated + # arm of its #if, so the debug switch would change what the lane is measuring.) run: | cmake -S . -B "${BUILD_DIR}" -G Ninja \ -DCMAKE_C_COMPILER=clang-20 \ @@ -387,12 +398,31 @@ jobs: # The lane is worthless if the option silently did not take, and that is a one-character # mistake away at all times (a typo'd -D is not an error in CMake). Two checks, both cheap: # the comparator's entry point must be in the library, and the fill entry point with it. + # + # `nm` and NOT `nm -D`. The library is built CXX_VISIBILITY_PRESET hidden in every non-Debug + # configuration (CMakeLists.txt:600-604) and the MGPipe entry points are plain namespace + # functions with no export attribute, so not one of them appears in the DYNAMIC table: on a + # perfectly healthy verify build `nm -D --defined-only ... | grep -c MGPipe` answers 0 out of + # ~11900 exported symbols, and a gate spelled that way is red forever for a reason that has + # nothing to do with what it claims to test. The static symbol table has them as local `t` + # entries, this artifact is never stripped, and `No MG_Remote in the pull build` below already + # uses this spelling. The symbol count guards the remaining hole: a stripped library would + # make both greps fail for a third, silent reason. - name: The verify library really carries the comparator run: | test -f "${BUILD_DIR}/libMobileGL.so" - nm -D --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -q MGPipeVerifyInputs - nm -D --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -q MGPipeFillForVerb - echo "libMobileGL.so exports MGPipeVerifyInputs and MGPipeFillForVerb" + defined=$(nm --defined-only "${BUILD_DIR}/libMobileGL.so" | wc -l) + if [ "${defined}" -lt 1000 ]; then + echo "::error::nm --defined-only sees only ${defined} symbols in ${BUILD_DIR}/libMobileGL.so - it looks stripped, so the two checks below could not have failed honestly" + exit 1 + fi + for entry in MGPipeVerifyInputs MGPipeFillForVerb; do + if ! nm --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -q "${entry}"; then + echo "::error::libMobileGL.so defines no ${entry}: -DMOBILEGL_PIPE_VERIFY=ON did not take, and every lane that consumes this artifact would run the comparator-free library and pass having compared nothing" + exit 1 + fi + done + echo "libMobileGL.so defines MGPipeVerifyInputs and MGPipeFillForVerb (${defined} defined symbols)" - name: Show ccache stats if: always() @@ -487,6 +517,13 @@ jobs: # tests and reds here instead of reporting a green run of nothing. The other half is # PipeVerifyArmingScenario.Armed, which fails when the library never printed its arming # line - the failure mode a bare `MOBILEGL_PIPE_VERIFY=1` cannot detect by itself. + # + # SCOPE, stated so nobody reads more into a green than is there: this is every integration + # ENTRY under the comparator, not every integration CONFIGURATION. The `integration` job + # runs a second, filtered pass with MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH=1 for the + # upload ring's staged-copy tier; that pass is 186 entries here and, at the 5-10x the + # comparator costs, is not affordable inside this job's budget. The tier is covered by + # `integration`, unverified, and P2 can take it once the comparator's cost is known. env: MOBILEGL_ITEST_REQUIRE_GPU: "1" MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH: "1" @@ -501,22 +538,35 @@ jobs: ctest --output-on-failure -L integration-verify --no-tests=error fi - - name: Every verify process really armed + # The arming lanes' logs, and ONLY those. Each lane shares one MOBILEGL_LOG_FILE_PATH and the + # library opens it fopen(path, "w"), so after an ambient lane of 400-odd processes the file + # holds the LAST one - grepping it would say nothing about the other 405 and would red a + # healthy lane whenever the last entry happened not to issue a verb (which is what the + # PoisonOmissionScenario parent, the last ambient entry, does by construction: it forks, + # execve()s and reads files). The DirectGLES.VerifyArming. / DirectVulkan.VerifyArming. + # entries are one process each on a log path nothing else writes, so this grep means exactly + # what it says. + # + # What it proves: arming is a property of (this library, this environment), and these two + # processes ran the same library with the same MOBILEGL_PIPE_VERIFY=1 as their ~400 ambient + # siblings. It is not, and cannot be, a per-process census - the shared log cannot support one. + # It catches the case ctest cannot: an arming entry that SKIPPED still reports green. + - name: The verify lanes armed the comparator working-directory: build-verify run: | shopt -s nullglob - logs=(MobileGL/MG_IntegrationTest/pipe-verify-*.log) - if [ ${#logs[@]} -eq 0 ]; then - echo "::error::the verify lane wrote no pipe-verify-*.log at all" + logs=(MobileGL/MG_IntegrationTest/pipe-verify-arming-*.log) + if [ ${#logs[@]} -lt 2 ]; then + echo "::error::found ${#logs[@]} pipe-verify-arming-*.log (expected one per backend). The VerifyArming. entries did not run, so nothing in this job establishes that the comparator was ever armed." exit 1 fi for log in "${logs[@]}"; do if ! grep -q "MGPipe: verify armed" "${log}"; then - echo "::error::${log} carries no arming line: that lane ran without the comparator" + echo "::error::${log} carries no arming line: that lane's process ran the whole scenario without the comparator, so every green entry beside it is green for no reason" exit 1 fi done - echo "arming line present in ${#logs[@]} lane log(s)" + echo "arming line present in all ${#logs[@]} arming-lane log(s)" # NEGATIVE CONTROL A (gate G4). The knob perturbs one field in the snapshot arm before the # entry compare, so a working comparator must abort the run. This step passes when ctest @@ -1150,7 +1200,10 @@ jobs: # /build-linux/libMobileGL.so frozen into every case, so the swap happens here # rather than through a variable: the verify .so is put where that path points. The nm # check is what makes the swap falsifiable - a run against the ordinary library would - # export no comparator, ignore MOBILEGL_PIPE_VERIFY entirely, and match its golden. + # carry no comparator, ignore MOBILEGL_PIPE_VERIFY entirely, and match its golden. + # + # `nm`, not `nm -D`, for the reason spelled out in build-linux-verify: everything MGPipe is + # hidden-visibility in a Release build and the dynamic table has none of it. run: | tar -xzf mobilegl-linux-runtime-verify.tgz tar -xzf mobilegl-trace-replay.tgz @@ -1158,7 +1211,10 @@ jobs: test -f build-retrace/tools/trace_replay/mobilegl_trace_replay mkdir -p build-linux cp build-verify/libMobileGL.so build-linux/libMobileGL.so - nm -D --defined-only build-linux/libMobileGL.so | grep -q MGPipeVerifyInputs + if ! nm --defined-only build-linux/libMobileGL.so | grep -q MGPipeVerifyInputs; then + echo "::error::the library unpacked at build-linux/libMobileGL.so defines no MGPipeVerifyInputs, so this retrace would replay against a comparator-free build and pass on its golden having verified nothing" + exit 1 + fi echo "the library at build-linux/libMobileGL.so is the verify build" - name: Retrace and validate under MOBILEGL_PIPE_VERIFY @@ -1185,18 +1241,38 @@ jobs: # The retrace lane's own always-on negative control, on one case so it costs one short trace: # with a snapshot field corrupted, the SAME replay must fail. Without it, "40 traces, zero # divergences" would be a statement about a comparator nobody watched. + # + # The rerun replays into the SAME case directory, so the verified run's images are put aside + # first and restored before the verdict: "Upload actual image" below runs `if: always()` and + # would otherwise ship the deliberately corrupted run's output under the name of the good one. + # The restore happens whichever way the control goes, which is why the ctest exit status is + # captured rather than tested inline. - name: Negative control - a corrupted snapshot field must red this retrace if: ${{ matrix.case == 'OpenRA' && matrix.backend == 'DirectGLES' }} working-directory: build-retrace/tools/trace_replay run: | export MOBILEGL_PIPE_VERIFY=1 export MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters - if ctest -V --no-tests=error --timeout 10800 \ - -R '^MobileGLTraceReplay\.OpenRA\.DirectGLES$'; then + GOOD_OUTPUT="${RUNNER_TEMP}/openra-verified-output" + rm -rf "${GOOD_OUTPUT}" + if [ -d OpenRA ]; then + cp -a OpenRA "${GOOD_OUTPUT}" + fi + set +e + ctest -V --no-tests=error --timeout 10800 \ + -R '^MobileGLTraceReplay\.OpenRA\.DirectGLES$' + control_rc=$? + set -e + if [ -d "${GOOD_OUTPUT}" ]; then + rm -rf OpenRA + mv "${GOOD_OUTPUT}" OpenRA + echo "restored the verified run's OpenRA output over the corrupted rerun's" + fi + if [ "${control_rc}" -eq 0 ]; then echo "::error::MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters left the OpenRA retrace GREEN, so the comparator is not comparing and the whole verify retrace lane proves nothing." exit 1 fi - echo "the corrupted field turned the retrace red, as it must" + echo "the corrupted field turned the retrace red, as it must (ctest exit ${control_rc})" - name: Upload core dumps if: failure() @@ -1354,14 +1430,19 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + # Both retrace lanes, not just the pull one: `retrace verify (backend, case)` downloads + # the same trace-fixture- artifact, and a failed verify retrace is exactly when + # someone needs that fixture to reproduce locally. The two prefixes are stripped in + # order, longest first, because "retrace (" is not a prefix of "retrace verify (". declare -A failed_cases=() while IFS= read -r job_name; do - case_name="${job_name#retrace (*, }" + case_name="${job_name#retrace verify (*, }" + case_name="${case_name#retrace (*, }" case_name="${case_name%)}" failed_cases["${case_name}"]=1 done < <( gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs?per_page=100" \ - --jq '.jobs[] | select(.name | startswith("retrace (")) | select(.conclusion == "failure" or .conclusion == "cancelled" or .conclusion == "timed_out" or .conclusion == "action_required") | .name' + --jq '.jobs[] | select((.name | startswith("retrace (")) or (.name | startswith("retrace verify ("))) | select(.conclusion == "failure" or .conclusion == "cancelled" or .conclusion == "timed_out" or .conclusion == "action_required") | .name' ) if ((${#failed_cases[@]})); then From 80a6b3900311b47e18cb92fac28b775cc3193e83 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 05:31:50 -0400 Subject: [PATCH 067/529] [Fix] (Pipe): fill the capability set on a texture op and a dispatch, and the shader blit's viewport, provoking vertex and buffer bindings - The verify retrace aborted four cases with Fatal{UnmigratedPipeInput, "IsCapabilityEnabled@GenerateMipmap"} (x3) and "@DispatchCompute" (x1): Magma materialises a texture's queued clear inside both verbs (GenerateMipmap -> MaterializePendingClearForTexture, DispatchCompute -> PrepareStorageImageTextures -> the same), and the clear pre-compensates its colour against GL_FRAMEBUFFER_SRGB in VkClearManager::PreCompensateSrgbClearColor. Neither class named the field. - Audited every class the same way rather than stopping at those two rows. Two more helper-program draws sit inside verbs whose class did not name what they read: GenerateMipmap takes GenerateDepthMipmapWithShader for a depth texture and BlitFramebuffer takes TryBlitToDefaultFramebufferWithShader for the default framebuffer. Both bind their helper's descriptors through BindProgramUniformBuffers, whose sampler resolver reads the draw framebuffer for its feedback-loop check and whose buffer-block resolvers read the frontend binding points; the blit additionally sets the dynamic viewport through ApplyGLViewportState -> ComputeGLViewport and picks its pipeline's provoking vertex through GetOrCreateBlitPipeline -> SelectProvokingVertexMode. - kTextureOp gains IsCapabilityEnabled, GetFramebufferBindingSlot and GetBufferBindingPoint; kDispatch gains IsCapabilityEnabled; kBlitOrCopy gains GetViewportIndexed, GetDepthRangeIndexed, GetProvokingVertexMode and GetBufferBindingPoint. Every row carries the path it was derived from. - Also unfolds the kReadback transform-feedback rows 9087f133 landed on one 1200-column line back into the file's one-row-per-line shape; no row changes. --- MobileGL/MG_Pipe/FillPoints.def | 31 ++++++++++++++++++- MobileGL/MG_Pipe/generated/PipeFillPoints.inc | 12 +++---- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/MobileGL/MG_Pipe/FillPoints.def b/MobileGL/MG_Pipe/FillPoints.def index e66bff85c..b71a9b4cc 100644 --- a/MobileGL/MG_Pipe/FillPoints.def +++ b/MobileGL/MG_Pipe/FillPoints.def @@ -173,6 +173,10 @@ X(kDispatch, GetSamplingResolutionGeneration) \ X(kDispatch, GetImageTextureBinding) \ X(kDispatch, GetFramebufferBindingSlot) \ + /* Magma's PrepareStorageImageTextures materialises a queued clear for every */ \ + /* storage image the dispatch writes, and the clear pre-compensates its colour */ \ + /* against GL_FRAMEBUFFER_SRGB (VkClearManager::PreCompensateSrgbClearColor). */ \ + X(kDispatch, IsCapabilityEnabled) \ X(kDispatch, GetPatchVertices) \ X(kDispatch, GetPatchDefaultOuterLevel) \ X(kDispatch, GetPatchDefaultInnerLevel) \ @@ -214,6 +218,17 @@ X(kBlitOrCopy, GetColorMaskIndexed) \ X(kBlitOrCopy, GetDepthMask) \ X(kBlitOrCopy, GetStencilState) \ + /* Magma's shader blit to the default framebuffer */ \ + /* (TryBlitToDefaultFramebufferWithShader) is a real draw of a backend-owned */ \ + /* helper program: it sets the dynamic viewport through ApplyGLViewportState */ \ + /* -> ComputeGLViewport (viewport 0 and its depth range), picks the pipeline's */ \ + /* provoking vertex through GetOrCreateBlitPipeline -> SelectProvokingVertexMode, */ \ + /* and binds the helper's descriptors through BindProgramUniformBuffers, whose */ \ + /* buffer-block resolvers read the frontend binding points. */ \ + X(kBlitOrCopy, GetViewportIndexed) \ + X(kBlitOrCopy, GetDepthRangeIndexed) \ + X(kBlitOrCopy, GetProvokingVertexMode) \ + X(kBlitOrCopy, GetBufferBindingPoint) \ /* kTextureOp */ \ X(kTextureOp, GetActiveTextureUnit) \ X(kTextureOp, GetTextureUnitObject) \ @@ -222,6 +237,15 @@ X(kTextureOp, GetSamplingResolutionGeneration) \ X(kTextureOp, GetTextureBindGeneration) \ X(kTextureOp, GetMaxTouchedTextureUnit) \ + /* Magma's GenerateMipmap materialises the texture's queued clear before it */ \ + /* blits (MaterializePendingClearForTexture -> PreCompensateSrgbClearColor, */ \ + /* which reads GL_FRAMEBUFFER_SRGB), and a depth texture takes the shader path */ \ + /* (GenerateDepthMipmapWithShader -> BindProgramUniformBuffers), whose sampler */ \ + /* resolver reads the draw framebuffer for the feedback-loop check and whose */ \ + /* buffer-block resolvers read the frontend binding points. */ \ + X(kTextureOp, IsCapabilityEnabled) \ + X(kTextureOp, GetFramebufferBindingSlot) \ + X(kTextureOp, GetBufferBindingPoint) \ /* kReadback */ \ X(kReadback, GetPixelStoreParameters) \ X(kReadback, GetBufferBindingSlot) \ @@ -237,7 +261,12 @@ X(kReadback, GetSamplingResolutionGeneration) \ X(kReadback, GetTextureBindGeneration) \ X(kReadback, GetMaxTouchedTextureUnit) \ - X(kReadback, GetImageTextureBinding) /* The depth/stencil read emulation draws (ScopedEmulationDrawState, */ /* DirectGLES.cpp:5224) and pauses an active capture around its own draw, so */ /* a readback reads the transform-feedback state exactly as a draw does. */ X(kReadback, IsTransformFeedbackActive) X(kReadback, IsTransformFeedbackPaused) \ + X(kReadback, GetImageTextureBinding) \ + /* The depth/stencil read emulation draws (ScopedEmulationDrawState, */ \ + /* DirectGLES.cpp) and pauses an active capture around its own draw, so a */ \ + /* readback reads the transform-feedback state exactly as a draw does. */ \ + X(kReadback, IsTransformFeedbackActive) \ + X(kReadback, IsTransformFeedbackPaused) \ /* kXfbSpan */ \ X(kXfbSpan, GetTransformFeedbackProgram) \ X(kXfbSpan, GetBufferBindingPoint) \ diff --git a/MobileGL/MG_Pipe/generated/PipeFillPoints.inc b/MobileGL/MG_Pipe/generated/PipeFillPoints.inc index 41c6c2f0f..1512f363d 100644 --- a/MobileGL/MG_Pipe/generated/PipeFillPoints.inc +++ b/MobileGL/MG_Pipe/generated/PipeFillPoints.inc @@ -279,14 +279,14 @@ inline constexpr Bool MGPipeFieldMaskHas(const MGPipeFieldMask& mask, MGPipeInpu inline constexpr MGPipeFieldMask kMGPipeClassFieldMask[kMGPipeVerbClassCount] = { // kDraw: 54 fields (47 own + 7 sticky) {{0x7ffbfff7bfffc3eeull, 0x0000000000000000ull}}, - // kDispatch: 21 fields (14 own + 7 sticky) - {{0x5c00f2281d3003c0ull, 0x0000000000000000ull}}, + // kDispatch: 22 fields (15 own + 7 sticky) + {{0x5c40f2281d3003c0ull, 0x0000000000000000ull}}, // kClear: 25 fields (18 own + 7 sticky) {{0x5c50ffa001347900ull, 0x0000000000000000ull}}, - // kBlitOrCopy: 25 fields (18 own + 7 sticky) - {{0x5f50ffa001344101ull, 0x0000000000000000ull}}, - // kTextureOp: 14 fields (7 own + 7 sticky) - {{0x5c00f22001200101ull, 0x0000000000000000ull}}, + // kBlitOrCopy: 29 fields (22 own + 7 sticky) + {{0x5f70ffe0013c4181ull, 0x0000000000000000ull}}, + // kTextureOp: 17 fields (10 own + 7 sticky) + {{0x5c40f22001300181ull, 0x0000000000000000ull}}, // kReadback: 24 fields (17 own + 7 sticky) {{0x5f50f3a041300541ull, 0x0000000000000000ull}}, // kXfbSpan: 15 fields (8 own + 7 sticky) From 6b681c4a63f4a9cfa516c56c4a478f4d853f6943 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 05:32:13 -0400 Subject: [PATCH 068/529] [Fix] (Pipe, State): refresh a pushed PipeInputs field when the frontend moves it inside a verb - The verify lane aborted eight integration entries and two retrace cases with Fatal{PipeVerifyDiffer, "GetSamplingResolutionGeneration@DrawArrays", where=read}, always one line after "ResolveSamplerDescriptor: using fallback texture for unbound sampler". The backends write into frontend objects during their own verb - Magma synthesises a fallback texture for an unbound sampler and gives it a shape, materialises a queued clear, overrides a unit's sampler filter - and every one of those writes moves a counter MGP_FILL already copied, so the pushed block stops equalling the live context for the rest of the verb. That is a real divergence, not a harness artefact: the pull build reads the moved value and the push build reads the boundary one. - Takes the findings' preferred option, push on mutation, over the volatile-in- verb class: it keeps the comparator's invariant ("the pushed block equals the live context at every read") literally true, keeps push semantics equal to pull, and is the shape P2's tracker needs. The fallback would have had to skip compare-at-read for the field, which is the one comparator arm that is real in P1 - it would have blinded the gate on the very field that found the bug. - MG_Pipe/PipeMutation.h declares MGP_NOTE_MUTATION(Field), a no-op that includes nothing in the pull build; MG_Impl/Pipe/PipeFill.cpp defines the notice next to the filler it shares CopyField with. The notice refreshes one field's value when a context is live, a verb has been filled, and the field is in that verb class's may-read mask; it never touches the poison stamp, so a stamp MOBILEGL_PIPE_POISON_OMIT withheld stays withheld and a field the verb never filled stays Fatal{UnmigratedPipeInput} rather than being healed. - The enumeration behind the three hook sites: of the ~40 backend->frontend write sites, only the texture family reaches a pushed value. Every path through them funnels into TextureState::BumpSamplingResolutionGeneration (SamplerObject::BumpVersion for the sampler setters, TextureObjectBase::BumpShapeVersion for AllocateStorage / SetInternalFormat / TruncateMipmapLevels / SetSamples / SetFixedSampleLocations), BumpTextureBindGeneration (a default texture becoming defined, delete-unbind, a unit's sampler object changing) or NoteUnitTouched (which also moves the touched-unit high-water mark), so the notice sits on the counters rather than on each writer and covers the whole family including writers added later. The buffer, program and VAO writes reach no pushed field: their objects are read back through O-class live references, not copied values. --- MobileGL/MG_Impl/Pipe/PipeFill.cpp | 28 +++++++++++++ MobileGL/MG_Pipe/PipeMutation.h | 42 +++++++++++++++++++ .../GLState/TextureState/TextureState.h | 31 ++++++++++++-- 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 MobileGL/MG_Pipe/PipeMutation.h diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 9768d6725..5c88caecf 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -462,6 +463,33 @@ namespace MobileGL::MG_Pipe { } #endif // MOBILEGL_PIPE_VERIFY + // ---- push on mutation (P1 lane finding F2) ---- + // A backend that writes a frontend object inside its own verb moves a value the verb + // boundary already copied: Magma's ResolveSamplerDescriptor synthesises a fallback + // texture for an unbound sampler and its AllocateStorage/SetInternalFormat bump the + // context's sampling-resolution generation, so every read of that field after the + // fallback differs from the live context (the two SampledSetStaleness / six + // UnboundImageDescriptor entries the verify lane aborted on). The frontend mutator + // spells MGP_NOTE_MUTATION(Field) at the point of the move and lands here. + // + // Only the value is refreshed. The stamp is deliberately left alone: a field whose stamp + // this verb withheld (negative control B) must stay stale, and a field the verb never + // filled must stay Fatal{UnmigratedPipeInput} on the next read rather than be healed by + // an unrelated frontend write. + void MGPipeNoteFrontendMutation(MGPipeInputField field) { + PipeInputs& inputs = gPipeInputs; + auto* ctx = LiveContext(); + if (ctx == nullptr) return; + const auto verb = inputs.CurrentVerb(); + if (verb == MGPipeVerb::kVerbCount) return; // nothing has filled the block yet + const auto index = static_cast(field); + if (kMGPipeInputFieldSticky[index]) return; // forwarded: no storage to refresh + const MGPipeFieldMask& mask = + kMGPipeClassFieldMask[static_cast(kMGPipeVerbClass[static_cast(verb)])]; + if (!MGPipeFieldMaskHas(mask, field)) return; // this verb never pushed it + MGPipeFillAccess::CopyField(inputs, *ctx, field); + } + void MGPipeSetPoisonOmission(const char* verb, const char* field) { if (verb == nullptr || field == nullptr) { g_omission = PoisonOmission{}; diff --git a/MobileGL/MG_Pipe/PipeMutation.h b/MobileGL/MG_Pipe/PipeMutation.h new file mode 100644 index 000000000..fcbdc03e9 --- /dev/null +++ b/MobileGL/MG_Pipe/PipeMutation.h @@ -0,0 +1,42 @@ +// MobileGL - MobileGL/MG_Pipe/PipeMutation.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#ifndef MOBILEGL_MG_PIPE_MUTATION_H // belt and braces: reachable as and <..> +#define MOBILEGL_MG_PIPE_MUTATION_H +// Push-on-mutation (P1 lane finding F2). MGP_FILL copies a verb's may-read set out of the +// live GLContext at the verb boundary; the backend then reads that copy for the whole verb. +// A backend that WRITES a frontend object inside its own verb - Magma synthesising a +// fallback texture for an unbound sampler, materialising a queued clear, or overriding a +// sampler's filter - moves a value the boundary already copied, and every read after that +// point sees a block that no longer equals the live context. That is a real divergence, not +// a harness artefact: the pull build reads the moved value and the push build does not. +// +// The frontend mutator that moves such a value spells MGP_NOTE_MUTATION(Field) right where +// it moves it. The notice refreshes that ONE field in the pushed block when the field +// belongs to the verb currently in flight, so "the pushed block equals the live context at +// every read" stays literally true and the push build keeps pull semantics. It refreshes +// the value only and never the poison stamp, so a withheld stamp (MOBILEGL_PIPE_POISON_OMIT, +// negative control B) stays withheld. +// +// In the pull build the macro is ((void)0) and this header includes nothing, so the pull +// build is byte-identical to a tree without it. +#if MOBILEGL_PIPE_PUSH +#include +namespace MobileGL::MG_Pipe { + // MG_Impl/Pipe/PipeFill.cpp (the client side, the only place that may spell pGLContext). + // A no-op unless a context is live, a verb has been filled, and `field` is in that verb + // class's may-read mask; a forwarded (sticky) field has no storage and is never copied. + void MGPipeNoteFrontendMutation(MGPipeInputField field); +} // namespace MobileGL::MG_Pipe +#define MGP_NOTE_MUTATION(Field) \ + ::MobileGL::MG_Pipe::MGPipeNoteFrontendMutation(::MobileGL::MG_Pipe::MGPipeInputField::Field) +#else +#define MGP_NOTE_MUTATION(Field) ((void)0) +#endif +#endif diff --git a/MobileGL/MG_State/GLState/TextureState/TextureState.h b/MobileGL/MG_State/GLState/TextureState/TextureState.h index c85ae8a4d..ab7af05a4 100644 --- a/MobileGL/MG_State/GLState/TextureState/TextureState.h +++ b/MobileGL/MG_State/GLState/TextureState/TextureState.h @@ -8,6 +8,7 @@ #pragma once #include +#include #include #include "MG_State/GLState/TextureState/TextureObject.h" #include "MG_Util/Types.h" @@ -75,7 +76,14 @@ namespace MobileGL::MG_State::GLState { // Units above it have provably-empty binding slots, so per-draw backend scans // can stop there instead of walking all MAX_TEXTURE_IMAGE_UNITS units. void NoteUnitTouched(Int unit, Bool bindingChanged = true) { - if (unit > m_maxTouchedUnit && unit < MAX_TEXTURE_IMAGE_UNITS) m_maxTouchedUnit = unit; + if (unit > m_maxTouchedUnit && unit < MAX_TEXTURE_IMAGE_UNITS) { + m_maxTouchedUnit = unit; + // Push-on-mutation (MG_Pipe/PipeMutation.h): the high-water mark is a pushed + // PipeInputs field, and a bind reached from inside a verb - a backend binding + // its own synthesised fallback texture - would otherwise leave the block + // describing a smaller scan range than the live context has. + MGP_NOTE_MUTATION(GetMaxTouchedTextureUnit); + } // Every texture/sampler bind entry point (glBindTexture / glBindTextureUnit / // glBindTextures / glBindSampler) routes through here, so bumping the generation here // - plus in MarkTextureObjectForDeletion for delete-unbind - covers every change to @@ -85,11 +93,23 @@ namespace MobileGL::MG_State::GLState { // Re-binding the object a slot already holds changes nothing that the generation // guards; such callers pass bindingChanged=false so only the high-water mark advances // and the backend fast path survives the redundant re-binds apps issue every frame. - if (bindingChanged) ++m_textureBindGeneration; + if (bindingChanged) { + ++m_textureBindGeneration; + MGP_NOTE_MUTATION(GetTextureBindGeneration); + } } Int GetMaxTouchedUnit() const { return m_maxTouchedUnit; } Uint64 GetTextureBindGeneration() const { return m_textureBindGeneration; } - void BumpTextureBindGeneration() { ++m_textureBindGeneration; } + // Both counters below are pushed PipeInputs fields AND are moved by writes the + // backends make into frontend objects during their own verb - a synthesised fallback + // texture's AllocateStorage/SetInternalFormat, a sampler override's SetMinFilter, a + // default texture becoming defined. Every such path funnels through these two + // methods (and the bind branch above), so noticing here covers the whole family + // rather than each writer (P1 lane finding F2; MG_Pipe/PipeMutation.h). + void BumpTextureBindGeneration() { + ++m_textureBindGeneration; + MGP_NOTE_MUTATION(GetTextureBindGeneration); + } // Sibling of the bind generation for everything that changes WHICH native texture a // backend ends up putting on a unit WITHOUT any binding moving. Two families feed it: @@ -108,7 +128,10 @@ namespace MobileGL::MG_State::GLState { // its sampled-set memo carries THIS generation alongside the bind one. Any memo of a // resolved per-unit binding - or of which textures a draw samples at all - needs both. Uint64 GetSamplingResolutionGeneration() const { return m_samplingResolutionGeneration; } - void BumpSamplingResolutionGeneration() { ++m_samplingResolutionGeneration; } + void BumpSamplingResolutionGeneration() { + ++m_samplingResolutionGeneration; + MGP_NOTE_MUTATION(GetSamplingResolutionGeneration); + } // Globally-unique, never-reused id of THIS texture state, i.e. of the context that owns // it. Both generations above restart at 0 with a new context, so a backend memo keyed on From 9bd6d3940365073a6f3412b1dafca8b1c42ff8bb Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 05:32:25 -0400 Subject: [PATCH 069/529] [Test] (Pipe): pin the push-on-mutation shape - a frontend write inside a verb refreshes the pushed field, and only its value - AFrontendMutationInsideAVerbRefreshesThePushedField: fills DrawArrays, then does what UniformManager's fallback path does mid-draw (a SamplerObject filter change) and what a bind reached from inside a verb does (NoteTextureUnitTouched), and asserts the block still equals the live context for GetSamplingResolutionGeneration, GetTextureBindGeneration and GetMaxTouchedTextureUnit. - AFrontendMutationInsideAVerbDoesNotDivergeAtRead: the lane failure end to end, under the armed comparator in a forked child - the read after the mutation must complete and the log must carry no Fatal{. - TheMutationNoticeRefreshesTheValueButNotTheStamp: the notice must not restamp a field whose stamp the fill withheld (negative control B), and must not stamp a field the verb class never fills (the generation under a kQuery verb). - Falsified: with the notice's body short-circuited, the first two fail (the read-side one by SIGABRT on Fatal{PipeVerifyDiffer, "GetSamplingResolutionGeneration@DrawArrays", where=read}) and the third stays green, which is what a guard case should do. - The three names also exist as visible GTEST_SKIPs in the pull build, as every other case in this file does. --- MobileGL/MG_Test/Pipe/PipeInputsTest.cpp | 105 +++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp b/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp index 69be8437b..3d2df3a19 100644 --- a/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp +++ b/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp @@ -170,6 +170,15 @@ TEST(PipeInputsTest,BadVerifyCorruptKnobIsFatalNamingTheKnob) { TEST(PipeInputsTest,VerifyFatalOffLogsTheDivergenceAndContinues) { GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; } +TEST(PipeInputsTest,AFrontendMutationInsideAVerbRefreshesThePushedField) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(PipeInputsTest,TheMutationNoticeRefreshesTheValueButNotTheStamp) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(PipeInputsTest,AFrontendMutationInsideAVerbDoesNotDivergeAtRead) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} #else // MOBILEGL_PIPE_PUSH @@ -515,6 +524,102 @@ TEST_F(PipeInputsTest, EveryVerbFillsItsClassAndNothingElse) { #endif } +// Push on mutation (P1 lane finding F2, MG_Pipe/PipeMutation.h). The backends write into +// frontend objects inside their own verb - Magma synthesises a fallback texture for an +// unbound sampler and overrides a unit's sampler filter (VulkanRenderer/UniformManager) - +// and every such write moves a counter the verb boundary already copied. The frontend +// mutator refreshes that field, so the block still equals the live context at every read. +// Without the notice the two reads below differ and the pushed value is the stale one. +TEST_F(PipeInputsTest, AFrontendMutationInsideAVerbRefreshesThePushedField) { + auto& ctx = *MG_State::pGLContext; + MGPipeFillForVerb(MGPipeVerb::DrawArrays); + ASSERT_EQ(gPipeInputs.GetSamplingResolutionGeneration(), ctx.GetSamplingResolutionGeneration()); + ASSERT_EQ(gPipeInputs.GetTextureBindGeneration(), ctx.GetTextureBindGeneration()); + ASSERT_EQ(gPipeInputs.GetMaxTouchedTextureUnit(), ctx.GetMaxTouchedTextureUnit()); + + // What UniformManager's fallback path does mid-draw: change a sampler object's filter, + // which bumps the context-wide sampling-resolution generation (SamplerObject.cpp). + const Uint64 samplingBefore = ctx.GetSamplingResolutionGeneration(); + auto sampler = MakeShared(0u); + sampler->SetMinFilter(sampler->GetMinFilter() == SamplerFilterMode::Nearest ? SamplerFilterMode::Linear + : SamplerFilterMode::Nearest); + ASSERT_NE(ctx.GetSamplingResolutionGeneration(), samplingBefore) << "the mutation did not move the counter"; + EXPECT_EQ(gPipeInputs.GetSamplingResolutionGeneration(), ctx.GetSamplingResolutionGeneration()); + + // And a bind reached from inside a verb moves both the bind generation and the + // high-water mark of touched units (TextureState::NoteUnitTouched). + const Uint64 bindBefore = ctx.GetTextureBindGeneration(); + const Int unit = ctx.GetMaxTouchedTextureUnit() + 1; + ctx.NoteTextureUnitTouched(unit); + ASSERT_NE(ctx.GetTextureBindGeneration(), bindBefore) << "the bind did not move the counter"; + ASSERT_EQ(ctx.GetMaxTouchedTextureUnit(), unit); + EXPECT_EQ(gPipeInputs.GetTextureBindGeneration(), ctx.GetTextureBindGeneration()); + EXPECT_EQ(gPipeInputs.GetMaxTouchedTextureUnit(), ctx.GetMaxTouchedTextureUnit()); +} + +// The notice refreshes the VALUE and never the stamp: a field this verb withheld the stamp +// of (negative control B) must stay stale, and a field outside the verb class's mask must +// stay unfilled rather than be healed by an unrelated frontend write. +TEST_F(PipeInputsTest, TheMutationNoticeRefreshesTheValueButNotTheStamp) { +#if !MOBILEGL_PIPE_POISON + GTEST_SKIP() << "poison not compiled in (MOBILEGL_PIPE_POISON=0)"; +#else + auto& ctx = *MG_State::pGLContext; + MGPipeSetPoisonOmission("DrawArrays", "GetSamplingResolutionGeneration"); + MGPipeFillForVerb(MGPipeVerb::DrawArrays); + ASSERT_FALSE(Fresh(MGPipeInputField::GetSamplingResolutionGeneration)); + ctx.BumpSamplingResolutionGeneration(); + EXPECT_FALSE(Fresh(MGPipeInputField::GetSamplingResolutionGeneration)) + << "the notice restamped a field whose stamp the fill withheld"; + + // FenceSync is a kQuery verb: its mask holds no texture field at all, so the notice must + // leave the generation unfilled and a read of it Fatal{UnmigratedPipeInput}. + MGPipeSetPoisonOmission(nullptr, nullptr); + MGPipeFillForVerb(MGPipeVerb::FenceSync); + ASSERT_FALSE(Fresh(MGPipeInputField::GetSamplingResolutionGeneration)); + ctx.BumpSamplingResolutionGeneration(); + EXPECT_FALSE(Fresh(MGPipeInputField::GetSamplingResolutionGeneration)) + << "the notice stamped a field the verb class never fills"; +#endif +} + +// The lane failure itself (six DirectVulkan.Verify.UnboundImageDescriptorScenario entries, +// two SampledSetStalenessScenario, two retrace cases): a frontend write made inside a draw +// used to make the very next read of the pushed block differ from the live context. The +// child reproduces it end to end under the armed comparator and must survive; without the +// notice it dies of Fatal{PipeVerifyDiffer, "GetSamplingResolutionGeneration@DrawArrays", +// where=read}. +TEST_F(PipeInputsTest, AFrontendMutationInsideAVerbDoesNotDivergeAtRead) { +#if !MOBILEGL_PIPE_VERIFY + GTEST_SKIP() << "verify not compiled in (MOBILEGL_PIPE_VERIFY=OFF)"; +#elif !MGTEST_HAVE_FORK + GTEST_SKIP() << "no fork() on this platform"; +#else + ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; + const ChildResult r = RunInChild([] { + MG_Config::Features.PipeVerify = true; + MGPipeFillForVerb(MGPipeVerb::DrawArrays); + (void)gPipeInputs.GetSamplingResolutionGeneration(); // boundary == live: completes + auto& ctx = *MG_State::pGLContext; + const Uint64 before = ctx.GetSamplingResolutionGeneration(); + auto sampler = MakeShared(0u); + sampler->SetMinFilter(sampler->GetMinFilter() == SamplerFilterMode::Nearest ? SamplerFilterMode::Linear + : SamplerFilterMode::Nearest); + if (ctx.GetSamplingResolutionGeneration() == before) { + ::_exit(7); // the mutation did not take: the case would pass for the wrong reason + } + (void)gPipeInputs.GetSamplingResolutionGeneration(); // Fatal{PipeVerifyDiffer} without the notice + ctx.NoteTextureUnitTouched(ctx.GetMaxTouchedTextureUnit() + 1); + (void)gPipeInputs.GetTextureBindGeneration(); + (void)gPipeInputs.GetMaxTouchedTextureUnit(); + }); + ASSERT_TRUE(ExitedWith(r, 0)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("MGPipe: verify armed"), std::string::npos) << r.Log; + EXPECT_EQ(r.Log.find("Fatal{"), std::string::npos) << r.Log; + EXPECT_EQ(r.Log.find("PipeVerifyDiffer"), std::string::npos) << r.Log; +#endif +} + #endif // MOBILEGL_PIPE_PUSH int main(int argc, char** argv) { From ef6227e19b1dac2689f3b12f7990d36f43403413 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 05:11:06 -0400 Subject: [PATCH 070/529] [Test] (Pipe): let a test that drives a backend helper directly declare the verb it stands in - ScopedPipeVerb (MG_Test/ScopedPipeVerb.h): an RAII "as if we were inside verb X" object that runs the real MGPipeFillForVerb for the verb it names, so the eleven unit entries that construct a GLContext by hand and call a backend helper with no GL entry point in between stop reading an empty, unstamped PipeInputs block - it weakens nothing: it fills exactly that verb class's may-read mask, so a read outside it is still Fatal{UnmigratedPipeInput} naming the field and the declared verb; leaving the scope re-arms the poison with a one-field kQuery fill, so one case's declaration cannot cover a later one when the binary runs as a single process - placed the way MGP_FILL is placed in production: immediately before the backend call, after every frontend mutation it is meant to see; a second call after the test moved state is a second verb (Renew()), a helper of another class gets a nested scope - no-op in the pull build, no test renamed, no test added, no production source touched --- .../MG_Test/Framebuffer/FramebufferTest.cpp | 30 ++++++- MobileGL/MG_Test/SanityTest.cpp | 18 ++++- MobileGL/MG_Test/ScopedPipeVerb.h | 78 +++++++++++++++++++ MobileGL/MG_Test/Texture/TextureTest.cpp | 7 ++ 4 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 MobileGL/MG_Test/ScopedPipeVerb.h diff --git a/MobileGL/MG_Test/Framebuffer/FramebufferTest.cpp b/MobileGL/MG_Test/Framebuffer/FramebufferTest.cpp index 96a072a39..77ea16233 100644 --- a/MobileGL/MG_Test/Framebuffer/FramebufferTest.cpp +++ b/MobileGL/MG_Test/Framebuffer/FramebufferTest.cpp @@ -22,6 +22,7 @@ #include #include #include +#include using namespace MobileGL; @@ -1229,6 +1230,11 @@ TEST_F(FramebufferTest, DrawIntoAWidenedDrawBufferReachesTheDriverWithAlphaWrite // What the application asked for: write every channel of every draw buffer. MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + // SyncRenderState is reached from a verb, never on its own: a test that calls it directly + // has to say which verb it stands in, or the block it reads is unfilled and unstamped and + // its first read is Fatal{UnmigratedPipeInput} in a push build. forColorClear=false is the + // draw arm. + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); // What the driver was told. Draw buffer 0 is untouched; draw buffer 1 loses alpha. @@ -1260,14 +1266,22 @@ TEST_F(FramebufferTest, ClearIntoAWidenedDrawBufferKeepsAlphaWritableAndSubstitu MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); // A draw first, so the mask really is doctored when the clear arrives... - MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); + { + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); + MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); + } ASSERT_EQ(g_driverIndexedColorMasks[1].a, GL_FALSE); // ...and now the clear, with NOTHING changed in the frontend parameter block. The frontend's // render-state version has not moved, so only the purpose-aware memo can force this push - // without it the clear would inherit the draw's alpha-off mask and never write the 1.0. ResetRecordedColorMasks(); - MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/true); + { + // A different verb CLASS, so a scope of its own: kClear's fill set is what a clear may + // read, and this half has to go through on that set alone. + MG_Test::ScopedPipeVerb clear(MG_Pipe::MGPipeVerb::Clear); + MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/true); + } ASSERT_TRUE(g_driverIndexedColorMasks[1].seen) << "the clear must re-push the colour mask"; EXPECT_EQ(g_driverIndexedColorMasks[1].a, GL_TRUE) << "a clear is what puts the 1.0 in the stored alpha"; @@ -1305,6 +1319,7 @@ TEST_F(FramebufferTest, ApplicationAlphaMaskOffIsStillHonouredOnANativeDrawBuffe MG_Impl::GLImpl::ColorMaski(0, GL_TRUE, GL_TRUE, GL_TRUE, GL_FALSE); MG_Impl::GLImpl::ColorMaski(1, GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); MG_Impl::GLImpl::ColorMaski(2, GL_FALSE, GL_TRUE, GL_FALSE, GL_TRUE); + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); EXPECT_EQ(g_driverIndexedColorMasks[0].a, GL_FALSE) << "the application's own alpha mask survives"; @@ -1337,6 +1352,7 @@ TEST_F(FramebufferTest, DualSourceBlendFactorsReachTheDriverWhenTheExtensionIsTh MG_Impl::GLImpl::BlendFunc(GL_SRC1_COLOR, GL_ONE_MINUS_SRC1_COLOR); ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "GL_SRC1_* is core since 3.3; glBlendFunc must take it"; ResetRecordedBlend(); + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); ASSERT_TRUE(g_driverBlend[0].factorsSeen); @@ -1357,6 +1373,7 @@ TEST_F(FramebufferTest, DualSourceBlendIsDeclinedRatherThanThrownWhenTheExtensio ResetRecordedBlend(); // The whole point: this used to be `throw std::runtime_error` straight through the GL ABI. + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); ASSERT_NO_THROW(MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false)); ASSERT_TRUE(g_driverBlend[0].enableSeen) << "the blend enable still has to be pushed"; @@ -1374,6 +1391,7 @@ TEST_F(FramebufferTest, DualSourceBlendIsDeclinedRatherThanThrownWhenTheExtensio // is what has to push it. MG_Impl::GLImpl::BlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); ResetRecordedBlend(); + draw.Renew(); MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); ASSERT_TRUE(g_driverBlend[0].factorsSeen); EXPECT_TRUE(g_driverBlend[0].enabled); @@ -1400,6 +1418,7 @@ TEST_F(FramebufferTest, DualSourceFactorsAreDeclinedEvenWithBlendingDisabled) { ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR); ResetRecordedBlend(); + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); ASSERT_NO_THROW(MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false)); for (Uint i = 0; i < kRecordedDrawBuffers; ++i) { @@ -1416,7 +1435,10 @@ TEST_F(FramebufferTest, DualSourceFactorsAreDeclinedEvenWithBlendingDisabled) { // the flag only steers the alpha-widen colour mask, so it must not reopen this either. MG_Impl::GLImpl::BlendFunc(GL_SRC1_COLOR, GL_ONE_MINUS_SRC1_COLOR); ResetRecordedBlend(); - ASSERT_NO_THROW(MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/true)); + { + MG_Test::ScopedPipeVerb clear(MG_Pipe::MGPipeVerb::Clear); + ASSERT_NO_THROW(MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/true)); + } for (Uint i = 0; i < kRecordedDrawBuffers; ++i) { EXPECT_NE(g_driverBlend[i].srcRGB, static_cast(GL_SRC1_COLOR)) << "draw buffer " << i; EXPECT_NE(g_driverBlend[i].dstRGB, static_cast(GL_ONE_MINUS_SRC1_COLOR)) << "draw buffer " << i; @@ -1427,6 +1449,7 @@ TEST_F(FramebufferTest, DualSourceFactorsAreDeclinedEvenWithBlendingDisabled) { MG_Impl::GLImpl::Enable(GL_BLEND); MG_Impl::GLImpl::BlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); ResetRecordedBlend(); + draw.Renew(); MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); ASSERT_TRUE(g_driverBlend[0].factorsSeen); EXPECT_TRUE(g_driverBlend[0].enabled) << "the enable has to be pushed - the shadow said 'off' because it was"; @@ -1444,6 +1467,7 @@ TEST_F(FramebufferTest, DualSourceFactorsWithBlendingDisabledStillReachACapableD MG_Impl::GLImpl::Disable(GL_BLEND); MG_Impl::GLImpl::BlendFunc(GL_SRC1_ALPHA, GL_ONE_MINUS_SRC1_ALPHA); ResetRecordedBlend(); + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); ASSERT_TRUE(g_driverBlend[0].factorsSeen); diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index 7af54ec04..7fd2a8a23 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -321,7 +322,10 @@ TEST(DirectGLESSanity, BindsAMultisampleTextureDespiteTheDefaultMipmapFilter) { backendTexture = MakeShared(); const GLuint backendTextureId = backendTexture->GetBackendTextureId(); - // The symptom itself: the per-unit walk has to actually bind it. + // The symptom itself: the per-unit walk has to actually bind it. BindCurrentTextures() is + // the per-draw walk - it reads GetProgramForDraw - so the block it reads is the one a draw + // fills; without saying so, its first read is Fatal{UnmigratedPipeInput} in a push build. + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); DirectGLES::BindCurrentTextures(); ASSERT_EQ(state.bindCalls.size(), 1u) << "the multisample texture was not bound; every texelFetch against it reads zero"; @@ -362,6 +366,9 @@ TEST(DirectGLESSanity, BindingZeroClearsPreviousNativeTextureBinding) { backendTexture = MakeShared(); const GLuint backendTextureId = backendTexture->GetBackendTextureId(); + // Each walk below is the texture half of one draw, so each gets its own verb (the second + // and third stand after frontend state moved, exactly as a second entry point's fill would). + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); DirectGLES::BindCurrentTextures(); ASSERT_EQ(state.bindCalls.size(), 1u); EXPECT_EQ(state.bindCalls[0].target, GL_TEXTURE_2D); @@ -369,6 +376,7 @@ TEST(DirectGLESSanity, BindingZeroClearsPreviousNativeTextureBinding) { // The default 1D slot maps to the same native ES target as 2D. It must not clear and force a // redundant rebind while the real 2D frontend object remains current. + draw.Renew(); DirectGLES::BindCurrentTextures(); EXPECT_EQ(state.bindCalls.size(), 1u); @@ -379,6 +387,7 @@ TEST(DirectGLESSanity, BindingZeroClearsPreviousNativeTextureBinding) { .GetBoundObject() .get())); + draw.Renew(); DirectGLES::BindCurrentTextures(); ASSERT_EQ(state.bindCalls.size(), 2u); @@ -3015,7 +3024,9 @@ TEST(DirectGLESTextureSync, UnitMemoRefusesToDriveATwinFromAnotherTexture) { ASSERT_NE(resident, nullptr); // First sync: builds the memo with unit 0 -> `resident`, and gives `resident`'s twin its - // 16x16 backend storage. + // 16x16 backend storage. The unit walk is the per-draw one, so both syncs below stand in a + // draw - the fill is what a real glDraw* would have done before reaching this helper. + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); MG_Backend::DirectGLES::TextureImpl::SyncNeccessaryTextures(); auto* residentSlot = MG_Backend::DirectGLES::TextureImpl::g_backendTextureObjects.Find(resident.get()); ASSERT_NE(residentSlot, nullptr); @@ -3031,6 +3042,9 @@ TEST(DirectGLESTextureSync, UnitMemoRefusesToDriveATwinFromAnotherTexture) { MG_State::pGLContext->GetTextureUnitObject(0).GetBindingSlot(TextureTarget::Texture2D).Bind(foreign); const SizeT specsBeforeReplay = specs.size(); + // The second draw. Its fill re-reads the memo's keys off the live context, which is the + // premise being tested: the silent slot swap moved none of them. + draw.Renew(); MG_Backend::DirectGLES::TextureImpl::SyncNeccessaryTextures(); // `foreign` must have been synced through its OWN twin... diff --git a/MobileGL/MG_Test/ScopedPipeVerb.h b/MobileGL/MG_Test/ScopedPipeVerb.h new file mode 100644 index 000000000..1172cf900 --- /dev/null +++ b/MobileGL/MG_Test/ScopedPipeVerb.h @@ -0,0 +1,78 @@ +// MobileGL - MobileGL/MG_Test/ScopedPipeVerb.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include +#if MOBILEGL_PIPE_PUSH +#include +#endif + +namespace MobileGL::MG_Test { + // "This test is standing inside verb X." + // + // A unit test that constructs a GLContext by hand and then calls a BACKEND helper + // directly enters through no GL entry point, so no MGP_FILL ever fires (P1 brief D7) and + // in a push build the PipeInputs block the helper's MGB_CTX reads is empty and unstamped: + // its first accessor read is Fatal{UnmigratedPipeInput, "Field@"}. The test is + // right and the poison is right - what was missing is the verb, and this object is how a + // test states it. Constructing it runs the real filler for `verb`, exactly the call + // MG_Impl makes before that verb reaches a backend; destroying it leaves the verb again. + // + // It is not an escape hatch and it weakens nothing: + // - it fills exactly kMGPipeClassFieldMask[class of verb] out of the live GLContext, so + // a read of a field that verb does not fill is still Fatal, naming the field and this + // verb - the fill table stays the only thing that says what a verb may read; + // - leaving the scope re-arms the poison. The exit fill is a kQuery verb, whose class + // mask is a single field, so every field this scope stamped goes stale the moment the + // scope ends. That matters when the suite runs as one process (a developer running + // the test binary directly, rather than one ctest entry per case): without it, one + // case's declaration would cover a later case that forgot to make one; + // - it is a no-op in the pull build, where MGB_CTX is the live context and there is + // nothing to fill, so the pull build stays byte-identical. + // + // Place it the way MGP_FILL is placed in production (P1 brief D7): immediately BEFORE the + // backend call, after every frontend mutation that call is meant to see. A test that + // drives the helper again with frontend state changed in between issues a second verb - + // Renew() - because that is what a second GL entry point would have done. A test that + // drives a helper of a DIFFERENT verb class opens a nested scope for it. + class ScopedPipeVerb { + public: + explicit ScopedPipeVerb([[maybe_unused]] MG_Pipe::MGPipeVerb verb) +#if MOBILEGL_PIPE_PUSH + : m_verb(verb) { + MG_Pipe::MGPipeFillForVerb(m_verb); + } +#else + { + } +#endif + + ScopedPipeVerb(const ScopedPipeVerb&) = delete; + ScopedPipeVerb& operator=(const ScopedPipeVerb&) = delete; + + // A second verb of the same kind begins: re-fill and re-stamp, so a helper driven + // again after the test moved frontend state sees the new values, the way the next GL + // entry point's MGP_FILL would. + void Renew() { +#if MOBILEGL_PIPE_PUSH + MG_Pipe::MGPipeFillForVerb(m_verb); +#endif + } + +#if MOBILEGL_PIPE_PUSH + ~ScopedPipeVerb() { MG_Pipe::MGPipeFillForVerb(kLeaveVerb); } + + private: + // Leaving is a fill of the narrowest verb class there is: kQuery names one field, so + // the serial bump lands and every field this scope stamped falls behind it. The one + // field is a transform-feedback counter read - no side effect, and nothing to undo. + static constexpr MG_Pipe::MGPipeVerb kLeaveVerb = MG_Pipe::MGPipeVerb::GetGpuTimestampNs; + MG_Pipe::MGPipeVerb m_verb; +#endif + }; +} // namespace MobileGL::MG_Test diff --git a/MobileGL/MG_Test/Texture/TextureTest.cpp b/MobileGL/MG_Test/Texture/TextureTest.cpp index e1aa29f8d..7bd51045f 100644 --- a/MobileGL/MG_Test/Texture/TextureTest.cpp +++ b/MobileGL/MG_Test/Texture/TextureTest.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -4477,6 +4478,10 @@ TEST_F(TextureTest, StorePackedWordsToClientCopiesWordsVerbatimUnderPackParams) MG_Impl::GLImpl::PixelStorei(GL_PACK_ALIGNMENT, 8); // rows of 3 words (12 B) pad to 16 B MG_Impl::GLImpl::PixelStorei(GL_PACK_SKIP_ROWS, 1); MG_Impl::GLImpl::PixelStorei(GL_PACK_SKIP_PIXELS, 1); + // The store reads the PACK block out of the frontend, and it is only ever reached from a + // readback verb: a test that calls it directly says so, or the block is unfilled and the + // first read is Fatal{UnmigratedPipeInput} in a push build. + MG_Test::ScopedPipeVerb readback(MG_Pipe::MGPipeVerb::ReadPixels); ASSERT_TRUE(ReadbackImpl::StorePackedWordsToClient(reinterpret_cast(source), /*width=*/3, /*sliceHeight=*/2, /*sliceCount=*/1, GL_UNSIGNED_INT_5_9_9_9_REV, destination, @@ -4499,6 +4504,8 @@ TEST_F(TextureTest, StorePackedWordsToClientCopiesWordsVerbatimUnderPackParams) MG_Impl::GLImpl::PixelStorei(GL_PACK_SKIP_PIXELS, 0); MG_Impl::GLImpl::PixelStorei(GL_PACK_ALIGNMENT, 1); MG_Impl::GLImpl::PixelStorei(GL_PACK_SWAP_BYTES, GL_TRUE); + // A second readback, after the PACK parameters moved. + readback.Renew(); ASSERT_TRUE(ReadbackImpl::StorePackedWordsToClient(reinterpret_cast(source), /*width=*/3, /*sliceHeight=*/1, /*sliceCount=*/1, GL_UNSIGNED_INT_5_9_9_9_REV, destination, From 62a7786184181a3af7bec620493628f2cbb8a61c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 06:14:10 -0400 Subject: [PATCH 071/529] [Fix] (Pipe, Purity): end a declared verb honestly, and gate the header MG_State now includes - MGPipeLeaveVerb() bumps the serial and puts the current verb back to none, so a test that drives a backend helper directly stops declaring where it says it stops and a later unguarded read aborts as "@" instead of naming an unrelated verb - check_include_closure.py gains a fourth probe: F2 put MGP_NOTE_MUTATION into frontend mutators, so MG_State includes MG_Pipe/PipeMutation.h and that header must never reach back into MG_State, MG_Impl or a backend --- MobileGL/MG_Impl/Pipe/PipeFill.cpp | 10 ++++++++++ MobileGL/MG_Impl/Pipe/PipeFill.h | 9 +++++++++ MobileGL/MG_Test/ScopedPipeVerb.h | 11 ++++++----- scripts/check_include_closure.py | 19 +++++++++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 5c88caecf..1276cb4c1 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -552,6 +552,16 @@ namespace MobileGL::MG_Pipe { ctx->RecordError(code, Move(info)); } + void MGPipeLeaveVerb() { + PipeInputs& inputs = gPipeInputs; +#if MOBILEGL_PIPE_POISON + // Same bump the next fill would make, without a verb to fill from: no field is + // stamped, so every stamp this verb made falls behind the serial. + ++MGPipeFillAccess::Filled(inputs).CurrentVerbSerial; +#endif + MGPipeFillAccess::SetVerb(inputs, MGPipeVerb::kVerbCount); + } + // ---- the filler ---- void MGPipeFillForVerb(MGPipeVerb verb) { PipeInputs& inputs = gPipeInputs; diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.h b/MobileGL/MG_Impl/Pipe/PipeFill.h index deda59914..f03a3b824 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.h +++ b/MobileGL/MG_Impl/Pipe/PipeFill.h @@ -24,6 +24,15 @@ namespace MobileGL::MG_Pipe { // runs the entry compare against a second snapshot (P1 brief D8). void MGPipeFillForVerb(MGPipeVerb verb); + // Ends the verb in flight without starting another: bumps the serial, so every field the + // verb stamped goes stale, and puts the current verb back to "none", so a read made after + // it aborts as Fatal{UnmigratedPipeInput, "@"} - which is what such a read + // is - instead of naming whichever verb happened to be filled last. Nothing in the GL + // entry points calls this: a real verb is always followed by the next verb's fill. It + // exists for a caller that drives a backend helper directly and wants its declaration to + // stop where it says it stops (MG_Test/ScopedPipeVerb.h). + void MGPipeLeaveVerb(); + // PipeFill.cpp. Negative control B (P1 brief D6): the filler withholds the STAMP - never // the value - of `field` at `verb`, so that verb's read of it is // Fatal{UnmigratedPipeInput, "Field@Verb"} while every other verb is unaffected. The diff --git a/MobileGL/MG_Test/ScopedPipeVerb.h b/MobileGL/MG_Test/ScopedPipeVerb.h index 1172cf900..aa0d90fcf 100644 --- a/MobileGL/MG_Test/ScopedPipeVerb.h +++ b/MobileGL/MG_Test/ScopedPipeVerb.h @@ -65,13 +65,14 @@ namespace MobileGL::MG_Test { } #if MOBILEGL_PIPE_PUSH - ~ScopedPipeVerb() { MG_Pipe::MGPipeFillForVerb(kLeaveVerb); } + // Leaving bumps the serial and puts the current verb back to "none": every field + // this scope stamped goes stale, and a later test that forgets its own declaration + // aborts with "@" rather than with the name of a verb it never issued. + // That matters when the suite runs as one process (a developer running the test + // binary directly, rather than one ctest entry per case). + ~ScopedPipeVerb() { MG_Pipe::MGPipeLeaveVerb(); } private: - // Leaving is a fill of the narrowest verb class there is: kQuery names one field, so - // the serial bump lands and every field this scope stamped falls behind it. The one - // field is a transform-feedback counter read - no side effect, and nothing to undo. - static constexpr MG_Pipe::MGPipeVerb kLeaveVerb = MG_Pipe::MGPipeVerb::GetGpuTimestampNs; MG_Pipe::MGPipeVerb m_verb; #endif }; diff --git a/scripts/check_include_closure.py b/scripts/check_include_closure.py index 5465edafc..5fbde4c7b 100755 --- a/scripts/check_include_closure.py +++ b/scripts/check_include_closure.py @@ -106,6 +106,25 @@ "Why": "P7 ships the reflection artifacts over the wire; the archive header must not " "depend on the compiler front end that produced them.", }, + { + "Name": "mutation-header", + "Header": "MobileGL/MG_Pipe/PipeMutation.h", + "Tu": "#include \n", + "Forbidden": [ + "MobileGL/MG_Impl/", + "MobileGL/MG_State/GLState/Core.h", + "MobileGL/MG_Remote/", + ], + "Allow": [], + "TextLimits": {}, + "Why": "P1 finding F2 put MGP_NOTE_MUTATION in frontend mutators, so MG_State now " + "includes this header (TextureState.h, SamplerObject.h). It may only DECLARE " + "the notice: reaching MG_Impl would pull the fill implementation into the " + "state machine that calls it, and reaching GLState/Core.h would close the " + "cycle back onto the context. The MGPipeTypes -> BackendObject -> TextureEnum " + "reach it inherits from MGPipe.h is D1's known exception, which is why gate A " + "asserts MGPipeValueTypes.h rather than MGPipeTypes.h.", + }, { "Name": "wire-header", "Header": "MobileGL/MG_Remote/Transport/ITransport.h", From e7a6a72f6a3b92fc98afed21f0f53d339747512b Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 06:17:12 -0400 Subject: [PATCH 072/529] [Docs] (Disaggregated): record the P1 landing and what its verify lane found - README status, the ROADMAP P1 row with the measured site and accessor counts, and the ARCHITECTURE correction from 293 to the 277 arrow sites the tree actually has - MEASUREMENTS gains the P1 scale table, the two classes of finding the verify lane produced (nine missing fill rows, three fields a backend moves inside its own verb) with the push-on-mutation decision and its three hooks, and the acceptance numbers --- docs/Disaggregated/ARCHITECTURE.md | 2 +- docs/Disaggregated/MEASUREMENTS.md | 49 +++++++++++++++++++++++++++++- docs/Disaggregated/README.md | 2 +- docs/Disaggregated/ROADMAP.md | 2 +- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/docs/Disaggregated/ARCHITECTURE.md b/docs/Disaggregated/ARCHITECTURE.md index dbdcda8a0..42be28696 100644 --- a/docs/Disaggregated/ARCHITECTURE.md +++ b/docs/Disaggregated/ARCHITECTURE.md @@ -341,7 +341,7 @@ struct PipeInputs { | 阶段 | 改什么 | 证明 | |---|---|---| -| A 别名 | 机械 `sed`:`MG_State::pGLContext->` → `MGB_CTX->`(293 处)+ 手工转换 58 行非箭头用法(~34 处 `MOBILEGL_ASSERT` 删除、7 处空守卫、3 处三元、`.get()` 裸指针捕获与 `decltype` 别名、14 处 `!= nullptr`、1 处注释);逐 verb 类填充点填 `gPipeInputs` | `nm --defined-only` 不变;`.text` 差异可逐行归因(空守卫/三元的重写推迟到 P2) | +| A 别名 | 机械 `sed`:`MG_State::pGLContext->` → `MGB_CTX->`(**实测 277 处**,293 出自过期的 vendored 清单)+ 手工转换 58 行非箭头用法(~34 处 `MOBILEGL_ASSERT` 删除、7 处空守卫、3 处三元、`.get()` 裸指针捕获与 `decltype` 别名、14 处 `!= nullptr`、1 处注释);逐 verb 类填充点填 `gPipeInputs` | `nm --defined-only` 不变;`.text` 差异可逐行归因(空守卫/三元的重写推迟到 P2) | | B 推送 | tracker 填 `gPipeInputs`,填充器按 `MOBILEGL_PIPE_PUSH` 位图逐字段让位 | `MOBILEGL_PIPE_VERIFY=1`:tracker 再填一份快照版,G4 比对器逐字段每 draw 比一次 | | C 句柄化 | `SharedPtr<前端对象>` 字段 → `MGPipeHandle` + POD 描述符;memo 重键;写回变回调 | 全套门(§13) | diff --git a/docs/Disaggregated/MEASUREMENTS.md b/docs/Disaggregated/MEASUREMENTS.md index 0230f4b81..8ffb807d7 100644 --- a/docs/Disaggregated/MEASUREMENTS.md +++ b/docs/Disaggregated/MEASUREMENTS.md @@ -1,4 +1,4 @@ -# P0 实测 +# 实测记录(P0、P1) > 每张表都写明设备、提交与命令,以便复现。设备:`35d0befa` = Xiaomi 24129PN74C,Adreno 830,Android 16;`3B159D009VZ00000` = Oppo PLG110,Mali,Android 16(ColorOS)。设备运行日期 2026-09-05。设备锁协议照旧。 @@ -94,3 +94,50 @@ python3 tools/trace_replay/run_android_retrace_local.py \ 3. `--env` 值里嵌入的 `/data/...` 会被 runner 的 bash.exe 做 MSYS 路径转换(`MSYS2_ARG_CONV_EXCL="/data/*"` 只覆盖开头匹配)——用 `MSYS_NO_PATHCONV=1` 跑。 4. `coherent_as_flush` 管线完好:`--ez coherent_as_flush true` → `trace_replay_core.cpp` 的 `setenv`,独立于 `--env` 透传。 5. **`minecraft-1.21.1-neoforge-create-indirect-in-world` 在两台设备上都失败**(Adreno 830:Espryt ~4.5 分钟后黑帧,Magma 纹理上传提交时 `VK_ERROR_DEVICE_LOST`;Mali:SSIM 0.85 / 0.45)。Adreno 830 上用 `dev@81b17c0b` 基线 APK 复现,**是基线就有的问题,不是本分支造成**;它是 P3a/P8 验收清单里的用例,需先在 `dev` 修。 + +--- + +# P1 实测(`feat/disaggregated`,lavapipe / llvmpipe) + +## 6. 规模:站点、访问器、填充点 + +| 量 | 值 | 出处 | +|---|---|---| +| 后端 `pGLContext->` 箭头站点 | 277(Espryt 113、Magma 164) | 计划写 293,出自过期的 vendored 清单 | +| 非箭头行 | 58(Espryt 9、Magma 49,其中 43 条是 Magma 的逐 verb `MOBILEGL_ASSERT`) | 与计划一致 | +| `PipeInputs` 字段 | 63 | 计划写 61;`GetBoundTransformFeedbackLifetimeId`、`HasOpenTransformFeedbackSpan` 是 D21 之后新增的读点 | +| 后端调用的不同访问器 | 62(Espryt 32、Magma 56) | `GetBoundTransformFeedbackName` 已无人读,留作已标注的死行 | +| 填充点 | 83 条 `MGP_FILL`,覆盖 69 个 verb、9 个类 | `MG_Pipe/FillPoints.def` | +| `SyncPersistentMappedRange` / `SyncGpuWrites` | 20 / 6 | 与计划一致 | + +## 7. verify 通道发现的两类真问题 + +**缺填充行(9 处)。** 逐 verb poison 在 79 例 retrace 与 818 条 integration-verify 上抓出三组:`kReadback` 缺 `IsTransformFeedbackActive`/`IsTransformFeedbackPaused`(深度/模板回读仿真的 `ScopedEmulationDrawState` 会暂停在飞的捕获)、`kTextureOp` 与 `kDispatch` 缺 `IsCapabilityEnabled`、`kBlitOrCopy`/`kTextureOp` 缺着色器 blit 用到的 viewport 与顶点/缓冲绑定。其中 8 行是静态过近似(代码路径可达但通道未跑到),过近似会让那对 (类, 字段) 的 poison 永久失效,**P2 收紧填充表时先复查这 8 行**。 + +**verb 内后端改前端(3 个字段)。** Magma 在自己的 draw 里写前端对象(为未绑定采样器合成回退纹理、材质化排队清除、覆写采样器 filter),把边界已经拷走的值挪了位,8 条 DirectVulkan 用例与 2 条 trace 因此报 `Fatal{PipeVerifyDiffer, "GetSamplingResolutionGeneration@Draw*", where=read}`。**选定的解法是 push-on-mutation**:前端计数器移动时用 `MGP_NOTE_MUTATION(Field)`(`MG_Pipe/PipeMutation.h`)刷新推送块里的那一个字段(只刷值不刷戳记),"推送块在每次读取时都等于活上下文"这条不变式因此字面成立,也正是 P2 tracker 需要的形状。 + +三个被这样处理的字段与它们的钩子: + +| 字段 | 钩子 | +|---|---| +| `GetSamplingResolutionGeneration` | `TextureState::BumpSamplingResolutionGeneration()` | +| `GetTextureBindGeneration` | `TextureState::BumpTextureBindGeneration()` 与 `NoteUnitTouched()` 的 `bindingChanged` 分支 | +| `GetMaxTouchedTextureUnit` | `NoteUnitTouched()` 的高水位分支 | + +钩子挂在**计数器**上而不是四十个写入点上:后端写前端的全部路径(`SamplerObject` 的 8 个 setter、`ITextureObject` 的 6 个、纹理单元绑定路径)都经由这三个计数器,`BufferObject`/`ProgramObject`/`VertexArrayObject` 的后端写入不移动任何推送字段(它们是句柄类读点)。 + +## 8. P1 验收(合入后在 `~/w7/pipe` 实测) + +| 门 | 结果 | +|---|---| +| pull 构建符号与 `.text` | 0 增 / 0 删 / 0 改尺寸 / 0 重命名,`.text` 字节不变 | +| `MG_Backend` 里的 `pGLContext` | 0(唯一保留处是开关头文件的 pull 分支) | +| 单元(pull / push / verify) | 1485 全绿 × 3 | +| `integration-gpu`(pull) | 878 全绿 | +| `integration-verify` | 818 全绿,零 `Fatal{` | +| 79 例 retrace(`MOBILEGL_PIPE_VERIFY=1`) | 79/79 通过,79/79 带 armed 摘要,零 `Fatal{` | +| 两个阴性对照 | 4 条专用条目全绿(篡改字段变红、抽掉一个填充戳记在那条 verb 上变红) | +| 测试名 | 0 删除,+29 | + +**verify 构建的代价**:`integration-verify` 818 条在 4 路并行下约与 `integration-gpu` 同量级;79 例 retrace 在 4 路下约 20 分钟。 + diff --git a/docs/Disaggregated/README.md b/docs/Disaggregated/README.md index bb4785770..88595dd7e 100644 --- a/docs/Disaggregated/README.md +++ b/docs/Disaggregated/README.md @@ -1,6 +1,6 @@ # MGPipe:MobileGL 前后端拆分 -> 状态:**P0、P0.5 已落地**(`feat/disaggregated@5d99ee43`,基线 `dev@50fb1343`)。下一步 P1 → P2,第 43 天 GO/NO-GO。见 `ROADMAP.md`。 +> 状态:**P0、P0.5、P1 已落地**(`feat/disaggregated`,基线 `dev@50fb1343`)。下一步 P2,第 43 天 GO/NO-GO。见 `ROADMAP.md`。 ## 是什么 diff --git a/docs/Disaggregated/ROADMAP.md b/docs/Disaggregated/ROADMAP.md index e6336aa56..de2095548 100644 --- a/docs/Disaggregated/ROADMAP.md +++ b/docs/Disaggregated/ROADMAP.md @@ -14,7 +14,7 @@ |---|---|---|---|---| | **P0** 卫生、度量、门、骨架 | 9–11 | ✅ 边界计数器(字节 / 动态 accessor / 六个 memo 门 / 上传形状);`PipeCalls.def` 完整目录 + payload POD + 七个生成器 + CI `pipe-gates`;`gen_pipe_dirty_surface.py`;`check_doc_citations.py`;八个 `MOBILEGL_PIPE_*` 开关;`MG_Remote/{Protocol,Transport}` 骨架(`SCM_RIGHTS` 第一优先、双 tail 双三元组的 `RingControl`、双向 doorbell、校验型 `Framing`、`ShmSegment`、`InProcessTransport`)+ `protocol.fbs` + `flatc-check` + `MG_Test/Wire` 五个套件;三个严格 no-op 收益(`GetInteger64i_v`/`GetProgramiv` 退役、`RenderbufferObject::GetLifetimeId()`、D21 XFB 计数槽重键);compute 限制进 `DynamicBackendParameters`;spike A、spike B;retrace 通道 `--env` 透传 | ✅ 单元/集成/40 trace 逐名不变;wire 层测试(fd 传递、doorbell、ring、封帧、inproc)绿;两台设备的字节/调用基线在案;spike A/B 出结论;citation lint 绿 | — | | **P0.5** 值头与制品头抽取 | 6–9 | ✅(`5d99ee43`)`MG_Pipe/MGPipeValueTypes.h`(`RenderStateParameters`、`SamplerParameters`、`PixelStoreParameters`、`VertexAttribute`… 不 include `MG_State/GLState`);`MG_State/GLState/ProgramState/ProgramArtifacts.h`(五个反射类型,不 include `ShaderObject.h`/`SpvcSession.h`,8 个 includer 零改动——类内 `using` 别名保住每一种既有拼写);`Visit()` 归档 + `sizeof` 绊线;CI `-H` include 闭包断言(`scripts/check_include_closure.py`,text + clang 两模式、自带阴性对照、`--require-all` 棘轮;`scripts/symbol_report.py` 做逐符号归因)。实测落地:测试名零删除、`MG_Backend` 零 diff、`.text` 字节不变、符号 0 增 / 0 删 / 42 重命名;`DynamicBackendParameters` 未搬(含 `SizeT` 与 `TextureTarget` 成员,搬动不是纯移动),`MGPipeTypes.h` 仍 include `BackendObject.h`,闭包门 A 因此断言 `MGPipeValueTypes.h` | 全套测试逐名不变(纯搬移);两条闭包断言绿且人为加回一个 `MG_State` include 能变红;`nm`/`.text` 变化可逐符号归因 | P0;**P1 与 P7 的硬前置** | -| **P1** `PipeInputs` 替换与 verify harness | 10–13 | `MG_Backend/MGPipe/PipeInputs.h`(Espryt 32 / Magma 55 访问器);`sed` 293 处 + 58 行非箭头清单逐条转换(显式交付物);逐 verb 类填充点(G5 表,~93 个边界站点);逐 verb 世代 poison;G4 影子比对器 + 第三种 CI 模式;20 处 `SyncPersistentMappedRange` + 6 处 `SyncGpuWrites` 的逐站点归属表 | pull 构建 `nm --defined-only` 不变、`.text` 差异逐行归因(空守卫/三元重写推迟到 P2);40 trace + 全部集成测试在 `MOBILEGL_PIPE_VERIFY=1` 下零分歧;故意损坏一个快照字段能让 verify 变红;故意在 `glGenerateMipmap` 的填充表漏一个字段能在**那条 verb** 上触发 poison Fatal | P0.5 | +| **P1** `PipeInputs` 替换与 verify harness | 10–13 | ✅ `MG_Backend/MGPipe/PipeInputs.h`(63 字段;Espryt 32 / Magma 56 访问器);**实测 277 处箭头 + 58 行非箭头**(Espryt 113+9、Magma 164+49)逐条转换;逐 verb 类填充点(G5 表,~93 个边界站点);逐 verb 世代 poison;G4 影子比对器 + 第三种 CI 模式;20 处 `SyncPersistentMappedRange` + 6 处 `SyncGpuWrites` 的逐站点归属表 | pull 构建 `nm --defined-only` 不变、`.text` 差异逐行归因(空守卫/三元重写推迟到 P2);40 trace + 全部集成测试在 `MOBILEGL_PIPE_VERIFY=1` 下零分歧;故意损坏一个快照字段能让 verify 变红;故意在 `glGenerateMipmap` 的填充表漏一个字段能在**那条 verb** 上触发 poison Fatal | P0.5 | | **P2** 渲染状态 CSO + 第一片 Track H + 残余值块 | 18–26 | `MG_Impl/Pipe/Tracker`(dirty 位、5 个聚合世代、抑制器骨架);`gen_pipe_dirty_surface.py` 首轮映射成门;`MGPipeRenderStateSpans` + G7 setter 一致性测试;`CsoCache`(64 项,键 = pipeline 子集);`create/bind_render_state` + `set_dynamic_state`(Espryt `SyncRenderState` 一行不动;Magma `ComputePipelineStateHash`/`GetOrCreatePipeline`/`ApplyDynamicDrawStateTail` 改从 CSO 与动态 payload 取);`set_pixel_pack_state`、`set_patch_state`、`set_vertex_attrib_defaults`;`set_residual_value_state` + `ResidualValueBlock` 绊线;**第一片 Track H**:Espryt 0b(`SlotAllocator` + 6 个 registry → slot 数组 + 删 `TwinLookupMemo`×3/`OwnerEquals`/`g_fbSlotCache`/GC)与 Magma 子系统 4(`VertexInputStateFactory`/`VaoDrawMemo` 重键,删前端 VAO 里的后端裸指针);`MOBILEGL_PIPE_LEGACY_MEMOS`;补 `FramebufferSrgb`/`DepthClamp` 存储 | 集成 × 2 后端 × {pull, push} 逐名相同;40 trace push 下 SSIM ≥ 0.99 双后端;verify 零分歧;`HandleRecycleScenario` 绿且重键前红;G7 测试绿且拿掉一个字段能红;两台设备配对逐线程 CPU p50/p99 不差且 tracker 绝对 ns 在上限内;Blaze3D blend-toggle 微基准;CSO 内容寻址关闭的负面对照 | P1 | | **P3a** handle wave 1(Espryt):buffer、VAO | 18–23 | 7 个 `BufferBackendOps` → `resource_*`、`buffer_subdata_resident`(可 null)、`resource_flush_range`、`resource_readback`、`map_persistent`(不碰实现);pool 与延迟释放原样搬;vertex elements 三件(两个视图都带);`set_vertex_buffers`(`baseInstance` 显式字段);`set_index_buffer`;Adreno SIGSEGV workaround 保留 | 全套门;buffer/VAO 族场景(`LargeArenaAdoption`、`StorageBufferRegrow` 发布 `map-persistent-roundtrips`、`VertexAttribBinding`、`MultiDraw`、`PrimitiveRestart`…);Create/rd12/26.3/sodium trace;MC 26.3 在 Adreno 上 p99 不变。**再基线检查点 1:超过 27 天必须重定基线** | P2 | | **P4a** handle wave 2(Espryt):FBO / 纹理 / sampler / program 身份与描述符 | 26–34 | `set_framebuffer_state`(解析后的 `ReadSurface`、内联格式、`ContentHash`、`{0,1}`);sampler CSO(含 `borderColorForm`);sampler view + `set_texture_params`;`set_sampler_views`/`bind_sampler_states`/`set_shader_images`;shader CSO(SPIR-V + 归档);`set_draw/dispatch_program`;`set_global_constants`;`CompositeResolver`;纹理/renderbuffer 的 `resource_*`。emulation 在 split 下显式 Fatal 直到 P8 | 全套门;framebuffer/纹理/program 族场景;**新增"只作 attachment / image 单元 / CopyImage 端点的纹理其 `glTexParameter` 生效"场景(落地前必须红)**;两台设备 `KHR-GL46.direct_state_access.framebuffers*` 与整个 `packed_pixels` 块(~3300 例,句柄复用压力测试)。**再基线检查点 1b:超过 39 天** | P3a | From 48268068815545ab4e7b51cea55165a8bddf5151 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 06:32:37 -0400 Subject: [PATCH 073/529] [CI] (Workflows): run the test and apk lanes on every push to feat/disaggregated - the MGPipe phases land as a series of pushes and each one needs the full lane; dispatching by hand after every merge is a step that gets forgotten - both entries carry a remove-before-merging-to-dev note: dev's trigger set is what ships --- .github/workflows/apk.yml | 4 ++++ .github/workflows/test.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/apk.yml b/.github/workflows/apk.yml index a8a53269c..d0fcb4efe 100644 --- a/.github/workflows/apk.yml +++ b/.github/workflows/apk.yml @@ -6,6 +6,10 @@ on: - dev - Feat/Backend-Direct-GLES - Feat/Backend-Direct-Vulkan + # TEMPORARY, remove before merging the MGPipe work into dev: the disaggregation + # branch runs the full lane on every push so a phase's landing is not gated on + # someone remembering to dispatch the workflow by hand. + - feat/disaggregated workflow_dispatch: jobs: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3d686f676..2fd1d717d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,10 @@ on: - dev - Feat/Backend-Direct-GLES - Feat/Backend-Direct-Vulkan + # TEMPORARY, remove before merging the MGPipe work into dev: the disaggregation + # branch runs the full lane on every push so a phase's landing is not gated on + # someone remembering to dispatch the workflow by hand. + - feat/disaggregated workflow_dispatch: inputs: baseline_sha: From 9c6a8a25d88593d802e69a5d4fdee498a5b6816a Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 07:45:07 -0400 Subject: [PATCH 074/529] [Feat] (Pipe): land the P2 contract - real storage for the three swallowed capabilities, the render-state chunk table and its subset hash, the in-process applier, the slot allocator, the subsystem bitmask and the residual ratchet down to 8 - FramebufferSrgb, DepthClamp and TextureCubeMapSeamless get real storage. All three fell to SetCapability's "not supported currently" arm and IsCapabilityEnabled's default: glEnable was swallowed and glIsEnabled lied, so DirectGLES' sRGB block and the DirectVulkan read points consumed a constant. The three Bools land in the three alignment bytes at [581, 584) between ColorMasks and ClearColor, so sizeof(RenderStateParameters) stays 1168 and NO existing offset moves - Espryt's kBlendSpanBegin/kBlendSpanEnd (312/536) and the whole chunk table depend on that. - MGPipeRenderStateSpans.{h,cpp}: the pipeline/dynamic split, written in exactly one place. The rule is the only rule - a byte is pipeline state iff a public RenderState setter that calls BumpVersions() writes it - which makes G7's "the subset hash moves iff m_pipelineStateVersion moves" true by construction. 16 boundaries, all offsetof or sizeof, alternating dynamic/pipeline: 8 dynamic chunks / 772 bytes and 7 pipeline chunks / 396 bytes, partitioning [0, 1168) exactly, asserted at compile time. MGPipeComputePipelineSubsetHash is XXH64 over the seven pipeline chunks, seeded with a table version so a chunk-table change invalidates every persisted key. - The pipeline subset is now a strict SUPERSET of the 24 members ComputePipelineStateHash hashed: 44 members, adding sample coverage, the front face, the provoking vertex, the scissor-test mask, the back polygon mode, eleven capability bools the hash never read and the three above. Demoting those setters to ++m_version instead would have changed MG_State semantics in the PULL build for the push path's sake. The hash runs only when m_pipelineStateVersion moves, which is exactly when Magma re-hashed before. - PipeApply.{h,cpp}: the in-process applier, the server half of the P2 calls. The server's working RenderStateParameters IS PipeInputs::m_renderState, which is why DirectGLES' SyncRenderState is not one line changed and why the verify comparator stops being a tautology. Per-context CSO store indexed by slot, gen-validated; the residual block's capability bits are compared against the assembled block, so a capability a later call takes over and forgets to carry is Fatal{PipeResidualDiverged}. MGPipeDeriveRenderStateFields is a declared STUB - its 29 derivations are commit c1. - SlotAllocator.{h,cpp}: the client's per-kind {slot, gen} allocator, free list plus high-water, first allocatable slot 1, gen bumping only on slot REUSE, a debug assert on gen wrap, the composite ShaderCso band held back, and a lifetimeId -> slot map per kind so a GL name never enters a key. In the contract because both Track H slices need it. - ResidualValueBlock 1248 -> 8 bytes, one Uint64 of capability bits. RenderStateParameters retired to create/bind_render_state and set_dynamic_state, Pack to set_pixel_pack_state, the patch quintet to set_patch_state. gen_pipe.py now emits the member-by-member offsetof assertions the ratchet comment always promised. - gen_pipe.py: PIPELINE_STATE_MEMBERS grows to the 44-member set in declaration order and PipeSpanTable.inc's "deliberately absent" block records the answers instead of the questions; Coverage.def gains MGP_COVERAGE_EMITTED_LIST (34 rows) and PipeFilled.inc gains kMGPipeFieldEmittedBy[], which is what lets the residual fill loop skip a field a P2 call now supplies. One more --self-test negative control covers the new list. - MOBILEGL_PIPE_PUSH becomes a per-subsystem bitmask with named bits (0..6 migrated at P2, bit 63 the CSO-content-addressing negative control), defaulting to 0x7f in a push build and staying 0 in a pull build. New CMake option MOBILEGL_PIPE_LEGACY_MEMOS, ON, forced ON when MOBILEGL_PIPE_PUSH=OFF where it is the only arm. New Features.PipeHandleAbaControl under MOBILEGL_PIPE_PUSH, negative control C for HandleRecycleScenario. - PipeStats gains CallClass::{RenderStateCsoMints, RenderStateCsoBinds} (csom / csob on the summary line), and they are PUSH-ONLY: growing the enum in the pull build would resize the counter arrays, the name table and FormatWindowLine for two counters that could never leave zero, and G1 admits no such resize. - Four MG_Test/Pipe stubs plus their CMake registration, so the packages that own their contents never touch MG_Test/Pipe/CMakeLists.txt. G1, pull build, symbol_report --threshold 0: 0 added, 0 removed, 0 renamed, 4 resized, and every resize is attributed: RenderState::RenderState() 1700 -> 1848 (+148) the three {} RenderState::SetCapability(CapabilityInput,bool) 850 -> 927 (+77) three switch arms RenderState::IsCapabilityEnabled(CapabilityInput) 239 -> 268 (+29) three switch arms _GLOBAL__sub_I_DirectGLES.cpp 1340 -> 1331 (-9) the static initialiser of DirectGLES.cpp's `static RenderStateParameters g_syncedRenderStateParameters` re-scheduling around the three new default-initialised members. A shrink, and the only unforeseen entry; it is a direct consequence of the struct gaining members and touches no interface. --- CMakeLists.txt | 24 ++ MobileGL/Config.h | 23 +- MobileGL/ConfigLoader.cpp | 15 + MobileGL/MG_Backend/MGPipe/PipeInputs.h | 7 + MobileGL/MG_Impl/Pipe/SlotAllocator.cpp | 174 +++++++++++ MobileGL/MG_Impl/Pipe/SlotAllocator.h | 100 +++++++ MobileGL/MG_Pipe/Coverage.def | 55 +++- MobileGL/MG_Pipe/MGPipe.h | 32 ++- MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp | 196 +++++++++++++ MobileGL/MG_Pipe/MGPipeRenderStateSpans.h | 203 +++++++++++++ MobileGL/MG_Pipe/MGPipeTypes.h | 36 ++- MobileGL/MG_Pipe/MGPipeValueTypes.h | 15 + MobileGL/MG_Pipe/PipeApply.cpp | 270 ++++++++++++++++++ MobileGL/MG_Pipe/PipeApply.h | 107 +++++++ MobileGL/MG_Pipe/PipeFields.def | 8 +- MobileGL/MG_Pipe/generated/PipeFilled.inc | 91 ++++++ MobileGL/MG_Pipe/generated/PipeSpanTable.inc | 94 +++--- MobileGL/MG_Pipe/generated/PipeWire.inc | 8 + .../GLState/RenderState/RenderState.cpp | 10 +- .../GLState/RenderState/RenderState.h | 11 +- MobileGL/MG_Test/Pipe/CMakeLists.txt | 30 ++ MobileGL/MG_Test/Pipe/CsoCacheTest.cpp | 32 +++ MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp | 69 +++-- .../MG_Test/Pipe/RenderStateSpansTest.cpp | 33 +++ MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp | 34 +++ MobileGL/MG_Test/Pipe/TrackerTest.cpp | 32 +++ MobileGL/MG_Test/Util/PipeStatsTest.cpp | 10 + MobileGL/MG_Util/Metrics/PipeStats.cpp | 10 + MobileGL/MG_Util/Metrics/PipeStats.h | 16 ++ scripts/gen_pipe.py | 211 +++++++++++--- 30 files changed, 1826 insertions(+), 130 deletions(-) create mode 100755 MobileGL/MG_Impl/Pipe/SlotAllocator.cpp create mode 100755 MobileGL/MG_Impl/Pipe/SlotAllocator.h create mode 100755 MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp create mode 100755 MobileGL/MG_Pipe/MGPipeRenderStateSpans.h create mode 100755 MobileGL/MG_Pipe/PipeApply.cpp create mode 100755 MobileGL/MG_Pipe/PipeApply.h create mode 100644 MobileGL/MG_Test/Pipe/CsoCacheTest.cpp create mode 100644 MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp create mode 100644 MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp create mode 100644 MobileGL/MG_Test/Pipe/TrackerTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8f8f0ef97..2e1b38653 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,6 +27,13 @@ option(MOBILEGL_BUILD_SERVER_SPIKE "Build the P0 spike-A MobileGLServer delivery # MGPipe/PipeInputs source is compiled, every MGP_FILL is ((void)0). option(MOBILEGL_PIPE_PUSH "Backends read frontend state through the MGPipe PipeInputs block instead of MG_State::pGLContext (ARCHITECTURE.md 9.2 phase A)" OFF) option(MOBILEGL_PIPE_VERIFY "Compile SnapshotFromGLContext() and the G4 per-verb shadow comparator; implies MOBILEGL_PIPE_PUSH; never shipped" OFF) +# Track H's old-versus-new arm (ARCHITECTURE.md 9.6). With a MOBILEGL_PIPE_PUSH bit clear +# the backend would still run the RE-KEYED memo code, so the bitmask alone stops being a +# valid A/B the moment a handle wave lands: this option compiles the pre-handle arm - the +# registries, OwnerEquals, the TwinLookupMemos, g_fbSlotCache, ComputePipelineStateHash, +# the address-keyed VaoDrawMemo - beside it, behind the same PipeInputs interface. ON for +# the whole migration window; it retires with the pull path itself at P13. +option(MOBILEGL_PIPE_LEGACY_MEMOS "Compile the pre-handle memo arm beside the {slot, gen} arm so Track H has a real A/B (ARCHITECTURE.md 9.6)" ON) set(MOBILEGL_LOG_ACTIVE_LEVEL "MOBILEGL_LOG_LEVEL_INFO" CACHE STRING "MobileGL active log level macro") set(MOBILEGL_VULKAN_LIBRARY "" CACHE FILEPATH "Vulkan loader/MoltenVK library to link for iOS builds") @@ -464,11 +471,25 @@ if (MOBILEGL_PIPE_VERIFY AND NOT MOBILEGL_PIPE_PUSH) set(MOBILEGL_PIPE_PUSH ON) endif() +# In a pull build the legacy arm is the ONLY arm, so the option cannot be off there. +# A normal variable, not a forced cache write, for the same reason as the two above. +if (NOT MOBILEGL_PIPE_PUSH AND NOT MOBILEGL_PIPE_LEGACY_MEMOS) + message(STATUS "MobileGL: MOBILEGL_PIPE_PUSH=OFF forces MOBILEGL_PIPE_LEGACY_MEMOS ON for this " + "configure: with nothing pushed it is the only arm there is") + set(MOBILEGL_PIPE_LEGACY_MEMOS ON) +endif() + if (MOBILEGL_PIPE_PUSH) message(STATUS "MobileGL: PipeInputs push ON, appending the MGPipe fill sources") list(APPEND SOURCE_FILES MobileGL/MG_Backend/MGPipe/PipeInputs.cpp MobileGL/MG_Impl/Pipe/PipeFill.cpp + # P2's contract: the chunk table and its subset hash, the in-process applier, and + # the client's {slot, gen} allocator. All three are push-only, which is how the + # pull build gains no symbol from P2 (G1) - a declaration emits nothing. + MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp + MobileGL/MG_Pipe/PipeApply.cpp + MobileGL/MG_Impl/Pipe/SlotAllocator.cpp ) endif() @@ -551,6 +572,9 @@ endif() if (MOBILEGL_PIPE_VERIFY) list(APPEND MOBILEGL_COMPILE_DEF -DMOBILEGL_PIPE_VERIFY=1) endif() +if (MOBILEGL_PIPE_LEGACY_MEMOS) + list(APPEND MOBILEGL_COMPILE_DEF -DMOBILEGL_PIPE_LEGACY_MEMOS=1) +endif() message(STATUS "MOBILEGL_COMPILE_DEF=${MOBILEGL_COMPILE_DEF}") diff --git a/MobileGL/Config.h b/MobileGL/Config.h index 4a6dd7b68..279193f37 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -319,10 +319,18 @@ namespace MobileGL::MG_Config { // --- MGPipe (the disaggregation plan's explicit frontend/backend boundary) --- // MOBILEGL_PIPE_PUSH: per-subsystem bitmask selecting which state the frontend // PUSHES over MGPipe instead of leaving the backend to pull it out of GLContext. - // 0 - the default and the only shipped value until the migration lands - is "pull - // everything", i.e. exactly today's behaviour. One bit of it also turns OFF - // client-side content addressing of CSOs, which is the negative control the CSO - // design is measured against. Accepts decimal or 0x-prefixed hex. + // 0 - the only shipped value until the migration lands - is "pull everything", + // i.e. exactly today's behaviour, and is the default of a PULL build, where the + // knob is meaningless anyway. A PUSH build defaults to every subsystem migrated so + // far (MG_Pipe::kMGPipeSubsystemsMigratedAtP2), so MOBILEGL_PIPE_PUSH=0 in the + // environment is the all-pull control. Accepts decimal or 0x-prefixed hex, and + // operators pass it as hex, so the bits are listed here (MG_Pipe/MGPipe.h owns them): + // 0x01 render state (create/bind_render_state + set_dynamic_state) + // 0x02 pixel pack 0x04 patch state 0x08 vertex attrib defaults + // 0x10 residual values 0x20 Espryt slots 0x40 Magma vertex input + // 1<<63 NOT a subsystem, a BEHAVIOUR: turn OFF client-side content addressing of + // CSOs, so every pipeline-version change mints a fresh CSO and the map is + // never probed. The negative control the CSO design is measured against. Uint64 PipePush = 0; // MOBILEGL_PIPE_VERIFY: per-draw, per-FIELD shadow comparison of the pushed state // against a snapshot taken from GLContext the old way, printing the first field @@ -349,6 +357,13 @@ namespace MobileGL::MG_Config { // Fatal{UnmigratedPipeInput} (negative control B). Unknown name is // Fatal{PipeVerifyBadKnob}. String PipePoisonOmit; + // MOBILEGL_PIPE_HANDLE_ABA_CONTROL (negative control C, P2 brief D18): defeat the + // two guards the {slot, gen} re-key replaces - hash the raw BufferObject* instead + // of its lifetime id, and skip the VAO lifetime-id compare - so + // HandleRecycleScenario.AbaControl reproduces the ABA and asserts the WRONG pixels. + // That is what proves the reproducer still reproduces. Under MOBILEGL_PIPE_PUSH + // only, so it cannot exist in a shipping pull build. + Bool PipeHandleAbaControl = false; #endif // MOBILEGL_PIPE_STATS: dump the boundary counters (bytes, calls, roundtrips, // texture pulls, upload shapes, residual-block bytes, index mirror bytes). diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index 5c0aa5919..7d66491d9 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -7,6 +7,11 @@ // End of Source File Header #include "Config.h" +#if MOBILEGL_PIPE_PUSH +// For kMGPipeSubsystemsMigratedAtP2, the push build's PipePush default. Push-only, so +// the pull build's translation unit is unchanged. +#include +#endif #include #include @@ -242,7 +247,16 @@ namespace MobileGL::MG_ConfigLoader { // MGPipe. Nothing here needs adding to an allow-list: InitializeAcceptedEnvVariables // accepts every MOBILEGL_ / LIBGL_ prefixed variable in the environment, so a name // that starts with MOBILEGL_ is visible to these queries by construction. +#if MOBILEGL_PIPE_PUSH + // A push build with the knob unset runs every subsystem migrated so far, so the + // shipped path is the one the gates measure; MOBILEGL_PIPE_PUSH=0 in the + // environment is the all-subsystems-pull control that reproduces P1 exactly. + features.PipePush = QueryEnvUint64("MOBILEGL_PIPE_PUSH", MG_Pipe::kMGPipeSubsystemsMigratedAtP2); +#else + // Meaningless in a pull build: there is nothing to push. Config.h documents 0 as + // "pull everything" and that stays literally true. features.PipePush = QueryEnvUint64("MOBILEGL_PIPE_PUSH", 0); +#endif features.PipeVerify = QueryEnvFlag("MOBILEGL_PIPE_VERIFY"); #if MOBILEGL_PIPE_PUSH // Defaults ON: read as a tri-state so only an explicitly falsy value turns it off. @@ -250,6 +264,7 @@ namespace MobileGL::MG_ConfigLoader { QueryEnvQuirkOverride("MOBILEGL_PIPE_VERIFY_FATAL") != MG_Config::QuirkOverride::ForceOff; QueryEnvVariable("MOBILEGL_PIPE_VERIFY_CORRUPT", features.PipeVerifyCorrupt, ""); QueryEnvVariable("MOBILEGL_PIPE_POISON_OMIT", features.PipePoisonOmit, ""); + features.PipeHandleAbaControl = QueryEnvFlag("MOBILEGL_PIPE_HANDLE_ABA_CONTROL"); #endif features.PipeStats = QueryEnvFlag("MOBILEGL_PIPE_STATS"); // Defaults ON, so the flag has to be read as a tri-state rather than as a plain diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.h b/MobileGL/MG_Backend/MGPipe/PipeInputs.h index 6e237de13..4c1a07a0d 100644 --- a/MobileGL/MG_Backend/MGPipe/PipeInputs.h +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.h @@ -609,6 +609,13 @@ namespace MobileGL::MG_Pipe { // The one door into the storage from the client side (MG_Impl/Pipe/PipeFill.cpp): // the filler's per-field copies and stamps, and the verify snapshot. friend struct MGPipeFillAccess; + // The other door, and the one that exists because of what this block IS after P2: + // the server's working RenderStateParameters. MG_Pipe/PipeApply.cpp scatters + // bind_render_state's and set_dynamic_state's chunks straight into m_renderState, + // which is why DirectGLES' SyncRenderState is not one line changed. It deliberately + // does NOT stamp the poison generations - a stamp says "the filler published this + // for THIS verb", which is the walk's statement, not the applier's. + friend struct MGPipeApplyAccess; // ---- identity ---- const void* m_contextIdentity = nullptr; diff --git a/MobileGL/MG_Impl/Pipe/SlotAllocator.cpp b/MobileGL/MG_Impl/Pipe/SlotAllocator.cpp new file mode 100755 index 000000000..ef0d1f85d --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/SlotAllocator.cpp @@ -0,0 +1,174 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/SlotAllocator.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// SlotAllocator.h. Compiled only under MOBILEGL_PIPE_PUSH. +#include + +namespace MobileGL::MG_Pipe { + namespace { + // The ShaderCso band the ordinary allocator must never enter: the top 1/16 of the + // ShaderCso slot space is reserved for PROGRAM PIPELINE COMPOSITES, which are minted + // client-side out of the stage programs bound to a pipeline object. Reserving a band + // rather than a flag keeps the composite resolver's lifetime bookkeeping out of here + // (MGPipeHandles.h, ARCHITECTURE.md 5.6.3). + Bool SlotIsAllocatable(MGPipeKind kind, Uint32 slot) { + if (slot < kMGPipeFirstAllocatableSlot) return false; + if (kind != MGPipeKind::ShaderCso) return true; + return slot < kMGPipeShaderCsoCompositeSlotBase; + } + } // namespace + + MGPipeSlotAllocator::KindState& MGPipeSlotAllocator::StateOf(MGPipeKind kind) { + const SizeT index = static_cast(kind); + MOBILEGL_ASSERT(index < kKindCount, "MGPipeKind %zu out of range", index); + return m_kinds[index < kKindCount ? index : 0]; + } + + const MGPipeSlotAllocator::KindState& MGPipeSlotAllocator::StateOf(MGPipeKind kind) const { + const SizeT index = static_cast(kind); + MOBILEGL_ASSERT(index < kKindCount, "MGPipeKind %zu out of range", index); + return m_kinds[index < kKindCount ? index : 0]; + } + + MGPipeHandle MGPipeSlotAllocator::Allocate(MGPipeKind kind) { + KindState& state = StateOf(kind); + if (state.Slots.empty()) { + // Slot 0 exists so the vector is slot-indexed, and is never handed out. + state.Slots.resize(kMGPipeFirstAllocatableSlot); + } + + Uint32 slot = 0; + Bool reused = false; + while (!state.FreeList.empty()) { + const Uint32 candidate = state.FreeList.back(); + state.FreeList.pop_back(); + if (!SlotIsAllocatable(kind, candidate)) continue; + slot = candidate; + reused = true; + break; + } + + if (!reused) { + slot = static_cast(state.Slots.size()); + MOBILEGL_ASSERT(SlotIsAllocatable(kind, slot), + "MGPipe slot space of kind %u is exhausted at slot %u", + static_cast(kind), slot); + if (!SlotIsAllocatable(kind, slot)) return kMGPipeNullHandle; + state.Slots.emplace_back(); + } + + SlotState& entry = state.Slots[slot]; + if (entry.EverHandedOut) { + // The one place Gen may move. 2^32 recycles of ONE slot is ~50 days of continuous + // churn at one recycle per frame at 1000 fps, which is why the bound is asserted + // in a debug allocator rather than defended in release. + MOBILEGL_ASSERT(entry.Gen != ~Uint32{0}, + "MGPipe handle generation wrapped on kind %u slot %u; {slot, gen} is " + "no longer unique", + static_cast(kind), slot); + ++entry.Gen; + } + entry.EverHandedOut = true; + entry.Live = true; + entry.LifetimeId = 0; + ++state.LiveCount; + return MGPipeHandle{slot, entry.Gen}; + } + + MGPipeHandle MGPipeSlotAllocator::AllocateFor(MGPipeKind kind, Uint64 lifetimeId) { + const MGPipeHandle handle = Allocate(kind); + if (MGPipeHandleIsNull(handle)) return handle; + KindState& state = StateOf(kind); + state.Slots[handle.Slot].LifetimeId = lifetimeId; + if (lifetimeId != 0) { + MOBILEGL_ASSERT(state.ByLifetimeId.find(lifetimeId) == state.ByLifetimeId.end(), + "lifetime id %llu already owns a slot of kind %u", + static_cast(lifetimeId), static_cast(kind)); + state.ByLifetimeId[lifetimeId] = handle.Slot; + } + return handle; + } + + MGPipeHandle MGPipeSlotAllocator::FindByLifetimeId(MGPipeKind kind, Uint64 lifetimeId) const { + if (lifetimeId == 0) return kMGPipeNullHandle; + const KindState& state = StateOf(kind); + const auto it = state.ByLifetimeId.find(lifetimeId); + if (it == state.ByLifetimeId.end()) return kMGPipeNullHandle; + const Uint32 slot = it->second; + if (slot >= state.Slots.size() || !state.Slots[slot].Live) return kMGPipeNullHandle; + return MGPipeHandle{slot, state.Slots[slot].Gen}; + } + + MGPipeHandle MGPipeSlotAllocator::Acquire(MGPipeKind kind, Uint64 lifetimeId) { + const MGPipeHandle existing = FindByLifetimeId(kind, lifetimeId); + if (!MGPipeHandleIsNull(existing)) return existing; + return AllocateFor(kind, lifetimeId); + } + + void MGPipeSlotAllocator::Free(MGPipeKind kind, MGPipeHandle handle) { + KindState& state = StateOf(kind); + if (handle.Slot >= state.Slots.size()) return; + SlotState& entry = state.Slots[handle.Slot]; + // A stale handle must not free the slot its successor now owns - that is the whole + // reason the generation is in the key. + if (!entry.Live || entry.Gen != handle.Gen) return; + if (entry.LifetimeId != 0) { + const auto it = state.ByLifetimeId.find(entry.LifetimeId); + if (it != state.ByLifetimeId.end() && it->second == handle.Slot) { + state.ByLifetimeId.erase(it); + } + } + entry.Live = false; + entry.LifetimeId = 0; + --state.LiveCount; + state.FreeList.push_back(handle.Slot); + } + + Bool MGPipeSlotAllocator::IsLive(MGPipeKind kind, MGPipeHandle handle) const { + const KindState& state = StateOf(kind); + if (handle.Slot >= state.Slots.size()) return false; + const SlotState& entry = state.Slots[handle.Slot]; + return entry.Live && entry.Gen == handle.Gen; + } + + Uint32 MGPipeSlotAllocator::GenOfSlot(MGPipeKind kind, Uint32 slot) const { + const KindState& state = StateOf(kind); + if (slot >= state.Slots.size()) return 0; + return state.Slots[slot].Gen; + } + + Uint64 MGPipeSlotAllocator::LifetimeIdOfSlot(MGPipeKind kind, Uint32 slot) const { + const KindState& state = StateOf(kind); + if (slot >= state.Slots.size()) return 0; + return state.Slots[slot].LifetimeId; + } + + Uint32 MGPipeSlotAllocator::HighWater(MGPipeKind kind) const { + return static_cast(StateOf(kind).Slots.size()); + } + + Uint32 MGPipeSlotAllocator::LiveCount(MGPipeKind kind) const { return StateOf(kind).LiveCount; } + + Uint32 MGPipeSlotAllocator::FreeCount(MGPipeKind kind) const { + return static_cast(StateOf(kind).FreeList.size()); + } + + void MGPipeSlotAllocator::Reset() { + for (KindState& state : m_kinds) { + state.Slots.clear(); + state.FreeList.clear(); + state.ByLifetimeId.clear(); + state.LiveCount = 0; + } + } + + MGPipeSlotAllocator& MGPipeSlots() { + static MGPipeSlotAllocator allocator; + return allocator; + } +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Impl/Pipe/SlotAllocator.h b/MobileGL/MG_Impl/Pipe/SlotAllocator.h new file mode 100755 index 000000000..78e5320d0 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/SlotAllocator.h @@ -0,0 +1,100 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/SlotAllocator.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +#include + +// The CLIENT's slot allocator: the thing that mints every MGPipeHandle in the system +// (ARCHITECTURE.md 4.2 - no create_* call in the catalogue returns a server-cast handle, +// which is what lets the whole catalogue be remoted with zero creation round trips). +// +// Per kind: a free list plus a high-water mark, so slots stay DENSE and the server's object +// table is an array rather than a hash map. It has nothing to do with MG_State's +// IndexGenerator - that container's LIFO GL-name reuse is the very problem {slot, gen} +// exists to close, and the whole point of the identity is that an ABA on the GL name, on +// the heap address or on the lifetime id cannot reproduce a handle. +// +// Gen increments ONLY when a slot is reused, never on a respecify: a glBufferData on a live +// buffer keeps the same {slot, gen}, because the object is the same object. Two generations +// exist in the design and they are strictly separate - this is the client's answer to "is +// this still the same GL object"; MGGen is the server's epoch for "did I recast my driver +// object", and no MGPipe call may require the client to know it. +// +// The lifetimeId -> slot map is what keeps a GL NAME out of every key (ARCHITECTURE.md 4.2): +// the frontend object's lifetime id is the client's own identity for it, so the backend key +// is the handle and the frontend key is the lifetime id, and neither is a recyclable name. +// +// Lives in MG_Impl (the client side, unrestricted) and is compiled only under +// MOBILEGL_PIPE_PUSH. It is in the P2 CONTRACT commit rather than in a Track H package +// because both Track H slices - Espryt 0b and Magma subsystem 4 - key off it. +namespace MobileGL::MG_Pipe { + + class MGPipeSlotAllocator { + public: + static constexpr SizeT kKindCount = static_cast(MGPipeKind::KindCount); + + // A fresh {slot, gen} of this kind, from the free list if one is waiting and from the + // high-water mark otherwise. Never returns slot 0 (reserved: null, and the default + // framebuffer for kind Framebuffer), and never returns a ShaderCso slot inside the + // composite band, which the program-pipeline resolver mints out of separately. + MGPipeHandle Allocate(MGPipeKind kind); + // Allocate and remember `lifetimeId` as this handle's frontend identity. + MGPipeHandle AllocateFor(MGPipeKind kind, Uint64 lifetimeId); + // The handle a lifetime id was allocated for, or kMGPipeNullHandle. A recycled heap + // address does NOT reproduce a mapping: MG_State hands out a fresh lifetime id per + // object, so the map key is unique for the life of the process. + MGPipeHandle FindByLifetimeId(MGPipeKind kind, Uint64 lifetimeId) const; + // FindByLifetimeId, then AllocateFor when it misses. The ordinary client path. + MGPipeHandle Acquire(MGPipeKind kind, Uint64 lifetimeId); + + // Returns the slot to the free list. The Gen bump happens on the NEXT handout of that + // slot, not here, so a handle that is freed twice cannot skip a generation and the + // "gen moves only on reuse" contract holds for an object that is never reused. + void Free(MGPipeKind kind, MGPipeHandle handle); + + Bool IsLive(MGPipeKind kind, MGPipeHandle handle) const; + // 0 for a slot that was never handed out; the generation of the LAST handout + // otherwise, live or not. + Uint32 GenOfSlot(MGPipeKind kind, Uint32 slot) const; + Uint64 LifetimeIdOfSlot(MGPipeKind kind, Uint32 slot) const; + // One past the highest slot ever handed out of this kind, i.e. what a server-side + // slot-indexed table must be sized to. + Uint32 HighWater(MGPipeKind kind) const; + Uint32 LiveCount(MGPipeKind kind) const; + Uint32 FreeCount(MGPipeKind kind) const; + + // Context teardown / server reset / a unit test's fixture. + void Reset(); + + private: + struct SlotState { + Uint32 Gen = 0; + Bool Live = false; + Bool EverHandedOut = false; + Uint64 LifetimeId = 0; + }; + + struct KindState { + // Indexed by slot; [0] is the reserved slot and is never live. + Vector Slots; + Vector FreeList; + UnorderedMap ByLifetimeId; + Uint32 LiveCount = 0; + }; + + KindState& StateOf(MGPipeKind kind); + const KindState& StateOf(MGPipeKind kind) const; + + Array m_kinds{}; + }; + + // The monolith's one client allocator. Under split there is one per client context. + MGPipeSlotAllocator& MGPipeSlots(); +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Pipe/Coverage.def b/MobileGL/MG_Pipe/Coverage.def index 583b5af01..f43eeed75 100644 --- a/MobileGL/MG_Pipe/Coverage.def +++ b/MobileGL/MG_Pipe/Coverage.def @@ -71,9 +71,10 @@ X(GetProgramForDispatch, SetDispatchProgram) \ X(GetProgramForDraw, SetDrawProgram) \ X(GetProgramObject, CreateShaderState) \ - /* Not in ComputePipelineStateHash today even though Vulkan makes it pipeline */ \ - /* state; recorded here so the G7 chunk table has to answer for it before it */ \ - /* freezes (section 10.3-5). */ \ + /* ANSWERED by P2: it is in the pipeline half. SetProvokingVertexMode calls */ \ + /* BumpVersions(), and the chunk table's rule is exactly that, so it rides */ \ + /* pipeline chunk P4 - a strict superset of what ComputePipelineStateHash used */ \ + /* to hash (MGPipeRenderStateSpans.cpp records the provenance). */ \ X(GetProvokingVertexMode, CreateRenderState) \ X(GetRenderStateParameters, CreateRenderState) \ X(GetRenderStateParametersVersion, BindRenderState) \ @@ -128,4 +129,52 @@ X(handle-ify (wire handle), kStructuralHandle) \ X(Buffer ops delta, ResourceRespecify) +// X(Accessor, PipeCall) - the EMITTED list (P2 brief D5): which P2 call now SUPPLIES this +// PipeInputs field, so the per-verb residual fill loop no longer has to pull it out of +// GLContext. gen_pipe.py turns it into kMGPipeFieldEmittedBy[] (generated/PipeFilled.inc); +// a field with no row here keeps going through the fill loop, which is what makes the +// MOBILEGL_PIPE_PUSH bitmask a true per-subsystem A/B rather than an all-or-nothing switch. +// +// Every name must be an accessor in MGP_COVERAGE_ACCESSOR_LIST and every call must be a +// real call in PipeCalls.def; gen_pipe.py refuses anything else. +// +// The one row whose call differs from the accessor list's is GetPrimitiveRestartIndex: +// coverage maps it onto draw_vbo because that is where a backend reads it, but the VALUE +// travels in dynamic chunk D6, so set_dynamic_state is what supplies it. +#define MGP_COVERAGE_EMITTED_LIST(X) \ + X(GetBlendColor, SetDynamicState) \ + X(GetBlendEquationIndexed, CreateRenderState) \ + X(GetBlendFuncIndexed, CreateRenderState) \ + X(GetClampReadColor, SetDynamicState) \ + X(GetClearColor, SetDynamicState) \ + X(GetClearDepth, SetDynamicState) \ + X(GetClearStencil, SetDynamicState) \ + X(GetColorMaskIndexed, CreateRenderState) \ + X(GetCullFaceMode, CreateRenderState) \ + X(GetCurrentVertexAttribute, SetVertexAttribDefaults) \ + X(GetDepthFunc, CreateRenderState) \ + X(GetDepthMask, CreateRenderState) \ + X(GetDepthRangeIndexed, SetDynamicState) \ + X(GetLineWidth, SetDynamicState) \ + X(GetLogicOp, CreateRenderState) \ + X(GetMinSampleShadingValue, CreateRenderState) \ + X(GetPatchDefaultInnerLevel, SetPatchState) \ + X(GetPatchDefaultOuterLevel, SetPatchState) \ + X(GetPatchVertices, SetPatchState) \ + X(GetPipelineStateVersion, BindRenderState) \ + X(GetPixelStoreParameters, SetPixelPackState) \ + X(GetPolygonModeFront, CreateRenderState) \ + X(GetPolygonOffsetFactor, SetDynamicState) \ + X(GetPolygonOffsetUnits, SetDynamicState) \ + X(GetPrimitiveRestartIndex, SetDynamicState) \ + X(GetProvokingVertexMode, CreateRenderState) \ + X(GetRenderStateParameters, CreateRenderState) \ + X(GetRenderStateParametersVersion, BindRenderState) \ + X(GetScissorBox, SetDynamicState) \ + X(GetStencilState, CreateRenderState) \ + X(GetViewport, SetDynamicState) \ + X(GetViewportIndexed, SetDynamicState) \ + X(IsCapabilityEnabled, CreateRenderState) \ + X(IsCapabilityEnabledIndexed, CreateRenderState) + // clang-format on diff --git a/MobileGL/MG_Pipe/MGPipe.h b/MobileGL/MG_Pipe/MGPipe.h index 5a1db9df0..b1caf7d5f 100644 --- a/MobileGL/MG_Pipe/MGPipe.h +++ b/MobileGL/MG_Pipe/MGPipe.h @@ -55,10 +55,34 @@ namespace MobileGL::MG_Pipe { }; // The pipeline/dynamic split of RenderStateParameters, defined exactly once (section - // 4.5.2). Generated by G7 from the field list ComputePipelineStateHash already hashes; - // MGPipeRenderStateSpans.cpp and the setter-consistency test land with P2, which is - // when the chunk table can be filled with real offsets. - struct MGPipeRenderStateSpans; + // 4.5.2): MG_Pipe/MGPipeRenderStateSpans.{h,cpp}, which landed with P2 and computes + // every chunk boundary with offsetof. Include that header to use it; what stays here + // is the generated member list at the bottom of this file, which is what the chunk + // table was derived from. + + // ---- MOBILEGL_PIPE_PUSH's runtime bitmask (Config.h Features.PipePush) ---- + // + // One bit per SUBSYSTEM, so an A/B is per subsystem rather than all-or-nothing, and + // bit 63 for the one BEHAVIOUR the design has to be measured against. Bits are + // allocated in ROADMAP order and never reused: an operator's recorded 0x7f has to keep + // meaning what it meant. + // + // A clear subsystem bit means "keep pulling", which after P2 is only a valid control + // while MOBILEGL_PIPE_LEGACY_MEMOS compiles the pre-handle arm beside it. + inline constexpr Uint64 kMGPipeSubsystemRenderState = 1ull << 0; + inline constexpr Uint64 kMGPipeSubsystemPixelPack = 1ull << 1; + inline constexpr Uint64 kMGPipeSubsystemPatchState = 1ull << 2; + inline constexpr Uint64 kMGPipeSubsystemVertexAttribDefaults = 1ull << 3; + inline constexpr Uint64 kMGPipeSubsystemResidualValues = 1ull << 4; + inline constexpr Uint64 kMGPipeSubsystemEsprytSlots = 1ull << 5; // Track H, Espryt 0b + inline constexpr Uint64 kMGPipeSubsystemMagmaVertexInput = 1ull << 6; // Track H, Magma subsystem 4 + // bits 7..62 reserved for the later phases, allocated in ROADMAP order. + // NOT a subsystem, a BEHAVIOUR: turn OFF client-side content addressing of CSOs, so + // every pipeline-version change mints a fresh CSO and the map is never probed. This is + // the negative control the whole CSO design is measured against (ROADMAP.md P2). + inline constexpr Uint64 kMGPipeBehaviourNoCsoContentAddressing = 1ull << 63; + // The default of a push build with the knob unset (ConfigLoader.cpp). + inline constexpr Uint64 kMGPipeSubsystemsMigratedAtP2 = 0x7full; // bits 0..6 // The catalogue itself. Only macros, so it is safe to expand inside the namespace, and // consumers (the unit test, later the transport) get MGP_CALL_LIST from this header. diff --git a/MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp b/MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp new file mode 100755 index 000000000..c0342f09a --- /dev/null +++ b/MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp @@ -0,0 +1,196 @@ +// MobileGL - MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The definitions behind MGPipeRenderStateSpans.h and behind the two arrays +// generated/PipeSpanTable.inc has declared since P0. Compiled ONLY under +// MOBILEGL_PIPE_PUSH (CMakeLists.txt appends it to SOURCE_FILES there), which is how the +// pull build gains no symbol from the split - a declaration emits nothing. +// +// PROVENANCE OF THE PIPELINE HALF. It began as the enumeration +// VulkanRenderer::ComputePipelineStateHash carried above itself, which was the contract +// that function had without being able to say so; it moves here because this file is now +// that contract. Verbatim, from VulkanRenderer.cpp at feat/disaggregated@48268068: +// +// Value hash over every fixed-function GL state the pipeline payload reads that +// the memo key's other fields (mode, program hash, vertex-input hash, render-pass +// hash, transform flags) do not already pin down. Enumerated against the payload +// build in GetOrCreatePipeline - any new GL-state read there must be added here: +// - capability bits: CullFace, DepthTest, PolygonOffsetFill (mode gating rides +// the memo's mode key), RasterizerDiscard, ColorLogicOp, StencilTest, +// PrimitiveRestart(+FixedIndex), SampleShading, SampleMask, plus the depth write mask +// - patch vertices, polygon mode, cull face mode, depth func, logic op, +// min sample shading, the glSampleMaski word +// - front/back stencil ops + compare funcs (ref/mask are dynamic state) +// - per draw buffer up to the render pass's colour span: indexed blend enable, +// blend factors/equations, indexed colour write mask (broadcast from index 0 +// when the device lacks independentBlend - the same read the payload does) +// FBO-derived payload inputs (attachment presence/formats/draw-buffer gating) are +// pinned by the render-pass hash key, exactly as the version-keyed memo relied on. +// +// P2's pipeline half is a strict SUPERSET of that list. It adds SampleCoverageValue, +// SampleCoverageInvert, FrontFaceModeSetting, ProvokingVertexModeSetting, +// ScissorTestEnabledMask, PolygonModeBack, the eleven capability bools the hash never read +// (DebugOutput, DebugOutputSynchronous, Dither, LineSmooth, PolygonOffsetLine, +// PolygonOffsetPoint, PolygonSmooth, SampleAlphaToCoverage, SampleAlphaToOne, SampleCoverage, +// ProgramPointSize) and the three capabilities P2 gave storage to (FramebufferSrgb, +// DepthClamp, TextureCubeMapSeamless). All of them are written by a setter that calls +// BumpVersions(), so under the header's rule they are pipeline. The alternative - demoting +// those setters to ++m_version - would change MG_State semantics in the PULL build for the +// sake of the push path. Growing the subset costs nothing measurable: the hash runs only +// when m_pipelineStateVersion moves, which is exactly when Magma recomputed +// ComputePipelineStateHash before. +// +// The render-pass facts are deliberately NOT here. ComputePipelineStateHash's signature is +// (colorAttachmentCount, rasterizationSamples) and it folds ResolveEffectiveSampleMask, so +// it was never a pure function of RenderStateParameters; a CSO handle cannot replace it on +// its own and Magma keeps renderPassHash as a separate memo-key component. +#include +#include + +#include + +namespace MobileGL::MG_Pipe { + namespace { + // Half-local chunk index -> global chunk index. The halves alternate, so this is + // arithmetic rather than a table. + constexpr SizeT GlobalPipelineChunk(SizeT halfIndex) { return halfIndex * 2 + 1; } + constexpr SizeT GlobalDynamicChunk(SizeT halfIndex) { return halfIndex * 2; } + + const Uint8* BytesOf(const RenderStateParameters& params) { + return reinterpret_cast(¶ms); + } + Uint8* BytesOf(RenderStateParameters& params) { return reinterpret_cast(¶ms); } + + SizeT BlobBytes(Uint32 chunkMask, SizeT halfCount, SizeT (*toGlobal)(SizeT)) { + SizeT total = 0; + for (SizeT i = 0; i < halfCount; ++i) { + if ((chunkMask & (1u << i)) == 0) continue; + total += MGPipeRenderStateChunkAt(toGlobal(i)).Length; + } + return total; + } + + void Gather(const RenderStateParameters& params, Uint32 chunkMask, void* dst, SizeT halfCount, + SizeT (*toGlobal)(SizeT)) { + Uint8* out = static_cast(dst); + const Uint8* src = BytesOf(params); + for (SizeT i = 0; i < halfCount; ++i) { + if ((chunkMask & (1u << i)) == 0) continue; + const MGPStateChunk chunk = MGPipeRenderStateChunkAt(toGlobal(i)); + std::memcpy(out, src + chunk.Offset, chunk.Length); + out += chunk.Length; + } + } + + void Scatter(const void* src, Uint32 chunkMask, RenderStateParameters& dst, SizeT halfCount, + SizeT (*toGlobal)(SizeT)) { + const Uint8* in = static_cast(src); + Uint8* out = BytesOf(dst); + for (SizeT i = 0; i < halfCount; ++i) { + if ((chunkMask & (1u << i)) == 0) continue; + const MGPStateChunk chunk = MGPipeRenderStateChunkAt(toGlobal(i)); + std::memcpy(out + chunk.Offset, in, chunk.Length); + in += chunk.Length; + } + } + + Uint32 ChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b, + SizeT halfCount, SizeT (*toGlobal)(SizeT)) { + const Uint8* left = BytesOf(a); + const Uint8* right = BytesOf(b); + Uint32 mask = 0; + for (SizeT i = 0; i < halfCount; ++i) { + const MGPStateChunk chunk = MGPipeRenderStateChunkAt(toGlobal(i)); + if (std::memcmp(left + chunk.Offset, right + chunk.Offset, chunk.Length) != 0) { + mask |= 1u << i; + } + } + return mask; + } + + constexpr Uint32 AllChunks(SizeT halfCount) { + return halfCount >= 32 ? ~Uint32{0} : static_cast((Uint64{1} << halfCount) - 1); + } + } // namespace + + // The two arrays generated/PipeSpanTable.inc declares. Every entry is + // MGPipeRenderStateChunkAt(), so a boundary can only be written once. + const MGPStateChunk kMGPipePipelineChunks[kMGPipePipelineChunkCount] = { + MGPipeRenderStateChunkAt(GlobalPipelineChunk(0)), MGPipeRenderStateChunkAt(GlobalPipelineChunk(1)), + MGPipeRenderStateChunkAt(GlobalPipelineChunk(2)), MGPipeRenderStateChunkAt(GlobalPipelineChunk(3)), + MGPipeRenderStateChunkAt(GlobalPipelineChunk(4)), MGPipeRenderStateChunkAt(GlobalPipelineChunk(5)), + MGPipeRenderStateChunkAt(GlobalPipelineChunk(6)), + }; + static_assert(sizeof(kMGPipePipelineChunks) / sizeof(kMGPipePipelineChunks[0]) == kMGPipePipelineChunkCount, + "kMGPipePipelineChunks lost an entry"); + + const MGPStateChunk kMGPipeDynamicChunks[kMGPipeDynamicChunkCount] = { + MGPipeRenderStateChunkAt(GlobalDynamicChunk(0)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(1)), + MGPipeRenderStateChunkAt(GlobalDynamicChunk(2)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(3)), + MGPipeRenderStateChunkAt(GlobalDynamicChunk(4)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(5)), + MGPipeRenderStateChunkAt(GlobalDynamicChunk(6)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(7)), + }; + static_assert(sizeof(kMGPipeDynamicChunks) / sizeof(kMGPipeDynamicChunks[0]) == kMGPipeDynamicChunkCount, + "kMGPipeDynamicChunks lost an entry"); + + void MGPipeGatherPipelineBytes(const RenderStateParameters& params, void* dst) { + Gather(params, AllChunks(kMGPipePipelineChunkCount), dst, kMGPipePipelineChunkCount, + GlobalPipelineChunk); + } + + void MGPipeScatterPipelineBytes(const void* src, RenderStateParameters& dst) { + Scatter(src, AllChunks(kMGPipePipelineChunkCount), dst, kMGPipePipelineChunkCount, + GlobalPipelineChunk); + } + + SizeT MGPipePipelineChunkBlobBytes(Uint32 chunkMask) { + return BlobBytes(chunkMask, kMGPipePipelineChunkCount, GlobalPipelineChunk); + } + + void MGPipeGatherPipelineChunks(const RenderStateParameters& params, Uint32 chunkMask, void* dst) { + Gather(params, chunkMask, dst, kMGPipePipelineChunkCount, GlobalPipelineChunk); + } + + void MGPipeScatterPipelineChunks(const void* src, Uint32 chunkMask, RenderStateParameters& dst) { + Scatter(src, chunkMask, dst, kMGPipePipelineChunkCount, GlobalPipelineChunk); + } + + SizeT MGPipeDynamicChunkBlobBytes(Uint32 chunkMask) { + return BlobBytes(chunkMask, kMGPipeDynamicChunkCount, GlobalDynamicChunk); + } + + void MGPipeGatherDynamicChunks(const RenderStateParameters& params, Uint32 chunkMask, void* dst) { + Gather(params, chunkMask, dst, kMGPipeDynamicChunkCount, GlobalDynamicChunk); + } + + void MGPipeScatterDynamicChunks(const void* src, Uint32 chunkMask, RenderStateParameters& dst) { + Scatter(src, chunkMask, dst, kMGPipeDynamicChunkCount, GlobalDynamicChunk); + } + + Uint32 MGPipeDynamicChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b) { + return ChunksThatMoved(a, b, kMGPipeDynamicChunkCount, GlobalDynamicChunk); + } + + Uint32 MGPipePipelineChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b) { + return ChunksThatMoved(a, b, kMGPipePipelineChunkCount, GlobalPipelineChunk); + } + + Uint64 MGPipeHashPipelineBytes(const void* bytes) { + return static_cast( + XXH64(bytes, kMGPipePipelineChunkBytes, kMGPipeRenderStateChunkTableVersion)); + } + + Uint64 MGPipeComputePipelineSubsetHash(const RenderStateParameters& params) { + // 396 bytes on the stack. A streaming XXH64_state_t would allocate; gathering first + // is also what CsoCache wants, because the same bytes are what a hash hit memcmps + // against before the handle is reused. + Uint8 gathered[kMGPipePipelineChunkBytes]; + MGPipeGatherPipelineBytes(params, gathered); + return MGPipeHashPipelineBytes(gathered); + } +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Pipe/MGPipeRenderStateSpans.h b/MobileGL/MG_Pipe/MGPipeRenderStateSpans.h new file mode 100755 index 000000000..ec1aed09f --- /dev/null +++ b/MobileGL/MG_Pipe/MGPipeRenderStateSpans.h @@ -0,0 +1,203 @@ +// MobileGL - MobileGL/MG_Pipe/MGPipeRenderStateSpans.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +#include "MGPipeTypes.h" +#include "MGPipeValueTypes.h" + +// G7: the pipeline/dynamic split of RenderStateParameters, written in EXACTLY ONE PLACE +// (ARCHITECTURE.md 5.3, D-B1). +// +// The rule that decides the split, and it is the only rule: +// +// A byte of RenderStateParameters is in the PIPELINE half if and only if some public +// RenderState setter that calls BumpVersions() writes it. Every other byte is in the +// DYNAMIC half. There is no third set. +// +// That makes the G7 invariant - the pipeline-subset hash moves IF AND ONLY IF +// m_pipelineStateVersion moves - true by CONSTRUCTION rather than by inspection, and it is +// what MG_Test/Pipe/RenderStateSpansTest.cpp walks every setter to confirm. +// +// The chunks alternate: chunk 0 is dynamic, chunk 1 is pipeline, and so on, so the whole +// table is 16 BOUNDARIES rather than 15 hand-written ranges. Every boundary is an offsetof +// or a sizeof - never a literal - because a python guess at a layout it cannot see is +// exactly the drift the setter-consistency test exists to catch. 8 dynamic chunks + 7 +// pipeline chunks = 15, and both counts fit the Uint32 ChunkMask of MGPRenderStateDesc and +// MGPDynamicState with room to spare. +// +// Note the two splits are ORTHOGONAL and coexist (ARCHITECTURE.md 5.3): DirectGLES' +// head [0, 312) / blend [312, 536) / tail [536, 1168) spans cut ACROSS this table, and +// nothing about them changes. StencilFaceState is deliberately NOT reordered - reordering +// would move Espryt's shadow bytes for no gain. +namespace MobileGL::MG_Pipe { + + namespace MGPipeRenderStateChunkDetail { + using RSP = RenderStateParameters; + using SFS = StencilFaceState; + + inline constexpr SizeT kStencilFace0 = offsetof(RSP, StencilStates); + inline constexpr SizeT kStencilFace1 = kStencilFace0 + sizeof(SFS); + // The pipeline half of one stencil face is [Func, Ref) + [FailOp, end); the dynamic + // half is [Ref, FailOp) - Ref and ValueMask are VK_DYNAMIC_STATE_STENCIL_REFERENCE / + // _COMPARE_MASK and WriteMask is _WRITE_MASK, which is why glStencilFunc changing only + // the reference must not evict a cached pipeline (RenderState.cpp SetStencilFunc). + inline constexpr SizeT kFaceDynamicBegin = offsetof(SFS, Ref); + inline constexpr SizeT kFaceDynamicEnd = offsetof(SFS, FailOp); + } // namespace MGPipeRenderStateChunkDetail + + // 15 chunks, 16 boundaries, strictly ascending, [0, sizeof(RenderStateParameters)). + inline constexpr SizeT kMGPipeRenderStateChunkCount = 15; + + inline constexpr Array kMGPipeRenderStateChunkBoundaries = { + // D0 dynamic: Viewports[16], LineWidth, PointSize + SizeT{0}, + // P0 pipeline: PatchVertices, PatchDefaultOuterLevel, PatchDefaultInnerLevel + offsetof(RenderStateParameters, PatchVertices), + // D1 dynamic: PolygonOffsetFactor/Units/Clamp, ClipOrigin, ClipDepthMode + offsetof(RenderStateParameters, PolygonOffsetFactor), + // P1 pipeline: BlendStates[8], LogicOp, DepthTestEnabled, DepthFunc, DepthMask, + // ColorMasks[8], FramebufferSrgbEnabled, DepthClampEnabled, + // TextureCubeMapSeamlessEnabled + offsetof(RenderStateParameters, BlendStates), + // D2 dynamic: ClearColor, ClearDepth, ClearStencil, BlendColor, DepthRanges[16] + offsetof(RenderStateParameters, ClearColor), + // P2 pipeline: SampleCoverageValue, SampleCoverageInvert, SampleMaskValue, + // MinSampleShadingValue, StencilStates[0].Func + offsetof(RenderStateParameters, SampleCoverageValue), + // D3 dynamic: StencilStates[0].{Ref, ValueMask, WriteMask} + MGPipeRenderStateChunkDetail::kStencilFace0 + MGPipeRenderStateChunkDetail::kFaceDynamicBegin, + // P3 pipeline: StencilStates[0].{FailOp, PassDepthFailOp, PassDepthPassOp}, + // StencilStates[1].Func + MGPipeRenderStateChunkDetail::kStencilFace0 + MGPipeRenderStateChunkDetail::kFaceDynamicEnd, + // D4 dynamic: StencilStates[1].{Ref, ValueMask, WriteMask} + MGPipeRenderStateChunkDetail::kStencilFace1 + MGPipeRenderStateChunkDetail::kFaceDynamicBegin, + // P4 pipeline: StencilStates[1].{FailOp, PassDepthFailOp, PassDepthPassOp}, + // CullFaceEnabled, CullFaceModeSetting, FrontFaceModeSetting, + // ProvokingVertexModeSetting + MGPipeRenderStateChunkDetail::kStencilFace1 + MGPipeRenderStateChunkDetail::kFaceDynamicEnd, + // D5 dynamic: the four hints, PointFadeThresholdSize, PointSpriteCoordOrigin, + // ClampReadColor + offsetof(RenderStateParameters, LineSmoothHint), + // P5 pipeline: PolygonModeFront, PolygonModeBack + offsetof(RenderStateParameters, PolygonModeFront), + // D6 dynamic: PrimitiveRestartIndex + offsetof(RenderStateParameters, PrimitiveRestartIndex), + // P6 pipeline: the 20 capability bools ColorLogicOpEnabled..ProgramPointSizeEnabled, + // ScissorTestEnabledMask + offsetof(RenderStateParameters, ColorLogicOpEnabled), + // D7 dynamic: ScissorBoxes[16], ScissorBoxWrittenMask, ClipDistanceEnabledMask + offsetof(RenderStateParameters, ScissorBoxes), + sizeof(RenderStateParameters), + }; + + // Chunk 0 is dynamic and they alternate, which is not a coincidence: every boundary above + // is a transition between a run of BumpVersions()-written members and a run of + // ++m_version-only members, so two adjacent chunks of the same half would mean a boundary + // that separates nothing. + constexpr Bool MGPipeRenderStateChunkIsPipeline(SizeT index) { return (index % 2) == 1; } + + constexpr MGPStateChunk MGPipeRenderStateChunkAt(SizeT index) { + return MGPStateChunk{static_cast(kMGPipeRenderStateChunkBoundaries[index]), + static_cast(kMGPipeRenderStateChunkBoundaries[index + 1] - + kMGPipeRenderStateChunkBoundaries[index])}; + } + + namespace MGPipeRenderStateChunkDetail { + constexpr SizeT CountHalf(Bool pipeline) { + SizeT count = 0; + for (SizeT i = 0; i < kMGPipeRenderStateChunkCount; ++i) { + if (MGPipeRenderStateChunkIsPipeline(i) == pipeline) ++count; + } + return count; + } + constexpr SizeT BytesOfHalf(Bool pipeline) { + SizeT bytes = 0; + for (SizeT i = 0; i < kMGPipeRenderStateChunkCount; ++i) { + if (MGPipeRenderStateChunkIsPipeline(i) == pipeline) { + bytes += MGPipeRenderStateChunkAt(i).Length; + } + } + return bytes; + } + } // namespace MGPipeRenderStateChunkDetail + + inline constexpr SizeT kMGPipePipelineChunkCount = MGPipeRenderStateChunkDetail::CountHalf(true); + inline constexpr SizeT kMGPipeDynamicChunkCount = MGPipeRenderStateChunkDetail::CountHalf(false); + // The CSO's content-addressed identity is exactly this many bytes; CsoCache stores them + // per entry and memcmps them on a hash hit. + inline constexpr SizeT kMGPipePipelineChunkBytes = MGPipeRenderStateChunkDetail::BytesOfHalf(true); + inline constexpr SizeT kMGPipeDynamicChunkBytes = MGPipeRenderStateChunkDetail::BytesOfHalf(false); + + // Seeds MGPipeComputePipelineSubsetHash, so a chunk-table change invalidates every + // persisted key rather than silently aliasing an old one. BUMP IT whenever a boundary, + // an ordering or the halves' membership moves. + inline constexpr Uint64 kMGPipeRenderStateChunkTableVersion = 1; + + // ---- the trip wires. A mistake in the table is a build break, here. ---- + static_assert(kMGPipeRenderStateChunkBoundaries[0] == 0, + "the chunk table must start at byte 0 of RenderStateParameters"); + static_assert(kMGPipeRenderStateChunkBoundaries[kMGPipeRenderStateChunkCount] == + sizeof(RenderStateParameters), + "the chunk table must cover RenderStateParameters to its last byte"); + static_assert(kMGPipePipelineChunkCount == 7); + static_assert(kMGPipeDynamicChunkCount == 8); + static_assert(kMGPipePipelineChunkCount + kMGPipeDynamicChunkCount == kMGPipeRenderStateChunkCount); + static_assert(kMGPipePipelineChunkBytes + kMGPipeDynamicChunkBytes == sizeof(RenderStateParameters), + "the two halves must partition the block exactly - no gap, no overlap"); + static_assert(kMGPipeRenderStateChunkCount <= 32, + "a chunk index has to fit the Uint32 ChunkMask of MGPRenderStateDesc/MGPDynamicState"); + + // Sorted, non-overlapping and complete: because every chunk is [b[i], b[i+1]) the only + // way to violate that is a non-ascending boundary, so this is the whole check. + constexpr Bool MGPipeRenderStateChunkBoundariesAscend() { + for (SizeT i = 0; i < kMGPipeRenderStateChunkCount; ++i) { + if (!(kMGPipeRenderStateChunkBoundaries[i] < kMGPipeRenderStateChunkBoundaries[i + 1])) { + return false; + } + if (kMGPipeRenderStateChunkBoundaries[i + 1] > 0xffffu) return false; + } + return true; + } + static_assert(MGPipeRenderStateChunkBoundariesAscend(), + "the chunk boundaries must strictly ascend and fit MGPStateChunk's Uint16 fields"); + + // The measured sizes. They are DERIVED above; these two assertions only pin what the P2 + // brief and MEASUREMENTS.md quote, so a table change that moves them is loud. + static_assert(kMGPipePipelineChunkBytes == 396, "the pipeline subset is 396 bytes"); + static_assert(kMGPipeDynamicChunkBytes == 772, "the dynamic subset is 772 bytes"); + + // ---- the operations everything else is written against ---- + + // The 396 pipeline bytes of `params`, in ascending chunk order, into `dst`. + void MGPipeGatherPipelineBytes(const RenderStateParameters& params, void* dst); + // The inverse: `src` is kMGPipePipelineChunkBytes bytes in the same order. + void MGPipeScatterPipelineBytes(const void* src, RenderStateParameters& dst); + // Incremental create_render_state: only the pipeline chunks named by `chunkMask` (bit i + // is pipeline chunk i, 0-based within the pipeline half), concatenated ascending. + SizeT MGPipePipelineChunkBlobBytes(Uint32 chunkMask); + void MGPipeGatherPipelineChunks(const RenderStateParameters& params, Uint32 chunkMask, void* dst); + void MGPipeScatterPipelineChunks(const void* src, Uint32 chunkMask, RenderStateParameters& dst); + + // set_dynamic_state: bit i of `chunkMask` is dynamic chunk i, 0-based within the dynamic + // half; the blob is those chunks concatenated in ascending order. + SizeT MGPipeDynamicChunkBlobBytes(Uint32 chunkMask); + void MGPipeGatherDynamicChunks(const RenderStateParameters& params, Uint32 chunkMask, void* dst); + void MGPipeScatterDynamicChunks(const void* src, Uint32 chunkMask, RenderStateParameters& dst); + // Which dynamic chunks differ between two blocks - the chunk-level suppressor's answer. + Uint32 MGPipeDynamicChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b); + // Which pipeline chunks differ - the incremental-create mask against a base CSO. + Uint32 MGPipePipelineChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b); + + // XXH64 over the seven pipeline chunks in ascending order, seeded with the table version. + // Runs ONLY when m_pipelineStateVersion moved, i.e. never in the steady state. + Uint64 MGPipeComputePipelineSubsetHash(const RenderStateParameters& params); + // The same hash over already-gathered bytes (CsoCache holds them, so it does not re-gather). + Uint64 MGPipeHashPipelineBytes(const void* bytes); +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Pipe/MGPipeTypes.h b/MobileGL/MG_Pipe/MGPipeTypes.h index 7e84044d7..708bd992d 100644 --- a/MobileGL/MG_Pipe/MGPipeTypes.h +++ b/MobileGL/MG_Pipe/MGPipeTypes.h @@ -231,8 +231,13 @@ namespace MobileGL::MG_Pipe { // The half of the render state that must NOT mint a CSO: viewport, scissor, depth // range, blend colour, line width, polygon offset, stencil ref/write mask, clear - // values, sample coverage, hints and the point-size family. This is what keeps - // glViewport from evicting Magma's pipeline memo (D-B1). + // values, hints, the point-size family and the primitive-restart index. This is what + // keeps glViewport from evicting Magma's pipeline memo (D-B1). + // + // SAMPLE COVERAGE IS NOT IN IT, and this comment used to say it was. P2's rule is that + // a byte is pipeline state if and only if a public RenderState setter that calls + // BumpVersions() writes it, and SetSampleCoverage does - so SampleCoverageValue and + // SampleCoverageInvert are in pipeline chunk P2 (MGPipeRenderStateSpans.h). struct MGPDynamicState { Uint32 ChunkMask; Uint16 Version; @@ -514,14 +519,17 @@ namespace MobileGL::MG_Pipe { // to it because both sides are the same translation unit. G3 emits the offsetof // assertions; under split the block is serialized field-wise rather than memcpy'd. struct ResidualValueBlock { - RenderStateParameters RenderState; // until create/bind_render_state + set_dynamic_state land - PixelStoreParameters Pack; // until set_pixel_pack_state lands + // The 35 CapabilityInput bits, packed in enum order. P2 retired everything else: + // RenderStateParameters to create/bind_render_state + set_dynamic_state, Pack to + // set_pixel_pack_state, and the patch quintet to set_patch_state. + // + // What is left is deliberately REDUNDANT. Every one of the 35 capabilities is + // answerable from the assembled working block now that P2 gave FramebufferSrgb, + // DepthClamp and TextureCubeMapSeamless real storage - which is the point: the + // applier compares the two answers bit by bit, so the day a later call takes a + // capability over and forgets to carry it, the block says so on the next draw + // (Fatal{PipeResidualDiverged, ""}, MG_Pipe/PipeApply.cpp). Uint64 CapabilityBits; - Uint32 PatchVertices; - Uint32 Pad0; - Float PatchOuter[4]; - Float PatchInner[2]; - Uint32 Pad1[2]; }; static_assert(std::is_trivially_copyable_v); // The retirement ratchet. This number only ever goes DOWN: every stage that lands a real @@ -530,10 +538,12 @@ namespace MobileGL::MG_Pipe { // gone. Shrinking the block without lowering the number, or growing it at all, is a build // break - which is the point. // -// Stable across the ABIs MobileGL ships on: every member of RenderStateParameters and -// PixelStoreParameters is a fixed-width scalar or an array of one, with no pointer and no -// SizeT. -#define MGL_RESIDUAL_BLOCK_SIZE 1248 +// Stable across the ABIs MobileGL ships on: the one member is a fixed-width scalar. +// +// P2: 1248 -> 8. RenderStateParameters (1168) retired to create/bind_render_state and +// set_dynamic_state, PixelStoreParameters (28) to set_pixel_pack_state, and the patch +// quintet (52 with its padding) to set_patch_state. +#define MGL_RESIDUAL_BLOCK_SIZE 8 static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE, "the residual value block changed size; lower MGL_RESIDUAL_BLOCK_SIZE if a field " "retired, and do not raise it"); diff --git a/MobileGL/MG_Pipe/MGPipeValueTypes.h b/MobileGL/MG_Pipe/MGPipeValueTypes.h index 2b44d1e7a..888cb3adf 100644 --- a/MobileGL/MG_Pipe/MGPipeValueTypes.h +++ b/MobileGL/MG_Pipe/MGPipeValueTypes.h @@ -289,6 +289,21 @@ namespace MobileGL { // Every entry is initialized to all-true in RenderState's constructor. Array ColorMasks; + // GL_FRAMEBUFFER_SRGB / GL_DEPTH_CLAMP / GL_TEXTURE_CUBE_MAP_SEAMLESS. Until P2 these + // three fell to SetCapability's "not supported currently" arm - glEnable was swallowed + // and IsCapabilityEnabled answered a compile-time false, so DirectGLES' sRGB block and + // the DirectVulkan read points consumed a constant while glIsEnabled lied about it. + // Placed HERE, in the three alignment bytes between ColorMasks (32 bytes, align 1) and + // ClearColor (align 4), so sizeof(RenderStateParameters) stays 1168 and no existing + // offset moves: the Espryt span constants and the P2 chunk table both depend on that. + // All three are PIPELINE state (their setters call BumpVersions): FramebufferSrgb is + // what ARCHITECTURE.md 5.3 asks for, DepthClamp is + // VkPipelineRasterizationStateCreateInfo::depthClampEnable, and TextureCubeMapSeamless + // changes sampler interpretation. + Bool FramebufferSrgbEnabled = false; + Bool DepthClampEnabled = false; + Bool TextureCubeMapSeamlessEnabled = false; + // Clear State FloatVec4 ClearColor = FloatVec4(0.0f, 0.0f, 0.0f, 1.0f); Float ClearDepth = 1.0f; diff --git a/MobileGL/MG_Pipe/PipeApply.cpp b/MobileGL/MG_Pipe/PipeApply.cpp new file mode 100755 index 000000000..ba1dc59ef --- /dev/null +++ b/MobileGL/MG_Pipe/PipeApply.cpp @@ -0,0 +1,270 @@ +// MobileGL - MobileGL/MG_Pipe/PipeApply.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The in-process applier (PipeApply.h). Compiled only under MOBILEGL_PIPE_PUSH. +// +// This is the one .cpp under MG_Pipe/ that reaches UP to MG_Backend/MGPipe/PipeInputs.h, +// and that is the point: under split it becomes the server, and the server is where the +// working state lives. Nothing in MG_Pipe's HEADERS reaches it, so purity gate A +// (MGPipeValueTypes.h's include closure) is untouched. +#include + +#include + +#include +#include + +namespace MobileGL::MG_Pipe { + + // The applier's door into PipeInputs' storage, the write-side twin of PipeFill.cpp's + // MGPipeFillAccess. It does NOT stamp the poison generations: a stamp says "the filler + // published this field for THIS verb", and that statement belongs to the walk that + // called the applier, not to the applier - MG_Impl/Pipe/PipeFill.cpp stamps what it + // emitted, exactly as it stamps what it copied. + struct MGPipeApplyAccess { + static RenderStateParameters& RenderState(PipeInputs& inputs) { return inputs.m_renderState; } + static PixelStoreParameters& PackState(PipeInputs& inputs) { return inputs.m_pixelStore[0]; } + static Bool* Capabilities(PipeInputs& inputs) { return inputs.m_capability; } + static PipeInputs::CurrentVertexAttributeValue* VertexAttribDefaults(PipeInputs& inputs) { + return inputs.m_currentVertexAttribute; + } + static void SetRenderStateVersions(PipeInputs& inputs, Uint parameters, Uint pipeline) { + inputs.m_renderStateParametersVersion = parameters; + inputs.m_pipelineStateVersion = pipeline; + } + static void SetRenderStateParametersVersion(PipeInputs& inputs, Uint parameters) { + inputs.m_renderStateParametersVersion = parameters; + } + static void SetPatchState(PipeInputs& inputs, Uint vertices, const FloatVec4& outer, + const FloatVec2& inner) { + inputs.m_patchVertices = vertices; + inputs.m_patchDefaultOuterLevel = outer; + inputs.m_patchDefaultInnerLevel = inner; + } + }; + + namespace { + // CapabilityInput in enum order, so the residual block's bit i and this name agree by + // construction. The static_assert below is what makes a capability added to the enum + // without a name here a build break rather than an "" in a Fatal line. + constexpr const char* kCapabilityNames[] = { + "Blend", + "ClipDistance0", + "ClipDistance1", + "ClipDistance2", + "ClipDistance3", + "ClipDistance4", + "ClipDistance5", + "ClipDistance6", + "ClipDistance7", + "ColorLogicOp", + "CullFace", + "DebugOutput", + "DebugOutputSynchronous", + "DepthClamp", + "DepthTest", + "Dither", + "FramebufferSrgb", + "LineSmooth", + "Multisample", + "PolygonOffsetFill", + "PolygonOffsetLine", + "PolygonOffsetPoint", + "PolygonSmooth", + "PrimitiveRestart", + "PrimitiveRestartFixedIndex", + "RasterizerDiscard", + "SampleAlphaToCoverage", + "SampleAlphaToOne", + "SampleCoverage", + "SampleShading", + "SampleMask", + "ScissorTest", + "StencilTest", + "TextureCubeMapSeamless", + "ProgramPointSize", + }; + constexpr SizeT kCapabilityCount = static_cast(CapabilityInput::CapabilityInputCount); + static_assert(sizeof(kCapabilityNames) / sizeof(kCapabilityNames[0]) == kCapabilityCount, + "CapabilityInput gained a value; name it here or the residual trip wire " + "cannot say which capability diverged"); + static_assert(kCapabilityCount <= 64, + "ResidualValueBlock::CapabilityBits is a Uint64; 35 bits fit, 65 would not"); + + MGPipeApplierState g_applier{}; + + MGPipeRenderStateCsoRecord* FindCso(MGPipeHandle handle) { + if (handle.Slot >= g_applier.RenderStateCsos.size()) return nullptr; + MGPipeRenderStateCsoRecord& record = g_applier.RenderStateCsos[handle.Slot]; + if (!record.Live || record.Gen != handle.Gen) return nullptr; + return &record; + } + + constexpr Uint32 kAllPipelineChunks = + static_cast((Uint64{1} << kMGPipePipelineChunkCount) - 1); + } // namespace + + MGPipeApplierState& MGPipeApplier() { return g_applier; } + + void MGPipeApplierReset() { + g_applier.RenderStateCsos.clear(); + g_applier.BoundRenderStateCso = kMGPipeNullHandle; + g_applier.Residual = ResidualValueBlock{}; + g_applier.HasResidual = false; + } + + void MGPipeApplyCreateRenderState(const MGPRenderStateDesc& desc, const void* chunkBytes) { + MOBILEGL_ASSERT(desc.Cso.Slot >= kMGPipeFirstAllocatableSlot, + "create_render_state named the reserved slot 0"); + if (desc.Cso.Slot >= g_applier.RenderStateCsos.size()) { + g_applier.RenderStateCsos.resize(desc.Cso.Slot + 1); + } + MGPipeRenderStateCsoRecord& record = g_applier.RenderStateCsos[desc.Cso.Slot]; + + if (MGPipeHandleIsNull(desc.BaseCso)) { + // A brand-new CSO carries its whole content; there is no earlier record to + // inherit the unnamed chunks from. + MOBILEGL_ASSERT((desc.ChunkMask & kAllPipelineChunks) == kAllPipelineChunks, + "create_render_state with no BaseCso must name every pipeline chunk " + "(mask=0x%x, expected 0x%x)", + desc.ChunkMask, kAllPipelineChunks); + record.PipelineBytes = {}; + } else { + const MGPipeRenderStateCsoRecord* base = FindCso(desc.BaseCso); + MOBILEGL_ASSERT(base != nullptr, + "create_render_state named a dead BaseCso {slot=%u, gen=%u}", + desc.BaseCso.Slot, desc.BaseCso.Gen); + if (base != nullptr) record.PipelineBytes = base->PipelineBytes; + } + + // The chunk bytes land in the record's own gathered order, so the record is always a + // complete pipeline half whatever mask minted it. + RenderStateParameters staging{}; + MGPipeScatterPipelineBytes(record.PipelineBytes.data(), staging); + MGPipeScatterPipelineChunks(chunkBytes, desc.ChunkMask, staging); + MGPipeGatherPipelineBytes(staging, record.PipelineBytes.data()); + + record.Gen = desc.Cso.Gen; + record.Live = true; + } + + void MGPipeApplyBindRenderState(const MGPBindRenderState& bind) { + const MGPipeRenderStateCsoRecord* record = FindCso(bind.Cso); + MOBILEGL_ASSERT(record != nullptr, "bind_render_state named a dead CSO {slot=%u, gen=%u}", + bind.Cso.Slot, bind.Cso.Gen); + if (record == nullptr) return; + + PipeInputs& inputs = gPipeInputs; + MGPipeScatterPipelineBytes(record->PipelineBytes.data(), + MGPipeApplyAccess::RenderState(inputs)); + MGPipeApplyAccess::SetRenderStateVersions(inputs, bind.Version, bind.PipelineVersion); + g_applier.BoundRenderStateCso = bind.Cso; + MGPipeDeriveRenderStateFields(inputs); + } + + void MGPipeApplyDeleteRenderState(const MGPHandleOnly& handle) { + MOBILEGL_ASSERT(handle.Kind == static_cast(MGPipeKind::RenderStateCso), + "delete_render_state on kind %u", handle.Kind); + MGPipeRenderStateCsoRecord* record = FindCso(handle.Handle); + if (record == nullptr) return; + record->Live = false; + // The Gen stays: it is the CLIENT allocator that bumps it when the slot is handed + // out again (MGPipeHandles.h: "Gen increments only when a SLOT IS REUSED"), and a + // server-side bump here would put the two identities out of step. + if (g_applier.BoundRenderStateCso == handle.Handle) { + g_applier.BoundRenderStateCso = kMGPipeNullHandle; + } + } + + void MGPipeApplySetDynamicState(const MGPDynamicState& dyn, const void* chunkBytes) { + PipeInputs& inputs = gPipeInputs; + MGPipeScatterDynamicChunks(chunkBytes, dyn.ChunkMask, MGPipeApplyAccess::RenderState(inputs)); + MGPipeApplyAccess::SetRenderStateParametersVersion(inputs, dyn.Version); + MGPipeDeriveRenderStateFields(inputs); + } + + void MGPipeApplySetPixelPackState(const MGPPixelPackState& pack) { + MGPipeApplyAccess::PackState(gPipeInputs) = pack.Pack; + } + + void MGPipeApplySetPatchState(const MGPPatchState& patch) { + PipeInputs& inputs = gPipeInputs; + RenderStateParameters& working = MGPipeApplyAccess::RenderState(inputs); + working.PatchVertices = patch.Vertices; + working.PatchDefaultOuterLevel = + FloatVec4(patch.Outer[0], patch.Outer[1], patch.Outer[2], patch.Outer[3]); + working.PatchDefaultInnerLevel = FloatVec2(patch.Inner[0], patch.Inner[1]); + MGPipeApplyAccess::SetPatchState(inputs, working.PatchVertices, working.PatchDefaultOuterLevel, + working.PatchDefaultInnerLevel); + } + + void MGPipeApplySetVertexAttribDefaults(const MGPVertexAttribDefaults& hdr, + const MGPAttribValue* tail) { + PipeInputs& inputs = gPipeInputs; + PipeInputs::CurrentVertexAttributeValue* slots = MGPipeApplyAccess::VertexAttribDefaults(inputs); + Uint32 consumed = 0; + for (Uint32 location = 0; location < PipeInputs::kMaxVertexAttribs; ++location) { + if ((hdr.Mask & (1u << location)) == 0) continue; + MOBILEGL_ASSERT(consumed < hdr.Count, + "set_vertex_attrib_defaults: Mask names more attributes than Count"); + if (consumed >= hdr.Count) break; + const MGPAttribValue& value = tail[consumed++]; + MOBILEGL_ASSERT(value.Location == location, + "set_vertex_attrib_defaults: tail out of ascending location order " + "(%u where %u was expected)", + value.Location, location); + PipeInputs::CurrentVertexAttributeValue& slot = slots[location]; + // The three views are always populated; which one a shader input consumes is + // ClassifyVertexAttribType's answer, not the carrier's, so all three cross. + std::memcpy(slot.floatValue.data(), value.Data, sizeof(slot.floatValue)); + std::memcpy(slot.intValue.data(), value.Data, sizeof(slot.intValue)); + std::memcpy(slot.uintValue.data(), value.Data, sizeof(slot.uintValue)); + } + MOBILEGL_ASSERT(consumed == hdr.Count, + "set_vertex_attrib_defaults: Count %u does not match the %u attributes Mask " + "names", + hdr.Count, consumed); + } + + void MGPipeApplySetResidualValueState(const ResidualValueBlock& block) { + g_applier.Residual = block; + g_applier.HasResidual = true; + + // THE TRIP WIRE (ARCHITECTURE.md 9.4, P2 brief D9). CapabilityBits is redundant with + // the assembled working block by design: every one of the 35 capabilities is + // answerable from RenderStateParameters now that P2 closed the three storage holes. + // So the day a later call takes a capability over and forgets to carry it, the two + // answers part and this says so on the next draw - which is what a migration carrier + // is for. + const Bool* assembled = MGPipeApplyAccess::Capabilities(gPipeInputs); + for (SizeT i = 0; i < kCapabilityCount; ++i) { + const Bool carried = ((block.CapabilityBits >> i) & 1ull) != 0; + if (carried == assembled[i]) continue; +#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY + MGLOG_F("MGPipe: Fatal{PipeResidualDiverged, \"%s\"} carried=%d assembled=%d", + kCapabilityNames[i], static_cast(carried), static_cast(assembled[i])); + std::abort(); +#else + MGLOG_E("MGPipe: residual value block diverged on %s (carried=%d assembled=%d)", + kCapabilityNames[i], static_cast(carried), static_cast(assembled[i])); +#endif + } + } + + void MGPipeDeriveRenderStateFields(PipeInputs& inputs) { + // STUB (P2 package A commit c1, on p2/spans). The 29 derivations of brief D5 land + // here, each transcribed from its RenderState getter; until then the residual fill + // loop still copies those fields out of GLContext, which is exactly P1's behaviour, + // so an empty body is correct rather than merely harmless. The two transcriptions + // that are not one-liners and must be copied exactly are GetViewport() (viewport 0 + // ROUNDED TO INTEGERS) and IsCapabilityEnabled / IsCapabilityEnabledIndexed (the + // 35-way and 2-way switches, including Blend -> BlendStates[0].Enabled and + // ScissorTest -> ScissorTestEnabledMask & 1). + (void)inputs; + } +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Pipe/PipeApply.h b/MobileGL/MG_Pipe/PipeApply.h new file mode 100755 index 000000000..3147f4a9d --- /dev/null +++ b/MobileGL/MG_Pipe/PipeApply.h @@ -0,0 +1,107 @@ +// MobileGL - MobileGL/MG_Pipe/PipeApply.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +#include "MGPipeRenderStateSpans.h" +#include "MGPipeTypes.h" + +// The in-process applier: the SERVER half of the calls P2 emits. Under split this file is +// MG_Remote/Server/PipeApplier (ARCHITECTURE.md 8.3); in the monolith it writes +// MG_Backend/MGPipe/PipeInputs' gPipeInputs directly, so a call and its effect are one +// function call apart and nothing is serialised. +// +// THE SERVER'S PER-CONTEXT WORKING BLOCK *IS* PipeInputs::m_renderState. bind_render_state +// and set_dynamic_state scatter their chunks straight into it, which is why DirectGLES' +// SyncRenderState is not one line changed (ROADMAP.md P2, G5): the block Espryt binds by +// const reference is the assembled block. It is also what makes the MOBILEGL_PIPE_VERIFY +// comparator a real oracle instead of a tautology - the compare-at-read now proves +// "assembled == live", field by field, at every backend read. +// +// This header FORWARD-DECLARES PipeInputs rather than including it: the applier's callers +// (MG_Impl/Pipe) already have it, and MG_Pipe sits below MG_Backend. +// +// Compiled only under MOBILEGL_PIPE_PUSH (CMakeLists.txt), so the pull build gains no symbol. +namespace MobileGL::MG_Pipe { + struct PipeInputs; + + // --------------------------------------------------------------------------------- + // The CSO store + // --------------------------------------------------------------------------------- + + // One record per live render-state CSO, indexed by MGPipeHandle::Slot. It keeps the 396 + // pipeline bytes because an incremental create_render_state names only the chunks that + // moved against a BaseCso - the rest has to come from somewhere, and that somewhere is + // the record the client is naming. + struct MGPipeRenderStateCsoRecord { + Uint32 Gen = 0; + Bool Live = false; + Array PipelineBytes{}; + }; + + struct MGPipeApplierState { + // Indexed by slot; slot 0 is the reserved null handle and is never live + // (MGPipeHandles.h kMGPipeFirstAllocatableSlot). + Vector RenderStateCsos; + // The last bind, so a rebind of the same handle can be answered without a scatter. + MGPipeHandle BoundRenderStateCso = kMGPipeNullHandle; + // The residual block as last received. Compared against the assembled state on every + // set_residual_value_state; a disagreement is the D9 trip wire. + ResidualValueBlock Residual{}; + Bool HasResidual = false; + }; + + // The monolith's single applier. Under split there is one per served context. + MGPipeApplierState& MGPipeApplier(); + // Drops every CSO and the residual mirror. Context teardown, server reset, and the unit + // tests' per-case fixture. + void MGPipeApplierReset(); + + // --------------------------------------------------------------------------------- + // The seven apply entry points (ARCHITECTURE.md 5.3, ROADMAP.md P2) + // --------------------------------------------------------------------------------- + + // create_render_state. `chunkBytes` is the pipeline chunks named by desc.ChunkMask, + // concatenated in ascending chunk order (MGPipeGatherPipelineChunks' output). A + // brand-new CSO must name every chunk; an incremental one starts from desc.BaseCso. + void MGPipeApplyCreateRenderState(const MGPRenderStateDesc& desc, const void* chunkBytes); + // bind_render_state: 12 bytes, no blob, no hashing. Scatters the record's seven pipeline + // chunks into the working block and publishes both versions. + void MGPipeApplyBindRenderState(const MGPBindRenderState& bind); + // delete_render_state: frees the slot. The client's allocator owns the Gen bump on + // REUSE; the record only stops being live here. CsoCache's LRU eviction emits this. + void MGPipeApplyDeleteRenderState(const MGPHandleOnly& handle); + // set_dynamic_state: the dynamic chunks named by dyn.ChunkMask, concatenated ascending. + void MGPipeApplySetDynamicState(const MGPDynamicState& dyn, const void* chunkBytes); + // set_pixel_pack_state. PACK only, deliberately (MGPipeTypes.h, ARCHITECTURE.md 4.6 D5). + void MGPipeApplySetPixelPackState(const MGPPixelPackState& pack); + // set_patch_state. The trio also travels in pipeline chunk P0, and the applier asserts + // under verify that the two carriers agree - the redundancy is a trip wire, not waste. + void MGPipeApplySetPatchState(const MGPPatchState& patch); + // set_vertex_attrib_defaults: `tail` is hdr.Count MGPAttribValues for the attributes + // named by hdr.Mask, in ascending location order. + void MGPipeApplySetVertexAttribDefaults(const MGPVertexAttribDefaults& hdr, const MGPAttribValue* tail); + // set_residual_value_state: what has no call of its own. Since P2 that is one Uint64 of + // capability bits, and every one of them is ALSO answerable from the assembled working + // block - which is the point. A disagreement is Fatal{PipeResidualDiverged, ""}. + void MGPipeApplySetResidualValueState(const ResidualValueBlock& block); + + // --------------------------------------------------------------------------------- + // The derivation step (ARCHITECTURE.md 5.3, P2 brief D5) + // --------------------------------------------------------------------------------- + + // Recomputes every PipeInputs field that is a pure function of the working + // RenderStateParameters, instead of pulling it out of GLContext a second time. Called by + // the applier after ANY scatter. + // + // The guard is the oracle P1 built: MOBILEGL_PIPE_VERIFY's compare-at-read re-reads each + // of these from the live context at every backend read, so a transcription error is + // caught on the first draw that reads it. + void MGPipeDeriveRenderStateFields(PipeInputs& inputs); +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Pipe/PipeFields.def b/MobileGL/MG_Pipe/PipeFields.def index 30dd14979..61644b1ca 100644 --- a/MobileGL/MG_Pipe/PipeFields.def +++ b/MobileGL/MG_Pipe/PipeFields.def @@ -144,8 +144,11 @@ #define MGP_FIELDS_MGPPatchState(F) \ F(Vertices) F(Outer) F(Inner) +// P2 ratcheted this block from six rows to one: RenderStateParameters retired to +// create/bind_render_state + set_dynamic_state, Pack to set_pixel_pack_state and the +// patch trio to set_patch_state. What is left is the redundant capability trip wire. #define MGP_FIELDS_ResidualValueBlock(F) \ - F(RenderState) F(Pack) F(CapabilityBits) F(PatchVertices) F(PatchOuter) F(PatchInner) + F(CapabilityBits) #define MGP_FIELDS_MGPResidualValueState(F) \ F(Version) F(Blob) @@ -231,7 +234,8 @@ F(Viewports) F(LineWidth) F(PointSize) F(PatchVertices) F(PatchDefaultOuterLevel) \ F(PatchDefaultInnerLevel) F(PolygonOffsetFactor) F(PolygonOffsetUnits) F(PolygonOffsetClamp) \ F(ClipOrigin) F(ClipDepthMode) F(BlendStates) F(LogicOp) F(DepthTestEnabled) F(DepthFunc) \ - F(DepthMask) F(ColorMasks) F(ClearColor) F(ClearDepth) F(ClearStencil) F(BlendColor) \ + F(DepthMask) F(ColorMasks) F(FramebufferSrgbEnabled) F(DepthClampEnabled) \ + F(TextureCubeMapSeamlessEnabled) F(ClearColor) F(ClearDepth) F(ClearStencil) F(BlendColor) \ F(DepthRanges) F(SampleCoverageValue) F(SampleCoverageInvert) F(SampleMaskValue) \ F(MinSampleShadingValue) F(StencilStates) F(CullFaceEnabled) F(CullFaceModeSetting) \ F(FrontFaceModeSetting) F(ProvokingVertexModeSetting) F(LineSmoothHint) F(PolygonSmoothHint) \ diff --git a/MobileGL/MG_Pipe/generated/PipeFilled.inc b/MobileGL/MG_Pipe/generated/PipeFilled.inc index 4c485cfd5..90ee5fb72 100644 --- a/MobileGL/MG_Pipe/generated/PipeFilled.inc +++ b/MobileGL/MG_Pipe/generated/PipeFilled.inc @@ -299,6 +299,97 @@ inline constexpr const char* kMGPipeInputFieldFilledBy[kMGPipeInputFieldCount] = "SetStreamOutputTargets", }; +// P2 brief D5: the call that now SUPPLIES a field, so the residual fill loop no +// longer pulls it out of GLContext. kNone means the field is still pulled - which +// is what makes MOBILEGL_PIPE_PUSH a true per-subsystem A/B instead of a single +// switch. Rows come from Coverage.def's MGP_COVERAGE_EMITTED_LIST. +enum class MGPipeFieldEmitter : Uint8 { + kNone = 0, + BindRenderState, + CreateRenderState, + SetDynamicState, + SetPatchState, + SetPixelPackState, + SetVertexAttribDefaults, +}; + +inline constexpr const char* kMGPipeFieldEmitterNames[] = { + "kNone", + "BindRenderState", + "CreateRenderState", + "SetDynamicState", + "SetPatchState", + "SetPixelPackState", + "SetVertexAttribDefaults", +}; + +inline constexpr MGPipeFieldEmitter kMGPipeFieldEmittedBy[kMGPipeInputFieldCount] = { + MGPipeFieldEmitter::kNone, // GetActiveTextureUnit + MGPipeFieldEmitter::SetDynamicState, // GetBlendColor + MGPipeFieldEmitter::CreateRenderState, // GetBlendEquationIndexed + MGPipeFieldEmitter::CreateRenderState, // GetBlendFuncIndexed + MGPipeFieldEmitter::kNone, // GetBoundTransformFeedbackName + MGPipeFieldEmitter::kNone, // GetBoundVertexArray + MGPipeFieldEmitter::kNone, // GetBufferBindingSlot + MGPipeFieldEmitter::kNone, // GetBufferBindingPoint + MGPipeFieldEmitter::kNone, // GetBufferBindingPointCount + MGPipeFieldEmitter::kNone, // GetTouchedBufferBindingPointCount + MGPipeFieldEmitter::SetDynamicState, // GetClampReadColor + MGPipeFieldEmitter::SetDynamicState, // GetClearColor + MGPipeFieldEmitter::SetDynamicState, // GetClearDepth + MGPipeFieldEmitter::SetDynamicState, // GetClearStencil + MGPipeFieldEmitter::CreateRenderState, // GetColorMaskIndexed + MGPipeFieldEmitter::CreateRenderState, // GetCullFaceMode + MGPipeFieldEmitter::SetVertexAttribDefaults, // GetCurrentVertexAttribute + MGPipeFieldEmitter::CreateRenderState, // GetDepthFunc + MGPipeFieldEmitter::CreateRenderState, // GetDepthMask + MGPipeFieldEmitter::SetDynamicState, // GetDepthRangeIndexed + MGPipeFieldEmitter::kNone, // GetFramebufferBindingSlot + MGPipeFieldEmitter::kNone, // GetImageTextureBinding + MGPipeFieldEmitter::SetDynamicState, // GetLineWidth + MGPipeFieldEmitter::CreateRenderState, // GetLogicOp + MGPipeFieldEmitter::kNone, // GetMaxTouchedTextureUnit + MGPipeFieldEmitter::CreateRenderState, // GetMinSampleShadingValue + MGPipeFieldEmitter::SetPatchState, // GetPatchDefaultInnerLevel + MGPipeFieldEmitter::SetPatchState, // GetPatchDefaultOuterLevel + MGPipeFieldEmitter::SetPatchState, // GetPatchVertices + MGPipeFieldEmitter::BindRenderState, // GetPipelineStateVersion + MGPipeFieldEmitter::SetPixelPackState, // GetPixelStoreParameters + MGPipeFieldEmitter::CreateRenderState, // GetPolygonModeFront + MGPipeFieldEmitter::SetDynamicState, // GetPolygonOffsetFactor + MGPipeFieldEmitter::SetDynamicState, // GetPolygonOffsetUnits + MGPipeFieldEmitter::SetDynamicState, // GetPrimitiveRestartIndex + MGPipeFieldEmitter::kNone, // GetProgramForDispatch + MGPipeFieldEmitter::kNone, // GetProgramForDraw + MGPipeFieldEmitter::kNone, // GetProgramObject + MGPipeFieldEmitter::CreateRenderState, // GetProvokingVertexMode + MGPipeFieldEmitter::CreateRenderState, // GetRenderStateParameters + MGPipeFieldEmitter::BindRenderState, // GetRenderStateParametersVersion + MGPipeFieldEmitter::kNone, // GetSamplingResolutionGeneration + MGPipeFieldEmitter::SetDynamicState, // GetScissorBox + MGPipeFieldEmitter::CreateRenderState, // GetStencilState + MGPipeFieldEmitter::kNone, // GetTextureBindGeneration + MGPipeFieldEmitter::kNone, // GetTextureContextId + MGPipeFieldEmitter::kNone, // GetTextureObject + MGPipeFieldEmitter::kNone, // GetTextureUnitObject + MGPipeFieldEmitter::kNone, // GetTransformFeedbackCapturedVertices + MGPipeFieldEmitter::kNone, // GetTransformFeedbackGeneration + MGPipeFieldEmitter::kNone, // GetTransformFeedbackPausedPrimitiveCounter + MGPipeFieldEmitter::kNone, // GetTransformFeedbackProgram + MGPipeFieldEmitter::SetDynamicState, // GetViewport + MGPipeFieldEmitter::SetDynamicState, // GetViewportIndexed + MGPipeFieldEmitter::CreateRenderState, // IsCapabilityEnabled + MGPipeFieldEmitter::CreateRenderState, // IsCapabilityEnabledIndexed + MGPipeFieldEmitter::kNone, // IsTransformFeedbackActive + MGPipeFieldEmitter::kNone, // IsTransformFeedbackPaused + MGPipeFieldEmitter::kNone, // InvalidateCompileEnv + MGPipeFieldEmitter::kNone, // ValidateProgramName + MGPipeFieldEmitter::kNone, // RecordError + MGPipeFieldEmitter::kNone, // GetBoundTransformFeedbackLifetimeId + MGPipeFieldEmitter::kNone, // HasOpenTransformFeedbackSpan +}; +inline constexpr SizeT kMGPipeEmittedFieldCount = 34; + struct MGPipeFilledState { Uint64 CurrentVerbSerial; Uint64 FilledGen[kMGPipeInputFieldCount]; diff --git a/MobileGL/MG_Pipe/generated/PipeSpanTable.inc b/MobileGL/MG_Pipe/generated/PipeSpanTable.inc index eb94a9b69..37269fae5 100644 --- a/MobileGL/MG_Pipe/generated/PipeSpanTable.inc +++ b/MobileGL/MG_Pipe/generated/PipeSpanTable.inc @@ -14,54 +14,80 @@ // D-B1 rejected three CSOs and demanded this table instead, so the table needs its own // completeness trip wire: MG_Test walks every public RenderState setter and asserts that -// the pipeline-subset hash moves IF AND ONLY IF m_pipelineStateVersion moves. That test -// and MGPipeRenderStateSpans.cpp land with P2; what P0 pins is the MEMBER LIST, taken from -// what VulkanRenderer::ComputePipelineStateHash hashes today, so the later offsets are -// derived from a list that was reviewed rather than invented. +// the pipeline-subset hash moves IF AND ONLY IF m_pipelineStateVersion moves +// (MG_Test/Pipe/RenderStateSpansTest.cpp). // -// Deliberately absent, and each absence is a question P2 has to answer before the chunk -// table freezes: -// - FramebufferSrgb and DepthClamp have NO STORAGE at all (RenderState.cpp's SetCapability -// falls to "not supported currently" and IsCapabilityEnabled returns false), so six -// backend read points are constant false today. Pipeline state or dead capability? -// - ProvokingVertexModeSetting is Vulkan pipeline state but is not hashed today. -// - FrontFaceModeSetting, ClipOrigin and ClipDepthMode are pipeline state on Vulkan and -// are handled elsewhere in the payload path rather than in the memo word. +// P2 replaced P0's provenance with a RULE, and the rule is the only thing that decides +// membership: a member is pipeline state IF AND ONLY IF some public RenderState setter that +// calls BumpVersions() writes it. That is what makes the G7 invariant true by construction +// rather than by inspection, and it turns the subset into a strict SUPERSET of the 24 +// members VulkanRenderer::ComputePipelineStateHash used to hash. +// +// The three questions P0 left open are ANSWERED here, and the answers are in this list: +// - FramebufferSrgb, DepthClamp and TextureCubeMapSeamless had NO STORAGE at all - +// SetCapability fell to "not supported currently" and IsCapabilityEnabled answered a +// compile-time false. P2 gave all three real storage in the three padding bytes between +// ColorMasks and ClearColor, and their setters call BumpVersions(), so: pipeline state. +// - ProvokingVertexModeSetting: SetProvokingVertexMode calls BumpVersions(), so pipeline. +// - FrontFaceModeSetting likewise. ClipOrigin and ClipDepthMode do NOT (SetClipControl is +// ++m_version only), so they are dynamic, in chunk D1. // // The complement of this list is the DYNAMIC subset - the half whose whole purpose is that // glViewport must not mint a new CSO. inline constexpr const char* const kMGPipePipelineStateMembers[] = { - "CullFaceEnabled", - "DepthTestEnabled", - "PolygonOffsetFillEnabled", - "RasterizerDiscardEnabled", - "ColorLogicOpEnabled", - "StencilTestEnabled", - "PrimitiveRestartEnabled", - "PrimitiveRestartFixedIndexEnabled", - "DepthMask", - "SampleShadingEnabled", - "MultisampleEnabled", - "SampleMaskEnabled", - "SampleMaskValue", - "MinSampleShadingValue", "PatchVertices", "PatchDefaultOuterLevel", "PatchDefaultInnerLevel", - "PolygonModeFront", - "CullFaceModeSetting", - "DepthFunc", - "LogicOp", - "StencilStates", "BlendStates", + "LogicOp", + "DepthTestEnabled", + "DepthFunc", + "DepthMask", "ColorMasks", + "FramebufferSrgbEnabled", + "DepthClampEnabled", + "TextureCubeMapSeamlessEnabled", + "SampleCoverageValue", + "SampleCoverageInvert", + "SampleMaskValue", + "MinSampleShadingValue", + "StencilStates", + "CullFaceEnabled", + "CullFaceModeSetting", + "FrontFaceModeSetting", + "ProvokingVertexModeSetting", + "PolygonModeFront", + "PolygonModeBack", + "ColorLogicOpEnabled", + "DebugOutputEnabled", + "DebugOutputSynchronousEnabled", + "DitherEnabled", + "LineSmoothEnabled", + "MultisampleEnabled", + "PolygonOffsetFillEnabled", + "PolygonOffsetLineEnabled", + "PolygonOffsetPointEnabled", + "PolygonSmoothEnabled", + "PrimitiveRestartEnabled", + "PrimitiveRestartFixedIndexEnabled", + "RasterizerDiscardEnabled", + "SampleAlphaToCoverageEnabled", + "SampleAlphaToOneEnabled", + "SampleCoverageEnabled", + "SampleMaskEnabled", + "SampleShadingEnabled", + "StencilTestEnabled", + "ProgramPointSizeEnabled", + "ScissorTestEnabledMask", }; -inline constexpr SizeT kMGPipePipelineStateMemberCount = 24; +inline constexpr SizeT kMGPipePipelineStateMemberCount = 44; static_assert(kMGPipePipelineStateMemberCount == sizeof(kMGPipePipelineStateMembers) / sizeof(kMGPipePipelineStateMembers[0])); -// Filled in by MG_Pipe/MGPipeRenderStateSpans.cpp (P2), which computes the offsets -// in C++ with offsetof rather than guessing them in python. +// Defined by MG_Pipe/MGPipeRenderStateSpans.cpp (P2), which computes every +// boundary in C++ with offsetof rather than guessing it in python. 7 pipeline +// chunks / 396 bytes and 8 dynamic chunks / 772 bytes, and the two halves +// partition [0, sizeof(RenderStateParameters)) exactly - asserted there. extern const MGPStateChunk kMGPipePipelineChunks[]; extern const MGPStateChunk kMGPipeDynamicChunks[]; diff --git a/MobileGL/MG_Pipe/generated/PipeWire.inc b/MobileGL/MG_Pipe/generated/PipeWire.inc index fca186f1b..df1704050 100644 --- a/MobileGL/MG_Pipe/generated/PipeWire.inc +++ b/MobileGL/MG_Pipe/generated/PipeWire.inc @@ -929,3 +929,11 @@ inline Bool MGPipeApplyWireRecord(MGPWireOp op, const void* record, Uint64 size, } #undef MGP_WIRE_CHECK_BOUNDS + +// The ResidualValueBlock layout, from PipeFields.def's +// MGP_FIELDS_ResidualValueBlock. Retiring a field without lowering +// MGL_RESIDUAL_BLOCK_SIZE is a build break, which is the point. +static_assert(offsetof(ResidualValueBlock, CapabilityBits) == 0, + "the residual block's first member must sit at offset 0"); +static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE, + "the residual ratchet only ever goes down"); diff --git a/MobileGL/MG_State/GLState/RenderState/RenderState.cpp b/MobileGL/MG_State/GLState/RenderState/RenderState.cpp index 475b31c39..8f12820ea 100644 --- a/MobileGL/MG_State/GLState/RenderState/RenderState.cpp +++ b/MobileGL/MG_State/GLState/RenderState/RenderState.cpp @@ -317,9 +317,11 @@ namespace MobileGL { SET_CAPABILITY(ColorLogicOp, enabled); SET_CAPABILITY(DebugOutput, enabled); SET_CAPABILITY(DebugOutputSynchronous, enabled); + SET_CAPABILITY(DepthClamp, enabled); SET_CAPABILITY(DepthTest, enabled); SET_CAPABILITY(CullFace, enabled); SET_CAPABILITY(Dither, enabled); + SET_CAPABILITY(FramebufferSrgb, enabled); SET_CAPABILITY(LineSmooth, enabled); SET_CAPABILITY(Multisample, enabled); SET_CAPABILITY(PolygonOffsetFill, enabled); @@ -335,6 +337,7 @@ namespace MobileGL { SET_CAPABILITY(SampleMask, enabled); SET_CAPABILITY(SampleShading, enabled); SET_CAPABILITY(StencilTest, enabled); + SET_CAPABILITY(TextureCubeMapSeamless, enabled); SET_CAPABILITY(ProgramPointSize, enabled); case CapabilityInput::Blend: { Bool stateChanged = false; @@ -378,7 +381,9 @@ namespace MobileGL { ++m_version; break; } - default: // not supported currently + // Every CapabilityInput now has storage; the arm is a backstop for a value + // outside the enum, not a silent swallow of a real glEnable. + default: break; } #undef SET_CAPABILITY @@ -392,9 +397,11 @@ namespace MobileGL { RETURN_CAPABILITY(ColorLogicOp); RETURN_CAPABILITY(DebugOutput); RETURN_CAPABILITY(DebugOutputSynchronous); + RETURN_CAPABILITY(DepthClamp); RETURN_CAPABILITY(DepthTest); RETURN_CAPABILITY(CullFace); RETURN_CAPABILITY(Dither); + RETURN_CAPABILITY(FramebufferSrgb); RETURN_CAPABILITY(LineSmooth); RETURN_CAPABILITY(Multisample); RETURN_CAPABILITY(PolygonOffsetFill); @@ -410,6 +417,7 @@ namespace MobileGL { RETURN_CAPABILITY(SampleMask); RETURN_CAPABILITY(SampleShading); RETURN_CAPABILITY(StencilTest); + RETURN_CAPABILITY(TextureCubeMapSeamless); RETURN_CAPABILITY(ProgramPointSize); case CapabilityInput::Blend: return m_parameters.BlendStates[0].Enabled; diff --git a/MobileGL/MG_State/GLState/RenderState/RenderState.h b/MobileGL/MG_State/GLState/RenderState/RenderState.h index 654126ec7..40c9b076d 100644 --- a/MobileGL/MG_State/GLState/RenderState/RenderState.h +++ b/MobileGL/MG_State/GLState/RenderState/RenderState.h @@ -169,11 +169,16 @@ namespace MobileGL { // 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. Uint16 m_pipelineStateVersion = 0; - RenderStateParameters m_parameters; + // Value-initialised, PADDING INCLUDED. The MGPipe CSO key is a byte-range + // hash over RenderStateParameters and the residual block's trip wire is a + // byte-level compare, so indeterminate padding would make a CSO handle + // reproducible only within one context and would make the trip wire + // meaningless. Costs one .text resize of this constructor. + RenderStateParameters m_parameters{}; // Pixel Store - PixelStoreParameters m_pixelStorePackParameters; - PixelStoreParameters m_pixelStoreUnpackParameters; + PixelStoreParameters m_pixelStorePackParameters{}; + PixelStoreParameters m_pixelStoreUnpackParameters{}; }; } // namespace GLState } // namespace MG_State diff --git a/MobileGL/MG_Test/Pipe/CMakeLists.txt b/MobileGL/MG_Test/Pipe/CMakeLists.txt index 1bd8f0efd..680254d8b 100644 --- a/MobileGL/MG_Test/Pipe/CMakeLists.txt +++ b/MobileGL/MG_Test/Pipe/CMakeLists.txt @@ -53,6 +53,36 @@ if (MSVC) target_compile_options(PipeInputsTest PRIVATE /Zc:preprocessor) endif() + +# The four P2 suites. Their targets and this registration are the CONTRACT commit's; their +# CONTENTS belong to the packages named in each file's header, so no package after A has to +# come back to this file. Each links gtest_main - none of them needs a main() of its own, +# unlike PipeInputsTest, whose abort cases read a log file back. +foreach(pipeTest RenderStateSpansTest TrackerTest SlotAllocatorTest CsoCacheTest) + add_executable(${pipeTest} ${pipeTest}.cpp) + + target_include_directories(${pipeTest} PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/MobileGL/MG_Pipe + ${MGL_ROOT}/3rdparty/xxHash + ${MGL_ROOT}/3rdparty/Vulkan-Headers/include + ${MGL_ROOT}/3rdparty/SPIRV-Reflect + ) + + target_link_libraries(${pipeTest} PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} + ) + + if (MSVC) + target_compile_options(${pipeTest} PRIVATE /Zc:preprocessor) + endif() +endforeach() + include(GoogleTest) gtest_discover_tests(PipeCatalogueTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(PipeInputsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +foreach(pipeTest RenderStateSpansTest TrackerTest SlotAllocatorTest CsoCacheTest) + gtest_discover_tests(${pipeTest} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +endforeach() diff --git a/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp b/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp new file mode 100644 index 000000000..6dae4b003 --- /dev/null +++ b/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp @@ -0,0 +1,32 @@ +// MobileGL - MobileGL/MG_Test/Pipe/CsoCacheTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The 64-entry render-state CSO cache: hash, probe, memcmp, LRU evict, and the content-addressing-off control (P2 brief D7). +// +// STUB, and deliberately one. It is created by the P2 CONTRACT commit together with its +// CMakeLists.txt registration, so that the package which owns its CONTENTS +// (P2 package B, p2/tracker) never has to touch MG_Test/Pipe/CMakeLists.txt - no two P2 +// packages edit the same file, which is what keeps the integrator's rebases clean. +// +// The placeholder case is not decoration: without it the binary has no test, and +// gtest_discover_tests on a binary with no test is a silently green lane. +#include + +#include "Includes.h" +#include + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + // The cache does not exist yet; the behaviour bit it is measured against does, and it + // is deliberately the TOP bit so no subsystem allocation can ever collide with it. + TEST(CsoCache, PlaceholderUntilTheOwningPackageFillsThisIn) { + EXPECT_EQ(kMGPipeBehaviourNoCsoContentAddressing, 1ull << 63); + } +} // namespace diff --git a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp index 9af675200..1baf1f3e7 100644 --- a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp +++ b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp @@ -110,8 +110,13 @@ TEST(PipeCatalogue, UninstalledTablesAreAllNull) { TEST(PipeCatalogue, ResidualBlockSizeIsPinned) { static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE); EXPECT_EQ(sizeof(ResidualValueBlock), static_cast(MGL_RESIDUAL_BLOCK_SIZE)); - // It carries the whole of both value structs today; that is what the later stages eat. - EXPECT_GE(sizeof(ResidualValueBlock), sizeof(RenderStateParameters) + sizeof(PixelStoreParameters)); + // P2 ate 1240 of the 1248: RenderStateParameters retired to create/bind_render_state and + // set_dynamic_state, PixelStoreParameters to set_pixel_pack_state, the patch quintet to + // set_patch_state. What is left is one Uint64 of capability bits, and it is redundant on + // purpose - the applier's trip wire compares it against the assembled block. + EXPECT_EQ(sizeof(ResidualValueBlock), 8u); + EXPECT_LT(sizeof(ResidualValueBlock), sizeof(RenderStateParameters)); + EXPECT_EQ(offsetof(ResidualValueBlock, CapabilityBits), 0u); } // P0.5 moved the value structs into MG_Pipe/MGPipeValueTypes.h. These are the runtime twins @@ -138,13 +143,18 @@ TEST(PipeCatalogue, ValueTypeLayoutsArePinned) { EXPECT_EQ(kMGMaxDrawBuffers, 8u); } -// The move did not alter the carrier: the residual block is still the render-state struct, -// then the pack struct, then the 8-aligned capability word, at the offsets it had before. +// P2 ATE THE TWO VALUE STRUCTS AND THE PATCH TAIL the name still remembers, and the name +// stays because a removed test name is a gate failure of its own (G14, additions only). +// What it now pins is the other half of the same statement: the carrier is one capability +// word, at offset 0, and the members it used to carry are gone rather than merely moved - +// which is exactly what "MGL_RESIDUAL_BLOCK_SIZE only ever goes down" has to mean. TEST(PipeCatalogue, ResidualBlockIsExactlyItsTwoValueStructsPlusPatchTail) { - EXPECT_EQ(offsetof(ResidualValueBlock, RenderState), 0u); - EXPECT_EQ(offsetof(ResidualValueBlock, Pack), sizeof(RenderStateParameters)); - EXPECT_EQ(offsetof(ResidualValueBlock, CapabilityBits), 1200u); - EXPECT_EQ(offsetof(ResidualValueBlock, PatchVertices), 1208u); + EXPECT_EQ(offsetof(ResidualValueBlock, CapabilityBits), 0u); + EXPECT_EQ(sizeof(ResidualValueBlock), sizeof(Uint64)); + // The three carriers that took the retired members over. + EXPECT_EQ(sizeof(MGPPixelPackState), sizeof(PixelStoreParameters)); + EXPECT_EQ(sizeof(MGPPatchState), 40u); + EXPECT_EQ(sizeof(MGPBindRenderState), 12u); } // G3's opcode numbering is the wire protocol. Position in PipeCalls.def, 1-based, no holes. @@ -307,21 +317,35 @@ TEST(PipeCatalogue, FloatVectorsCompareBitwise) { EXPECT_TRUE(MGPipeFieldEqual(1.5f, 1.5f)); EXPECT_FALSE(MGPipeFieldEqual(-0.f, 0.f)); + // The residual carrier is one field since P2, so the nested-struct case it used to + // demonstrate is demonstrated on RenderStateParameters directly - which is where it + // actually matters now that the block travels as create/bind_render_state chunks. ResidualValueBlock left{}; ResidualValueBlock right{}; const char* field = nullptr; EXPECT_TRUE(MGPipeVerify(left, right, &field)); - right.RenderState.BlendStates[3].SrcFactorRGB = BlendFactor::DstColor; + right.CapabilityBits = 1ull << static_cast(CapabilityInput::FramebufferSrgb); EXPECT_FALSE(MGPipeVerify(left, right, &field)); - EXPECT_STREQ(field, "RenderState"); + EXPECT_STREQ(field, "CapabilityBits"); + + RenderStateParameters leftState{}; + RenderStateParameters rightState{}; const char* inner = nullptr; - EXPECT_FALSE(MGPipeVerify(left.RenderState, right.RenderState, &inner)); + EXPECT_TRUE(MGPipeVerify(leftState, rightState, &inner)); + rightState.BlendStates[3].SrcFactorRGB = BlendFactor::DstColor; + EXPECT_FALSE(MGPipeVerify(leftState, rightState, &inner)); EXPECT_STREQ(inner, "BlendStates"); - // A NaN patch level in the render state equals itself too. - right = left; - left.RenderState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f}; - right.RenderState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f}; - EXPECT_TRUE(MGPipeVerify(left, right, &field)); + // P2's three new capability bools are members like any other, so the comparator names + // them rather than folding them into a neighbour's padding. + rightState = leftState; + rightState.FramebufferSrgbEnabled = true; + EXPECT_FALSE(MGPipeVerify(leftState, rightState, &inner)); + EXPECT_STREQ(inner, "FramebufferSrgbEnabled"); + // A NaN patch level equals itself too. + rightState = leftState; + leftState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f}; + rightState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f}; + EXPECT_TRUE(MGPipeVerify(leftState, rightState, &inner)); } // The six value structs have field lists of their own (P1 brief D8): 63 + 6 payloads, and @@ -352,9 +376,16 @@ TEST(PipeCatalogue, SixValueStructsHaveFieldLists) { // G7 pins the member list the pipeline/dynamic split is derived from. TEST(PipeCatalogue, PipelineSubsetMembersArePinned) { - EXPECT_EQ(kMGPipePipelineStateMemberCount, 24u); - EXPECT_STREQ(kMGPipePipelineStateMembers[0], "CullFaceEnabled"); - EXPECT_STREQ(kMGPipePipelineStateMembers[kMGPipePipelineStateMemberCount - 1], "ColorMasks"); + // 44 as of P2, in DECLARATION order. It grew from the 24 members + // ComputePipelineStateHash used to hash because the chunk table's rule is "a byte is + // pipeline state iff a setter that calls BumpVersions() writes it", and that is a strict + // superset: sample coverage, front face, provoking vertex, the scissor-test mask, the + // back polygon mode, eleven capability bools the hash never read, and the three + // capabilities P2 gave storage to. + EXPECT_EQ(kMGPipePipelineStateMemberCount, 44u); + EXPECT_STREQ(kMGPipePipelineStateMembers[0], "PatchVertices"); + EXPECT_STREQ(kMGPipePipelineStateMembers[kMGPipePipelineStateMemberCount - 1], + "ScissorTestEnabledMask"); } // The reverse channel is exactly ten callbacks (section 7.1). diff --git a/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp b/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp new file mode 100644 index 000000000..016ec6419 --- /dev/null +++ b/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp @@ -0,0 +1,33 @@ +// MobileGL - MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// G7: the render-state chunk table, its subset hash and the setter-consistency walk (P2 brief D19). +// +// STUB, and deliberately one. It is created by the P2 CONTRACT commit together with its +// CMakeLists.txt registration, so that the package which owns its CONTENTS +// (P2 package A, commit c2 on p2/spans) never has to touch MG_Test/Pipe/CMakeLists.txt - no two P2 +// packages edit the same file, which is what keeps the integrator's rebases clean. +// +// The placeholder case is not decoration: without it the binary has no test, and +// gtest_discover_tests on a binary with no test is a silently green lane. +#include + +#include "Includes.h" +#include + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + // The one fact this file can already state in EVERY build: the member list the chunk + // table was derived from is non-empty and is what generated/PipeSpanTable.inc pins. + TEST(RenderStateSpans, PlaceholderUntilTheOwningPackageFillsThisIn) { + EXPECT_GT(kMGPipePipelineStateMemberCount, 0u); + EXPECT_STREQ(kMGPipePipelineStateMembers[0], "PatchVertices"); + } +} // namespace diff --git a/MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp b/MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp new file mode 100644 index 000000000..af31673e5 --- /dev/null +++ b/MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp @@ -0,0 +1,34 @@ +// MobileGL - MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The client slot allocator's identity contract: gen moves only on reuse, and a recycled address never reproduces a handle (P2 brief C.0 c3). +// +// STUB, and deliberately one. It is created by the P2 CONTRACT commit together with its +// CMakeLists.txt registration, so that the package which owns its CONTENTS +// (P2 package A, commit c3 on p2/spans) never has to touch MG_Test/Pipe/CMakeLists.txt - no two P2 +// packages edit the same file, which is what keeps the integrator's rebases clean. +// +// The placeholder case is not decoration: without it the binary has no test, and +// gtest_discover_tests on a binary with no test is a silently green lane. +#include + +#include "Includes.h" +#include + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + // Slot 0 is reserved for every kind - null, and the default framebuffer for kind + // Framebuffer - so the first allocatable slot is 1 in every build. + TEST(SlotAllocator, PlaceholderUntilTheOwningPackageFillsThisIn) { + EXPECT_EQ(kMGPipeFirstAllocatableSlot, 1u); + EXPECT_TRUE(MGPipeHandleIsNull(kMGPipeNullHandle)); + EXPECT_FALSE(MGPipeHandleIsNull(kMGPipeDefaultFramebuffer)); + } +} // namespace diff --git a/MobileGL/MG_Test/Pipe/TrackerTest.cpp b/MobileGL/MG_Test/Pipe/TrackerTest.cpp new file mode 100644 index 000000000..b83d6aba0 --- /dev/null +++ b/MobileGL/MG_Test/Pipe/TrackerTest.cpp @@ -0,0 +1,32 @@ +// MobileGL - MobileGL/MG_Test/Pipe/TrackerTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The frontend state tracker: the dirty walk, the five aggregate generations, the per-bit fire counters (P2 brief D4). +// +// STUB, and deliberately one. It is created by the P2 CONTRACT commit together with its +// CMakeLists.txt registration, so that the package which owns its CONTENTS +// (P2 package B, p2/tracker) never has to touch MG_Test/Pipe/CMakeLists.txt - no two P2 +// packages edit the same file, which is what keeps the integrator's rebases clean. +// +// The placeholder case is not decoration: without it the binary has no test, and +// gtest_discover_tests on a binary with no test is a silently green lane. +#include + +#include "Includes.h" +#include + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + // The tracker does not exist yet; what is true in every build is that the subsystem + // bitmask it dispatches on is allocated and does not overlap the behaviour bit. + TEST(Tracker, PlaceholderUntilTheOwningPackageFillsThisIn) { + EXPECT_EQ(kMGPipeSubsystemsMigratedAtP2 & kMGPipeBehaviourNoCsoContentAddressing, 0ull); + } +} // namespace diff --git a/MobileGL/MG_Test/Util/PipeStatsTest.cpp b/MobileGL/MG_Test/Util/PipeStatsTest.cpp index 9ccd4e45c..b00fbe5f4 100644 --- a/MobileGL/MG_Test/Util/PipeStatsTest.cpp +++ b/MobileGL/MG_Test/Util/PipeStatsTest.cpp @@ -151,6 +151,12 @@ namespace { } EXPECT_NE(line.find("gates["), String::npos) << line; EXPECT_NE(line.find("tex[emit="), String::npos) << line; +#if MOBILEGL_PIPE_PUSH + // P2's two render-state CSO counters ride the same line, short-named. Push-only: + // the pull build has no CSO to mint and must stay symbol-identical. + EXPECT_NE(line.find("cso[csom="), String::npos) << line; + EXPECT_NE(line.find("csob="), String::npos) << line; +#endif } // Per-frame fields carry two decimals for the same reason acc/draw does: they are small @@ -267,6 +273,10 @@ namespace { EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageIndirectCmd), "stage-indirect-cmd"); EXPECT_STREQ(PS::NameOf(PS::ByteClass::PersistentMapPush), "persistent-map-push"); EXPECT_STREQ(PS::NameOf(PS::ByteClass::ResidualValueBlock), "residual-value-block"); +#if MOBILEGL_PIPE_PUSH + EXPECT_STREQ(PS::NameOf(PS::CallClass::RenderStateCsoMints), "render-state-cso-mints"); + EXPECT_STREQ(PS::NameOf(PS::CallClass::RenderStateCsoBinds), "render-state-cso-binds"); +#endif EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytRenderState), "espryt-render-state"); EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytTextureSyncList), "espryt-texture-sync-list"); EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytUnitBindingsEpoch), "espryt-unit-bindings-epoch"); diff --git a/MobileGL/MG_Util/Metrics/PipeStats.cpp b/MobileGL/MG_Util/Metrics/PipeStats.cpp index 8813b915d..858007aa2 100644 --- a/MobileGL/MG_Util/Metrics/PipeStats.cpp +++ b/MobileGL/MG_Util/Metrics/PipeStats.cpp @@ -173,6 +173,9 @@ namespace MobileGL::MG_Util::PipeStats { const char* const kCallClassNames[kCallClassCount] = { "draws", "accessor-calls", "tex-upload-emissions", "tex-upload-box", "tex-upload-rect", "tex-upload-jobs", +#if MOBILEGL_PIPE_PUSH + "render-state-cso-mints", "render-state-cso-binds", +#endif }; const char* const kGateNames[kGateCount] = { "espryt-render-state", "espryt-texture-sync-list", "espryt-unit-bindings-epoch", @@ -416,6 +419,13 @@ namespace MobileGL::MG_Util::PipeStats { line += " box=" + std::to_string(calls[static_cast(CallClass::TextureUploadBoxEmissions)]); line += " rect=" + std::to_string(calls[static_cast(CallClass::TextureUploadRectEmissions)]); line += " jobs=" + std::to_string(calls[static_cast(CallClass::TextureUploadJobs)]); +#if MOBILEGL_PIPE_PUSH + // Push-only, like the two counters themselves: in a pull build there is no CSO to + // mint, and a "csom=0 csob=0" that can never be anything else is noise on the one + // line an operator greps. + line += "] cso[csom=" + std::to_string(calls[static_cast(CallClass::RenderStateCsoMints)]); + line += " csob=" + std::to_string(calls[static_cast(CallClass::RenderStateCsoBinds)]); +#endif line += "] gates["; for (Uint32 i = 0; i < kGateCount; ++i) { if (i != 0) { diff --git a/MobileGL/MG_Util/Metrics/PipeStats.h b/MobileGL/MG_Util/Metrics/PipeStats.h index 0e8e74e42..49e32d779 100644 --- a/MobileGL/MG_Util/Metrics/PipeStats.h +++ b/MobileGL/MG_Util/Metrics/PipeStats.h @@ -98,6 +98,22 @@ namespace MobileGL::MG_Util::PipeStats { // Driver upload jobs issued by those emissions: 1 per box emission, N per rect-list // emission. TextureUploadJobs, +#if MOBILEGL_PIPE_PUSH + // P2's two, and they are PUSH-ONLY on purpose: a render-state CSO exists only in a + // push build, and the pull build has to stay symbol-identical (G1) - growing this + // enum there would resize the counter arrays, the name table and FormatWindowLine + // for a pair of counters that could never leave zero. + // + // Render-state CSOs MINTED: a pipeline-subset hash that missed the CsoCache and had + // to be created. The Blaze3D blend toggle is the shape this exists to answer for - + // enable/draw/disable/draw forever must mint 2 and then never mint again - and it is + // half of what a P13 retune of the 64-entry capacity reads. + RenderStateCsoMints, + // Render-state CSOs BOUND: one per bind_render_state, mint or reuse. mints/binds is + // the cache's hit rate, and it is the number the CSO content-addressing negative + // control moves. + RenderStateCsoBinds, +#endif Count }; diff --git a/scripts/gen_pipe.py b/scripts/gen_pipe.py index 7ae0fe566..db1deeba0 100644 --- a/scripts/gen_pipe.py +++ b/scripts/gen_pipe.py @@ -62,41 +62,71 @@ // This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe. """ -# G7. The pipeline subset of RenderStateParameters, BY MEMBER NAME, taken from the fields -# VulkanRenderer::ComputePipelineStateHash hashes today -# (MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp:4805-4906 at dev@81b17c0b, -# including ResolveEffectiveSampleMask, which the hash folds in twice - once as the -# effective enable bit and once as the mask word). +# G7. The pipeline subset of RenderStateParameters, BY MEMBER NAME, in DECLARATION order. +# +# P0 took this list from what VulkanRenderer::ComputePipelineStateHash hashed. P2 replaced +# that provenance with a RULE, and the rule is the only thing that decides membership: +# +# A member is in the pipeline subset if and only if some public RenderState setter that +# calls BumpVersions() writes it. Everything else is dynamic. There is no third set. +# +# That makes G7's invariant - the pipeline-subset hash moves IFF m_pipelineStateVersion +# moves - true by construction, and it makes the subset a strict SUPERSET of the 24 members +# the Vulkan hash read: it adds sample coverage, the front face, the provoking vertex, the +# scissor-test mask, the back polygon mode, the eleven capability bools the hash never read, +# and the three capabilities P2 gave storage to. The alternative - demoting those setters to +# ++m_version - would change MG_State semantics in the PULL build for the push path's sake. # # Names only: offsets are NOT computed here. The chunk table with real offsets is # MGPipeRenderStateSpans.cpp, built in C++ with offsetof, because a python guess at the # layout of a struct it cannot see is exactly the kind of drift the G7 setter-consistency -# test exists to catch (plan B section 4.5.2). +# test exists to catch (plan B section 4.5.2). StencilStates is named once and straddles: +# per face, Func and the three ops are pipeline, Ref/ValueMask/WriteMask are dynamic. PIPELINE_STATE_MEMBERS = [ - "CullFaceEnabled", - "DepthTestEnabled", - "PolygonOffsetFillEnabled", - "RasterizerDiscardEnabled", - "ColorLogicOpEnabled", - "StencilTestEnabled", - "PrimitiveRestartEnabled", - "PrimitiveRestartFixedIndexEnabled", - "DepthMask", - "SampleShadingEnabled", - "MultisampleEnabled", - "SampleMaskEnabled", - "SampleMaskValue", - "MinSampleShadingValue", "PatchVertices", "PatchDefaultOuterLevel", "PatchDefaultInnerLevel", - "PolygonModeFront", - "CullFaceModeSetting", - "DepthFunc", - "LogicOp", - "StencilStates", "BlendStates", + "LogicOp", + "DepthTestEnabled", + "DepthFunc", + "DepthMask", "ColorMasks", + "FramebufferSrgbEnabled", + "DepthClampEnabled", + "TextureCubeMapSeamlessEnabled", + "SampleCoverageValue", + "SampleCoverageInvert", + "SampleMaskValue", + "MinSampleShadingValue", + "StencilStates", + "CullFaceEnabled", + "CullFaceModeSetting", + "FrontFaceModeSetting", + "ProvokingVertexModeSetting", + "PolygonModeFront", + "PolygonModeBack", + "ColorLogicOpEnabled", + "DebugOutputEnabled", + "DebugOutputSynchronousEnabled", + "DitherEnabled", + "LineSmoothEnabled", + "MultisampleEnabled", + "PolygonOffsetFillEnabled", + "PolygonOffsetLineEnabled", + "PolygonOffsetPointEnabled", + "PolygonSmoothEnabled", + "PrimitiveRestartEnabled", + "PrimitiveRestartFixedIndexEnabled", + "RasterizerDiscardEnabled", + "SampleAlphaToCoverageEnabled", + "SampleAlphaToOneEnabled", + "SampleCoverageEnabled", + "SampleMaskEnabled", + "SampleShadingEnabled", + "StencilTestEnabled", + "ProgramPointSizeEnabled", + "ScissorTestEnabledMask", ] @@ -482,7 +512,20 @@ def parse_coverage(): if name in dict(sticky): sys.exit("Coverage.def: sticky field %s is listed twice" % name) sticky.append((name, reason)) - return accessors, deltas, sticky + emitted = [] + block = re.search(r"#define MGP_COVERAGE_EMITTED_LIST\(X\)(.*?)\n\n", text, re.S) + if not block: + sys.exit("Coverage.def: MGP_COVERAGE_EMITTED_LIST is missing") + seen = set() + for name, call in re.findall(r"X\((\w+)\s*,\s*(\w+)\)", block.group(1)): + if name not in accessor_names: + sys.exit("Coverage.def: emitted field %s is not an accessor in " + "MGP_COVERAGE_ACCESSOR_LIST" % name) + if name in seen: + sys.exit("Coverage.def: emitted field %s is listed twice" % name) + seen.add(name) + emitted.append((name, call)) + return accessors, deltas, sticky, emitted INVENTORY_ROW_RE = re.compile(r"^\|\s*(\d+)\s*\|([^|]*)\|([^|]*)\|([^|]*)\|") @@ -559,7 +602,7 @@ def gen_thunks(calls): return "\n".join(out) -def gen_wire(calls): +def gen_wire(calls, residual_fields=None): out = [banner("PipeWire.inc", "G3: wire records, size assertions and the applier's bounds gate.", "PipeCalls.def")] out.append("""// Every record is a fixed header plus its payload, padded to the stream's 8-byte @@ -641,6 +684,25 @@ def gen_wire(calls): } #undef MGP_WIRE_CHECK_BOUNDS""") + # The migration carrier's layout, asserted MEMBER BY MEMBER and not only by sizeof + # (plan 6.3): a heterogeneous POD is where padding differs across ABIs, and the monolith + # verify harness is blind to it because both sides are the same translation unit. The + # first member is pinned at 0 and the rest are pinned to ascend, which is the strongest + # statement a generator that cannot see the layout can make; sizeof plus + # MGL_RESIDUAL_BLOCK_SIZE pins the rest, and the ratchet only ever goes DOWN. + if residual_fields: + out.append("") + out.append("// The ResidualValueBlock layout, from PipeFields.def's") + out.append("// MGP_FIELDS_ResidualValueBlock. Retiring a field without lowering") + out.append("// MGL_RESIDUAL_BLOCK_SIZE is a build break, which is the point.") + out.append("static_assert(offsetof(ResidualValueBlock, %s) == 0," % residual_fields[0]) + out.append(" \"the residual block's first member must sit at offset 0\");") + for previous, member in zip(residual_fields, residual_fields[1:]): + out.append("static_assert(offsetof(ResidualValueBlock, %s) >" % member) + out.append(" offsetof(ResidualValueBlock, %s)," % previous) + out.append(" \"the residual block's members must stay in declaration order\");") + out.append("static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE,") + out.append(" \"the residual ratchet only ever goes down\");") return "\n".join(out) + "\n" @@ -744,7 +806,48 @@ def gen_verify(payloads): return "\n".join(out) + "\n" -def gen_filled(accessors, calls, sticky): +def gen_emitted_by(accessors, calls, emitted): + """P2 brief D5: which P2 call SUPPLIES each PipeInputs field, so the per-verb residual + fill loop can skip it. Emitters are the distinct calls named in MGP_COVERAGE_EMITTED_LIST, + sorted so the enum is stable against the order rows are written in.""" + call_names = set(c.Name for c in calls) + emitted_map = dict(emitted) + for name, call in emitted: + if call not in call_names: + sys.exit("Coverage.def: MGP_COVERAGE_EMITTED_LIST names %s for %s, which is not a " + "call in PipeCalls.def" % (call, name)) + emitters = sorted(set(call for _, call in emitted)) + out = [] + out.append("// P2 brief D5: the call that now SUPPLIES a field, so the residual fill loop no") + out.append("// longer pulls it out of GLContext. kNone means the field is still pulled - which") + out.append("// is what makes MOBILEGL_PIPE_PUSH a true per-subsystem A/B instead of a single") + out.append("// switch. Rows come from Coverage.def's MGP_COVERAGE_EMITTED_LIST.") + out.append("enum class MGPipeFieldEmitter : Uint8 {") + out.append(" kNone = 0,") + for emitter in emitters: + out.append(" %s," % emitter) + out.append("};") + out.append("") + out.append("inline constexpr const char* kMGPipeFieldEmitterNames[] = {") + out.append(" \"kNone\",") + for emitter in emitters: + out.append(" \"%s\"," % emitter) + out.append("};") + out.append("") + out.append("inline constexpr MGPipeFieldEmitter kMGPipeFieldEmittedBy[kMGPipeInputFieldCount] = {") + for name, _ in accessors: + emitter = emitted_map.get(name) + if emitter is None: + out.append(" MGPipeFieldEmitter::kNone, // %s" % name) + else: + out.append(" MGPipeFieldEmitter::%s, // %s" % (emitter, name)) + out.append("};") + out.append("inline constexpr SizeT kMGPipeEmittedFieldCount = %d;" % len(emitted)) + out.append("") + return "\n".join(out) + + +def gen_filled(accessors, calls, sticky, emitted): call_names = set(c.Name for c in calls) sticky_map = dict(sticky) out = [banner("PipeFilled.inc", "G5: PipeInputs field ids and the per-verb poison generations.", @@ -796,6 +899,7 @@ def gen_filled(accessors, calls, sticky): out.append(" \"%s\",%s" % (call, marker)) out.append("};") out.append("") + out.append(gen_emitted_by(accessors, calls, emitted)) out.append("""struct MGPipeFilledState { Uint64 CurrentVerbSerial; Uint64 FilledGen[kMGPipeInputFieldCount]; @@ -1041,19 +1145,23 @@ def gen_span_table(): "the field list in scripts/gen_pipe.py")] out.append("""// D-B1 rejected three CSOs and demanded this table instead, so the table needs its own // completeness trip wire: MG_Test walks every public RenderState setter and asserts that -// the pipeline-subset hash moves IF AND ONLY IF m_pipelineStateVersion moves. That test -// and MGPipeRenderStateSpans.cpp land with P2; what P0 pins is the MEMBER LIST, taken from -// what VulkanRenderer::ComputePipelineStateHash hashes today, so the later offsets are -// derived from a list that was reviewed rather than invented. +// the pipeline-subset hash moves IF AND ONLY IF m_pipelineStateVersion moves +// (MG_Test/Pipe/RenderStateSpansTest.cpp). +// +// P2 replaced P0's provenance with a RULE, and the rule is the only thing that decides +// membership: a member is pipeline state IF AND ONLY IF some public RenderState setter that +// calls BumpVersions() writes it. That is what makes the G7 invariant true by construction +// rather than by inspection, and it turns the subset into a strict SUPERSET of the 24 +// members VulkanRenderer::ComputePipelineStateHash used to hash. // -// Deliberately absent, and each absence is a question P2 has to answer before the chunk -// table freezes: -// - FramebufferSrgb and DepthClamp have NO STORAGE at all (RenderState.cpp's SetCapability -// falls to "not supported currently" and IsCapabilityEnabled returns false), so six -// backend read points are constant false today. Pipeline state or dead capability? -// - ProvokingVertexModeSetting is Vulkan pipeline state but is not hashed today. -// - FrontFaceModeSetting, ClipOrigin and ClipDepthMode are pipeline state on Vulkan and -// are handled elsewhere in the payload path rather than in the memo word. +// The three questions P0 left open are ANSWERED here, and the answers are in this list: +// - FramebufferSrgb, DepthClamp and TextureCubeMapSeamless had NO STORAGE at all - +// SetCapability fell to "not supported currently" and IsCapabilityEnabled answered a +// compile-time false. P2 gave all three real storage in the three padding bytes between +// ColorMasks and ClearColor, and their setters call BumpVersions(), so: pipeline state. +// - ProvokingVertexModeSetting: SetProvokingVertexMode calls BumpVersions(), so pipeline. +// - FrontFaceModeSetting likewise. ClipOrigin and ClipDepthMode do NOT (SetClipControl is +// ++m_version only), so they are dynamic, in chunk D1. // // The complement of this list is the DYNAMIC subset - the half whose whole purpose is that // glViewport must not mint a new CSO. @@ -1066,8 +1174,10 @@ def gen_span_table(): out.append("static_assert(kMGPipePipelineStateMemberCount ==") out.append(" sizeof(kMGPipePipelineStateMembers) / sizeof(kMGPipePipelineStateMembers[0]));") out.append("") - out.append("// Filled in by MG_Pipe/MGPipeRenderStateSpans.cpp (P2), which computes the offsets") - out.append("// in C++ with offsetof rather than guessing them in python.") + out.append("// Defined by MG_Pipe/MGPipeRenderStateSpans.cpp (P2), which computes every") + out.append("// boundary in C++ with offsetof rather than guessing it in python. 7 pipeline") + out.append("// chunks / 396 bytes and 8 dynamic chunks / 772 bytes, and the two halves") + out.append("// partition [0, sizeof(RenderStateParameters)) exactly - asserted there.") out.append("extern const MGPStateChunk kMGPipePipelineChunks[];") out.append("extern const MGPStateChunk kMGPipeDynamicChunks[];") return "\n".join(out) + "\n" @@ -1117,6 +1227,11 @@ def self_test(accessors): accessors, text=verb_row.sub("X(DrawArrays, kDraw) X(NotAVerb, kDraw)", fill_text, count=1)))) controls.append(("field row naming a non-accessor", lambda: parse_fill_points( accessors, text=field_row.sub("X(kDraw, NotAnAccessor)", fill_text, count=1)))) + # The EMITTED list's own gate: a row naming a call that is not in PipeCalls.def would + # generate an enumerator nothing can dispatch on. + calls_for_control = parse_calls() + controls.append(("emitted row naming a call that does not exist", lambda: gen_emitted_by( + [("GetViewport", "SetDynamicState")], calls_for_control, [("GetViewport", "NotACall")]))) trips = 0 for name, fn in controls: trips += expect_trip(name, fn) @@ -1143,7 +1258,7 @@ def main(): payloads = parse_verify_payloads() check_call_payloads_have_field_lists(calls, payloads) check_field_lists_cover_struct_members(parse_field_lists(), payloads) - accessors, deltas, sticky = parse_coverage() + accessors, deltas, sticky, emitted = parse_coverage() if args.self_test: return self_test(accessors) scan_live_accessors(accessors) @@ -1157,9 +1272,11 @@ def main(): changed = [] write(os.path.join(GENERATED_DIR, "PipeTables.inc"), gen_tables(calls), args.check, changed) write(os.path.join(GENERATED_DIR, "PipeThunks.inc"), gen_thunks(calls), args.check, changed) - write(os.path.join(GENERATED_DIR, "PipeWire.inc"), gen_wire(calls), args.check, changed) + write(os.path.join(GENERATED_DIR, "PipeWire.inc"), + gen_wire(calls, parse_field_lists().get("ResidualValueBlock")), args.check, changed) write(os.path.join(GENERATED_DIR, "PipeVerify.inc"), gen_verify(payloads), args.check, changed) - write(os.path.join(GENERATED_DIR, "PipeFilled.inc"), gen_filled(accessors, calls, sticky), args.check, changed) + write(os.path.join(GENERATED_DIR, "PipeFilled.inc"), gen_filled(accessors, calls, sticky, emitted), + args.check, changed) write(os.path.join(GENERATED_DIR, "PipeFillPoints.inc"), gen_fill_points(accessors, sticky, verbs, classes, fields), args.check, changed) write(os.path.join(GENERATED_DIR, "PipeCoverage.inc"), coverage_text, args.check, changed) @@ -1167,9 +1284,9 @@ def main(): screen = sum(1 for c in calls if c.IsScreen) print("gen_pipe: %d calls (%d screen, %d context), %d verify payloads, %d PipeInputs fields " - "(%d sticky), %d verbs, %d classes" + "(%d sticky, %d emitted by a P2 call), %d verbs, %d classes" % (len(calls), screen, len(calls) - screen, len(payloads), len(accessors), len(sticky), - len(verbs), len(classes))) + len(emitted), len(verbs), len(classes))) print("gen_pipe: inventory %d rows: %d -> call, %d client-resolved, %d reverse-channel, " "%d structural handle, %d UNMAPPED" % (len(rows), mapped, pseudo["kClientResolved"], pseudo["kReverseChannel"], From 810850b13a8f3894d4e916b8e3df4a5a6469303f Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 07:58:53 -0400 Subject: [PATCH 075/529] [Feat] (Pipe): derive every render-state PipeInputs field from the assembled working block instead of pulling it again from GLContext - 29 of the 47 kDraw PipeInputs fields are pure functions of RenderStateParameters. Once bind_render_state and set_dynamic_state have assembled the working block - which IS PipeInputs::m_renderState - copying those fields out of GLContext a second time is the per-verb pull P2 exists to remove. The applier now derives them after any scatter. - Each line is a transcription of the RenderState getter of the same name; GLContext's accessors are one-line forwards to those, so the derivation and the pull path answer the same question from the same bytes. Two are not field copies and are transcribed exactly: GetViewport (viewport 0 rounded with std::lround, because glGetIntegerv on float state rounds to nearest and truncating a 63.5-wide viewport would hand the backends a rectangle one pixel short), and IsCapabilityEnabled (the 35-way switch, including Blend -> BlendStates[0].Enabled, ScissorTest -> ScissorTestEnabledMask & 1 and the ClipDistance run). The indexed twin is the same for Blend[8] and ScissorTest[16]. - IsCapabilityEnabled could not have been written before the contract commit: DepthClamp, FramebufferSrgb and TextureCubeMapSeamless fell to `default: return false`, so three of the 35 answers were a compile-time constant rather than state. - This departs from P1 brief D4's "no derivation logic is re-implemented in PipeInputs", deliberately: the alternative is to keep pulling those 29 fields per verb. The guard is the oracle P1 built - MOBILEGL_PIPE_VERIFY's compare-at-read re-reads every one of them from the live context at EVERY backend read and compares field-wise, so a transcription error is caught on the first draw that reads it, across 79 retraces and the integration-verify entries. - The body sits in MGPipeApplyAccess, the struct PipeInputs already names as its friend, so no new friend and no new accessor per field. Pull build untouched: PipeApply.cpp compiles only under MOBILEGL_PIPE_PUSH. --- MobileGL/MG_Pipe/PipeApply.cpp | 163 +++++++++++++++++++++++++++++++-- 1 file changed, 154 insertions(+), 9 deletions(-) diff --git a/MobileGL/MG_Pipe/PipeApply.cpp b/MobileGL/MG_Pipe/PipeApply.cpp index ba1dc59ef..c115c1618 100755 --- a/MobileGL/MG_Pipe/PipeApply.cpp +++ b/MobileGL/MG_Pipe/PipeApply.cpp @@ -16,6 +16,7 @@ #include +#include #include #include @@ -46,6 +47,155 @@ namespace MobileGL::MG_Pipe { inputs.m_patchDefaultOuterLevel = outer; inputs.m_patchDefaultInnerLevel = inner; } + + // ---------------------------------------------------------------------------- + // D5: the 29 PipeInputs fields that are PURE FUNCTIONS of RenderStateParameters. + // + // Once bind_render_state / set_dynamic_state have assembled the working block, + // copying these out of GLContext a second time would be exactly the per-verb pull + // P2 exists to remove - so the applier DERIVES them instead. Each line below is a + // transcription of the RenderState getter of the same name (RenderState.cpp); + // GLContext's accessors are one-line forwards to those, so this block and the pull + // path answer the same question from the same bytes. + // + // This departs from P1 brief D4's "no derivation logic is re-implemented in + // PipeInputs", deliberately and with a guard: MOBILEGL_PIPE_VERIFY's compare-at-read + // re-reads every one of these from the live context AT EVERY BACKEND READ and + // compares field-wise, so a transcription error is caught on the first draw that + // reads it. RenderStateSpansTest.DerivationMatchesTheFrontendGetters walks every + // setter and checks all 29 against GLContext on top of that. + // ---------------------------------------------------------------------------- + + // RenderState::IsCapabilityEnabled, transcribed against the assembled block. One of + // the two derivations that is not a field copy, and the reason D3's three storage + // holes had to close FIRST: before P2, DepthClamp, FramebufferSrgb and + // TextureCubeMapSeamless fell to `default: return false` and this could not have + // been written at all. + static Bool DeriveCapability(const RenderStateParameters& p, CapabilityInput cap) { +#define MGP_DERIVE_CAPABILITY(capability) \ + case CapabilityInput::capability: \ + return p.capability##Enabled; + switch (cap) { + MGP_DERIVE_CAPABILITY(ColorLogicOp) + MGP_DERIVE_CAPABILITY(DebugOutput) + MGP_DERIVE_CAPABILITY(DebugOutputSynchronous) + MGP_DERIVE_CAPABILITY(DepthClamp) + MGP_DERIVE_CAPABILITY(DepthTest) + MGP_DERIVE_CAPABILITY(CullFace) + MGP_DERIVE_CAPABILITY(Dither) + MGP_DERIVE_CAPABILITY(FramebufferSrgb) + MGP_DERIVE_CAPABILITY(LineSmooth) + MGP_DERIVE_CAPABILITY(Multisample) + MGP_DERIVE_CAPABILITY(PolygonOffsetFill) + MGP_DERIVE_CAPABILITY(PolygonOffsetLine) + MGP_DERIVE_CAPABILITY(PolygonOffsetPoint) + MGP_DERIVE_CAPABILITY(PolygonSmooth) + MGP_DERIVE_CAPABILITY(PrimitiveRestart) + MGP_DERIVE_CAPABILITY(PrimitiveRestartFixedIndex) + MGP_DERIVE_CAPABILITY(RasterizerDiscard) + MGP_DERIVE_CAPABILITY(SampleAlphaToCoverage) + MGP_DERIVE_CAPABILITY(SampleAlphaToOne) + MGP_DERIVE_CAPABILITY(SampleCoverage) + MGP_DERIVE_CAPABILITY(SampleMask) + MGP_DERIVE_CAPABILITY(SampleShading) + MGP_DERIVE_CAPABILITY(StencilTest) + MGP_DERIVE_CAPABILITY(TextureCubeMapSeamless) + MGP_DERIVE_CAPABILITY(ProgramPointSize) + // The non-indexed query of an INDEXED capability answers for index 0 + // (GL 4.6 core 22.1) - RenderState.cpp says it in the same words. + case CapabilityInput::Blend: + return p.BlendStates[0].Enabled; + case CapabilityInput::ScissorTest: + return (p.ScissorTestEnabledMask & 1u) != 0; + // CapabilityInput lists ClipDistance0..7 contiguously, so the subtraction below + // is in range for exactly the eight values that reach here - RenderState.cpp's + // file-local ClipDistanceBit is the same expression. + case CapabilityInput::ClipDistance0: + case CapabilityInput::ClipDistance1: + case CapabilityInput::ClipDistance2: + case CapabilityInput::ClipDistance3: + case CapabilityInput::ClipDistance4: + case CapabilityInput::ClipDistance5: + case CapabilityInput::ClipDistance6: + case CapabilityInput::ClipDistance7: + return (p.ClipDistanceEnabledMask & + (1u << (static_cast(cap) - static_cast(CapabilityInput::ClipDistance0)))) != 0; + default: + return false; + } +#undef MGP_DERIVE_CAPABILITY + } + + static void DeriveRenderStateFields(PipeInputs& inputs) { + const RenderStateParameters& p = inputs.m_renderState; + + // Per draw buffer: GetBlendEquationIndexed, GetBlendFuncIndexed, + // GetColorMaskIndexed and IsCapabilityEnabledIndexed(Blend). + for (Uint i = 0; i < kMGMaxDrawBuffers; ++i) { + const PerBufferBlendState& blend = p.BlendStates[i]; + inputs.m_blendEquation[i][0] = blend.ColorEquation; + inputs.m_blendEquation[i][1] = blend.AlphaEquation; + inputs.m_blendFunc[i][0] = blend.SrcFactorRGB; + inputs.m_blendFunc[i][1] = blend.DstFactorRGB; + inputs.m_blendFunc[i][2] = blend.SrcFactorAlpha; + inputs.m_blendFunc[i][3] = blend.DstFactorAlpha; + inputs.m_colorMask[i] = p.ColorMasks[i]; + inputs.m_capabilityIndexed.Blend[i] = blend.Enabled; + } + + // Per viewport: GetViewportIndexed, GetDepthRangeIndexed and + // IsCapabilityEnabledIndexed(ScissorTest). + for (Uint i = 0; i < PipeInputs::kMaxViewports; ++i) { + inputs.m_viewportIndexed[i] = p.Viewports[i]; + inputs.m_depthRange[i] = p.DepthRanges[i]; + inputs.m_capabilityIndexed.ScissorTest[i] = (p.ScissorTestEnabledMask & (1u << i)) != 0; + } + + // GetViewport: viewport 0 ROUNDED - the other derivation that is not a field + // copy. glGetIntegerv on floating-point state rounds to nearest (GL 4.6 core + // 22.2), and truncating a 63.5-wide viewport would also hand the backends a + // rectangle one pixel short of what was asked for. std::lround, exactly as + // RenderState::GetViewport does it. + const FloatVec4& viewport = p.Viewports[0]; + inputs.m_viewport = + IntVec4(static_cast(std::lround(viewport.x())), static_cast(std::lround(viewport.y())), + static_cast(std::lround(viewport.z())), static_cast(std::lround(viewport.w()))); + + // The scalar copies, in the order MGP_COVERAGE_EMITTED_LIST names them. + inputs.m_blendColor = p.BlendColor; + inputs.m_clampReadColor = p.ClampReadColor; + inputs.m_clearColor = p.ClearColor; + inputs.m_clearDepth = p.ClearDepth; + inputs.m_clearStencil = p.ClearStencil; + inputs.m_cullFaceMode = p.CullFaceModeSetting; + inputs.m_depthFunc = p.DepthFunc; + inputs.m_depthMask = p.DepthMask; + inputs.m_lineWidth = p.LineWidth; + inputs.m_logicOp = p.LogicOp; + inputs.m_minSampleShadingValue = p.MinSampleShadingValue; + inputs.m_patchDefaultInnerLevel = p.PatchDefaultInnerLevel; + inputs.m_patchDefaultOuterLevel = p.PatchDefaultOuterLevel; + inputs.m_patchVertices = p.PatchVertices; + inputs.m_polygonModeFront = p.PolygonModeFront; + inputs.m_polygonOffsetFactor = p.PolygonOffsetFactor; + inputs.m_polygonOffsetUnits = p.PolygonOffsetUnits; + inputs.m_primitiveRestartIndex = p.PrimitiveRestartIndex; + inputs.m_provokingVertexMode = p.ProvokingVertexModeSetting; + // GetScissorBox answers for rectangle 0, like GetViewport - but WITHOUT any + // rounding, because the scissor rectangle is integer state to begin with. + inputs.m_scissorBox = p.ScissorBoxes[0]; + + // GetStencilState: Front is index 0 and Back is index 1 on both sides + // (RenderState.cpp's GetStencilFaceIndex and PipeInputs::GetStencilState agree), + // so the two faces copy straight across. + for (SizeT face = 0; face < PipeInputs::kStencilFaceCount; ++face) { + inputs.m_stencil[face] = p.StencilStates[face]; + } + + for (SizeT i = 0; i < PipeInputs::kCapabilityCount; ++i) { + inputs.m_capability[i] = DeriveCapability(p, static_cast(i)); + } + } }; namespace { @@ -257,14 +407,9 @@ namespace MobileGL::MG_Pipe { } void MGPipeDeriveRenderStateFields(PipeInputs& inputs) { - // STUB (P2 package A commit c1, on p2/spans). The 29 derivations of brief D5 land - // here, each transcribed from its RenderState getter; until then the residual fill - // loop still copies those fields out of GLContext, which is exactly P1's behaviour, - // so an empty body is correct rather than merely harmless. The two transcriptions - // that are not one-liners and must be copied exactly are GetViewport() (viewport 0 - // ROUNDED TO INTEGERS) and IsCapabilityEnabled / IsCapabilityEnabledIndexed (the - // 35-way and 2-way switches, including Blend -> BlendStates[0].Enabled and - // ScissorTest -> ScissorTestEnabledMask & 1). - (void)inputs; + // The derivation itself lives in MGPipeApplyAccess above, because that is the one + // struct PipeInputs names as a friend - see D5 there for what it recomputes, which + // getter each line was transcribed from, and why the verify comparator is its guard. + MGPipeApplyAccess::DeriveRenderStateFields(inputs); } } // namespace MobileGL::MG_Pipe From eec92cd221eda13b25b1f374b2021c2c832d112f Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:16:53 -0400 Subject: [PATCH 076/529] [Test] (Pipe): walk every RenderState setter and assert the pipeline-subset hash moves exactly when the pipeline version does - SetterConsistency is G7. It drives every public RenderState setter with a value that differs from the one stored, and asserts the pipeline-subset hash moves IF AND ONLY IF m_pipelineStateVersion moves. Every case also asserts m_version moved, which is the vacuity guard: a setter handed the value it already holds satisfies "neither moved" trivially and proves nothing. - The cases that are not just a list: SetStencilFunc twice (a reference-only change must move the version and NOT the hash, because Ref/ValueMask are dynamic and Func is pipeline, and RenderState.cpp's pipeline bump is conditional on Func); SetPolygonMode with only the back face moving (PolygonModeBack is one of the members P2's subset added over the 24 ComputePipelineStateHash hashed); the eight ClipDistance capabilities (the one family that moves m_version alone, so the hash must hold); SetScissorBox across the ScissorBoxWrittenMask transition; all 25 SET_CAPABILITY names including the three that had no storage before the contract commit; and SetPixelStoreParam, which must move neither counter and touch no byte of the block. - Verified red for the right reason: moving the P1/D2 boundary so ColorMasks falls in the dynamic half - the partition stays complete, so it still compiles - makes SetterConsistency fail naming SetColorMask and SetColorMaskIndexed, and nothing else. - ChunkTablePartitionsTheBlock re-states the header's static_assert at run time and adds the half the hash cannot check for itself: membership. It walks PipeFields.def's MGP_FIELDS_RenderStateParameters - the same list gen_pipe.py checks against the struct - and asserts a member is covered by a pipeline chunk exactly when kMGPipePipelineStateMembers names it, that every other member is wholly dynamic, and that StencilStates straddles at exactly the sub-member granularity the table intends. - DerivationMatchesTheFrontendGetters drives 30-odd setters on a live GLContext AFTER the filler has run, assembles the working block through the real create/bind/set_dynamic_state path, and compares the derived fields against the frontend getters. The stale fill is the point: an ASSERT_NE before each apply proves the block disagrees first, so nothing here can pass by comparing the filler with itself. It runs in THREE verb phases, because the fill table is the only thing that says what a verb may read: 25 of these fields are kDraw's, the three clear values are kClear's and GetClampReadColor is kReadback's alone, and reading a clear value under DrawArrays is Fatal{UnmigratedPipeInput} - correctly, and the poison caught exactly that in the verify build before this shape. - GetViewport's rounding is exercised on (1.5, 2.5, 63.5, 32.25) and the expected literal is std::lround's answer - half away from zero - not the banker's rounding nearbyint gives. - DynamicChunksCoverMagmasDynamicTailKey checks every GL-state input of DirectVulkan's ApplyDynamicDrawStateTail against the dynamic half, and records the one exception the design implies but no document states: ScissorTestEnabledMask is read by DynamicTailKey's scissorEnabled yet is PIPELINE state, because SetCapability(ScissorTest) calls BumpVersions(). Harmless - BumpVersions moves m_version too, so MGPDynamicState::Version still moves and the tail still re-runs - and asserted the other way round so a later table edit that demotes the mask is loud here. - Replaces the contract commit's placeholder case, which existed only so the target had a test before this package filled it in. The four names are the same four in every build: in a pull build each is a visible SKIP, never a vanishing test. --- .../MG_Test/Pipe/RenderStateSpansTest.cpp | 622 +++++++++++++++++- 1 file changed, 610 insertions(+), 12 deletions(-) diff --git a/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp b/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp index 016ec6419..8afe544c6 100644 --- a/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp +++ b/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp @@ -6,28 +6,626 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header -// G7: the render-state chunk table, its subset hash and the setter-consistency walk (P2 brief D19). +// G7: the render-state chunk table, its subset hash and the setter-consistency walk +// (P2 brief D19). Four cases: // -// STUB, and deliberately one. It is created by the P2 CONTRACT commit together with its -// CMakeLists.txt registration, so that the package which owns its CONTENTS -// (P2 package A, commit c2 on p2/spans) never has to touch MG_Test/Pipe/CMakeLists.txt - no two P2 -// packages edit the same file, which is what keeps the integrator's rebases clean. +// ChunkTablePartitionsTheBlock - the table is sorted, non-overlapping and +// complete, and its two halves are exactly the +// membership generated/PipeSpanTable.inc names. +// SetterConsistency - THE gate. For every public RenderState setter, +// the pipeline-subset hash moves IF AND ONLY IF +// m_pipelineStateVersion moves. +// DerivationMatchesTheFrontendGetters - D5's 29 derived PipeInputs fields against the +// frontend getters they were transcribed from. +// DynamicChunksCoverMagmasDynamicTailKey - every GL-state input of DirectVulkan's +// DynamicTailKey, against the dynamic half. // -// The placeholder case is not decoration: without it the binary has no test, and -// gtest_discover_tests on a binary with no test is a silently green lane. +// The suite needs the push sources (MGPipeRenderStateSpans.cpp and PipeApply.cpp are +// compiled only under MOBILEGL_PIPE_PUSH), so every case is a visible SKIP in a pull build +// rather than a vanishing test - and the four names are the SAME four in every build, which +// is what keeps `ctest -N` name-for-name identical between the pull and the push tree. #include +#include +#include +#include + #include "Includes.h" #include +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include +#include +#endif + using namespace MobileGL; using namespace MobileGL::MG_Pipe; namespace { - // The one fact this file can already state in EVERY build: the member list the chunk - // table was derived from is non-empty and is what generated/PipeSpanTable.inc pins. - TEST(RenderStateSpans, PlaceholderUntilTheOwningPackageFillsThisIn) { - EXPECT_GT(kMGPipePipelineStateMemberCount, 0u); - EXPECT_STREQ(kMGPipePipelineStateMembers[0], "PatchVertices"); +#if MOBILEGL_PIPE_PUSH + using MG_State::GLState::RenderState; + using GLContext = MG_State::GLState::GLContext; + + // --------------------------------------------------------------------------------- + // The member table, built from the SAME list gen_pipe.py checks against the struct + // (PipeFields.def's MGP_FIELDS_RenderStateParameters). Using that list rather than a + // hand-written one is what makes "every member is accounted for" true: a member added + // to RenderStateParameters without a row there already fails pipe-gates, and a member + // added WITH a row lands here automatically and has to be classified by the test. + // --------------------------------------------------------------------------------- + struct Member { + const char* Name; + SizeT Offset; + SizeT Size; + }; + +#define MGP_RS_MEMBER_ROW(name) \ + Member{#name, offsetof(RenderStateParameters, name), sizeof(RenderStateParameters::name)}, + constexpr Member kMembers[] = {MGP_FIELDS_RenderStateParameters(MGP_RS_MEMBER_ROW)}; +#undef MGP_RS_MEMBER_ROW + constexpr SizeT kMemberCount = sizeof(kMembers) / sizeof(kMembers[0]); + + SizeT OverlapWith(const MGPStateChunk* chunks, SizeT chunkCount, SizeT offset, SizeT size) { + SizeT covered = 0; + for (SizeT i = 0; i < chunkCount; ++i) { + const SizeT chunkBegin = chunks[i].Offset; + const SizeT chunkEnd = chunkBegin + chunks[i].Length; + const SizeT begin = offset > chunkBegin ? offset : chunkBegin; + const SizeT end = (offset + size) < chunkEnd ? (offset + size) : chunkEnd; + if (begin < end) covered += end - begin; + } + return covered; + } + + SizeT PipelineBytesOf(SizeT offset, SizeT size) { + return OverlapWith(kMGPipePipelineChunks, kMGPipePipelineChunkCount, offset, size); + } + + Bool IsWhollyDynamic(SizeT offset, SizeT size) { + return OverlapWith(kMGPipeDynamicChunks, kMGPipeDynamicChunkCount, offset, size) == size; + } + + Bool IsWhollyPipeline(SizeT offset, SizeT size) { return PipelineBytesOf(offset, size) == size; } +#endif // MOBILEGL_PIPE_PUSH + + // ------------------------------------------------------------------------------------- + // 1. The table itself. + // ------------------------------------------------------------------------------------- + TEST(RenderStateSpans, ChunkTablePartitionsTheBlock) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +#else + // Sorted, non-overlapping and complete. That is already a static_assert in + // MGPipeRenderStateSpans.h - a gap there is a build break, not a red test - and the + // point of re-asserting it at run time is that a reader of the suite sees the + // invariant stated rather than having to find the header. + SizeT walked = 0; + for (SizeT i = 0; i < kMGPipeRenderStateChunkCount; ++i) { + const MGPStateChunk chunk = MGPipeRenderStateChunkAt(i); + EXPECT_EQ(SizeT{chunk.Offset}, walked) << "chunk " << i << " does not start where its predecessor ended"; + EXPECT_GT(chunk.Length, 0u) << "chunk " << i << " is empty"; + walked = SizeT{chunk.Offset} + SizeT{chunk.Length}; + } + EXPECT_EQ(walked, sizeof(RenderStateParameters)); + EXPECT_EQ(kMGPipePipelineChunkBytes + kMGPipeDynamicChunkBytes, sizeof(RenderStateParameters)); + EXPECT_EQ(kMGPipePipelineChunkBytes, SizeT{396}); + EXPECT_EQ(kMGPipeDynamicChunkBytes, SizeT{772}); + + // The two exported arrays are the two halves of that same table, ascending. + for (SizeT i = 0; i + 1 < kMGPipePipelineChunkCount; ++i) { + EXPECT_LT(kMGPipePipelineChunks[i].Offset, kMGPipePipelineChunks[i + 1].Offset); + } + for (SizeT i = 0; i + 1 < kMGPipeDynamicChunkCount; ++i) { + EXPECT_LT(kMGPipeDynamicChunks[i].Offset, kMGPipeDynamicChunks[i + 1].Offset); + } + + // Membership. kMGPipePipelineChunks covers every member kMGPipePipelineStateMembers + // names and NO byte of any member it does not - which is the half of G7 that the + // hash cannot check for itself, because a hash over the wrong bytes is still a hash. + std::set pipelineNames; + for (SizeT i = 0; i < kMGPipePipelineStateMemberCount; ++i) { + pipelineNames.insert(kMGPipePipelineStateMembers[i]); + } + SizeT namedFound = 0; + for (SizeT i = 0; i < kMemberCount; ++i) { + const Member& member = kMembers[i]; + const SizeT pipelineBytes = PipelineBytesOf(member.Offset, member.Size); + if (pipelineNames.count(member.Name) != 0) { + ++namedFound; + EXPECT_GT(pipelineBytes, SizeT{0}) + << member.Name << " is named as pipeline state but no pipeline chunk covers it"; + } else { + EXPECT_EQ(pipelineBytes, SizeT{0}) + << member.Name << " is not named as pipeline state but " << pipelineBytes + << " of its bytes are in the pipeline half"; + EXPECT_TRUE(IsWhollyDynamic(member.Offset, member.Size)) + << member.Name << " is neither wholly pipeline nor wholly dynamic"; + } + } + EXPECT_EQ(namedFound, kMGPipePipelineStateMemberCount) + << "a name in kMGPipePipelineStateMembers matches no member of RenderStateParameters"; + + // StencilStates is the ONE member that straddles, and it straddles at sub-member + // granularity by design: Ref/ValueMask/WriteMask are VK_DYNAMIC_STATE_STENCIL_*, so + // glStencilFunc changing only the reference must not evict a cached pipeline. + // StencilFaceState is deliberately NOT reordered - reordering it would move Espryt's + // shadow bytes for no gain - so the split is a hole in the middle of each face. + for (SizeT face = 0; face < 2; ++face) { + const SizeT base = offsetof(RenderStateParameters, StencilStates) + face * sizeof(StencilFaceState); + EXPECT_TRUE(IsWhollyDynamic(base + offsetof(StencilFaceState, Ref), + offsetof(StencilFaceState, FailOp) - offsetof(StencilFaceState, Ref))) + << "stencil face " << face << ": Ref/ValueMask/WriteMask must be dynamic"; + EXPECT_TRUE(IsWhollyPipeline(base + offsetof(StencilFaceState, Func), sizeof(StencilFaceState::Func))) + << "stencil face " << face << ": Func must be pipeline"; + EXPECT_TRUE(IsWhollyPipeline(base + offsetof(StencilFaceState, FailOp), + sizeof(StencilFaceState) - offsetof(StencilFaceState, FailOp))) + << "stencil face " << face << ": the three ops must be pipeline"; + } +#endif + } + + // ------------------------------------------------------------------------------------- + // 2. G7 itself: the setter walk. + // ------------------------------------------------------------------------------------- + TEST(RenderStateSpans, SetterConsistency) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +#else + RenderState rs; + + // Drives one setter and asserts the G7 invariant on it. The m_version expectation is + // the VACUITY GUARD: a setter handed a value equal to the one already stored would + // satisfy "neither moved" trivially and prove nothing. The one setter that moves no + // counter at all, SetPixelStoreParam, is driven separately at the end. + const auto check = [&rs](const char* name, void (*apply)(RenderState&)) { + const Uint64 hashBefore = MGPipeComputePipelineSubsetHash(rs.GetAllParameters()); + const Uint versionBefore = rs.GetVersion(); + const Uint pipelineBefore = rs.GetPipelineStateVersion(); + apply(rs); + const Uint64 hashAfter = MGPipeComputePipelineSubsetHash(rs.GetAllParameters()); + EXPECT_NE(versionBefore, rs.GetVersion()) + << name << " wrote a value equal to the one already stored - the case proves nothing"; + EXPECT_EQ(hashBefore != hashAfter, pipelineBefore != rs.GetPipelineStateVersion()) + << name << ": the pipeline-subset hash " << (hashBefore != hashAfter ? "MOVED" : "held") + << " but m_pipelineStateVersion " + << (pipelineBefore != rs.GetPipelineStateVersion() ? "MOVED" : "held"); + }; + + // ---- Rasterization ---- + check("SetViewport", [](RenderState& s) { s.SetViewport(IntVec4(1, 2, 30, 40)); }); + check("SetViewportIndexed", [](RenderState& s) { s.SetViewportIndexed(3, FloatVec4(4.f, 5.f, 60.f, 70.f)); }); + check("SetLineWidth", [](RenderState& s) { s.SetLineWidth(3.5f); }); + check("SetPointSize", [](RenderState& s) { s.SetPointSize(7.25f); }); + check("SetPatchVertices", [](RenderState& s) { s.SetPatchVertices(4); }); + check("SetPatchDefaultOuterLevel", + [](RenderState& s) { s.SetPatchDefaultOuterLevel(FloatVec4(2.f, 3.f, 4.f, 5.f)); }); + check("SetPatchDefaultInnerLevel", [](RenderState& s) { s.SetPatchDefaultInnerLevel(FloatVec2(6.f, 7.f)); }); + check("SetPolygonOffset", [](RenderState& s) { s.SetPolygonOffset(1.5f, 2.5f); }); + check("SetPolygonOffsetClamped", [](RenderState& s) { s.SetPolygonOffsetClamped(3.5f, 4.5f, 0.25f); }); + check("SetClipControl", [](RenderState& s) { s.SetClipControl(GL_UPPER_LEFT, GL_ZERO_TO_ONE); }); + check("SetHint", [](RenderState& s) { s.SetHint(GL_LINE_SMOOTH_HINT, GL_NICEST); }); + check("SetPointFadeThresholdSize", [](RenderState& s) { s.SetPointFadeThresholdSize(2.5f); }); + check("SetPointSpriteCoordOrigin", [](RenderState& s) { s.SetPointSpriteCoordOrigin(GL_LOWER_LEFT); }); + check("SetClampReadColor", [](RenderState& s) { s.SetClampReadColor(GL_TRUE); }); + check("SetPrimitiveRestartIndex", [](RenderState& s) { s.SetPrimitiveRestartIndex(0xabcdu); }); + + // SetPolygonMode with ONLY the back face changing, because PolygonModeBack is one of + // the members P2's subset added over the 24 ComputePipelineStateHash used to hash - + // if it had stayed out, this is the case that would have caught it. + rs.SetPolygonMode(GL_LINE, GL_LINE); + check("SetPolygonMode(back only)", [](RenderState& s) { s.SetPolygonMode(GL_LINE, GL_POINT); }); + check("SetPolygonMode(front only)", [](RenderState& s) { s.SetPolygonMode(GL_FILL, GL_POINT); }); + + // ---- Capabilities: every SET_CAPABILITY name, D3's three new ones included ---- +#define MGP_CHECK_CAPABILITY(cap) \ + check("SetCapability(" #cap ")", [](RenderState& s) { \ + s.SetCapability(CapabilityInput::cap, !s.IsCapabilityEnabled(CapabilityInput::cap)); \ + }); + MGP_CHECK_CAPABILITY(ColorLogicOp) + MGP_CHECK_CAPABILITY(DebugOutput) + MGP_CHECK_CAPABILITY(DebugOutputSynchronous) + MGP_CHECK_CAPABILITY(DepthClamp) + MGP_CHECK_CAPABILITY(DepthTest) + MGP_CHECK_CAPABILITY(CullFace) + MGP_CHECK_CAPABILITY(Dither) + MGP_CHECK_CAPABILITY(FramebufferSrgb) + MGP_CHECK_CAPABILITY(LineSmooth) + MGP_CHECK_CAPABILITY(Multisample) + MGP_CHECK_CAPABILITY(PolygonOffsetFill) + MGP_CHECK_CAPABILITY(PolygonOffsetLine) + MGP_CHECK_CAPABILITY(PolygonOffsetPoint) + MGP_CHECK_CAPABILITY(PolygonSmooth) + MGP_CHECK_CAPABILITY(PrimitiveRestart) + MGP_CHECK_CAPABILITY(PrimitiveRestartFixedIndex) + MGP_CHECK_CAPABILITY(RasterizerDiscard) + MGP_CHECK_CAPABILITY(SampleAlphaToCoverage) + MGP_CHECK_CAPABILITY(SampleAlphaToOne) + MGP_CHECK_CAPABILITY(SampleCoverage) + MGP_CHECK_CAPABILITY(SampleMask) + MGP_CHECK_CAPABILITY(SampleShading) + MGP_CHECK_CAPABILITY(StencilTest) + MGP_CHECK_CAPABILITY(TextureCubeMapSeamless) + MGP_CHECK_CAPABILITY(ProgramPointSize) + MGP_CHECK_CAPABILITY(Blend) + MGP_CHECK_CAPABILITY(ScissorTest) +#undef MGP_CHECK_CAPABILITY + + // The eight clip distances are the one family that moves m_version and NOT + // m_pipelineStateVersion (RenderState.cpp says why: no backend bakes a clip-distance + // enable into a pipeline object), so the hash must not move either - + // ClipDistanceEnabledMask lives in dynamic chunk D7. + for (Uint i = 0; i < 8; ++i) { + const CapabilityInput cap = + static_cast(static_cast(CapabilityInput::ClipDistance0) + i); + const Uint64 hashBefore = MGPipeComputePipelineSubsetHash(rs.GetAllParameters()); + const Uint versionBefore = rs.GetVersion(); + const Uint pipelineBefore = rs.GetPipelineStateVersion(); + rs.SetCapability(cap, true); + EXPECT_NE(versionBefore, rs.GetVersion()) << "ClipDistance" << i << " did not move m_version"; + EXPECT_EQ(pipelineBefore, rs.GetPipelineStateVersion()) + << "ClipDistance" << i << " moved m_pipelineStateVersion"; + EXPECT_EQ(hashBefore, MGPipeComputePipelineSubsetHash(rs.GetAllParameters())) + << "ClipDistance" << i << " moved the pipeline-subset hash"; + } + + // Both are DISABLES: the non-indexed toggles above have just enabled every draw + // buffer's blend and every viewport's scissor test, so re-enabling one index would + // write the value already stored and trip the vacuity guard. + check("SetCapabilityIndexed(Blend, 3)", + [](RenderState& s) { s.SetCapabilityIndexed(CapabilityInput::Blend, 3, false); }); + check("SetCapabilityIndexed(ScissorTest, 5)", + [](RenderState& s) { s.SetCapabilityIndexed(CapabilityInput::ScissorTest, 5, false); }); + + // ---- Blending ---- + check("SetBlendFunc", [](RenderState& s) { + s.SetBlendFunc(BlendFactor::SrcAlpha, BlendFactor::OneMinusSrcAlpha, BlendFactor::One, BlendFactor::Zero); + }); + check("SetBlendFuncIndexed", [](RenderState& s) { + s.SetBlendFuncIndexed(2, BlendFactor::DstColor, BlendFactor::SrcColor, BlendFactor::DstAlpha, + BlendFactor::SrcAlpha); + }); + check("SetBlendEquation", + [](RenderState& s) { s.SetBlendEquation(BlendEquation::Subtract, BlendEquation::Min); }); + check("SetBlendEquationIndexed", + [](RenderState& s) { s.SetBlendEquationIndexed(4, BlendEquation::ReverseSubtract, BlendEquation::Max); }); + check("SetLogicOp", [](RenderState& s) { s.SetLogicOp(LogicOperation::Xor); }); + + // ---- Depth and stencil ---- + check("SetDepthFunc", [](RenderState& s) { s.SetDepthFunc(DepthTestFunc::GreaterEqual); }); + check("SetDepthMask", [](RenderState& s) { s.SetDepthMask(false); }); + + // SetStencilFunc TWICE, and this pair is why the case exists. Ref and ValueMask are + // dynamic (VK_DYNAMIC_STATE_STENCIL_REFERENCE / _COMPARE_MASK) while Func is + // pipeline, and RenderState.cpp's ++m_pipelineStateVersion is conditional on Func + // moving - so a reference-only change must move the version and NOT the hash. + rs.SetStencilFunc(StencilFace::Front, DepthTestFunc::Equal, 1, 0xffu); + { + const Uint64 hashBefore = MGPipeComputePipelineSubsetHash(rs.GetAllParameters()); + const Uint versionBefore = rs.GetVersion(); + const Uint pipelineBefore = rs.GetPipelineStateVersion(); + rs.SetStencilFunc(StencilFace::Front, DepthTestFunc::Equal, 7, 0xffu); + EXPECT_NE(versionBefore, rs.GetVersion()) << "SetStencilFunc(ref only) did not move m_version"; + EXPECT_EQ(pipelineBefore, rs.GetPipelineStateVersion()) + << "SetStencilFunc(ref only) moved m_pipelineStateVersion"; + EXPECT_EQ(hashBefore, MGPipeComputePipelineSubsetHash(rs.GetAllParameters())) + << "SetStencilFunc(ref only) moved the pipeline-subset hash - Ref is not in the dynamic half"; + } + check("SetStencilFunc(func)", + [](RenderState& s) { s.SetStencilFunc(StencilFace::Front, DepthTestFunc::NotEqual, 7, 0xffu); }); + check("SetStencilMask", [](RenderState& s) { s.SetStencilMask(StencilFace::Back, 0x0fu); }); + check("SetStencilOp", [](RenderState& s) { + s.SetStencilOp(StencilFace::Back, StencilOperation::Replace, StencilOperation::IncrementClamp, + StencilOperation::DecrementWrap); + }); + + // ---- Colour mask, clear state, sampling ---- + check("SetColorMask", [](RenderState& s) { s.SetColorMask(BoolVec4(true, false, true, false)); }); + check("SetColorMaskIndexed", [](RenderState& s) { s.SetColorMaskIndexed(6, BoolVec4(false, false, true, true)); }); + check("SetClearColor", [](RenderState& s) { s.SetClearColor(FloatVec4(0.1f, 0.2f, 0.3f, 0.4f)); }); + check("SetClearDepth", [](RenderState& s) { s.SetClearDepth(0.75f); }); + check("SetClearStencil", [](RenderState& s) { s.SetClearStencil(9); }); + check("SetBlendColor", [](RenderState& s) { s.SetBlendColor(FloatVec4(0.5f, 0.6f, 0.7f, 0.8f)); }); + check("SetDepthRange", [](RenderState& s) { s.SetDepthRange(FloatVec2(0.25f, 0.75f)); }); + check("SetDepthRangeIndexed", [](RenderState& s) { s.SetDepthRangeIndexed(9, FloatVec2(0.1f, 0.9f)); }); + // SetSampleCoverage calls BumpVersions(), so under the rule it is PIPELINE state - + // which is why MGPipeTypes.h's MGPDynamicState comment no longer claims otherwise. + check("SetSampleCoverage", [](RenderState& s) { s.SetSampleCoverage(0.375f, true); }); + check("SetSampleMaskValue", [](RenderState& s) { s.SetSampleMaskValue(0x5a5au); }); + check("SetMinSampleShadingValue", [](RenderState& s) { s.SetMinSampleShadingValue(0.625f); }); + + // ---- Faces and scissor ---- + check("SetCullFaceMode", [](RenderState& s) { s.SetCullFaceMode(CullFaceMode::Front); }); + check("SetFrontFaceMode", [](RenderState& s) { s.SetFrontFaceMode(FrontFaceMode::Clockwise); }); + check("SetProvokingVertexMode", + [](RenderState& s) { s.SetProvokingVertexMode(ProvokingVertexMode::FirstVertex); }); + // The first glScissor on a context both writes the rectangles and flips the + // "never written" bit, so the transition and the steady state are separate cases. + check("SetScissorBox(first write)", [](RenderState& s) { s.SetScissorBox(IntVec4(1, 2, 3, 4)); }); + check("SetScissorBox(again)", [](RenderState& s) { s.SetScissorBox(IntVec4(5, 6, 7, 8)); }); + check("SetScissorBoxIndexed", [](RenderState& s) { s.SetScissorBoxIndexed(11, IntVec4(9, 10, 11, 12)); }); + + // SetPixelStoreParam moves NEITHER counter and touches no byte of + // RenderStateParameters: the pixel store lives in its own two structs and travels as + // set_pixel_pack_state. Driven here so the claim is tested rather than assumed. + { + const Uint64 hashBefore = MGPipeComputePipelineSubsetHash(rs.GetAllParameters()); + const Uint versionBefore = rs.GetVersion(); + const Uint pipelineBefore = rs.GetPipelineStateVersion(); + rs.SetPixelStoreParam(PixelStoreParam::PackAlignment, 8); + EXPECT_EQ(rs.GetPixelStoreParam(PixelStoreParam::PackAlignment), 8); + EXPECT_EQ(versionBefore, rs.GetVersion()); + EXPECT_EQ(pipelineBefore, rs.GetPipelineStateVersion()); + EXPECT_EQ(hashBefore, MGPipeComputePipelineSubsetHash(rs.GetAllParameters())); + } +#endif + } + + // ------------------------------------------------------------------------------------- + // 3. D5's derivations against the getters they were transcribed from. + // ------------------------------------------------------------------------------------- + TEST(RenderStateSpans, DerivationMatchesTheFrontendGetters) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +#else + // A live frontend context for the setters to write and the getters to answer from, + // restored on the way out so the case stays independent (SanityTest's idiom). + struct ContextGuard { + UniquePtr Previous; + ContextGuard() : Previous(Move(MG_State::pGLContext)) { + MG_State::pGLContext = MakeUnique(); + MGPipeApplierReset(); + } + ~ContextGuard() { + MGPipeApplierReset(); + MG_State::pGLContext = Move(Previous); + } + } guard; + GLContext& ctx = *MG_State::pGLContext; + + // Assembles the working block the way the tracker will: one brand-new CSO carrying + // every pipeline chunk, its bind, then every dynamic chunk. A fresh slot per call, + // so a later phase cannot be answered by an earlier phase's record. + Uint32 nextSlot = kMGPipeFirstAllocatableSlot; + const auto applyWholeBlock = [&ctx, &nextSlot]() { + const RenderStateParameters& live = ctx.GetRenderStateParameters(); + const Uint32 allPipeline = static_cast((Uint64{1} << kMGPipePipelineChunkCount) - 1); + const Uint32 allDynamic = static_cast((Uint64{1} << kMGPipeDynamicChunkCount) - 1); + + Array pipelineBytes{}; + MGPipeGatherPipelineBytes(live, pipelineBytes.data()); + MGPRenderStateDesc desc{}; + desc.Cso = MGPipeHandle{nextSlot++, 0}; + desc.BaseCso = kMGPipeNullHandle; + desc.ChunkMask = allPipeline; + MGPipeApplyCreateRenderState(desc, pipelineBytes.data()); + + MGPBindRenderState bind{}; + bind.Cso = desc.Cso; + bind.Version = static_cast(ctx.GetRenderStateParametersVersion()); + bind.PipelineVersion = static_cast(ctx.GetPipelineStateVersion()); + MGPipeApplyBindRenderState(bind); + + Vector dynamicBytes(MGPipeDynamicChunkBlobBytes(allDynamic)); + MGPipeGatherDynamicChunks(live, allDynamic, dynamicBytes.data()); + MGPDynamicState dyn{}; + dyn.ChunkMask = allDynamic; + dyn.Version = bind.Version; + MGPipeApplySetDynamicState(dyn, dynamicBytes.data()); + }; + + // THREE PHASES, one per verb class, because the poison is right and the fill table is + // the only thing that says what a verb may read: 25 of these fields are kDraw's, the + // three clear values are kClear's and GetClampReadColor is kReadback's + // (MG_Pipe/FillPoints.def). Reading a clear value under DrawArrays would be + // Fatal{UnmigratedPipeInput}, and rightly - a draw does not read it. + // + // Phase 1, kDraw. The fill happens FIRST, against the DEFAULT state; every mutation + // below is driven afterwards, so a field that still agrees with the context at the + // end can only have got there through the applier's derivation. + { + MG_Test::ScopedPipeVerb verb(MGPipeVerb::DrawArrays); + + ctx.SetViewportIndexed(0, FloatVec4(1.5f, 2.5f, 63.5f, 32.25f)); + ctx.SetViewportIndexed(7, FloatVec4(8.f, 9.f, 10.f, 11.f)); + ctx.SetLineWidth(3.5f); + ctx.SetPatchVertices(4); + ctx.SetPatchDefaultOuterLevel(FloatVec4(2.f, 3.f, 4.f, 5.f)); + ctx.SetPatchDefaultInnerLevel(FloatVec2(6.f, 7.f)); + ctx.SetPolygonOffsetClamped(1.5f, 2.5f, 0.25f); + ctx.SetClampReadColor(GL_TRUE); + ctx.SetPolygonMode(GL_LINE, GL_POINT); + ctx.SetPrimitiveRestartIndex(0xabcdu); + ctx.SetBlendFuncIndexed(2, BlendFactor::DstColor, BlendFactor::SrcColor, BlendFactor::DstAlpha, + BlendFactor::SrcAlpha); + ctx.SetBlendEquationIndexed(4, BlendEquation::ReverseSubtract, BlendEquation::Max); + ctx.SetCapabilityIndexed(CapabilityInput::Blend, 3, true); + ctx.SetCapabilityIndexed(CapabilityInput::ScissorTest, 5, true); + ctx.SetLogicOp(LogicOperation::Xor); + ctx.SetDepthFunc(DepthTestFunc::GreaterEqual); + ctx.SetDepthMask(false); + ctx.SetStencilFunc(StencilFace::Front, DepthTestFunc::Equal, 7, 0xf0u); + ctx.SetStencilOp(StencilFace::Back, StencilOperation::Replace, StencilOperation::IncrementClamp, + StencilOperation::DecrementWrap); + ctx.SetStencilMask(StencilFace::Back, 0x0fu); + ctx.SetColorMaskIndexed(6, BoolVec4(false, false, true, true)); + ctx.SetClearColor(FloatVec4(0.1f, 0.2f, 0.3f, 0.4f)); + ctx.SetClearDepth(0.75f); + ctx.SetClearStencil(9); + ctx.SetBlendColor(FloatVec4(0.5f, 0.6f, 0.7f, 0.8f)); + ctx.SetDepthRangeIndexed(9, FloatVec2(0.1f, 0.9f)); + ctx.SetMinSampleShadingValue(0.625f); + ctx.SetCullFaceMode(CullFaceMode::Front); + ctx.SetProvokingVertexMode(ProvokingVertexMode::FirstVertex); + ctx.SetScissorBox(IntVec4(1, 2, 3, 4)); + // One capability out of every arm of the 35-way switch: plain bools (the three D3 + // gave storage to among them), the two indexed ones through their non-indexed entry + // point, and a clip distance. + ctx.SetCapability(CapabilityInput::DepthTest, true); + ctx.SetCapability(CapabilityInput::FramebufferSrgb, true); + ctx.SetCapability(CapabilityInput::DepthClamp, true); + ctx.SetCapability(CapabilityInput::TextureCubeMapSeamless, true); + ctx.SetCapability(CapabilityInput::Blend, true); + ctx.SetCapability(CapabilityInput::ClipDistance3, true); + + // The vacuity guard: the block still holds what the fill copied out of the DEFAULT + // context, so it must currently DISAGREE with the live one. If this ever passes, the + // comparisons below would be checking the filler against itself. + ASSERT_NE(gPipeInputs.GetLineWidth(), ctx.GetLineWidth()); + + applyWholeBlock(); + + // ---- the 25 kDraw fields ---- + EXPECT_EQ(gPipeInputs.GetBlendColor(), ctx.GetBlendColor()); + for (Uint i = 0; i < kMGMaxDrawBuffers; ++i) { + BlendEquation gotColor{}, gotAlpha{}, wantColor{}, wantAlpha{}; + gPipeInputs.GetBlendEquationIndexed(i, gotColor, gotAlpha); + ctx.GetBlendEquationIndexed(i, wantColor, wantAlpha); + EXPECT_EQ(gotColor, wantColor) << "blend equation " << i; + EXPECT_EQ(gotAlpha, wantAlpha) << "blend equation " << i; + + BlendFactor gotSrcRGB{}, gotDstRGB{}, gotSrcA{}, gotDstA{}; + BlendFactor wantSrcRGB{}, wantDstRGB{}, wantSrcA{}, wantDstA{}; + gPipeInputs.GetBlendFuncIndexed(i, gotSrcRGB, gotDstRGB, gotSrcA, gotDstA); + ctx.GetBlendFuncIndexed(i, wantSrcRGB, wantDstRGB, wantSrcA, wantDstA); + EXPECT_EQ(gotSrcRGB, wantSrcRGB) << "blend func " << i; + EXPECT_EQ(gotDstRGB, wantDstRGB) << "blend func " << i; + EXPECT_EQ(gotSrcA, wantSrcA) << "blend func " << i; + EXPECT_EQ(gotDstA, wantDstA) << "blend func " << i; + + EXPECT_EQ(gPipeInputs.GetColorMaskIndexed(i), ctx.GetColorMaskIndexed(i)) << "colour mask " << i; + EXPECT_EQ(gPipeInputs.IsCapabilityEnabledIndexed(CapabilityInput::Blend, i), + ctx.IsCapabilityEnabledIndexed(CapabilityInput::Blend, i)) + << "indexed blend enable " << i; + } + EXPECT_EQ(gPipeInputs.GetCullFaceMode(), ctx.GetCullFaceMode()); + EXPECT_EQ(gPipeInputs.GetDepthFunc(), ctx.GetDepthFunc()); + EXPECT_EQ(gPipeInputs.GetDepthMask(), ctx.GetDepthMask()); + for (Uint i = 0; i < RenderStateParameters::MAX_VIEWPORTS; ++i) { + EXPECT_EQ(gPipeInputs.GetDepthRangeIndexed(i), ctx.GetDepthRangeIndexed(i)) << "depth range " << i; + EXPECT_EQ(gPipeInputs.GetViewportIndexed(i), ctx.GetViewportIndexed(i)) << "viewport " << i; + EXPECT_EQ(gPipeInputs.IsCapabilityEnabledIndexed(CapabilityInput::ScissorTest, i), + ctx.IsCapabilityEnabledIndexed(CapabilityInput::ScissorTest, i)) + << "indexed scissor enable " << i; + } + EXPECT_EQ(gPipeInputs.GetLineWidth(), ctx.GetLineWidth()); + EXPECT_EQ(gPipeInputs.GetLogicOp(), ctx.GetLogicOp()); + EXPECT_EQ(gPipeInputs.GetMinSampleShadingValue(), ctx.GetMinSampleShadingValue()); + EXPECT_EQ(gPipeInputs.GetPatchDefaultInnerLevel(), ctx.GetPatchDefaultInnerLevel()); + EXPECT_EQ(gPipeInputs.GetPatchDefaultOuterLevel(), ctx.GetPatchDefaultOuterLevel()); + EXPECT_EQ(gPipeInputs.GetPatchVertices(), ctx.GetPatchVertices()); + EXPECT_EQ(gPipeInputs.GetPolygonModeFront(), ctx.GetPolygonModeFront()); + EXPECT_EQ(gPipeInputs.GetPolygonOffsetFactor(), ctx.GetPolygonOffsetFactor()); + EXPECT_EQ(gPipeInputs.GetPolygonOffsetUnits(), ctx.GetPolygonOffsetUnits()); + EXPECT_EQ(gPipeInputs.GetPrimitiveRestartIndex(), ctx.GetPrimitiveRestartIndex()); + EXPECT_EQ(gPipeInputs.GetProvokingVertexMode(), ctx.GetProvokingVertexMode()); + EXPECT_EQ(gPipeInputs.GetScissorBox(), ctx.GetScissorBox()); + for (const StencilFace face : {StencilFace::Front, StencilFace::Back}) { + const StencilFaceState& got = gPipeInputs.GetStencilState(face); + const StencilFaceState& want = ctx.GetStencilState(face); + EXPECT_EQ(std::memcmp(&got, &want, sizeof(StencilFaceState)), 0) + << "stencil face " << static_cast(face); + } + // The rounding half of GetViewport, exercised on purpose: viewport 0 is + // (1.5, 2.5, 63.5, 32.25), so a transcription that truncated instead of rounding + // would hand the backends a 63-wide rectangle where 64 was asked for. The literal + // is std::lround's answer - round half AWAY FROM ZERO, so 1.5 -> 2 and 2.5 -> 3, + // not the banker's rounding a nearbyint() transcription would give. + EXPECT_EQ(gPipeInputs.GetViewport(), ctx.GetViewport()); + EXPECT_EQ(gPipeInputs.GetViewport(), IntVec4(2, 3, 64, 32)); + for (SizeT i = 0; i < static_cast(CapabilityInput::CapabilityInputCount); ++i) { + const CapabilityInput cap = static_cast(i); + EXPECT_EQ(gPipeInputs.IsCapabilityEnabled(cap), ctx.IsCapabilityEnabled(cap)) << "capability " << i; + } + } // phase 1, kDraw + + // Phase 2, kClear: the three clear values. The verb's own fill runs FIRST and copies + // what phase 1 left in the context, so the values are changed AGAIN afterwards - the + // block therefore disagrees before the apply and can only be made to agree by it. + { + MG_Test::ScopedPipeVerb verb(MGPipeVerb::Clear); + ctx.SetClearColor(FloatVec4(0.9f, 0.8f, 0.7f, 0.6f)); + ctx.SetClearDepth(0.125f); + ctx.SetClearStencil(21); + ASSERT_NE(gPipeInputs.GetClearDepth(), ctx.GetClearDepth()); + + applyWholeBlock(); + + EXPECT_EQ(gPipeInputs.GetClearColor(), ctx.GetClearColor()); + EXPECT_EQ(gPipeInputs.GetClearDepth(), ctx.GetClearDepth()); + EXPECT_EQ(gPipeInputs.GetClearStencil(), ctx.GetClearStencil()); + } + + // Phase 3, kReadback: GetClampReadColor, the one derived field no draw and no clear + // may read at all (FillPoints.def gives it to kReadback alone). Same shape. + { + MG_Test::ScopedPipeVerb verb(MGPipeVerb::ReadPixels); + ctx.SetClampReadColor(GL_FALSE); + ASSERT_NE(gPipeInputs.GetClampReadColor(), ctx.GetClampReadColor()); + + applyWholeBlock(); + + EXPECT_EQ(gPipeInputs.GetClampReadColor(), ctx.GetClampReadColor()); + EXPECT_EQ(gPipeInputs.GetClampReadColor(), static_cast(GL_FALSE)); + } +#endif + } + + // ------------------------------------------------------------------------------------- + // 4. Magma's DynamicTailKey against the dynamic half. + // ------------------------------------------------------------------------------------- + TEST(RenderStateSpans, DynamicChunksCoverMagmasDynamicTailKey) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +#else + // The complete GL-state input inventory of ApplyDynamicDrawStateTail, transcribed + // from the comment above `struct DynamicTailKey` + // (MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp). extentX/extentY/ + // preTransform/isDefaultFbo are backend facts, not GL state, and are not here. + struct Input { + const char* Name; + SizeT Offset; + SizeT Size; + }; + const SizeT stencil0 = offsetof(RenderStateParameters, StencilStates); + const SizeT stencil1 = stencil0 + sizeof(StencilFaceState); + const Input inputs[] = { + {"Viewports[0]", offsetof(RenderStateParameters, Viewports), sizeof(FloatVec4)}, + {"DepthRanges[0]", offsetof(RenderStateParameters, DepthRanges), sizeof(FloatVec2)}, + {"BlendColor", offsetof(RenderStateParameters, BlendColor), sizeof(FloatVec4)}, + {"PolygonOffsetFactor", offsetof(RenderStateParameters, PolygonOffsetFactor), sizeof(Float)}, + {"PolygonOffsetUnits", offsetof(RenderStateParameters, PolygonOffsetUnits), sizeof(Float)}, + {"LineWidth", offsetof(RenderStateParameters, LineWidth), sizeof(Float)}, + {"StencilStates[0].Ref", stencil0 + offsetof(StencilFaceState, Ref), sizeof(Int)}, + {"StencilStates[0].ValueMask", stencil0 + offsetof(StencilFaceState, ValueMask), sizeof(Uint32)}, + {"StencilStates[0].WriteMask", stencil0 + offsetof(StencilFaceState, WriteMask), sizeof(Uint32)}, + {"StencilStates[1].Ref", stencil1 + offsetof(StencilFaceState, Ref), sizeof(Int)}, + {"StencilStates[1].ValueMask", stencil1 + offsetof(StencilFaceState, ValueMask), sizeof(Uint32)}, + {"StencilStates[1].WriteMask", stencil1 + offsetof(StencilFaceState, WriteMask), sizeof(Uint32)}, + {"ScissorBoxes[0]", offsetof(RenderStateParameters, ScissorBoxes), sizeof(IntVec4)}, + }; + for (const Input& input : inputs) { + EXPECT_TRUE(IsWhollyDynamic(input.Offset, input.Size)) + << input.Name << " is read by DynamicTailKey but is not inside kMGPipeDynamicChunks"; + } + + // THE ONE EXCEPTION, and it is a fact about the tree rather than an oversight in it. + // DynamicTailKey's `scissorEnabled` reads ScissorTestEnabledMask bit 0, and that + // member is PIPELINE state under P2's rule, because SetCapability(ScissorTest) and + // SetCapabilityIndexed(ScissorTest, i) both call BumpVersions(). It is harmless: + // BumpVersions() moves m_version too, so MGPDynamicState::Version - the value + // ApplyDynamicDrawStateTail's own gate reads - still moves on a scissor-enable + // change and the tail still re-runs. Asserted the other way round, so a later table + // edit that quietly demotes the mask is loud here rather than silent. + EXPECT_FALSE(IsWhollyDynamic(offsetof(RenderStateParameters, ScissorTestEnabledMask), + sizeof(RenderStateParameters::ScissorTestEnabledMask))) + << "ScissorTestEnabledMask moved into the dynamic half; ApplyDynamicDrawStateTail's " + "scissorEnabled input and this expectation both need re-reading"; + EXPECT_TRUE(IsWhollyPipeline(offsetof(RenderStateParameters, ScissorTestEnabledMask), + sizeof(RenderStateParameters::ScissorTestEnabledMask))); +#endif } } // namespace From 02b970e9c1b60d53775d578ecca8c222842b1ead Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:16:53 -0400 Subject: [PATCH 077/529] [Test] (Pipe): pin the slot allocator's identity contract - gen moves only on reuse and a recycled address never reproduces a handle - Everything Track H keys off is only as sound as these statements, so each case is the answer to a bug the {slot, gen} pair exists to close rather than a coverage exercise. - GenMovesOnlyOnSlotReuse: the first handout of a slot is generation 0; nothing in the interface can move a live handle's generation, which is "never on a respecify" stated as an absence; the bump lands on the NEXT handout rather than on the free, so a double free cannot skip a generation; and the stale handle then fails IsLive and cannot free the slot its successor owns. Kinds are independent slot spaces. - FreedSlotComesBackBeforeHighWaterGrows: two freed slots are both handed back before a ninth is minted. Density is not a nicety - it is what lets the server's object table be an array indexed by slot rather than a hash map. - SlotZeroIsNeverHandedOut, over every kind and across a free/allocate churn: {0, 0} is null for every kind and {0, 1} is the default framebuffer, so neither may be minted. - LifetimeIdSurvivesARecycledAddress drives 64 construct/destroy rounds of a real VertexArrayObject through a volatile address sink (ObjectLifetimeIdTest's trick, so the new/delete pairs are not elided) and asserts that when the heap hands the same address back, the handle is still a different one. It also asserts Acquire is an identity - the same live object always answers the same handle - and that a freed lifetime id stops resolving. When the allocator refuses to repeat an address the case SKIPS with "inconclusive, not proven" rather than passing for the wrong reason. - CompositeShaderBandIsNeverHandedOut walks the ShaderCso space to the composite base and asserts the last ordinary slot is base - 1 and that the next call REFUSES rather than stepping in. It skips in a DEBUG build, where reaching the edge trips the allocator's own exhaustion assert on purpose; the INFO builds the gates run are where it is checked. - Replaces the contract commit's placeholder, whose one live claim survives as ReservedHandlesAreWhatMGPipeHandlesSaysTheyAre - the only case that is not push-only. --- MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp | 250 +++++++++++++++++++- 1 file changed, 241 insertions(+), 9 deletions(-) diff --git a/MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp b/MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp index af31673e5..3fb2e9a31 100644 --- a/MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp +++ b/MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp @@ -6,29 +6,261 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header -// The client slot allocator's identity contract: gen moves only on reuse, and a recycled address never reproduces a handle (P2 brief C.0 c3). +// The client slot allocator's IDENTITY CONTRACT (P2 brief C.0 c3). Everything Track H keys +// off - Espryt's six slot tables and Magma's VaoDrawMemo - is only as sound as these five +// statements, and each of them is the answer to a bug the {slot, gen} pair exists to close: // -// STUB, and deliberately one. It is created by the P2 CONTRACT commit together with its -// CMakeLists.txt registration, so that the package which owns its CONTENTS -// (P2 package A, commit c3 on p2/spans) never has to touch MG_Test/Pipe/CMakeLists.txt - no two P2 -// packages edit the same file, which is what keeps the integrator's rebases clean. +// GenMovesOnlyOnSlotReuse - a respecify must NOT move the generation, or every +// glBufferData would invalidate every memo; a REUSE +// must, or a stale handle would address its successor. +// FreedSlotComesBackBeforeHighWater - slots stay DENSE, which is what lets the server's +// object table be an array rather than a hash map. +// SlotZeroIsNeverHandedOut - {0, 0} is the null handle for every kind and {0, 1} +// is the default framebuffer. +// LifetimeIdSurvivesARecycledAddress - the frontend key is the lifetime id, never a heap +// address and never a GL name, so an ABA on either +// cannot reproduce a handle. This is the ABA +// HandleRecycleScenario reproduces end to end. +// CompositeShaderBandIsNeverHandedOut- the top 1/16 of the ShaderCso slot space belongs to +// the program-pipeline composite resolver. // -// The placeholder case is not decoration: without it the binary has no test, and -// gtest_discover_tests on a binary with no test is a silently green lane. +// Needs the push sources (SlotAllocator.cpp is compiled only under MOBILEGL_PIPE_PUSH), so +// every case is a visible SKIP in a pull build rather than a vanishing test, and the five +// names are the same five in every build. #include +#include +#include +#include + #include "Includes.h" #include +#if MOBILEGL_PIPE_PUSH +#include +#include +#endif + using namespace MobileGL; using namespace MobileGL::MG_Pipe; namespace { // Slot 0 is reserved for every kind - null, and the default framebuffer for kind - // Framebuffer - so the first allocatable slot is 1 in every build. - TEST(SlotAllocator, PlaceholderUntilTheOwningPackageFillsThisIn) { + // Framebuffer - so the first allocatable slot is 1 in every build, pull included. + TEST(SlotAllocator, ReservedHandlesAreWhatMGPipeHandlesSaysTheyAre) { EXPECT_EQ(kMGPipeFirstAllocatableSlot, 1u); EXPECT_TRUE(MGPipeHandleIsNull(kMGPipeNullHandle)); EXPECT_FALSE(MGPipeHandleIsNull(kMGPipeDefaultFramebuffer)); + EXPECT_EQ(kMGPipeDefaultFramebuffer.Slot, 0u); + } + + TEST(SlotAllocator, GenMovesOnlyOnSlotReuse) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +#else + MGPipeSlotAllocator allocator; + + const MGPipeHandle first = allocator.Allocate(MGPipeKind::Buffer); + const MGPipeHandle second = allocator.Allocate(MGPipeKind::Buffer); + EXPECT_EQ(first.Gen, 0u) << "a slot's FIRST handout is generation 0"; + EXPECT_EQ(second.Gen, 0u); + EXPECT_NE(first.Slot, second.Slot); + EXPECT_TRUE(allocator.IsLive(MGPipeKind::Buffer, first)); + + // A respecify is not an event here at all: nothing in the allocator's interface can + // move a live handle's generation, which is the contract "gen increments only when a + // slot is REUSED, never on a respecify" stated as an absence. + EXPECT_EQ(allocator.GenOfSlot(MGPipeKind::Buffer, first.Slot), first.Gen); + + allocator.Free(MGPipeKind::Buffer, first); + EXPECT_FALSE(allocator.IsLive(MGPipeKind::Buffer, first)); + // The bump happens on the NEXT handout, not on the free, so a slot that is freed and + // never reused keeps its generation - and a double free cannot skip one. + EXPECT_EQ(allocator.GenOfSlot(MGPipeKind::Buffer, first.Slot), first.Gen); + allocator.Free(MGPipeKind::Buffer, first); + EXPECT_EQ(allocator.GenOfSlot(MGPipeKind::Buffer, first.Slot), first.Gen); + + const MGPipeHandle reused = allocator.Allocate(MGPipeKind::Buffer); + EXPECT_EQ(reused.Slot, first.Slot) << "the free list did not hand the slot back"; + EXPECT_NE(reused.Gen, first.Gen) << "a REUSED slot must carry a new generation"; + EXPECT_EQ(reused.Gen, first.Gen + 1); + + // THE POINT OF THE GENERATION: the dead handle is not the live one, it does not + // validate, and it cannot free the slot its successor now owns. + EXPECT_FALSE(first == reused); + EXPECT_FALSE(allocator.IsLive(MGPipeKind::Buffer, first)); + EXPECT_TRUE(allocator.IsLive(MGPipeKind::Buffer, reused)); + allocator.Free(MGPipeKind::Buffer, first); + EXPECT_TRUE(allocator.IsLive(MGPipeKind::Buffer, reused)) << "a stale handle freed a live slot"; + + // Kinds are independent slot spaces: a Buffer slot 1 and a Texture slot 1 are + // different objects, and freeing one must not touch the other. + const MGPipeHandle texture = allocator.Allocate(MGPipeKind::Texture); + EXPECT_EQ(texture.Slot, kMGPipeFirstAllocatableSlot); + EXPECT_EQ(texture.Gen, 0u); + EXPECT_TRUE(allocator.IsLive(MGPipeKind::Texture, texture)); + EXPECT_TRUE(allocator.IsLive(MGPipeKind::Buffer, reused)); +#endif + } + + TEST(SlotAllocator, FreedSlotComesBackBeforeHighWaterGrows) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +#else + MGPipeSlotAllocator allocator; + + MGPipeHandle handles[8]; + for (MGPipeHandle& handle : handles) handle = allocator.Allocate(MGPipeKind::Framebuffer); + const Uint32 highWater = allocator.HighWater(MGPipeKind::Framebuffer); + EXPECT_EQ(allocator.LiveCount(MGPipeKind::Framebuffer), 8u); + EXPECT_EQ(allocator.FreeCount(MGPipeKind::Framebuffer), 0u); + + allocator.Free(MGPipeKind::Framebuffer, handles[2]); + allocator.Free(MGPipeKind::Framebuffer, handles[5]); + EXPECT_EQ(allocator.LiveCount(MGPipeKind::Framebuffer), 6u); + EXPECT_EQ(allocator.FreeCount(MGPipeKind::Framebuffer), 2u); + + // Density is the whole reason the server's object table can be an array: the two + // freed slots have to come back before a ninth is minted. + const MGPipeHandle a = allocator.Allocate(MGPipeKind::Framebuffer); + const MGPipeHandle b = allocator.Allocate(MGPipeKind::Framebuffer); + EXPECT_EQ(allocator.HighWater(MGPipeKind::Framebuffer), highWater) << "the high-water mark grew with two " + "slots waiting on the free list"; + EXPECT_TRUE((a.Slot == handles[2].Slot && b.Slot == handles[5].Slot) || + (a.Slot == handles[5].Slot && b.Slot == handles[2].Slot)) + << "the reused slots are not the two that were freed"; + + // Only now does the mark move. + const MGPipeHandle fresh = allocator.Allocate(MGPipeKind::Framebuffer); + EXPECT_GT(allocator.HighWater(MGPipeKind::Framebuffer), highWater); + EXPECT_EQ(fresh.Gen, 0u) << "a slot handed out for the FIRST time is generation 0"; + + allocator.Reset(); + EXPECT_EQ(allocator.HighWater(MGPipeKind::Framebuffer), 0u); + EXPECT_EQ(allocator.LiveCount(MGPipeKind::Framebuffer), 0u); + EXPECT_EQ(allocator.FreeCount(MGPipeKind::Framebuffer), 0u); +#endif + } + + TEST(SlotAllocator, SlotZeroIsNeverHandedOut) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +#else + MGPipeSlotAllocator allocator; + for (SizeT kindIndex = 1; kindIndex < MGPipeSlotAllocator::kKindCount; ++kindIndex) { + const MGPipeKind kind = static_cast(kindIndex); + for (int i = 0; i < 4; ++i) { + const MGPipeHandle handle = allocator.Allocate(kind); + EXPECT_GE(handle.Slot, kMGPipeFirstAllocatableSlot) + << "kind " << kindIndex << " handed out the reserved slot"; + EXPECT_FALSE(MGPipeHandleIsNull(handle)); + // {0, 1} is the default framebuffer and must never be minted either. + EXPECT_FALSE(handle == kMGPipeDefaultFramebuffer); + allocator.Free(kind, handle); + } + } + // Freeing a slot never puts 0 on the free list, so a churned kind still starts at 1. + const MGPipeHandle again = allocator.Allocate(MGPipeKind::Buffer); + EXPECT_EQ(again.Slot, kMGPipeFirstAllocatableSlot); +#endif + } + + TEST(SlotAllocator, LifetimeIdSurvivesARecycledAddress) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +#else + using MG_State::GLState::VertexArrayObject; + + // The allocation must actually happen: C++ permits eliding a new/delete pair, and an + // elided one would let two objects share an address for reasons that have nothing to + // do with the allocator. Publishing every pointer through a volatile sink keeps the + // pairs (MG_Test/State/ObjectLifetimeIdTest.cpp's trick, and the same one + // HandleRecycleScenario uses to reproduce the ABA through public GL). + static void* volatile addressSink = nullptr; + + MGPipeSlotAllocator allocator; + std::unordered_map handleAtAddress; + int reuseCount = 0; + + for (int attempt = 0; attempt < 64; ++attempt) { + auto object = std::make_unique(0u); + addressSink = object.get(); + const auto address = reinterpret_cast(object.get()); + const Uint64 lifetimeId = object->GetLifetimeId(); + + // Acquire is the ordinary client path: find by lifetime id, allocate on a miss. + const MGPipeHandle handle = allocator.Acquire(MGPipeKind::VertexElementsCso, lifetimeId); + EXPECT_FALSE(MGPipeHandleIsNull(handle)); + // Asking again with the same live object must answer the SAME handle - that is + // what makes the map an identity rather than a counter. + EXPECT_TRUE(allocator.Acquire(MGPipeKind::VertexElementsCso, lifetimeId) == handle); + EXPECT_EQ(allocator.LifetimeIdOfSlot(MGPipeKind::VertexElementsCso, handle.Slot), lifetimeId); + + const auto previous = handleAtAddress.find(address); + if (previous != handleAtAddress.end()) { + ++reuseCount; + // THE ABA. The heap handed the same address back, and the handle must still + // be a different one - either a different slot, or the same slot with a new + // generation. If this ever held, an address-keyed memo would serve the dead + // object's entry to the live one, which is the bug Track H removes. + EXPECT_FALSE(previous->second == handle) + << "a recycled heap address reproduced handle {slot=" << handle.Slot << ", gen=" << handle.Gen + << "}"; + } + handleAtAddress[address] = handle; + + // The object dies; the client's death notification frees the slot. + allocator.Free(MGPipeKind::VertexElementsCso, handle); + EXPECT_FALSE(allocator.IsLive(MGPipeKind::VertexElementsCso, handle)); + // And the lifetime id stops resolving, so a late lookup cannot resurrect it. + EXPECT_TRUE(MGPipeHandleIsNull(allocator.FindByLifetimeId(MGPipeKind::VertexElementsCso, lifetimeId))); + } + + if (reuseCount == 0) { + GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across " + "64 construct/destroy rounds, so the recycled-address case was never exercised"; + } + RecordProperty("address_reuses_observed", reuseCount); +#endif + } + + TEST(SlotAllocator, CompositeShaderBandIsNeverHandedOut) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +#elif MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG + // Exhausting the ShaderCso slot space below the composite band is what proves the + // band is held back, and reaching the band's edge trips the allocator's + // "slot space is exhausted" MOBILEGL_ASSERT - which is live, and correctly so, in a + // DEBUG build. The claim is checked in the INFO builds the gates run. + GTEST_SKIP() << "asserts are live in a DEBUG build and the exhaustion arm trips one on purpose"; +#else + MGPipeSlotAllocator allocator; + // Ordinary programs walk the low slots and never enter the band. + for (int i = 0; i < 8; ++i) { + const MGPipeHandle handle = allocator.Allocate(MGPipeKind::ShaderCso); + EXPECT_FALSE(MGPipeIsCompositeShaderSlot(handle.Slot)); + } + + // Walk the whole space up to the band. The last handout below the base must be the + // slot immediately under it, and the next call must refuse rather than step in - a + // composite handle minted by the ordinary allocator would collide with one the + // program-pipeline resolver mints for a different object entirely. + MGPipeHandle last = kMGPipeNullHandle; + while (allocator.HighWater(MGPipeKind::ShaderCso) < kMGPipeShaderCsoCompositeSlotBase) { + last = allocator.Allocate(MGPipeKind::ShaderCso); + ASSERT_FALSE(MGPipeIsCompositeShaderSlot(last.Slot)) + << "the ordinary allocator entered the composite band at slot " << last.Slot; + } + EXPECT_EQ(last.Slot, kMGPipeShaderCsoCompositeSlotBase - 1); + EXPECT_TRUE(MGPipeHandleIsNull(allocator.Allocate(MGPipeKind::ShaderCso))) + << "the allocator handed out a composite-band slot instead of refusing"; + + // Every other kind is unaffected: the band is a ShaderCso rule, not a global one. + MGPipeSlotAllocator plain; + for (Uint32 i = 0; i < 4; ++i) { + const MGPipeHandle handle = plain.Allocate(MGPipeKind::Buffer); + EXPECT_EQ(handle.Slot, kMGPipeFirstAllocatableSlot + i); + } +#endif } } // namespace From a9bb99a46a4b4b3969cb6ba17c2d3ad1680ef1b6 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 09:00:04 -0400 Subject: [PATCH 078/529] [Fix] (Pipe): scope the derivation to the chunks a scatter moved, and give the patch trio and the residual block trip wires that do not depend on call order - MGPipeDeriveRenderStateFieldsForChunks: the applier no longer recomputes all 29 fields on every scatter. The four wide walks - the 8-wide blend/colour-mask loop, the 16-wide viewport loop, the 16-wide depth-range loop and the 35-arm capability switch - are guarded by the chunks whose bytes they read, so a per-frame glViewport (the D8 case that sends dynamic chunk D0 alone) pays for one 16-entry copy instead of ~170 stores and 35 switch dispatches. That cost sat on the per-draw path and the gate it threatened is G11's pinned ns/draw. - Every guard is MGPipeRenderStateChunkBitsCovering(offsetof(member), sizeof(member)) over the members the guarded block reads, computed from the boundary table: there is no second, hand-maintained member-to-chunk mapping to go stale when a boundary moves. The scalar copies stay unguarded on purpose - they cannot go stale, which keeps the risk of the scoping confined to four guards. - MGP_PLAIN_CAPABILITY_LIST is written once and used twice, for DeriveCapability's switch arms and for the capability guard's chunk set, so the two cannot drift. - set_patch_state now asserts under poison/verify that the trio agrees with what pipeline chunk P0 delivered, which D6 and D10 both ask for and which was missing: a stale set_patch_state silently clobbered the CSO-delivered levels. Compared bitwise, because a NaN outer level is legal and must equal itself; armed only once a CSO has been bound, which states the ordering contract rather than assuming it. - set_residual_value_state's trip wire compares the carried bits against the WORKING BLOCK instead of PipeInputs::m_capability. Only the derivation writes m_capability, so a residual block emitted before the first bind of a context - what "once per context" means - compared against all-false storage while Dither and Multisample default to true, and aborted under poison. The check is unchanged in strength and now has no ordering contract at all. - PipeApply.h no longer implies the verify comparator is this package's oracle: it arms off MG_Config::Features.PipeVerify, which a unit-test process never sets, so the unit oracle is named for what it is and the comparator is credited to the retrace and integration-verify lanes. --- MobileGL/MG_Pipe/MGPipeRenderStateSpans.h | 57 +++++ MobileGL/MG_Pipe/PipeApply.cpp | 259 ++++++++++++++++------ MobileGL/MG_Pipe/PipeApply.h | 24 +- 3 files changed, 270 insertions(+), 70 deletions(-) mode change 100755 => 100644 MobileGL/MG_Pipe/MGPipeRenderStateSpans.h mode change 100755 => 100644 MobileGL/MG_Pipe/PipeApply.cpp mode change 100755 => 100644 MobileGL/MG_Pipe/PipeApply.h diff --git a/MobileGL/MG_Pipe/MGPipeRenderStateSpans.h b/MobileGL/MG_Pipe/MGPipeRenderStateSpans.h old mode 100755 new mode 100644 index ec1aed09f..1922d52b6 --- a/MobileGL/MG_Pipe/MGPipeRenderStateSpans.h +++ b/MobileGL/MG_Pipe/MGPipeRenderStateSpans.h @@ -173,6 +173,63 @@ namespace MobileGL::MG_Pipe { static_assert(kMGPipePipelineChunkBytes == 396, "the pipeline subset is 396 bytes"); static_assert(kMGPipeDynamicChunkBytes == 772, "the dynamic subset is 772 bytes"); + // ---- global chunk bits, so nothing downstream hand-maintains a second table ---- + + // The GLOBAL chunk indices (bit i is chunk i of the 15) whose byte range overlaps + // [offset, offset + size). It falls straight out of the boundary table, which is the + // whole point: the applier scopes its derivation by the chunks a scatter actually moved + // (D5/D8), and a hand-written member -> chunk mapping is exactly the second table that + // would go stale the first time a boundary moves. + constexpr Uint32 MGPipeRenderStateChunkBitsCovering(SizeT offset, SizeT size) { + Uint32 bits = 0; + for (SizeT i = 0; i < kMGPipeRenderStateChunkCount; ++i) { + const SizeT begin = kMGPipeRenderStateChunkBoundaries[i]; + const SizeT end = kMGPipeRenderStateChunkBoundaries[i + 1]; + if (offset < end && begin < offset + size) bits |= Uint32{1} << i; + } + return bits; + } + + // The wire masks are HALF-LOCAL (bit i of MGPRenderStateDesc::ChunkMask is pipeline chunk + // i); these widen them to the global indices the boundary table is written in. The + // halves alternate with chunk 0 dynamic, so the two conversions are arithmetic. + constexpr Uint32 MGPipeGlobalChunkBitsOfPipelineMask(Uint32 pipelineMask) { + Uint32 bits = 0; + for (SizeT i = 0; i < kMGPipePipelineChunkCount; ++i) { + if (((pipelineMask >> i) & 1u) != 0) bits |= Uint32{1} << (i * 2 + 1); + } + return bits; + } + constexpr Uint32 MGPipeGlobalChunkBitsOfDynamicMask(Uint32 dynamicMask) { + Uint32 bits = 0; + for (SizeT i = 0; i < kMGPipeDynamicChunkCount; ++i) { + if (((dynamicMask >> i) & 1u) != 0) bits |= Uint32{1} << (i * 2); + } + return bits; + } + inline constexpr Uint32 kMGPipeAllGlobalChunks = + static_cast((Uint64{1} << kMGPipeRenderStateChunkCount) - 1); + + // The two conversions must agree with MGPipeRenderStateChunkIsPipeline, and together they + // must cover the table exactly - a widening that dropped or doubled a chunk would make + // the applier's scoping silently wrong rather than loud. + namespace MGPipeRenderStateChunkDetail { + inline constexpr Uint32 kAllPipelineHalfBits = + static_cast((Uint64{1} << kMGPipePipelineChunkCount) - 1); + inline constexpr Uint32 kAllDynamicHalfBits = + static_cast((Uint64{1} << kMGPipeDynamicChunkCount) - 1); + inline constexpr Uint32 kWidenedPipeline = MGPipeGlobalChunkBitsOfPipelineMask(kAllPipelineHalfBits); + inline constexpr Uint32 kWidenedDynamic = MGPipeGlobalChunkBitsOfDynamicMask(kAllDynamicHalfBits); + } // namespace MGPipeRenderStateChunkDetail + static_assert((MGPipeRenderStateChunkDetail::kWidenedPipeline & + MGPipeRenderStateChunkDetail::kWidenedDynamic) == 0, + "the two half-local -> global widenings must not overlap"); + static_assert((MGPipeRenderStateChunkDetail::kWidenedPipeline | + MGPipeRenderStateChunkDetail::kWidenedDynamic) == kMGPipeAllGlobalChunks, + "the two half-local -> global widenings must cover the whole chunk table"); + static_assert(MGPipeRenderStateChunkBitsCovering(0, sizeof(RenderStateParameters)) == kMGPipeAllGlobalChunks, + "every chunk must be covered by the whole block"); + // ---- the operations everything else is written against ---- // The 396 pipeline bytes of `params`, in ascending chunk order, into `dst`. diff --git a/MobileGL/MG_Pipe/PipeApply.cpp b/MobileGL/MG_Pipe/PipeApply.cpp old mode 100755 new mode 100644 index c115c1618..af8dad6e4 --- a/MobileGL/MG_Pipe/PipeApply.cpp +++ b/MobileGL/MG_Pipe/PipeApply.cpp @@ -20,7 +20,80 @@ #include #include +// The 25 capabilities whose storage is a plain `Enabled` bool. Written ONCE and used +// twice - once for the switch arms of DeriveCapability and once for the chunk set that guards +// the capability walk - so the two cannot drift apart. The three P2 gave storage to +// (DepthClamp, FramebufferSrgb, TextureCubeMapSeamless) are in the list like any other; the +// three that are NOT are Blend (BlendStates[i].Enabled), ScissorTest (a 16-bit mask) and the +// eight ClipDistances (an 8-bit mask), each handled by name below. +#define MGP_PLAIN_CAPABILITY_LIST(X) \ + X(ColorLogicOp) \ + X(DebugOutput) \ + X(DebugOutputSynchronous) \ + X(DepthClamp) \ + X(DepthTest) \ + X(CullFace) \ + X(Dither) \ + X(FramebufferSrgb) \ + X(LineSmooth) \ + X(Multisample) \ + X(PolygonOffsetFill) \ + X(PolygonOffsetLine) \ + X(PolygonOffsetPoint) \ + X(PolygonSmooth) \ + X(PrimitiveRestart) \ + X(PrimitiveRestartFixedIndex) \ + X(RasterizerDiscard) \ + X(SampleAlphaToCoverage) \ + X(SampleAlphaToOne) \ + X(SampleCoverage) \ + X(SampleMask) \ + X(SampleShading) \ + X(StencilTest) \ + X(TextureCubeMapSeamless) \ + X(ProgramPointSize) + namespace MobileGL::MG_Pipe { + namespace { + // ---------------------------------------------------------------------------- + // WHICH CHUNKS EACH DERIVATION READS. + // + // The derivation is called after every scatter, and a scatter usually moves ONE + // chunk: a per-frame glViewport sends dynamic chunk D0 and nothing else (D8). So the + // three wide loops and the 35-arm capability switch are guarded by the chunks whose + // bytes they read, and a scatter that did not touch those bytes does not pay for them. + // + // Nothing here is hand-mapped. Every constant is + // MGPipeRenderStateChunkBitsCovering(offsetof(member), sizeof(member)) over the + // members the guarded block actually reads, so the ONLY claim a reader has to check + // is "does this block read anything else?" - and a boundary move re-computes the + // guards rather than invalidating them. + // ---------------------------------------------------------------------------- + using RSP = RenderStateParameters; +#define MGP_CHUNKS_OF(member) MGPipeRenderStateChunkBitsCovering(offsetof(RSP, member), sizeof(RSP::member)) + + // The per-draw-buffer loop reads BlendStates (equations, factors, Enabled) and + // ColorMasks, and nothing else. + constexpr Uint32 kChunksBlendLoop = MGP_CHUNKS_OF(BlendStates) | MGP_CHUNKS_OF(ColorMasks); + // m_viewportIndexed[16] and the rounded m_viewport both read Viewports, and nothing else. + constexpr Uint32 kChunksViewportLoop = MGP_CHUNKS_OF(Viewports); + constexpr Uint32 kChunksDepthRangeLoop = MGP_CHUNKS_OF(DepthRanges); + constexpr Uint32 kChunksScissorEnableLoop = MGP_CHUNKS_OF(ScissorTestEnabledMask); + // DeriveCapability's sources: the 25 plain bools, plus the three masks/arrays the + // three special arms read. +#define MGP_CAPABILITY_CHUNKS(capability) | MGP_CHUNKS_OF(capability##Enabled) + constexpr Uint32 kChunksCapabilityWalk = MGP_CHUNKS_OF(BlendStates) | + MGP_CHUNKS_OF(ScissorTestEnabledMask) | + MGP_CHUNKS_OF(ClipDistanceEnabledMask) + MGP_PLAIN_CAPABILITY_LIST(MGP_CAPABILITY_CHUNKS); +#undef MGP_CAPABILITY_CHUNKS + + // The scalar copies are left unguarded on purpose: they are ~20 stores and two + // 28-byte struct copies, so guarding each would cost more branches than it saves + // stores - and an unguarded copy cannot go stale, which keeps the risk of the scoping + // confined to the four guards above. +#undef MGP_CHUNKS_OF + } // namespace // The applier's door into PipeInputs' storage, the write-side twin of PipeFill.cpp's // MGPipeFillAccess. It does NOT stamp the poison generations: a stamp says "the filler @@ -76,31 +149,7 @@ namespace MobileGL::MG_Pipe { case CapabilityInput::capability: \ return p.capability##Enabled; switch (cap) { - MGP_DERIVE_CAPABILITY(ColorLogicOp) - MGP_DERIVE_CAPABILITY(DebugOutput) - MGP_DERIVE_CAPABILITY(DebugOutputSynchronous) - MGP_DERIVE_CAPABILITY(DepthClamp) - MGP_DERIVE_CAPABILITY(DepthTest) - MGP_DERIVE_CAPABILITY(CullFace) - MGP_DERIVE_CAPABILITY(Dither) - MGP_DERIVE_CAPABILITY(FramebufferSrgb) - MGP_DERIVE_CAPABILITY(LineSmooth) - MGP_DERIVE_CAPABILITY(Multisample) - MGP_DERIVE_CAPABILITY(PolygonOffsetFill) - MGP_DERIVE_CAPABILITY(PolygonOffsetLine) - MGP_DERIVE_CAPABILITY(PolygonOffsetPoint) - MGP_DERIVE_CAPABILITY(PolygonSmooth) - MGP_DERIVE_CAPABILITY(PrimitiveRestart) - MGP_DERIVE_CAPABILITY(PrimitiveRestartFixedIndex) - MGP_DERIVE_CAPABILITY(RasterizerDiscard) - MGP_DERIVE_CAPABILITY(SampleAlphaToCoverage) - MGP_DERIVE_CAPABILITY(SampleAlphaToOne) - MGP_DERIVE_CAPABILITY(SampleCoverage) - MGP_DERIVE_CAPABILITY(SampleMask) - MGP_DERIVE_CAPABILITY(SampleShading) - MGP_DERIVE_CAPABILITY(StencilTest) - MGP_DERIVE_CAPABILITY(TextureCubeMapSeamless) - MGP_DERIVE_CAPABILITY(ProgramPointSize) + MGP_PLAIN_CAPABILITY_LIST(MGP_DERIVE_CAPABILITY) // The non-indexed query of an INDEXED capability answers for index 0 // (GL 4.6 core 22.1) - RenderState.cpp says it in the same words. case CapabilityInput::Blend: @@ -126,40 +175,57 @@ namespace MobileGL::MG_Pipe { #undef MGP_DERIVE_CAPABILITY } - static void DeriveRenderStateFields(PipeInputs& inputs) { + // `chunkBits` names the GLOBAL chunks the scatter that called this actually moved; + // kMGPipeAllGlobalChunks is the whole-block form. See the guard constants at the top + // of this file for why the four wide walks are scoped and the scalars are not. + static void DeriveRenderStateFields(PipeInputs& inputs, Uint32 chunkBits) { const RenderStateParameters& p = inputs.m_renderState; // Per draw buffer: GetBlendEquationIndexed, GetBlendFuncIndexed, // GetColorMaskIndexed and IsCapabilityEnabledIndexed(Blend). - for (Uint i = 0; i < kMGMaxDrawBuffers; ++i) { - const PerBufferBlendState& blend = p.BlendStates[i]; - inputs.m_blendEquation[i][0] = blend.ColorEquation; - inputs.m_blendEquation[i][1] = blend.AlphaEquation; - inputs.m_blendFunc[i][0] = blend.SrcFactorRGB; - inputs.m_blendFunc[i][1] = blend.DstFactorRGB; - inputs.m_blendFunc[i][2] = blend.SrcFactorAlpha; - inputs.m_blendFunc[i][3] = blend.DstFactorAlpha; - inputs.m_colorMask[i] = p.ColorMasks[i]; - inputs.m_capabilityIndexed.Blend[i] = blend.Enabled; + if ((chunkBits & kChunksBlendLoop) != 0) { + for (Uint i = 0; i < kMGMaxDrawBuffers; ++i) { + const PerBufferBlendState& blend = p.BlendStates[i]; + inputs.m_blendEquation[i][0] = blend.ColorEquation; + inputs.m_blendEquation[i][1] = blend.AlphaEquation; + inputs.m_blendFunc[i][0] = blend.SrcFactorRGB; + inputs.m_blendFunc[i][1] = blend.DstFactorRGB; + inputs.m_blendFunc[i][2] = blend.SrcFactorAlpha; + inputs.m_blendFunc[i][3] = blend.DstFactorAlpha; + inputs.m_colorMask[i] = p.ColorMasks[i]; + inputs.m_capabilityIndexed.Blend[i] = blend.Enabled; + } } - // Per viewport: GetViewportIndexed, GetDepthRangeIndexed and - // IsCapabilityEnabledIndexed(ScissorTest). - for (Uint i = 0; i < PipeInputs::kMaxViewports; ++i) { - inputs.m_viewportIndexed[i] = p.Viewports[i]; - inputs.m_depthRange[i] = p.DepthRanges[i]; - inputs.m_capabilityIndexed.ScissorTest[i] = (p.ScissorTestEnabledMask & (1u << i)) != 0; + // GetViewportIndexed, and GetViewport: viewport 0 ROUNDED - the other derivation + // that is not a field copy. glGetIntegerv on floating-point state rounds to + // nearest (GL 4.6 core 22.2), and truncating a 63.5-wide viewport would also hand + // the backends a rectangle one pixel short of what was asked for. std::lround, + // exactly as RenderState::GetViewport does it. + if ((chunkBits & kChunksViewportLoop) != 0) { + for (Uint i = 0; i < PipeInputs::kMaxViewports; ++i) { + inputs.m_viewportIndexed[i] = p.Viewports[i]; + } + const FloatVec4& viewport = p.Viewports[0]; + inputs.m_viewport = + IntVec4(static_cast(std::lround(viewport.x())), static_cast(std::lround(viewport.y())), + static_cast(std::lround(viewport.z())), static_cast(std::lround(viewport.w()))); } - // GetViewport: viewport 0 ROUNDED - the other derivation that is not a field - // copy. glGetIntegerv on floating-point state rounds to nearest (GL 4.6 core - // 22.2), and truncating a 63.5-wide viewport would also hand the backends a - // rectangle one pixel short of what was asked for. std::lround, exactly as - // RenderState::GetViewport does it. - const FloatVec4& viewport = p.Viewports[0]; - inputs.m_viewport = - IntVec4(static_cast(std::lround(viewport.x())), static_cast(std::lround(viewport.y())), - static_cast(std::lround(viewport.z())), static_cast(std::lround(viewport.w()))); + // GetDepthRangeIndexed. Its own chunk (D2) - a glClearColor moves that chunk and + // a glViewport does not, so it cannot ride with the viewports. + if ((chunkBits & kChunksDepthRangeLoop) != 0) { + for (Uint i = 0; i < PipeInputs::kMaxViewports; ++i) { + inputs.m_depthRange[i] = p.DepthRanges[i]; + } + } + + // IsCapabilityEnabledIndexed(ScissorTest): 16 bits of one pipeline word. + if ((chunkBits & kChunksScissorEnableLoop) != 0) { + for (Uint i = 0; i < PipeInputs::kMaxViewports; ++i) { + inputs.m_capabilityIndexed.ScissorTest[i] = (p.ScissorTestEnabledMask & (1u << i)) != 0; + } + } // The scalar copies, in the order MGP_COVERAGE_EMITTED_LIST names them. inputs.m_blendColor = p.BlendColor; @@ -192,8 +258,12 @@ namespace MobileGL::MG_Pipe { inputs.m_stencil[face] = p.StencilStates[face]; } - for (SizeT i = 0; i < PipeInputs::kCapabilityCount; ++i) { - inputs.m_capability[i] = DeriveCapability(p, static_cast(i)); + // The 35-arm switch, dispatched 35 times. The widest single thing the derivation + // does, and the one a per-frame glViewport most obviously must not pay for. + if ((chunkBits & kChunksCapabilityWalk) != 0) { + for (SizeT i = 0; i < PipeInputs::kCapabilityCount; ++i) { + inputs.m_capability[i] = DeriveCapability(p, static_cast(i)); + } } } }; @@ -314,7 +384,10 @@ namespace MobileGL::MG_Pipe { MGPipeApplyAccess::RenderState(inputs)); MGPipeApplyAccess::SetRenderStateVersions(inputs, bind.Version, bind.PipelineVersion); g_applier.BoundRenderStateCso = bind.Cso; - MGPipeDeriveRenderStateFields(inputs); + // A bind scatters the WHOLE pipeline half - the record is always a complete one, + // whatever mask minted it - so the pipeline chunks are all "moved" here. + MGPipeDeriveRenderStateFieldsForChunks( + inputs, MGPipeGlobalChunkBitsOfPipelineMask(kAllPipelineChunks)); } void MGPipeApplyDeleteRenderState(const MGPHandleOnly& handle) { @@ -335,7 +408,7 @@ namespace MobileGL::MG_Pipe { PipeInputs& inputs = gPipeInputs; MGPipeScatterDynamicChunks(chunkBytes, dyn.ChunkMask, MGPipeApplyAccess::RenderState(inputs)); MGPipeApplyAccess::SetRenderStateParametersVersion(inputs, dyn.Version); - MGPipeDeriveRenderStateFields(inputs); + MGPipeDeriveRenderStateFieldsForChunks(inputs, MGPipeGlobalChunkBitsOfDynamicMask(dyn.ChunkMask)); } void MGPipeApplySetPixelPackState(const MGPPixelPackState& pack) { @@ -345,10 +418,50 @@ namespace MobileGL::MG_Pipe { void MGPipeApplySetPatchState(const MGPPatchState& patch) { PipeInputs& inputs = gPipeInputs; RenderStateParameters& working = MGPipeApplyAccess::RenderState(inputs); + const FloatVec4 outer(patch.Outer[0], patch.Outer[1], patch.Outer[2], patch.Outer[3]); + const FloatVec2 inner(patch.Inner[0], patch.Inner[1]); + +#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY + // THE SECOND TRIP WIRE (D6, D10). The patch trio travels TWICE - once in pipeline + // chunk P0, because it is pipeline state, and once as set_patch_state, because both + // backends bake it into the synthesized control stage from a shader-build path. The + // redundancy is the point: if the two carriers ever part, a stale set_patch_state + // silently clobbers what bind_render_state scattered and the tessellation levels a + // draw uses stop being the ones its CSO was minted for. + // + // Compared BITWISE, because a NaN outer level is a legal glPatchParameterfv value + // (ARCHITECTURE.md 5.2) and must compare equal to itself. + // + // Only once a CSO has delivered chunk P0: before the first bind_render_state of a + // context the working block still holds its defaults, and a set_patch_state that + // legitimately precedes the first bind has nothing to agree with yet. That is the + // ordering contract this trip wire places on the tracker - within a validate, the + // bind comes first. + if (!MGPipeHandleIsNull(g_applier.BoundRenderStateCso)) { + const Bool agrees = working.PatchVertices == patch.Vertices && + std::memcmp(&working.PatchDefaultOuterLevel, &outer, sizeof(outer)) == 0 && + std::memcmp(&working.PatchDefaultInnerLevel, &inner, sizeof(inner)) == 0; + if (!agrees) { + MGLOG_F("MGPipe: Fatal{PipePatchCarriersDiffer} set_patch_state says vertices=%u " + "outer=(%g,%g,%g,%g) inner=(%g,%g); chunk P0 delivered vertices=%u " + "outer=(%g,%g,%g,%g) inner=(%g,%g)", + patch.Vertices, static_cast(outer.x()), static_cast(outer.y()), + static_cast(outer.z()), static_cast(outer.w()), + static_cast(inner.x()), static_cast(inner.y()), working.PatchVertices, + static_cast(working.PatchDefaultOuterLevel.x()), + static_cast(working.PatchDefaultOuterLevel.y()), + static_cast(working.PatchDefaultOuterLevel.z()), + static_cast(working.PatchDefaultOuterLevel.w()), + static_cast(working.PatchDefaultInnerLevel.x()), + static_cast(working.PatchDefaultInnerLevel.y())); + std::abort(); + } + } +#endif + working.PatchVertices = patch.Vertices; - working.PatchDefaultOuterLevel = - FloatVec4(patch.Outer[0], patch.Outer[1], patch.Outer[2], patch.Outer[3]); - working.PatchDefaultInnerLevel = FloatVec2(patch.Inner[0], patch.Inner[1]); + working.PatchDefaultOuterLevel = outer; + working.PatchDefaultInnerLevel = inner; MGPipeApplyAccess::SetPatchState(inputs, working.PatchVertices, working.PatchDefaultOuterLevel, working.PatchDefaultInnerLevel); } @@ -391,25 +504,41 @@ namespace MobileGL::MG_Pipe { // So the day a later call takes a capability over and forgets to carry it, the two // answers part and this says so on the next draw - which is what a migration carrier // is for. - const Bool* assembled = MGPipeApplyAccess::Capabilities(gPipeInputs); + // + // The comparison reads the WORKING BLOCK, not PipeInputs::m_capability. Those two + // agree after any scatter - the derivation is what puts the block's answer there - + // but m_capability is written ONLY by the derivation, so comparing against it would + // make this trip wire depend on a bind_render_state or a set_dynamic_state having + // already been applied to this context. The residual block is emitted ONCE PER + // CONTEXT (D9) and may legitimately be the first call of all, at which point + // m_capability is still all-false while the block's own defaults have Dither and + // Multisample true - the trip wire would fire on a context that is perfectly correct. + // Asking DeriveCapability the same question the derivation asks removes that ordering + // contract without weakening the check by one bit. + const RenderStateParameters& working = MGPipeApplyAccess::RenderState(gPipeInputs); for (SizeT i = 0; i < kCapabilityCount; ++i) { const Bool carried = ((block.CapabilityBits >> i) & 1ull) != 0; - if (carried == assembled[i]) continue; + const Bool assembledBit = MGPipeApplyAccess::DeriveCapability(working, static_cast(i)); + if (carried == assembledBit) continue; #if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY MGLOG_F("MGPipe: Fatal{PipeResidualDiverged, \"%s\"} carried=%d assembled=%d", - kCapabilityNames[i], static_cast(carried), static_cast(assembled[i])); + kCapabilityNames[i], static_cast(carried), static_cast(assembledBit)); std::abort(); #else MGLOG_E("MGPipe: residual value block diverged on %s (carried=%d assembled=%d)", - kCapabilityNames[i], static_cast(carried), static_cast(assembled[i])); + kCapabilityNames[i], static_cast(carried), static_cast(assembledBit)); #endif } } void MGPipeDeriveRenderStateFields(PipeInputs& inputs) { // The derivation itself lives in MGPipeApplyAccess above, because that is the one - // struct PipeInputs names as a friend - see D5 there for what it recomputes, which - // getter each line was transcribed from, and why the verify comparator is its guard. - MGPipeApplyAccess::DeriveRenderStateFields(inputs); + // struct PipeInputs names as a friend - see D5 there for what it recomputes and which + // getter each line was transcribed from. + MGPipeApplyAccess::DeriveRenderStateFields(inputs, kMGPipeAllGlobalChunks); + } + + void MGPipeDeriveRenderStateFieldsForChunks(PipeInputs& inputs, Uint32 globalChunkBits) { + MGPipeApplyAccess::DeriveRenderStateFields(inputs, globalChunkBits); } } // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Pipe/PipeApply.h b/MobileGL/MG_Pipe/PipeApply.h old mode 100755 new mode 100644 index 3147f4a9d..02dba7f0d --- a/MobileGL/MG_Pipe/PipeApply.h +++ b/MobileGL/MG_Pipe/PipeApply.h @@ -97,11 +97,25 @@ namespace MobileGL::MG_Pipe { // --------------------------------------------------------------------------------- // Recomputes every PipeInputs field that is a pure function of the working - // RenderStateParameters, instead of pulling it out of GLContext a second time. Called by - // the applier after ANY scatter. + // RenderStateParameters, instead of pulling it out of GLContext a second time. // - // The guard is the oracle P1 built: MOBILEGL_PIPE_VERIFY's compare-at-read re-reads each - // of these from the live context at every backend read, so a transcription error is - // caught on the first draw that reads it. + // The oracle is the one P1 built: MOBILEGL_PIPE_VERIFY's compare-at-read re-reads each of + // these from the live context at every backend read, so a transcription error is caught + // on the first draw that reads it - on the retrace and integration-verify LANES, which is + // where the comparator arms (MG_Config::Features.PipeVerify). A unit-test process never + // runs the config loader, so the unit oracle is a different one: + // RenderStateSpansTest.DerivationMatchesTheFrontendGetters walks every setter and + // compares all 29 derived values against the frontend getters they were transcribed from. void MGPipeDeriveRenderStateFields(PipeInputs& inputs); + + // The same derivation, SCOPED to the chunks a scatter actually moved (bit i is global + // chunk i - MGPipeGlobalChunkBitsOf{Pipeline,Dynamic}Mask widens a wire mask to it). This + // is what the applier calls, and it is why a per-frame glViewport - the D8 case whose + // whole point is that it sends dynamic chunk D0 alone - does not pay for the 8-wide blend + // loop, the 16-wide depth-range loop or the 35-arm capability switch. Every guard's chunk + // set is computed from the boundary table with MGPipeRenderStateChunkBitsCovering, so a + // boundary move cannot leave one stale, and + // RenderStateSpansTest.IncrementalChunksKeepEveryDerivedFieldInStep drives the scoped + // path against the frontend getters family by family. + void MGPipeDeriveRenderStateFieldsForChunks(PipeInputs& inputs, Uint32 globalChunkBits); } // namespace MobileGL::MG_Pipe From ad1238bd6f5f940b2fe924f48c6ed81259c3e208 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 09:00:04 -0400 Subject: [PATCH 079/529] [Test] (Pipe): require a named pipeline member to be WHOLLY pipeline, drive every indexed setter at index 0, and walk the incremental chunk path - ChunkTablePartitionsTheBlock asserted only that a named pipeline member is TOUCHED by the pipeline half. A boundary that demotes part of one - four bytes at the head of BlendStates, i.e. the glEnablei(GL_BLEND, 0) bit - left the case green except for the two byte-count constants, which P3 will legitimately move; after that a partial demotion would have been invisible, and its consequence is one CSO handle serving two different pipeline states. Now IsWhollyPipeline for every named member, with StencilStates the one documented straddler. - Every indexed setter is driven at index 0 as well as at a middle index. Index 0 is the element both backends consume and the one a head-of-array boundary demotes first; with it, the demotion above also fails SetterConsistency, naming the setter. - The round trip D2 rests on is asserted: the assembled block is memcmp-equal to the live one. That is the only cover for the ~25 members with no derived field at all - SampleCoverage*, SampleMaskValue, PolygonModeBack, the hints, ScissorBoxes[1..15], ClipDistanceEnabledMask and the raw capability bools - which is exactly the set Espryt's SyncRenderState reads through its span memcmp. - IncrementalChunksKeepEveryDerivedFieldInStep: the shape the tracker actually emits. A create_render_state naming only the pipeline chunks that moved against a BaseCso (D7 step 2's miss path) and a set_dynamic_state naming only the dynamic chunks that moved (D8's suppressor), ten steps plus all 35 capabilities one at a time, each followed by the full derived-field comparison. It is the first caller of the base-inherit branch, of MGPipeScatterPipelineChunks, MGPipePipelineChunkBlobBytes, MGPipe{Dynamic,Pipeline}ChunksThatMoved and MGPipeHashPipelineBytes, and it is the oracle for the applier's chunk-scoped derivation - a whole-block apply asks for every chunk and so cannot tell a correct guard from one that is too narrow. - The 25 kDraw comparisons move into ExpectDerivedDrawFieldsMatch, shared by the whole-block walk and the incremental one. --- .../MG_Test/Pipe/RenderStateSpansTest.cpp | 350 +++++++++++++++--- 1 file changed, 293 insertions(+), 57 deletions(-) diff --git a/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp b/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp index 8afe544c6..7295f46d3 100644 --- a/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp +++ b/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp @@ -90,6 +90,91 @@ namespace { } Bool IsWhollyPipeline(SizeT offset, SizeT size) { return PipelineBytesOf(offset, size) == size; } + + // --------------------------------------------------------------------------------- + // D5's 25 kDraw derivations against the frontend getters they were transcribed from. + // Factored out because two cases need exactly this comparison: the whole-block walk + // below, and the INCREMENTAL walk that drives the applier's chunk-scoped derivation one + // family at a time. Everything here is a kDraw fill point (MG_Pipe/FillPoints.def), so + // the caller must be inside a kDraw verb; the three clear values and GetClampReadColor + // belong to kClear and kReadback and are checked in their own phases. + // --------------------------------------------------------------------------------- + void ExpectDerivedDrawFieldsMatch(GLContext& ctx, const char* tag) { + SCOPED_TRACE(tag); + EXPECT_EQ(gPipeInputs.GetBlendColor(), ctx.GetBlendColor()); + for (Uint i = 0; i < kMGMaxDrawBuffers; ++i) { + BlendEquation gotColor{}, gotAlpha{}, wantColor{}, wantAlpha{}; + gPipeInputs.GetBlendEquationIndexed(i, gotColor, gotAlpha); + ctx.GetBlendEquationIndexed(i, wantColor, wantAlpha); + EXPECT_EQ(gotColor, wantColor) << "blend equation " << i; + EXPECT_EQ(gotAlpha, wantAlpha) << "blend equation " << i; + + BlendFactor gotSrcRGB{}, gotDstRGB{}, gotSrcA{}, gotDstA{}; + BlendFactor wantSrcRGB{}, wantDstRGB{}, wantSrcA{}, wantDstA{}; + gPipeInputs.GetBlendFuncIndexed(i, gotSrcRGB, gotDstRGB, gotSrcA, gotDstA); + ctx.GetBlendFuncIndexed(i, wantSrcRGB, wantDstRGB, wantSrcA, wantDstA); + EXPECT_EQ(gotSrcRGB, wantSrcRGB) << "blend func " << i; + EXPECT_EQ(gotDstRGB, wantDstRGB) << "blend func " << i; + EXPECT_EQ(gotSrcA, wantSrcA) << "blend func " << i; + EXPECT_EQ(gotDstA, wantDstA) << "blend func " << i; + + EXPECT_EQ(gPipeInputs.GetColorMaskIndexed(i), ctx.GetColorMaskIndexed(i)) << "colour mask " << i; + EXPECT_EQ(gPipeInputs.IsCapabilityEnabledIndexed(CapabilityInput::Blend, i), + ctx.IsCapabilityEnabledIndexed(CapabilityInput::Blend, i)) + << "indexed blend enable " << i; + } + EXPECT_EQ(gPipeInputs.GetCullFaceMode(), ctx.GetCullFaceMode()); + EXPECT_EQ(gPipeInputs.GetDepthFunc(), ctx.GetDepthFunc()); + EXPECT_EQ(gPipeInputs.GetDepthMask(), ctx.GetDepthMask()); + for (Uint i = 0; i < RenderStateParameters::MAX_VIEWPORTS; ++i) { + EXPECT_EQ(gPipeInputs.GetDepthRangeIndexed(i), ctx.GetDepthRangeIndexed(i)) << "depth range " << i; + EXPECT_EQ(gPipeInputs.GetViewportIndexed(i), ctx.GetViewportIndexed(i)) << "viewport " << i; + EXPECT_EQ(gPipeInputs.IsCapabilityEnabledIndexed(CapabilityInput::ScissorTest, i), + ctx.IsCapabilityEnabledIndexed(CapabilityInput::ScissorTest, i)) + << "indexed scissor enable " << i; + } + EXPECT_EQ(gPipeInputs.GetLineWidth(), ctx.GetLineWidth()); + EXPECT_EQ(gPipeInputs.GetLogicOp(), ctx.GetLogicOp()); + EXPECT_EQ(gPipeInputs.GetMinSampleShadingValue(), ctx.GetMinSampleShadingValue()); + EXPECT_EQ(gPipeInputs.GetPatchDefaultInnerLevel(), ctx.GetPatchDefaultInnerLevel()); + EXPECT_EQ(gPipeInputs.GetPatchDefaultOuterLevel(), ctx.GetPatchDefaultOuterLevel()); + EXPECT_EQ(gPipeInputs.GetPatchVertices(), ctx.GetPatchVertices()); + EXPECT_EQ(gPipeInputs.GetPolygonModeFront(), ctx.GetPolygonModeFront()); + EXPECT_EQ(gPipeInputs.GetPolygonOffsetFactor(), ctx.GetPolygonOffsetFactor()); + EXPECT_EQ(gPipeInputs.GetPolygonOffsetUnits(), ctx.GetPolygonOffsetUnits()); + EXPECT_EQ(gPipeInputs.GetPrimitiveRestartIndex(), ctx.GetPrimitiveRestartIndex()); + EXPECT_EQ(gPipeInputs.GetProvokingVertexMode(), ctx.GetProvokingVertexMode()); + EXPECT_EQ(gPipeInputs.GetScissorBox(), ctx.GetScissorBox()); + EXPECT_EQ(gPipeInputs.GetViewport(), ctx.GetViewport()); + for (const StencilFace face : {StencilFace::Front, StencilFace::Back}) { + const StencilFaceState& got = gPipeInputs.GetStencilState(face); + const StencilFaceState& want = ctx.GetStencilState(face); + EXPECT_EQ(std::memcmp(&got, &want, sizeof(StencilFaceState)), 0) + << "stencil face " << static_cast(face); + } + for (SizeT i = 0; i < static_cast(CapabilityInput::CapabilityInputCount); ++i) { + const CapabilityInput cap = static_cast(i); + EXPECT_EQ(gPipeInputs.IsCapabilityEnabled(cap), ctx.IsCapabilityEnabled(cap)) << "capability " << i; + } + } + + // THE ROUND TRIP D2's whole argument rests on: "the block they read IS the assembled + // block" (ARCHITECTURE.md 5.3), which is what lets Espryt's SyncRenderState stay + // untouched. The 29 derived fields cover barely half of RenderStateParameters; the other + // ~25 members - SampleCoverageValue/Invert, SampleMaskValue, PolygonModeBack, PointSize, + // PointFadeThresholdSize, PointSpriteCoordOrigin, the four hints, ClipOrigin, + // ClipDepthMode, PolygonOffsetClamp, FrontFaceModeSetting, ScissorBoxes[1..15], + // ScissorBoxWrittenMask, ClipDistanceEnabledMask and the raw capability bools - have no + // derived field at all and are read RAW, through Espryt's span memcmp. This one line is + // the only thing in the suite that covers them. + void ExpectAssembledBlockIsTheLiveBlock(GLContext& ctx, const char* tag) { + SCOPED_TRACE(tag); + EXPECT_EQ(std::memcmp(&gPipeInputs.GetRenderStateParameters(), &ctx.GetRenderStateParameters(), + sizeof(RenderStateParameters)), + 0) + << "the assembled working block is not byte-identical to the live one - a chunk of " + "RenderStateParameters is not being carried, and Espryt reads those bytes raw"; + } #endif // MOBILEGL_PIPE_PUSH // ------------------------------------------------------------------------------------- @@ -136,8 +221,26 @@ namespace { const SizeT pipelineBytes = PipelineBytesOf(member.Offset, member.Size); if (pipelineNames.count(member.Name) != 0) { ++namedFound; - EXPECT_GT(pipelineBytes, SizeT{0}) - << member.Name << " is named as pipeline state but no pipeline chunk covers it"; + // WHOLLY pipeline, not merely touched by a pipeline chunk. A named member + // with only SOME of its bytes in the pipeline half is the silent form of the + // bug this suite exists to prevent: the demoted bytes drop out of the CSO's + // content-addressed identity, so one handle serves two different pipeline + // states and the same cached VkPipeline draws with, say, draw buffer 0's + // blend enable set both ways. The subset hash cannot see it - a hash over + // the wrong bytes is still a hash - and the setter walk cannot see it either + // as long as SOME byte of the member stayed pipeline, because the version + // and the hash then still move together. + // + // StencilStates is the ONE member allowed to straddle, by design and at + // sub-member granularity; the loop below pins its split face by face. + if (std::strcmp(member.Name, "StencilStates") == 0) { + EXPECT_GT(pipelineBytes, SizeT{0}) << "StencilStates has no pipeline bytes at all"; + continue; + } + EXPECT_TRUE(IsWhollyPipeline(member.Offset, member.Size)) + << member.Name << " is named as pipeline state but only " << pipelineBytes << " of its " + << member.Size << " bytes are in the pipeline half - the rest have silently left the " + "CSO's identity"; } else { EXPECT_EQ(pipelineBytes, SizeT{0}) << member.Name << " is not named as pipeline state but " << pipelineBytes @@ -198,6 +301,14 @@ namespace { // ---- Rasterization ---- check("SetViewport", [](RenderState& s) { s.SetViewport(IntVec4(1, 2, 30, 40)); }); check("SetViewportIndexed", [](RenderState& s) { s.SetViewportIndexed(3, FloatVec4(4.f, 5.f, 60.f, 70.f)); }); + // EVERY indexed setter is driven at INDEX 0 as well as at a middle index, and index 0 + // is the one that matters most: it is the element both backends actually consume + // (IsCapabilityEnabled(Blend) is BlendStates[0].Enabled, GetScissorBox/GetViewport + // answer for rectangle 0) and it is the element a chunk boundary landing at the HEAD + // of an array demotes first. A walk that only ever touches index 3 cannot tell a + // boundary that swallowed index 0 from a correct table. + check("SetViewportIndexed(0)", + [](RenderState& s) { s.SetViewportIndexed(0, FloatVec4(0.5f, 1.5f, 31.5f, 41.5f)); }); check("SetLineWidth", [](RenderState& s) { s.SetLineWidth(3.5f); }); check("SetPointSize", [](RenderState& s) { s.SetPointSize(7.25f); }); check("SetPatchVertices", [](RenderState& s) { s.SetPatchVertices(4); }); @@ -279,6 +390,13 @@ namespace { [](RenderState& s) { s.SetCapabilityIndexed(CapabilityInput::Blend, 3, false); }); check("SetCapabilityIndexed(ScissorTest, 5)", [](RenderState& s) { s.SetCapabilityIndexed(CapabilityInput::ScissorTest, 5, false); }); + // Index 0 of both: BlendStates[0].Enabled is the single bit glEnable(GL_BLEND) + // answers for and the first four bytes of chunk P1, and ScissorTestEnabledMask bit 0 + // is what DynamicTailKey::scissorEnabled reads. + check("SetCapabilityIndexed(Blend, 0)", + [](RenderState& s) { s.SetCapabilityIndexed(CapabilityInput::Blend, 0, false); }); + check("SetCapabilityIndexed(ScissorTest, 0)", + [](RenderState& s) { s.SetCapabilityIndexed(CapabilityInput::ScissorTest, 0, false); }); // ---- Blending ---- check("SetBlendFunc", [](RenderState& s) { @@ -288,10 +406,16 @@ namespace { s.SetBlendFuncIndexed(2, BlendFactor::DstColor, BlendFactor::SrcColor, BlendFactor::DstAlpha, BlendFactor::SrcAlpha); }); + check("SetBlendFuncIndexed(0)", [](RenderState& s) { + s.SetBlendFuncIndexed(0, BlendFactor::ConstantColor, BlendFactor::ConstantAlpha, BlendFactor::OneMinusDstColor, + BlendFactor::OneMinusDstAlpha); + }); check("SetBlendEquation", [](RenderState& s) { s.SetBlendEquation(BlendEquation::Subtract, BlendEquation::Min); }); check("SetBlendEquationIndexed", [](RenderState& s) { s.SetBlendEquationIndexed(4, BlendEquation::ReverseSubtract, BlendEquation::Max); }); + check("SetBlendEquationIndexed(0)", + [](RenderState& s) { s.SetBlendEquationIndexed(0, BlendEquation::Max, BlendEquation::Add); }); check("SetLogicOp", [](RenderState& s) { s.SetLogicOp(LogicOperation::Xor); }); // ---- Depth and stencil ---- @@ -325,12 +449,15 @@ namespace { // ---- Colour mask, clear state, sampling ---- check("SetColorMask", [](RenderState& s) { s.SetColorMask(BoolVec4(true, false, true, false)); }); check("SetColorMaskIndexed", [](RenderState& s) { s.SetColorMaskIndexed(6, BoolVec4(false, false, true, true)); }); + check("SetColorMaskIndexed(0)", + [](RenderState& s) { s.SetColorMaskIndexed(0, BoolVec4(false, true, false, true)); }); check("SetClearColor", [](RenderState& s) { s.SetClearColor(FloatVec4(0.1f, 0.2f, 0.3f, 0.4f)); }); check("SetClearDepth", [](RenderState& s) { s.SetClearDepth(0.75f); }); check("SetClearStencil", [](RenderState& s) { s.SetClearStencil(9); }); check("SetBlendColor", [](RenderState& s) { s.SetBlendColor(FloatVec4(0.5f, 0.6f, 0.7f, 0.8f)); }); check("SetDepthRange", [](RenderState& s) { s.SetDepthRange(FloatVec2(0.25f, 0.75f)); }); check("SetDepthRangeIndexed", [](RenderState& s) { s.SetDepthRangeIndexed(9, FloatVec2(0.1f, 0.9f)); }); + check("SetDepthRangeIndexed(0)", [](RenderState& s) { s.SetDepthRangeIndexed(0, FloatVec2(0.3f, 0.6f)); }); // SetSampleCoverage calls BumpVersions(), so under the rule it is PIPELINE state - // which is why MGPipeTypes.h's MGPDynamicState comment no longer claims otherwise. check("SetSampleCoverage", [](RenderState& s) { s.SetSampleCoverage(0.375f, true); }); @@ -347,6 +474,7 @@ namespace { check("SetScissorBox(first write)", [](RenderState& s) { s.SetScissorBox(IntVec4(1, 2, 3, 4)); }); check("SetScissorBox(again)", [](RenderState& s) { s.SetScissorBox(IntVec4(5, 6, 7, 8)); }); check("SetScissorBoxIndexed", [](RenderState& s) { s.SetScissorBoxIndexed(11, IntVec4(9, 10, 11, 12)); }); + check("SetScissorBoxIndexed(0)", [](RenderState& s) { s.SetScissorBoxIndexed(0, IntVec4(13, 14, 15, 16)); }); // SetPixelStoreParam moves NEITHER counter and touches no byte of // RenderStateParameters: the pixel store lives in its own two structs and travels as @@ -479,67 +607,16 @@ namespace { applyWholeBlock(); // ---- the 25 kDraw fields ---- - EXPECT_EQ(gPipeInputs.GetBlendColor(), ctx.GetBlendColor()); - for (Uint i = 0; i < kMGMaxDrawBuffers; ++i) { - BlendEquation gotColor{}, gotAlpha{}, wantColor{}, wantAlpha{}; - gPipeInputs.GetBlendEquationIndexed(i, gotColor, gotAlpha); - ctx.GetBlendEquationIndexed(i, wantColor, wantAlpha); - EXPECT_EQ(gotColor, wantColor) << "blend equation " << i; - EXPECT_EQ(gotAlpha, wantAlpha) << "blend equation " << i; - - BlendFactor gotSrcRGB{}, gotDstRGB{}, gotSrcA{}, gotDstA{}; - BlendFactor wantSrcRGB{}, wantDstRGB{}, wantSrcA{}, wantDstA{}; - gPipeInputs.GetBlendFuncIndexed(i, gotSrcRGB, gotDstRGB, gotSrcA, gotDstA); - ctx.GetBlendFuncIndexed(i, wantSrcRGB, wantDstRGB, wantSrcA, wantDstA); - EXPECT_EQ(gotSrcRGB, wantSrcRGB) << "blend func " << i; - EXPECT_EQ(gotDstRGB, wantDstRGB) << "blend func " << i; - EXPECT_EQ(gotSrcA, wantSrcA) << "blend func " << i; - EXPECT_EQ(gotDstA, wantDstA) << "blend func " << i; - - EXPECT_EQ(gPipeInputs.GetColorMaskIndexed(i), ctx.GetColorMaskIndexed(i)) << "colour mask " << i; - EXPECT_EQ(gPipeInputs.IsCapabilityEnabledIndexed(CapabilityInput::Blend, i), - ctx.IsCapabilityEnabledIndexed(CapabilityInput::Blend, i)) - << "indexed blend enable " << i; - } - EXPECT_EQ(gPipeInputs.GetCullFaceMode(), ctx.GetCullFaceMode()); - EXPECT_EQ(gPipeInputs.GetDepthFunc(), ctx.GetDepthFunc()); - EXPECT_EQ(gPipeInputs.GetDepthMask(), ctx.GetDepthMask()); - for (Uint i = 0; i < RenderStateParameters::MAX_VIEWPORTS; ++i) { - EXPECT_EQ(gPipeInputs.GetDepthRangeIndexed(i), ctx.GetDepthRangeIndexed(i)) << "depth range " << i; - EXPECT_EQ(gPipeInputs.GetViewportIndexed(i), ctx.GetViewportIndexed(i)) << "viewport " << i; - EXPECT_EQ(gPipeInputs.IsCapabilityEnabledIndexed(CapabilityInput::ScissorTest, i), - ctx.IsCapabilityEnabledIndexed(CapabilityInput::ScissorTest, i)) - << "indexed scissor enable " << i; - } - EXPECT_EQ(gPipeInputs.GetLineWidth(), ctx.GetLineWidth()); - EXPECT_EQ(gPipeInputs.GetLogicOp(), ctx.GetLogicOp()); - EXPECT_EQ(gPipeInputs.GetMinSampleShadingValue(), ctx.GetMinSampleShadingValue()); - EXPECT_EQ(gPipeInputs.GetPatchDefaultInnerLevel(), ctx.GetPatchDefaultInnerLevel()); - EXPECT_EQ(gPipeInputs.GetPatchDefaultOuterLevel(), ctx.GetPatchDefaultOuterLevel()); - EXPECT_EQ(gPipeInputs.GetPatchVertices(), ctx.GetPatchVertices()); - EXPECT_EQ(gPipeInputs.GetPolygonModeFront(), ctx.GetPolygonModeFront()); - EXPECT_EQ(gPipeInputs.GetPolygonOffsetFactor(), ctx.GetPolygonOffsetFactor()); - EXPECT_EQ(gPipeInputs.GetPolygonOffsetUnits(), ctx.GetPolygonOffsetUnits()); - EXPECT_EQ(gPipeInputs.GetPrimitiveRestartIndex(), ctx.GetPrimitiveRestartIndex()); - EXPECT_EQ(gPipeInputs.GetProvokingVertexMode(), ctx.GetProvokingVertexMode()); - EXPECT_EQ(gPipeInputs.GetScissorBox(), ctx.GetScissorBox()); - for (const StencilFace face : {StencilFace::Front, StencilFace::Back}) { - const StencilFaceState& got = gPipeInputs.GetStencilState(face); - const StencilFaceState& want = ctx.GetStencilState(face); - EXPECT_EQ(std::memcmp(&got, &want, sizeof(StencilFaceState)), 0) - << "stencil face " << static_cast(face); - } + ExpectDerivedDrawFieldsMatch(ctx, "whole block, kDraw"); + // ...and the ~25 members that have NO derived field, which only a byte compare of the + // whole block reaches. + ExpectAssembledBlockIsTheLiveBlock(ctx, "whole block, kDraw"); // The rounding half of GetViewport, exercised on purpose: viewport 0 is // (1.5, 2.5, 63.5, 32.25), so a transcription that truncated instead of rounding // would hand the backends a 63-wide rectangle where 64 was asked for. The literal // is std::lround's answer - round half AWAY FROM ZERO, so 1.5 -> 2 and 2.5 -> 3, // not the banker's rounding a nearbyint() transcription would give. - EXPECT_EQ(gPipeInputs.GetViewport(), ctx.GetViewport()); EXPECT_EQ(gPipeInputs.GetViewport(), IntVec4(2, 3, 64, 32)); - for (SizeT i = 0; i < static_cast(CapabilityInput::CapabilityInputCount); ++i) { - const CapabilityInput cap = static_cast(i); - EXPECT_EQ(gPipeInputs.IsCapabilityEnabled(cap), ctx.IsCapabilityEnabled(cap)) << "capability " << i; - } } // phase 1, kDraw // Phase 2, kClear: the three clear values. The verb's own fill runs FIRST and copies @@ -626,6 +703,165 @@ namespace { "scissorEnabled input and this expectation both need re-reading"; EXPECT_TRUE(IsWhollyPipeline(offsetof(RenderStateParameters, ScissorTestEnabledMask), sizeof(RenderStateParameters::ScissorTestEnabledMask))); +#endif + } + + // ------------------------------------------------------------------------------------- + // 5. The INCREMENTAL path, which is the shape the tracker actually emits: a + // create_render_state naming only the pipeline chunks that moved against a BaseCso + // (D7 step 2's miss path), and a set_dynamic_state naming only the dynamic chunks that + // moved (D8's chunk-level suppressor). Nothing but this case enters + // MGPipeApplyCreateRenderState's base-inherit branch, and nothing but this case drives + // the applier's CHUNK-SCOPED derivation - a whole-block apply asks for every chunk and + // so cannot tell a correctly scoped guard from one that is too narrow. + // ------------------------------------------------------------------------------------- + TEST(RenderStateSpans, IncrementalChunksKeepEveryDerivedFieldInStep) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +#else + struct ContextGuard { + UniquePtr Previous; + ContextGuard() : Previous(Move(MG_State::pGLContext)) { + MG_State::pGLContext = MakeUnique(); + MGPipeApplierReset(); + } + ~ContextGuard() { + MGPipeApplierReset(); + MG_State::pGLContext = Move(Previous); + } + } guard; + GLContext& ctx = *MG_State::pGLContext; + + // Every read below is a kDraw fill point, so one verb covers the whole case. The fill + // runs at construction against the DEFAULT context, which is what makes the first + // step's comparison meaningful rather than a comparison of the filler with itself. + MG_Test::ScopedPipeVerb verb(MGPipeVerb::DrawArrays); + + // `staged` is the tracker's "what the server has" mirror (D8). The masks are computed + // from it exactly as the tracker will compute them. + RenderStateParameters staged{}; + MGPipeHandle previousCso = kMGPipeNullHandle; + Uint32 nextSlot = kMGPipeFirstAllocatableSlot; + Bool firstPush = true; + + const auto push = [&](const char* tag) { + const RenderStateParameters& live = ctx.GetRenderStateParameters(); + const Uint32 movedPipeline = + firstPush ? static_cast((Uint64{1} << kMGPipePipelineChunkCount) - 1) + : MGPipePipelineChunksThatMoved(staged, live); + const Uint32 movedDynamic = + firstPush ? static_cast((Uint64{1} << kMGPipeDynamicChunkCount) - 1) + : MGPipeDynamicChunksThatMoved(staged, live); + + if (movedPipeline != 0) { + Vector blob(MGPipePipelineChunkBlobBytes(movedPipeline)); + MGPipeGatherPipelineChunks(live, movedPipeline, blob.data()); + MGPRenderStateDesc desc{}; + desc.Cso = MGPipeHandle{nextSlot++, 0}; + // The base-inherit branch: everything this desc does NOT name has to come + // from the record the client is pointing at. + desc.BaseCso = previousCso; + desc.ChunkMask = movedPipeline; + MGPipeApplyCreateRenderState(desc, blob.data()); + + MGPBindRenderState bind{}; + bind.Cso = desc.Cso; + bind.Version = static_cast(ctx.GetRenderStateParametersVersion()); + bind.PipelineVersion = static_cast(ctx.GetPipelineStateVersion()); + MGPipeApplyBindRenderState(bind); + previousCso = desc.Cso; + + // The reconstructed record must be the WHOLE pipeline half of the live block, + // byte for byte: an incremental create that inherited the wrong chunk would + // otherwise only show up as a wrong pixel much later, on the first bind that + // scatters it. This is also MGPipeHashPipelineBytes' only caller - and its + // agreement with the from-scratch hash is what lets CsoCache hash the bytes + // it already holds instead of re-gathering them. + Array gathered{}; + MGPipeGatherPipelineBytes(live, gathered.data()); + const MGPipeRenderStateCsoRecord& record = MGPipeApplier().RenderStateCsos[desc.Cso.Slot]; + EXPECT_EQ(std::memcmp(record.PipelineBytes.data(), gathered.data(), kMGPipePipelineChunkBytes), 0) + << tag << ": the incrementally created CSO is not the live pipeline half"; + EXPECT_EQ(MGPipeHashPipelineBytes(gathered.data()), MGPipeComputePipelineSubsetHash(live)) + << tag << ": hashing the gathered bytes disagrees with hashing the block"; + } + + if (movedDynamic != 0) { + Vector blob(MGPipeDynamicChunkBlobBytes(movedDynamic)); + MGPipeGatherDynamicChunks(live, movedDynamic, blob.data()); + MGPDynamicState dyn{}; + dyn.ChunkMask = movedDynamic; + dyn.Version = static_cast(ctx.GetRenderStateParametersVersion()); + MGPipeApplySetDynamicState(dyn, blob.data()); + } + + staged = live; + firstPush = false; + // Every derived field, after a scatter that named only the chunks that moved. A + // derivation guard that is too narrow leaves the previous step's value standing + // and this is where it shows. + ExpectDerivedDrawFieldsMatch(ctx, tag); + ExpectAssembledBlockIsTheLiveBlock(ctx, tag); + }; + + // Step 0: the whole block, so every later step is a genuine delta. + ctx.SetViewportIndexed(0, FloatVec4(1.5f, 2.5f, 63.5f, 32.25f)); + push("step 0: the whole block"); + + // One step per derivation guard, each moving as few chunks as the setter allows. + ctx.SetViewportIndexed(0, FloatVec4(4.f, 5.f, 60.f, 70.f)); + ctx.SetViewportIndexed(7, FloatVec4(8.f, 9.f, 10.f, 11.f)); + push("step 1: viewports only (dynamic chunk D0)"); + + ctx.SetDepthRangeIndexed(3, FloatVec2(0.2f, 0.8f)); + push("step 2: depth ranges only (dynamic chunk D2)"); + + ctx.SetBlendFuncIndexed(0, BlendFactor::DstColor, BlendFactor::SrcColor, BlendFactor::DstAlpha, + BlendFactor::SrcAlpha); + ctx.SetBlendEquationIndexed(5, BlendEquation::ReverseSubtract, BlendEquation::Max); + ctx.SetColorMaskIndexed(0, BoolVec4(false, true, false, true)); + push("step 3: blend and colour mask (pipeline chunk P1)"); + + ctx.SetCapabilityIndexed(CapabilityInput::Blend, 0, true); + push("step 4: indexed blend enable at index 0"); + + ctx.SetCapabilityIndexed(CapabilityInput::ScissorTest, 2, true); + push("step 5: indexed scissor enable (pipeline chunk P6)"); + + ctx.SetScissorBox(IntVec4(3, 4, 5, 6)); + push("step 6: scissor rectangles (dynamic chunk D7)"); + + ctx.SetStencilFunc(StencilFace::Front, DepthTestFunc::Equal, 7, 0xf0u); + ctx.SetStencilOp(StencilFace::Back, StencilOperation::Replace, StencilOperation::IncrementClamp, + StencilOperation::DecrementWrap); + ctx.SetStencilMask(StencilFace::Back, 0x0fu); + push("step 7: the stencil faces, which straddle four chunks"); + + ctx.SetLineWidth(3.5f); + ctx.SetPolygonOffsetClamped(1.5f, 2.5f, 0.25f); + ctx.SetLogicOp(LogicOperation::Xor); + ctx.SetDepthFunc(DepthTestFunc::GreaterEqual); + ctx.SetDepthMask(false); + ctx.SetCullFaceMode(CullFaceMode::Front); + ctx.SetProvokingVertexMode(ProvokingVertexMode::FirstVertex); + ctx.SetPrimitiveRestartIndex(0xabcdu); + ctx.SetMinSampleShadingValue(0.625f); + ctx.SetPatchVertices(4); + ctx.SetPatchDefaultOuterLevel(FloatVec4(2.f, 3.f, 4.f, 5.f)); + ctx.SetPatchDefaultInnerLevel(FloatVec2(6.f, 7.f)); + ctx.SetPolygonMode(GL_LINE, GL_POINT); + push("step 8: the unguarded scalars"); + + // EVERY capability, one at a time. This is what pins the capability walk's guard + // exhaustively: the 25 plain bools sit in three different pipeline chunks, Blend is + // BlendStates[0].Enabled, ScissorTest is a pipeline mask and the eight ClipDistances + // are a DYNAMIC mask - so a guard that named only "the capability chunk" would leave + // one of those families stale, and the flip that reaches it fails here by name. + for (SizeT i = 0; i < static_cast(CapabilityInput::CapabilityInputCount); ++i) { + const CapabilityInput cap = static_cast(i); + ctx.SetCapability(cap, !ctx.IsCapabilityEnabled(cap)); + push(("step 9: capability " + std::to_string(i)).c_str()); + } #endif } } // namespace From 7c2c1456f85897f6174989d7263c4b037fee0e2f Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 09:00:04 -0400 Subject: [PATCH 080/529] [Test] (Pipe): make the slot allocator's ABA case prove its claim without waiting for the heap, and narrow the DEBUG skip to the arm that needs it - LifetimeIdSurvivesARecycledAddress could not distinguish what its name claimed: Acquire never sees an address, and each round freed its handle, so the next handle differed whether or not the heap repeated the address - a restatement of GenMovesOnlyOnSlotReuse - and the case could silently GTEST_SKIP on a machine that never repeats one. The reuse count is now recorded rather than depended on, and a deterministic arm proves the strictly stronger form: re-acquiring the SAME LIFETIME ID after a free - the key the map is actually built on, which MG_State never reissues - still cannot reproduce the handle, because the reused slot carries a new generation. If that cannot reproduce a handle, no recycled address can. - CompositeShaderBandIsNeverHandedOut skipped the whole case in a DEBUG build, including the two arms that trip no assert. The eight low-slot handouts and the "every other kind is unaffected" arm now run in every build; only the exhaustion walk, which trips the allocator's own "slot space is exhausted" MOBILEGL_ASSERT on purpose, is behind the skip. --- MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp | 62 +++++++++++++++------ 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp b/MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp index 3fb2e9a31..9b84707ee 100644 --- a/MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp +++ b/MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp @@ -216,23 +216,38 @@ namespace { EXPECT_TRUE(MGPipeHandleIsNull(allocator.FindByLifetimeId(MGPipeKind::VertexElementsCso, lifetimeId))); } - if (reuseCount == 0) { - GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across " - "64 construct/destroy rounds, so the recycled-address case was never exercised"; - } + // Whether the heap repeats an address is the machine's business, not the allocator's, + // so the count is RECORDED and the case does not depend on it: the arm below proves + // the same property without waiting for luck, and it proves a STRICTLY STRONGER form + // of it. Acquire never sees an address at all (SlotAllocator.h) - it sees a lifetime + // id - so the sharpest possible ABA is not "the same address came back" but "the same + // LIFETIME ID came back", which is the key the map is actually built on. MG_State + // never reissues one, so this can only be built by hand; if even that cannot + // reproduce a handle, no recycled address can either. RecordProperty("address_reuses_observed", reuseCount); + + MGPipeSlotAllocator sharp; + const Uint64 repeatedLifetimeId = 0x5eed'0000'0000'0001ull; + const MGPipeHandle first = sharp.Acquire(MGPipeKind::VertexElementsCso, repeatedLifetimeId); + EXPECT_FALSE(MGPipeHandleIsNull(first)); + sharp.Free(MGPipeKind::VertexElementsCso, first); + const MGPipeHandle second = sharp.Acquire(MGPipeKind::VertexElementsCso, repeatedLifetimeId); + EXPECT_FALSE(second == first) + << "re-acquiring the SAME lifetime id after a free reproduced handle {slot=" << first.Slot + << ", gen=" << first.Gen << "}; an address-keyed or name-keyed memo would then serve the dead " + "object's entry to the live one"; + EXPECT_EQ(second.Slot, first.Slot) << "the freed slot was not the one handed back"; + EXPECT_EQ(second.Gen, first.Gen + 1) << "a reused slot must carry a new generation"; + // And the dead handle stays dead, which is what makes the ABA detectable rather than + // merely unlikely. + EXPECT_FALSE(sharp.IsLive(MGPipeKind::VertexElementsCso, first)); + EXPECT_TRUE(sharp.IsLive(MGPipeKind::VertexElementsCso, second)); #endif } TEST(SlotAllocator, CompositeShaderBandIsNeverHandedOut) { #if !MOBILEGL_PIPE_PUSH GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; -#elif MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG - // Exhausting the ShaderCso slot space below the composite band is what proves the - // band is held back, and reaching the band's edge trips the allocator's - // "slot space is exhausted" MOBILEGL_ASSERT - which is live, and correctly so, in a - // DEBUG build. The claim is checked in the INFO builds the gates run. - GTEST_SKIP() << "asserts are live in a DEBUG build and the exhaustion arm trips one on purpose"; #else MGPipeSlotAllocator allocator; // Ordinary programs walk the low slots and never enter the band. @@ -241,6 +256,23 @@ namespace { EXPECT_FALSE(MGPipeIsCompositeShaderSlot(handle.Slot)); } + // Every other kind is unaffected: the band is a ShaderCso rule, not a global one. + { + MGPipeSlotAllocator plain; + for (Uint32 i = 0; i < 4; ++i) { + const MGPipeHandle handle = plain.Allocate(MGPipeKind::Buffer); + EXPECT_EQ(handle.Slot, kMGPipeFirstAllocatableSlot + i); + } + } + + // ONLY THE EXHAUSTION ARM needs the DEBUG skip, and it is placed here so the two arms + // above run in every build. Walking the ShaderCso slot space up to the band is what + // proves the band is held back, and reaching the band's edge trips the allocator's + // own "slot space is exhausted" MOBILEGL_ASSERT - which is live, and correctly so, in + // a DEBUG build. The claim is checked in the INFO builds every gate runs. +#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG + GTEST_SKIP() << "asserts are live in a DEBUG build and only the exhaustion arm trips one on purpose"; +#else // Walk the whole space up to the band. The last handout below the base must be the // slot immediately under it, and the next call must refuse rather than step in - a // composite handle minted by the ordinary allocator would collide with one the @@ -254,13 +286,7 @@ namespace { EXPECT_EQ(last.Slot, kMGPipeShaderCsoCompositeSlotBase - 1); EXPECT_TRUE(MGPipeHandleIsNull(allocator.Allocate(MGPipeKind::ShaderCso))) << "the allocator handed out a composite-band slot instead of refusing"; - - // Every other kind is unaffected: the band is a ShaderCso rule, not a global one. - MGPipeSlotAllocator plain; - for (Uint32 i = 0; i < 4; ++i) { - const MGPipeHandle handle = plain.Allocate(MGPipeKind::Buffer); - EXPECT_EQ(handle.Slot, kMGPipeFirstAllocatableSlot + i); - } -#endif +#endif // the DEBUG guard on the exhaustion arm +#endif // MOBILEGL_PIPE_PUSH } } // namespace From bee07c3273c32c927fae22e9bb3f7b9d1f3c3471 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 09:51:14 -0400 Subject: [PATCH 081/529] [Fix] (Pipe): arm both applier trip wires off the applier's own scatter ledger instead of off a bound handle, and stop leaving a mixed CSO or a malformed attribute tail to a DEBUG-only assertion - MGPipeApplierState gains ScatteredChunkBits: the global chunk bits this applier has itself scattered into PipeInputs::m_renderState, set by bind_render_state (the whole pipeline half) and set_dynamic_state (the chunks it names), cleared by MGPipeApplierReset. set_patch_state's own write is deliberately NOT in it - that is the other carrier, and a wire comparing against bytes it had just written would be a tautology. - The residual trip wire now compares capability i only once every chunk that capability's answer is read out of is in the ledger. Both of the previous form's contracts were undeclared and one of them was wrong: with the render-state subsystem off (MOBILEGL_PIPE_PUSH=0x10 is a legal per-subsystem A/B, D14) the working block is the per-verb fill loop's, published per verb CLASS, and FillPoints.def does not publish GetRenderStateParameters at kDispatch or kTextureOp - so at a dispatch after a draw the block held the draw's bytes and a correct context could abort. With the ledger empty the wire now says nothing there, and with the subsystem on the applier is the block's only writer and its bytes are current at every class. - The per-capability grain is not decoration: a bind alone owns the pipeline half, and the eight ClipDistances are answered from ClipDistanceEnabledMask in dynamic chunk D7, so between a bind and the first set_dynamic_state exactly those eight are unanswerable. The source chunks come from the same MGP_PLAIN_CAPABILITY_LIST and the same boundary table DeriveCapability reads, so the two cannot drift. - The patch-carrier wire arms the same way, which replaces its "some CSO is bound" condition - a process-global that stayed set from the first bind onward - and covers the verb-class contract as well as the ordering one. - Both wires now run in the shipped push build too, counting and logging where a poison or verify build aborts (one MGP_TRIP_WIRE_REPORT/TAG pair, so only the fatal arm writes the "Fatal{...}" marker G4 greps for). A wire compiled out of every build a device runs is not a wire, and the counters are what let a unit case see it fire in every build. - create_render_state no longer leaves a dead BaseCso to MOBILEGL_ASSERT, which is inert at INFO - the level every gate and every shipped build uses. A recycled slot's record still holds the previous occupant's 396 bytes, so inheriting nothing and scattering the delta on top handed out a record that was half one CSO and half another. Both arms now start from a defined base and report. The same for a brand-new CSO that does not name every chunk. - set_vertex_attrib_defaults walks the mask's 32 bits rather than the slot array, consumes a tail entry for every named location so a named-but-unstorable attribute cannot desynchronise the rest, and reports all three consistency faults in every build. --- MobileGL/MG_Pipe/PipeApply.cpp | 253 ++++++++++++++++++++++++--------- MobileGL/MG_Pipe/PipeApply.h | 29 ++++ 2 files changed, 217 insertions(+), 65 deletions(-) diff --git a/MobileGL/MG_Pipe/PipeApply.cpp b/MobileGL/MG_Pipe/PipeApply.cpp index af8dad6e4..f9c3a75b1 100644 --- a/MobileGL/MG_Pipe/PipeApply.cpp +++ b/MobileGL/MG_Pipe/PipeApply.cpp @@ -20,6 +20,25 @@ #include #include +// THE VERDICT OF EVERY TRIP WIRE IN THIS FILE, IN ONE PLACE. +// +// MOBILEGL_ASSERT is inert at INFO (Defines.h), which is the level every P2 gate builds at, +// so nothing below is left to an assertion. A poison or verify build stops the process; a +// shipped push build logs at error level and carries on from a DEFINED state, and the +// applier counts the divergence so a unit case can see the wire fire there too. Only the +// poison/verify arm writes the "Fatal{...}" marker G4 greps the retrace logs for. +#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY +#define MGP_TRIP_WIRE_TAG(name) "Fatal{" name "}" +#define MGP_TRIP_WIRE_REPORT(...) \ + do { \ + MGLOG_F(__VA_ARGS__); \ + std::abort(); \ + } while (0) +#else +#define MGP_TRIP_WIRE_TAG(name) name +#define MGP_TRIP_WIRE_REPORT(...) MGLOG_E(__VA_ARGS__) +#endif + // The 25 capabilities whose storage is a plain `Enabled` bool. Written ONCE and used // twice - once for the switch arms of DeriveCapability and once for the chunk set that guards // the capability walk - so the two cannot drift apart. The three P2 gave storage to @@ -88,6 +107,48 @@ namespace MobileGL::MG_Pipe { MGP_PLAIN_CAPABILITY_LIST(MGP_CAPABILITY_CHUNKS); #undef MGP_CAPABILITY_CHUNKS + // The patch trio's chunk, which is what arms the set_patch_state trip wire: the + // question that wire asks is whether the chunk-P0 bytes in the working block are the + // APPLIER'S, and only a scatter puts them there. + constexpr Uint32 kChunksPatchTrio = MGP_CHUNKS_OF(PatchVertices) | + MGP_CHUNKS_OF(PatchDefaultOuterLevel) | + MGP_CHUNKS_OF(PatchDefaultInnerLevel); + + // Which chunks ONE capability's answer is read out of - the same sources + // DeriveCapability reads, written from the same list so the two cannot drift. This is + // what arms the residual trip wire PER CAPABILITY: a bind alone owns the pipeline + // half, and the eight ClipDistances are answered from ClipDistanceEnabledMask in + // DYNAMIC chunk D7, so between a bind and the first set_dynamic_state exactly those + // eight are unanswerable and the other 27 are not. + constexpr Uint32 CapabilitySourceChunks(CapabilityInput cap) { +#define MGP_CAPABILITY_SOURCE(capability) \ + case CapabilityInput::capability: \ + return MGP_CHUNKS_OF(capability##Enabled); + switch (cap) { + MGP_PLAIN_CAPABILITY_LIST(MGP_CAPABILITY_SOURCE) + case CapabilityInput::Blend: + return MGP_CHUNKS_OF(BlendStates); + case CapabilityInput::ScissorTest: + return MGP_CHUNKS_OF(ScissorTestEnabledMask); + case CapabilityInput::ClipDistance0: + case CapabilityInput::ClipDistance1: + case CapabilityInput::ClipDistance2: + case CapabilityInput::ClipDistance3: + case CapabilityInput::ClipDistance4: + case CapabilityInput::ClipDistance5: + case CapabilityInput::ClipDistance6: + case CapabilityInput::ClipDistance7: + return MGP_CHUNKS_OF(ClipDistanceEnabledMask); + // A capability with no storage cannot be answered from any byte, and + // DeriveCapability says so with a compile-time false. Demanding the whole table + // keeps such a value out of the comparison until every chunk is owned, which is + // the conservative direction: a wire that cannot be answered must not fire. + default: + return kMGPipeAllGlobalChunks; + } +#undef MGP_CAPABILITY_SOURCE + } + // The scalar copies are left unguarded on purpose: they are ~20 stores and two // 28-byte struct copies, so guarding each would cost more branches than it saves // stores - and an unguarded copy cannot go stale, which keeps the risk of the scoping @@ -336,6 +397,11 @@ namespace MobileGL::MG_Pipe { g_applier.BoundRenderStateCso = kMGPipeNullHandle; g_applier.Residual = ResidualValueBlock{}; g_applier.HasResidual = false; + g_applier.ScatteredChunkBits = 0; + g_applier.ResidualCapabilitiesCompared = 0; + g_applier.ResidualDivergences = 0; + g_applier.PatchCarrierComparisons = 0; + g_applier.PatchCarrierDivergences = 0; } void MGPipeApplyCreateRenderState(const MGPRenderStateDesc& desc, const void* chunkBytes) { @@ -346,20 +412,35 @@ namespace MobileGL::MG_Pipe { } MGPipeRenderStateCsoRecord& record = g_applier.RenderStateCsos[desc.Cso.Slot]; + // NEITHER BRANCH MAY LEAVE ITS BAD CASE TO MOBILEGL_ASSERT. The slot the client is + // naming may be a RECYCLED one whose record still holds the previous occupant's 396 + // bytes; in an INFO build - which is what every gate and every shipped build is - an + // assertion is a no-op, so inheriting nothing onto those bytes and then scattering + // the delta chunks on top would hand out a record that is half one CSO and half + // another, with no gate able to see it. Both arms therefore start from a DEFINED + // base and report through this file's trip-wire verdict. if (MGPipeHandleIsNull(desc.BaseCso)) { // A brand-new CSO carries its whole content; there is no earlier record to // inherit the unnamed chunks from. - MOBILEGL_ASSERT((desc.ChunkMask & kAllPipelineChunks) == kAllPipelineChunks, - "create_render_state with no BaseCso must name every pipeline chunk " - "(mask=0x%x, expected 0x%x)", - desc.ChunkMask, kAllPipelineChunks); record.PipelineBytes = {}; + if ((desc.ChunkMask & kAllPipelineChunks) != kAllPipelineChunks) { + MGP_TRIP_WIRE_REPORT("MGPipe: " MGP_TRIP_WIRE_TAG("PipeIncompleteCso") + " create_render_state {slot=%u, gen=%u} with no BaseCso named chunks " + "0x%x, not the whole pipeline half 0x%x; the rest is zeroed", + desc.Cso.Slot, desc.Cso.Gen, desc.ChunkMask, + static_cast(kAllPipelineChunks)); + } } else { const MGPipeRenderStateCsoRecord* base = FindCso(desc.BaseCso); - MOBILEGL_ASSERT(base != nullptr, - "create_render_state named a dead BaseCso {slot=%u, gen=%u}", - desc.BaseCso.Slot, desc.BaseCso.Gen); - if (base != nullptr) record.PipelineBytes = base->PipelineBytes; + record.PipelineBytes = + base != nullptr ? base->PipelineBytes : Array{}; + if (base == nullptr) { + MGP_TRIP_WIRE_REPORT("MGPipe: " MGP_TRIP_WIRE_TAG("PipeDeadBaseCso") + " create_render_state {slot=%u, gen=%u} named a dead BaseCso " + "{slot=%u, gen=%u}; the delta chunks land on zeroed bytes, not on " + "the recycled slot's previous occupant", + desc.Cso.Slot, desc.Cso.Gen, desc.BaseCso.Slot, desc.BaseCso.Gen); + } } // The chunk bytes land in the record's own gathered order, so the record is always a @@ -385,9 +466,11 @@ namespace MobileGL::MG_Pipe { MGPipeApplyAccess::SetRenderStateVersions(inputs, bind.Version, bind.PipelineVersion); g_applier.BoundRenderStateCso = bind.Cso; // A bind scatters the WHOLE pipeline half - the record is always a complete one, - // whatever mask minted it - so the pipeline chunks are all "moved" here. - MGPipeDeriveRenderStateFieldsForChunks( - inputs, MGPipeGlobalChunkBitsOfPipelineMask(kAllPipelineChunks)); + // whatever mask minted it - so the pipeline chunks are all "moved" here, and all + // enter the applier's ledger of the bytes it owns. + const Uint32 moved = MGPipeGlobalChunkBitsOfPipelineMask(kAllPipelineChunks); + g_applier.ScatteredChunkBits |= moved; + MGPipeDeriveRenderStateFieldsForChunks(inputs, moved); } void MGPipeApplyDeleteRenderState(const MGPHandleOnly& handle) { @@ -408,7 +491,9 @@ namespace MobileGL::MG_Pipe { PipeInputs& inputs = gPipeInputs; MGPipeScatterDynamicChunks(chunkBytes, dyn.ChunkMask, MGPipeApplyAccess::RenderState(inputs)); MGPipeApplyAccess::SetRenderStateParametersVersion(inputs, dyn.Version); - MGPipeDeriveRenderStateFieldsForChunks(inputs, MGPipeGlobalChunkBitsOfDynamicMask(dyn.ChunkMask)); + const Uint32 moved = MGPipeGlobalChunkBitsOfDynamicMask(dyn.ChunkMask); + g_applier.ScatteredChunkBits |= moved; + MGPipeDeriveRenderStateFieldsForChunks(inputs, moved); } void MGPipeApplySetPixelPackState(const MGPPixelPackState& pack) { @@ -421,7 +506,6 @@ namespace MobileGL::MG_Pipe { const FloatVec4 outer(patch.Outer[0], patch.Outer[1], patch.Outer[2], patch.Outer[3]); const FloatVec2 inner(patch.Inner[0], patch.Inner[1]); -#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY // THE SECOND TRIP WIRE (D6, D10). The patch trio travels TWICE - once in pipeline // chunk P0, because it is pipeline state, and once as set_patch_state, because both // backends bake it into the synthesized control stage from a shader-build path. The @@ -432,32 +516,40 @@ namespace MobileGL::MG_Pipe { // Compared BITWISE, because a NaN outer level is a legal glPatchParameterfv value // (ARCHITECTURE.md 5.2) and must compare equal to itself. // - // Only once a CSO has delivered chunk P0: before the first bind_render_state of a - // context the working block still holds its defaults, and a set_patch_state that - // legitimately precedes the first bind has nothing to agree with yet. That is the - // ordering contract this trip wire places on the tracker - within a validate, the - // bind comes first. - if (!MGPipeHandleIsNull(g_applier.BoundRenderStateCso)) { + // ARMED BY THE APPLIER'S OWN SCATTER LEDGER, not by "some CSO has been bound" + // (PipeApply.h, MGPipeApplierState::ScatteredChunkBits). The question is whether the + // chunk-P0 bytes in the working block are the applier's, and that single condition + // covers both contracts this wire needs: the ORDERING one - a set_patch_state that + // legitimately precedes the first bind of a context has nothing to agree with yet - + // and the VERB-CLASS one - with the render-state subsystem off those bytes are the + // per-verb fill loop's, and FillPoints.def does not publish GetRenderStateParameters + // at kDispatch or kTextureOp, so they go stale there. + // + // It runs in the shipped push build too, because a wire that is compiled out of + // every build a device runs is not a wire. The cost is a 24-byte memcmp on a call + // that is emitted when the tessellation state CHANGES, i.e. about once per program. + if ((g_applier.ScatteredChunkBits & kChunksPatchTrio) == kChunksPatchTrio) { + ++g_applier.PatchCarrierComparisons; const Bool agrees = working.PatchVertices == patch.Vertices && std::memcmp(&working.PatchDefaultOuterLevel, &outer, sizeof(outer)) == 0 && std::memcmp(&working.PatchDefaultInnerLevel, &inner, sizeof(inner)) == 0; if (!agrees) { - MGLOG_F("MGPipe: Fatal{PipePatchCarriersDiffer} set_patch_state says vertices=%u " - "outer=(%g,%g,%g,%g) inner=(%g,%g); chunk P0 delivered vertices=%u " - "outer=(%g,%g,%g,%g) inner=(%g,%g)", - patch.Vertices, static_cast(outer.x()), static_cast(outer.y()), - static_cast(outer.z()), static_cast(outer.w()), - static_cast(inner.x()), static_cast(inner.y()), working.PatchVertices, - static_cast(working.PatchDefaultOuterLevel.x()), - static_cast(working.PatchDefaultOuterLevel.y()), - static_cast(working.PatchDefaultOuterLevel.z()), - static_cast(working.PatchDefaultOuterLevel.w()), - static_cast(working.PatchDefaultInnerLevel.x()), - static_cast(working.PatchDefaultInnerLevel.y())); - std::abort(); + ++g_applier.PatchCarrierDivergences; + MGP_TRIP_WIRE_REPORT("MGPipe: " MGP_TRIP_WIRE_TAG("PipePatchCarriersDiffer") + " set_patch_state says vertices=%u outer=(%g,%g,%g,%g) inner=(%g,%g); " + "chunk P0 delivered vertices=%u outer=(%g,%g,%g,%g) inner=(%g,%g)", + patch.Vertices, static_cast(outer.x()), + static_cast(outer.y()), static_cast(outer.z()), + static_cast(outer.w()), static_cast(inner.x()), + static_cast(inner.y()), working.PatchVertices, + static_cast(working.PatchDefaultOuterLevel.x()), + static_cast(working.PatchDefaultOuterLevel.y()), + static_cast(working.PatchDefaultOuterLevel.z()), + static_cast(working.PatchDefaultOuterLevel.w()), + static_cast(working.PatchDefaultInnerLevel.x()), + static_cast(working.PatchDefaultInnerLevel.y())); } } -#endif working.PatchVertices = patch.Vertices; working.PatchDefaultOuterLevel = outer; @@ -470,17 +562,31 @@ namespace MobileGL::MG_Pipe { const MGPAttribValue* tail) { PipeInputs& inputs = gPipeInputs; PipeInputs::CurrentVertexAttributeValue* slots = MGPipeApplyAccess::VertexAttribDefaults(inputs); + // The loop walks the MASK's 32 bits, not the slot array, and every named bit consumes + // its tail entry even when there is no slot to write it to: a named-but-unstorable + // attribute that did not consume would write every attribute after it from the wrong + // entry. PipeInputs::kMaxVertexAttribs is VertexArrayObject's 32 today, so the + // out-of-range arm is unreachable - the guard is what keeps that true if the two ever + // stop agreeing. None of the three consistency checks may be left to MOBILEGL_ASSERT, + // which is inert at INFO: a malformed tail would then desynchronise the attribute + // writes without a word in exactly the builds that ship. Uint32 consumed = 0; - for (Uint32 location = 0; location < PipeInputs::kMaxVertexAttribs; ++location) { - if ((hdr.Mask & (1u << location)) == 0) continue; - MOBILEGL_ASSERT(consumed < hdr.Count, - "set_vertex_attrib_defaults: Mask names more attributes than Count"); - if (consumed >= hdr.Count) break; + const char* fault = nullptr; + for (Uint32 location = 0; location < 32 && fault == nullptr; ++location) { + if ((hdr.Mask & (Uint32{1} << location)) == 0) continue; + if (consumed >= hdr.Count) { + fault = "Mask names more attributes than Count"; + break; + } const MGPAttribValue& value = tail[consumed++]; - MOBILEGL_ASSERT(value.Location == location, - "set_vertex_attrib_defaults: tail out of ascending location order " - "(%u where %u was expected)", - value.Location, location); + if (value.Location != location) { + fault = "tail out of ascending location order"; + break; + } + if (location >= PipeInputs::kMaxVertexAttribs) { + fault = "Mask names a location the block has no slot for"; + continue; + } PipeInputs::CurrentVertexAttributeValue& slot = slots[location]; // The three views are always populated; which one a shader input consumes is // ClassifyVertexAttribType's answer, not the carrier's, so all three cross. @@ -488,15 +594,20 @@ namespace MobileGL::MG_Pipe { std::memcpy(slot.intValue.data(), value.Data, sizeof(slot.intValue)); std::memcpy(slot.uintValue.data(), value.Data, sizeof(slot.uintValue)); } - MOBILEGL_ASSERT(consumed == hdr.Count, - "set_vertex_attrib_defaults: Count %u does not match the %u attributes Mask " - "names", - hdr.Count, consumed); + if (fault == nullptr && consumed != hdr.Count) { + fault = "Count does not match the attributes Mask names"; + } + if (fault != nullptr) { + MGP_TRIP_WIRE_REPORT("MGPipe: " MGP_TRIP_WIRE_TAG("PipeAttribTailMalformed") + " set_vertex_attrib_defaults: %s (Mask=0x%x, Count=%u, consumed=%u)", + fault, hdr.Mask, hdr.Count, consumed); + } } void MGPipeApplySetResidualValueState(const ResidualValueBlock& block) { g_applier.Residual = block; g_applier.HasResidual = true; + g_applier.ResidualCapabilitiesCompared = 0; // THE TRIP WIRE (ARCHITECTURE.md 9.4, P2 brief D9). CapabilityBits is redundant with // the assembled working block by design: every one of the 35 capabilities is @@ -505,29 +616,41 @@ namespace MobileGL::MG_Pipe { // answers part and this says so on the next draw - which is what a migration carrier // is for. // - // The comparison reads the WORKING BLOCK, not PipeInputs::m_capability. Those two - // agree after any scatter - the derivation is what puts the block's answer there - - // but m_capability is written ONLY by the derivation, so comparing against it would - // make this trip wire depend on a bind_render_state or a set_dynamic_state having - // already been applied to this context. The residual block is emitted ONCE PER - // CONTEXT (D9) and may legitimately be the first call of all, at which point - // m_capability is still all-false while the block's own defaults have Dither and - // Multisample true - the trip wire would fire on a context that is perfectly correct. - // Asking DeriveCapability the same question the derivation asks removes that ordering - // contract without weakening the check by one bit. + // THE ORACLE IS THE WORKING BLOCK, AND THE WIRE IS ARMED PER CAPABILITY by the + // applier's own scatter ledger: capability i is compared only once every chunk its + // answer is read out of has been scattered by this applier. That is not a weakening, + // it is the wire's whole precondition: + // + // - with the render-state subsystem off (MOBILEGL_PIPE_PUSH=0x10 is a legal + // configuration - D14's per-subsystem A/B) the ledger is empty and the wire says + // nothing at all, which is right: the working block is then the per-verb fill + // loop's, published per verb CLASS, so at a kDispatch or kTextureOp verb it holds + // the previous draw's bytes and disagreeing with it means nothing; + // - with it on, the applier is the block's only writer and its bytes are current at + // every verb of every class - including the two above, which is the case a + // RenderStateSpansTest case drives on purpose. + // + // NOT PipeInputs::m_capability, which the earlier form compared against and which + // FillPoints.def does publish at seven classes rather than five: where that + // publication is what makes m_capability fresh, the fill loop filled it out of the + // same GLContext the client built CapabilityBits from, so the comparison is a + // tautology. The redundancy this wire exists to check is between the CARRIED bits and + // the ASSEMBLED block. const RenderStateParameters& working = MGPipeApplyAccess::RenderState(gPipeInputs); + const Uint32 owned = g_applier.ScatteredChunkBits; for (SizeT i = 0; i < kCapabilityCount; ++i) { + const CapabilityInput cap = static_cast(i); + const Uint32 sources = CapabilitySourceChunks(cap); + if ((owned & sources) != sources) continue; + ++g_applier.ResidualCapabilitiesCompared; const Bool carried = ((block.CapabilityBits >> i) & 1ull) != 0; - const Bool assembledBit = MGPipeApplyAccess::DeriveCapability(working, static_cast(i)); + const Bool assembledBit = MGPipeApplyAccess::DeriveCapability(working, cap); if (carried == assembledBit) continue; -#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY - MGLOG_F("MGPipe: Fatal{PipeResidualDiverged, \"%s\"} carried=%d assembled=%d", - kCapabilityNames[i], static_cast(carried), static_cast(assembledBit)); - std::abort(); -#else - MGLOG_E("MGPipe: residual value block diverged on %s (carried=%d assembled=%d)", - kCapabilityNames[i], static_cast(carried), static_cast(assembledBit)); -#endif + ++g_applier.ResidualDivergences; + MGP_TRIP_WIRE_REPORT("MGPipe: " MGP_TRIP_WIRE_TAG("PipeResidualDiverged, \"%s\"") + " carried=%d assembled=%d", + kCapabilityNames[i], static_cast(carried), + static_cast(assembledBit)); } } diff --git a/MobileGL/MG_Pipe/PipeApply.h b/MobileGL/MG_Pipe/PipeApply.h index 02dba7f0d..d37a84b67 100644 --- a/MobileGL/MG_Pipe/PipeApply.h +++ b/MobileGL/MG_Pipe/PipeApply.h @@ -55,6 +55,35 @@ namespace MobileGL::MG_Pipe { // set_residual_value_state; a disagreement is the D9 trip wire. ResidualValueBlock Residual{}; Bool HasResidual = false; + + // The GLOBAL chunk bits (MGPipeRenderStateSpans.h's numbering) this applier has + // itself scattered into the working block since the last reset - its own ledger of + // which bytes of PipeInputs::m_renderState are the APPLIER'S rather than the per-verb + // fill loop's. Both trip wires arm off it, and that is the whole of their contract: + // + // - with the render-state subsystem OFF (MOBILEGL_PIPE_PUSH bit 0 clear - the + // per-subsystem A/B of D14) nothing is ever scattered, the ledger stays empty and + // the wires say nothing. The working block is then the fill loop's, published per + // VERB CLASS (MG_Pipe/FillPoints.def), so at a kDispatch or kTextureOp verb - the + // two classes that publish IsCapabilityEnabled but NOT GetRenderStateParameters - + // it still holds the previous draw's bytes and is an oracle for nothing; + // - with it ON the applier is the block's only writer (D5 takes an emitted field + // out of the fill loop), so the bytes it has scattered are current at every verb + // of every class and comparing against them is honest. + // + // set_patch_state's own write to the working block deliberately does NOT enter the + // ledger: that is the OTHER carrier, and a wire comparing against bytes it had just + // written itself would be a tautology. + Uint32 ScatteredChunkBits = 0; + + // What the two trip wires last did. A wire nothing can observe is a gate that cannot + // go red for the reason it exists (ROADMAP.md), and only a poison or verify build + // aborts: the shipped push build counts and logs, so these counters are how a unit + // case sees the wire fire in EVERY build rather than in one. + Uint32 ResidualCapabilitiesCompared = 0; // of the 35, at the last set_residual_value_state + Uint32 ResidualDivergences = 0; // cumulative + Uint32 PatchCarrierComparisons = 0; // cumulative, armed set_patch_state calls only + Uint32 PatchCarrierDivergences = 0; // cumulative }; // The monolith's single applier. Under split there is one per served context. From ce370a3e84dcfd40a604b776a548683285c67793 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 09:51:14 -0400 Subject: [PATCH 082/529] [Test] (Pipe): drive both redundancy trip wires and the four apply entry points nothing in any build reached, and finish the setter walk's stencil faces and hint targets - Five of the applier's eight entry points had no caller in any build, including both wires the redundancy of the patch trio and of the residual block exists to arm. ROADMAP.md asks that every gate be able to go red for the reason it exists; these could not change colour at all. - Each wire is now driven in three states: DISARMED (the applier has not scattered the bytes it would compare, which is the MOBILEGL_PIPE_PUSH=0x10 shape), ARMED AND AGREEING, and ARMED AND DIVERGING. The diverging state is asserted in the form the build gives it - a poison or verify build aborts and the parent reads SIGABRT and the Fatal line out of the log, a shipped push build counts and logs - so neither case is skipped anywhere. - ResidualTripWireHoldsAcrossAVerbClassThatDoesNotPublishTheBlock is the draw-then-dispatch sequence itself: it aborts on the pre-ledger form of the wire and passes on this one. - The patch case carries a NaN outer level, a legal glPatchParameterfv value that must compare equal to itself, which is why the wire memcmps rather than compares. - set_pixel_pack_state, set_vertex_attrib_defaults and delete_render_state are driven and read back through the accessor a backend would use, each under the verb class that publishes it. MGPipeDeriveRenderStateFields - D-7's kept whole-block form, which production does not call - is checked against the chunk-scoped one. - The suite gains its own main() (and links gtest, not gtest_main) for PipeInputsTest's reason: the diverging cases read the wire's line back out of a log file this process names before anything logs. - SetterConsistency drives SetStencilOp and SetStencilFunc and SetStencilMask on BOTH faces and SetHint on all four targets. Face 0's Func ends chunk P2 and its three ops open P3, which face 1's Func closes, so the previous one-face-each walk never wrote P3 at all. --- MobileGL/MG_Test/Pipe/CMakeLists.txt | 36 +- .../MG_Test/Pipe/RenderStateSpansTest.cpp | 507 +++++++++++++++++- 2 files changed, 536 insertions(+), 7 deletions(-) diff --git a/MobileGL/MG_Test/Pipe/CMakeLists.txt b/MobileGL/MG_Test/Pipe/CMakeLists.txt index 680254d8b..04c4840b7 100644 --- a/MobileGL/MG_Test/Pipe/CMakeLists.txt +++ b/MobileGL/MG_Test/Pipe/CMakeLists.txt @@ -56,9 +56,13 @@ endif() # The four P2 suites. Their targets and this registration are the CONTRACT commit's; their # CONTENTS belong to the packages named in each file's header, so no package after A has to -# come back to this file. Each links gtest_main - none of them needs a main() of its own, -# unlike PipeInputsTest, whose abort cases read a log file back. -foreach(pipeTest RenderStateSpansTest TrackerTest SlotAllocatorTest CsoCacheTest) +# come back to this file. +# +# RenderStateSpansTest links gtest, not gtest_main, for PipeInputsTest's reason: its applier +# trip-wire cases read the wire's line back out of a log file, so the suite needs its own +# main() to point MOBILEGL_LOG_FILE_PATH at one before anything logs. The other three need no +# main() of their own. +foreach(pipeTest TrackerTest SlotAllocatorTest CsoCacheTest) add_executable(${pipeTest} ${pipeTest}.cpp) target_include_directories(${pipeTest} PRIVATE @@ -80,9 +84,33 @@ foreach(pipeTest RenderStateSpansTest TrackerTest SlotAllocatorTest CsoCacheTest endif() endforeach() +add_executable( + RenderStateSpansTest + RenderStateSpansTest.cpp +) + +target_include_directories(RenderStateSpansTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/MobileGL/MG_Pipe + ${MGL_ROOT}/3rdparty/xxHash + ${MGL_ROOT}/3rdparty/Vulkan-Headers/include + ${MGL_ROOT}/3rdparty/SPIRV-Reflect +) + +target_link_libraries(RenderStateSpansTest PRIVATE + GTest::gtest + ${LINK_LIBRARIES} +) + +if (MSVC) + target_compile_options(RenderStateSpansTest PRIVATE /Zc:preprocessor) +endif() + include(GoogleTest) gtest_discover_tests(PipeCatalogueTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(PipeInputsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) -foreach(pipeTest RenderStateSpansTest TrackerTest SlotAllocatorTest CsoCacheTest) +gtest_discover_tests(RenderStateSpansTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +foreach(pipeTest TrackerTest SlotAllocatorTest CsoCacheTest) gtest_discover_tests(${pipeTest} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) endforeach() diff --git a/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp b/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp index 7295f46d3..c49c94c0b 100644 --- a/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp +++ b/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp @@ -20,16 +20,42 @@ // DynamicChunksCoverMagmasDynamicTailKey - every GL-state input of DirectVulkan's // DynamicTailKey, against the dynamic half. // +// Plus the applier's own cases (section 6 at the bottom): the two REDUNDANCY TRIP WIRES of +// D9 and D10 driven in all three of their states - disarmed, armed and agreeing, armed and +// diverging - and the apply entry points nothing else in the suite reaches. A gate no +// command enters cannot go red for the reason it exists (ROADMAP.md), and until those cases +// existed five of the applier's eight entry points were called by nothing in any build. +// +// The suite therefore has its own main(), like PipeInputsTest: the diverging cases read the +// wire's line back out of a log file this process points MOBILEGL_LOG_FILE_PATH at before +// anything logs, and in a poison or verify build they fork, because the wire's verdict there +// is std::abort(). +// // The suite needs the push sources (MGPipeRenderStateSpans.cpp and PipeApply.cpp are // compiled only under MOBILEGL_PIPE_PUSH), so every case is a visible SKIP in a pull build // rather than a vanishing test - and the four names are the SAME four in every build, which // is what keeps `ctest -N` name-for-name identical between the pull and the push tree. #include +#include #include +#include +#include +#include #include +#include #include +#if !defined(_WIN32) +#include +#include +#include +#define MGTEST_HAVE_FORK 1 +#else +#include +#define MGTEST_HAVE_FORK 0 +#endif + #include "Includes.h" #include @@ -46,6 +72,27 @@ using namespace MobileGL; using namespace MobileGL::MG_Pipe; namespace { + // The log file main() points MOBILEGL_LOG_FILE_PATH at, and the readers the diverging + // cases use. Per PROCESS, because gtest_discover_tests runs every case as its own + // process, in parallel under ctest -j, and a shared name would let a sibling's line land + // in this process's read (PipeInputsTest's file header says the same). + std::string g_logPath; + + std::string ReadLog() { + std::ifstream in(g_logPath, std::ios::binary); + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); + } + + long ProcessId() { +#if defined(_WIN32) + return static_cast(::_getpid()); +#else + return static_cast(::getpid()); +#endif + } + #if MOBILEGL_PIPE_PUSH using MG_State::GLState::RenderState; using GLContext = MG_State::GLState::GLContext; @@ -318,7 +365,14 @@ namespace { check("SetPolygonOffset", [](RenderState& s) { s.SetPolygonOffset(1.5f, 2.5f); }); check("SetPolygonOffsetClamped", [](RenderState& s) { s.SetPolygonOffsetClamped(3.5f, 4.5f, 0.25f); }); check("SetClipControl", [](RenderState& s) { s.SetClipControl(GL_UPPER_LEFT, GL_ZERO_TO_ONE); }); - check("SetHint", [](RenderState& s) { s.SetHint(GL_LINE_SMOOTH_HINT, GL_NICEST); }); + // All four hint targets: SetHint dispatches on the target to one of four separate + // members, so driving one of them leaves three unwritten by the walk. + check("SetHint(line smooth)", [](RenderState& s) { s.SetHint(GL_LINE_SMOOTH_HINT, GL_NICEST); }); + check("SetHint(polygon smooth)", [](RenderState& s) { s.SetHint(GL_POLYGON_SMOOTH_HINT, GL_NICEST); }); + check("SetHint(texture compression)", + [](RenderState& s) { s.SetHint(GL_TEXTURE_COMPRESSION_HINT, GL_FASTEST); }); + check("SetHint(fragment shader derivative)", + [](RenderState& s) { s.SetHint(GL_FRAGMENT_SHADER_DERIVATIVE_HINT, GL_FASTEST); }); check("SetPointFadeThresholdSize", [](RenderState& s) { s.SetPointFadeThresholdSize(2.5f); }); check("SetPointSpriteCoordOrigin", [](RenderState& s) { s.SetPointSpriteCoordOrigin(GL_LOWER_LEFT); }); check("SetClampReadColor", [](RenderState& s) { s.SetClampReadColor(GL_TRUE); }); @@ -440,11 +494,22 @@ namespace { } check("SetStencilFunc(func)", [](RenderState& s) { s.SetStencilFunc(StencilFace::Front, DepthTestFunc::NotEqual, 7, 0xffu); }); - check("SetStencilMask", [](RenderState& s) { s.SetStencilMask(StencilFace::Back, 0x0fu); }); - check("SetStencilOp", [](RenderState& s) { + // BOTH FACES of both, because the two faces are four different chunks: face 0's Func + // ends chunk P2 and its three ops open P3, which face 1's Func closes. A walk that + // drove SetStencilOp on Back only and SetStencilFunc on Front only never writes P3 at + // all, and a boundary mistake inside it would be invisible here. + check("SetStencilFunc(back, func)", + [](RenderState& s) { s.SetStencilFunc(StencilFace::Back, DepthTestFunc::Less, 3, 0x0fu); }); + check("SetStencilMask(back)", [](RenderState& s) { s.SetStencilMask(StencilFace::Back, 0x0fu); }); + check("SetStencilMask(front)", [](RenderState& s) { s.SetStencilMask(StencilFace::Front, 0x33u); }); + check("SetStencilOp(back)", [](RenderState& s) { s.SetStencilOp(StencilFace::Back, StencilOperation::Replace, StencilOperation::IncrementClamp, StencilOperation::DecrementWrap); }); + check("SetStencilOp(front)", [](RenderState& s) { + s.SetStencilOp(StencilFace::Front, StencilOperation::Invert, StencilOperation::DecrementClamp, + StencilOperation::IncrementWrap); + }); // ---- Colour mask, clear state, sampling ---- check("SetColorMask", [](RenderState& s) { s.SetColorMask(BoolVec4(true, false, true, false)); }); @@ -862,6 +927,442 @@ namespace { ctx.SetCapability(cap, !ctx.IsCapabilityEnabled(cap)); push(("step 9: capability " + std::to_string(i)).c_str()); } +#endif + } + + // ===================================================================================== + // 6. THE APPLIER'S OWN CASES: the two redundancy trip wires, and the entry points + // nothing else drives. + // + // ROADMAP.md: every gate must be able to go red for the reason it exists. Both wires + // are therefore driven in three states - DISARMED (the applier has not scattered the + // bytes the wire compares against), ARMED AND AGREEING, ARMED AND DIVERGING - and the + // diverging state is asserted in whatever form the build gives it: a poison or verify + // build aborts and the parent reads SIGABRT and the Fatal line out of the log; a + // shipped push build counts the divergence and logs it, and that is asserted instead. + // Neither is skipped anywhere, so `ctest -R Residual` reaches the wire and not only + // the static_asserts. + // ===================================================================================== +#if MOBILEGL_PIPE_PUSH + constexpr SizeT kCapCount = static_cast(CapabilityInput::CapabilityInputCount); + + // A live frontend context and a clean applier, restored on the way out (SanityTest's + // idiom, and the same guard cases 3 and 5 declare inline). + struct ApplierContextGuard { + UniquePtr Previous; + ApplierContextGuard() : Previous(Move(MG_State::pGLContext)) { + MG_State::pGLContext = MakeUnique(); + MGPipeApplierReset(); + } + ~ApplierContextGuard() { + MGPipeApplierReset(); + MG_State::pGLContext = Move(Previous); + } + }; + + // The tracker's whole-block emission: one brand-new CSO carrying every pipeline chunk, + // its bind, and then every dynamic chunk. `withDynamic` false stops after the bind - + // the state in which the applier owns the pipeline half and not the dynamic one, which + // is what the residual wire's per-capability arming is about. + void ApplyWholeBlockFromContext(GLContext& ctx, Uint32& nextSlot, Bool withDynamic) { + const RenderStateParameters& live = ctx.GetRenderStateParameters(); + const Uint32 allPipeline = static_cast((Uint64{1} << kMGPipePipelineChunkCount) - 1); + const Uint32 allDynamic = static_cast((Uint64{1} << kMGPipeDynamicChunkCount) - 1); + + Array pipelineBytes{}; + MGPipeGatherPipelineBytes(live, pipelineBytes.data()); + MGPRenderStateDesc desc{}; + desc.Cso = MGPipeHandle{nextSlot++, 0}; + desc.BaseCso = kMGPipeNullHandle; + desc.ChunkMask = allPipeline; + MGPipeApplyCreateRenderState(desc, pipelineBytes.data()); + + MGPBindRenderState bind{}; + bind.Cso = desc.Cso; + bind.Version = static_cast(ctx.GetRenderStateParametersVersion()); + bind.PipelineVersion = static_cast(ctx.GetPipelineStateVersion()); + MGPipeApplyBindRenderState(bind); + if (!withDynamic) return; + + Vector dynamicBytes(MGPipeDynamicChunkBlobBytes(allDynamic)); + MGPipeGatherDynamicChunks(live, allDynamic, dynamicBytes.data()); + MGPDynamicState dyn{}; + dyn.ChunkMask = allDynamic; + dyn.Version = bind.Version; + MGPipeApplySetDynamicState(dyn, dynamicBytes.data()); + } + + // The residual block the tracker would emit for this context: the 35 capability answers + // the FRONTEND gives, packed in enum order. The wire's job is to disagree with the + // assembled block when the two have parted, so the carried side has to come from the + // frontend and not from the block. + ResidualValueBlock CarriedBitsOf(GLContext& ctx) { + ResidualValueBlock block{}; + for (SizeT i = 0; i < kCapCount; ++i) { + if (ctx.IsCapabilityEnabled(static_cast(i))) { + block.CapabilityBits |= Uint64{1} << i; + } + } + return block; + } + + MGPPatchState PatchStateOf(Uint32 vertices, const FloatVec4& outer, const FloatVec2& inner) { + MGPPatchState patch{}; + patch.Vertices = vertices; + patch.Outer[0] = outer.x(); + patch.Outer[1] = outer.y(); + patch.Outer[2] = outer.z(); + patch.Outer[3] = outer.w(); + patch.Inner[0] = inner.x(); + patch.Inner[1] = inner.y(); + return patch; + } + +#if MGTEST_HAVE_FORK + struct ChildResult { + int Status = -1; + std::string Log; + }; + + // Runs `body` in a forked child and returns its wait status and log delta. The child + // must not use gtest assertions; it _exit(0)s when `body` returns, so a body expected to + // die must be asserted dead by the parent (PipeInputsTest's shape, and its reason: + // gtest's own death tests are not used in this repository). + template + ChildResult RunInChild(Body body) { + ChildResult result; + // The log file is opened by the CHILD - nothing in the parent has logged at this + // point - and a process opens it FRESH, so two children in one process would each + // start writing at byte 0 and a delta taken against "what was there before" would be + // a substring of the second child's own line. Under ctest every case is its own + // process and the question never arises; running the binary by hand it does. So the + // file is removed first and the whole of what the child left is what comes back. + std::error_code ec; + std::filesystem::remove(g_logPath, ec); + std::fflush(nullptr); + const pid_t pid = ::fork(); + if (pid < 0) return result; + if (pid == 0) { + body(); + ::_exit(0); + } + int status = 0; + if (::waitpid(pid, &status, 0) != pid) return result; + result.Status = status; + result.Log = ReadLog(); + return result; + } + + Bool DiedOfAbort(const ChildResult& r) { return WIFSIGNALED(r.Status) && WTERMSIG(r.Status) == SIGABRT; } + std::string DescribeStatus(const ChildResult& r) { + if (r.Status < 0) return "fork/waitpid failed"; + if (WIFEXITED(r.Status)) return "exited " + std::to_string(WEXITSTATUS(r.Status)); + if (WIFSIGNALED(r.Status)) return "signal " + std::to_string(WTERMSIG(r.Status)); + return "status " + std::to_string(r.Status); + } +#endif // MGTEST_HAVE_FORK +#endif // MOBILEGL_PIPE_PUSH + + // ------------------------------------------------------------------------------------- + // 6a. The residual trip wire is SILENT until the applier owns the bytes it would compare. + // + // This is the shape MOBILEGL_PIPE_PUSH=0x10 has - the residual subsystem on and the + // render-state subsystem off, which D14 makes a legal per-subsystem A/B. The working + // block is then the per-verb fill loop's, published per verb CLASS, and disagreeing + // with it means nothing. An earlier form of this wire aborted here. + // ------------------------------------------------------------------------------------- + TEST(RenderStateSpans, ResidualTripWireIsSilentUntilTheApplierOwnsTheBytes) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +#else + ApplierContextGuard guard; + GLContext& ctx = *MG_State::pGLContext; + Uint32 nextSlot = kMGPipeFirstAllocatableSlot; + + // Nothing scattered: a block that disagrees on EVERY capability must pass in silence. + ResidualValueBlock everything{}; + everything.CapabilityBits = ~Uint64{0}; + MGPipeApplySetResidualValueState(everything); + EXPECT_EQ(MGPipeApplier().ScatteredChunkBits, 0u); + EXPECT_EQ(MGPipeApplier().ResidualCapabilitiesCompared, 0u); + EXPECT_EQ(MGPipeApplier().ResidualDivergences, 0u); + + // A bind owns the PIPELINE half, which answers 27 of the 35; the eight ClipDistances + // are answered from a dynamic chunk and stay unarmed. That grain is why the arming is + // per capability and not per applier. + ApplyWholeBlockFromContext(ctx, nextSlot, /*withDynamic=*/false); + MGPipeApplySetResidualValueState(CarriedBitsOf(ctx)); + const Uint32 armedByThePipelineHalf = MGPipeApplier().ResidualCapabilitiesCompared; + // SOME, and not all. The exact number is 27 under the shipped table, but it is a + // consequence of the table rather than of the wire, so the case asserts the property + // and lets the two directions below say which capabilities are on which side - a + // hard-coded 27 would turn any legitimate boundary move into a failure here as well + // as in the two cases that exist to catch it. + EXPECT_GT(armedByThePipelineHalf, 0u); + EXPECT_LT(armedByThePipelineHalf, static_cast(kCapCount)) + << "a bind alone cannot answer a capability whose mask is in the dynamic half"; + EXPECT_EQ(MGPipeApplier().ResidualDivergences, 0u); + + // ... and a block that disagrees on a CLIP DISTANCE still says nothing, because the + // chunk its answer is read out of has not been scattered. + ResidualValueBlock clipOnly = CarriedBitsOf(ctx); + clipOnly.CapabilityBits ^= Uint64{1} << static_cast(CapabilityInput::ClipDistance3); + MGPipeApplySetResidualValueState(clipOnly); + EXPECT_EQ(MGPipeApplier().ResidualDivergences, 0u); + + // The dynamic half arms the remaining eight. + ApplyWholeBlockFromContext(ctx, nextSlot, /*withDynamic=*/true); + MGPipeApplySetResidualValueState(CarriedBitsOf(ctx)); + EXPECT_EQ(MGPipeApplier().ResidualCapabilitiesCompared, static_cast(kCapCount)); + EXPECT_EQ(MGPipeApplier().ResidualDivergences, 0u); +#endif + } + + // ------------------------------------------------------------------------------------- + // 6b. The armed wire HOLDS at a verb class that does not publish the working block. + // + // A draw, a capability change, then a DISPATCH. kDispatch publishes + // IsCapabilityEnabled and NOT GetRenderStateParameters (MG_Pipe/FillPoints.def), so + // PipeInputs::m_capability is refilled from the live context here and the working + // block is not - which is exactly why the block has to be the APPLIER'S to be an + // oracle. It is: the applier scattered it, and the emission for this verb keeps it + // current. This is the case that pins the class contract shut. + // ------------------------------------------------------------------------------------- + TEST(RenderStateSpans, ResidualTripWireHoldsAcrossAVerbClassThatDoesNotPublishTheBlock) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +#else + ApplierContextGuard guard; + GLContext& ctx = *MG_State::pGLContext; + Uint32 nextSlot = kMGPipeFirstAllocatableSlot; + { + MG_Test::ScopedPipeVerb draw(MGPipeVerb::DrawArrays); + ApplyWholeBlockFromContext(ctx, nextSlot, /*withDynamic=*/true); + MGPipeApplySetResidualValueState(CarriedBitsOf(ctx)); + } + EXPECT_EQ(MGPipeApplier().ResidualDivergences, 0u); + + ctx.SetCapability(CapabilityInput::Dither, !ctx.IsCapabilityEnabled(CapabilityInput::Dither)); + { + MG_Test::ScopedPipeVerb dispatch(MGPipeVerb::DispatchCompute); + ApplyWholeBlockFromContext(ctx, nextSlot, /*withDynamic=*/true); + MGPipeApplySetResidualValueState(CarriedBitsOf(ctx)); + } + EXPECT_EQ(MGPipeApplier().ResidualCapabilitiesCompared, static_cast(kCapCount)); + EXPECT_EQ(MGPipeApplier().ResidualDivergences, 0u); +#endif + } + + // ------------------------------------------------------------------------------------- + // 6c. The armed wire FIRES, naming the capability. The red half of 6a/6b. + // ------------------------------------------------------------------------------------- + TEST(RenderStateSpans, ResidualTripWireFiresNamingTheCapability) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +#else + ApplierContextGuard guard; + GLContext& ctx = *MG_State::pGLContext; + Uint32 nextSlot = kMGPipeFirstAllocatableSlot; + ApplyWholeBlockFromContext(ctx, nextSlot, /*withDynamic=*/true); + + ResidualValueBlock diverging = CarriedBitsOf(ctx); + diverging.CapabilityBits ^= Uint64{1} << static_cast(CapabilityInput::Dither); + +#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY +#if MGTEST_HAVE_FORK + const ChildResult child = RunInChild([&diverging]() { MGPipeApplySetResidualValueState(diverging); }); + EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log; + EXPECT_NE(child.Log.find("Fatal{PipeResidualDiverged, \"Dither\"}"), std::string::npos) + << "the wire fired without naming the capability; log: " << child.Log; +#else + GTEST_SKIP() << "no fork on this platform; the wire's verdict here is std::abort()"; +#endif +#else + // A shipped push build counts and logs rather than aborting, so the wire is asserted + // in the form this build gives it. + const std::string before = ReadLog(); + MGPipeApplySetResidualValueState(diverging); + EXPECT_EQ(MGPipeApplier().ResidualDivergences, 1u); + EXPECT_NE(ReadLog().substr(before.size()).find("PipeResidualDiverged, \"Dither\""), std::string::npos) + << "the wire counted a divergence without logging which capability"; +#endif +#endif + } + + // ------------------------------------------------------------------------------------- + // 6d. The patch-carrier trip wire, in all three states - including a NaN outer level, + // which is a legal glPatchParameterfv value that must compare EQUAL to itself and + // which a `==` comparison would call a divergence. + // ------------------------------------------------------------------------------------- + TEST(RenderStateSpans, PatchCarrierTripWireFiresOnlyWhenTheApplierOwnsChunkP0) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +#else + ApplierContextGuard guard; + GLContext& ctx = *MG_State::pGLContext; + Uint32 nextSlot = kMGPipeFirstAllocatableSlot; + + // Disarmed: nothing has scattered chunk P0, so a set_patch_state that disagrees with + // whatever the working block happens to hold says nothing. + MGPipeApplySetPatchState(PatchStateOf(7, FloatVec4(11.f, 12.f, 13.f, 14.f), FloatVec2(15.f, 16.f))); + EXPECT_EQ(MGPipeApplier().PatchCarrierComparisons, 0u); + EXPECT_EQ(MGPipeApplier().PatchCarrierDivergences, 0u); + + const float nan = std::numeric_limits::quiet_NaN(); + const FloatVec4 outer(nan, 3.f, 4.f, 5.f); + const FloatVec2 inner(6.f, 7.f); + ctx.SetPatchVertices(4); + ctx.SetPatchDefaultOuterLevel(outer); + ctx.SetPatchDefaultInnerLevel(inner); + ApplyWholeBlockFromContext(ctx, nextSlot, /*withDynamic=*/true); + + // Armed and agreeing, NaN included. + MGPipeApplySetPatchState(PatchStateOf(4, outer, inner)); + EXPECT_EQ(MGPipeApplier().PatchCarrierComparisons, 1u); + EXPECT_EQ(MGPipeApplier().PatchCarrierDivergences, 0u); + + // Armed and diverging: the two carriers have parted on the vertex count. + const MGPPatchState diverging = PatchStateOf(3, outer, inner); +#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY +#if MGTEST_HAVE_FORK + const ChildResult child = RunInChild([&diverging]() { MGPipeApplySetPatchState(diverging); }); + EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log; + EXPECT_NE(child.Log.find("Fatal{PipePatchCarriersDiffer}"), std::string::npos) + << "the wire fired without saying so; log: " << child.Log; +#else + GTEST_SKIP() << "no fork on this platform; the wire's verdict here is std::abort()"; +#endif +#else + const std::string before = ReadLog(); + MGPipeApplySetPatchState(diverging); + EXPECT_EQ(MGPipeApplier().PatchCarrierDivergences, 1u); + EXPECT_NE(ReadLog().substr(before.size()).find("PipePatchCarriersDiffer"), std::string::npos); +#endif +#endif + } + + // ------------------------------------------------------------------------------------- + // 6e. D-7's kept WHOLE-BLOCK derivation entry point. Under split a scatter can arrive + // without a chunk mask, so MGPipeDeriveRenderStateFields stays; nothing in production + // calls it, and this is what says it still answers what the scoped form answers. + // ------------------------------------------------------------------------------------- + TEST(RenderStateSpans, WholeBlockDerivationAgreesWithTheChunkScopedOne) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +#else + ApplierContextGuard guard; + GLContext& ctx = *MG_State::pGLContext; + MG_Test::ScopedPipeVerb verb(MGPipeVerb::DrawArrays); + + ctx.SetViewportIndexed(0, FloatVec4(1.5f, 2.5f, 63.5f, 32.25f)); + ctx.SetBlendFuncIndexed(0, BlendFactor::DstColor, BlendFactor::SrcColor, BlendFactor::DstAlpha, + BlendFactor::SrcAlpha); + ctx.SetCapability(CapabilityInput::Dither, !ctx.IsCapabilityEnabled(CapabilityInput::Dither)); + ctx.SetCapability(CapabilityInput::ClipDistance5, true); + ctx.SetStencilFunc(StencilFace::Back, DepthTestFunc::Equal, 7, 0xf0u); + + Uint32 nextSlot = kMGPipeFirstAllocatableSlot; + ApplyWholeBlockFromContext(ctx, nextSlot, /*withDynamic=*/true); + ExpectDerivedDrawFieldsMatch(ctx, "the chunk-scoped derivation"); + + MGPipeDeriveRenderStateFields(gPipeInputs); + ExpectDerivedDrawFieldsMatch(ctx, "the whole-block derivation"); +#endif + } + + // ------------------------------------------------------------------------------------- + // 6f. The three remaining apply entry points, each driven and each read back through the + // accessor a backend would use - under the verb class that publishes it, because the + // fill table is still the only thing that says what a verb may read. + // ------------------------------------------------------------------------------------- + TEST(RenderStateSpans, TheRemainingApplyEntryPointsReachPipeInputs) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +#else + ApplierContextGuard guard; + GLContext& ctx = *MG_State::pGLContext; + + // set_pixel_pack_state. PACK only, deliberately - the unpack half has no carrier and + // keeps going through the residual fill loop, which is why Coverage.def no longer + // names GetPixelStoreParameters as emitted. Applied AFTER the verb's fill, or the + // fill would answer this comparison with its own copy. + { + MG_Test::ScopedPipeVerb readback(MGPipeVerb::ReadPixels); + MGPPixelPackState pack{}; + pack.Pack.Alignment = 8; + pack.Pack.RowLength = 37; + pack.Pack.SkipRows = 5; + pack.Pack.SwapBytes = true; + MGPipeApplySetPixelPackState(pack); + const PixelStoreParameters got = gPipeInputs.GetPixelStoreParameters(false); + EXPECT_EQ(got.Alignment, 8); + EXPECT_EQ(got.RowLength, 37); + EXPECT_EQ(got.SkipRows, 5); + EXPECT_TRUE(got.SwapBytes); + } + + // set_vertex_attrib_defaults: a var-tail call, and the one consumer of the set-hash + // suppressor. The tail is in ascending location order and Count matches Mask, which + // is the contract the applier now enforces in every build rather than in a debug one. + { + MG_Test::ScopedPipeVerb draw(MGPipeVerb::DrawArrays); + MGPVertexAttribDefaults hdr{}; + hdr.Mask = (1u << 2) | (1u << 9); + hdr.Count = 2; + MGPAttribValue tail[2]{}; + tail[0].Location = 2; + const float first[4] = {1.5f, 2.5f, 3.5f, 4.5f}; + std::memcpy(tail[0].Data, first, sizeof(first)); + tail[1].Location = 9; + const float second[4] = {-1.f, 0.f, 0.5f, 1.f}; + std::memcpy(tail[1].Data, second, sizeof(second)); + MGPipeApplySetVertexAttribDefaults(hdr, tail); + + EXPECT_EQ(gPipeInputs.GetCurrentVertexAttribute(2).floatValue[0], 1.5f); + EXPECT_EQ(gPipeInputs.GetCurrentVertexAttribute(2).floatValue[3], 4.5f); + EXPECT_EQ(gPipeInputs.GetCurrentVertexAttribute(9).floatValue[2], 0.5f); + } + + // delete_render_state: the record stops being live and a bound handle stops being + // bound. The client allocator owns the Gen bump on REUSE, so the record's Gen does + // not move here - a server-side bump would put the two identities out of step. + { + Uint32 nextSlot = kMGPipeFirstAllocatableSlot; + const Uint32 slot = nextSlot; + ApplyWholeBlockFromContext(ctx, nextSlot, /*withDynamic=*/true); + ASSERT_LT(slot, MGPipeApplier().RenderStateCsos.size()); + EXPECT_TRUE(MGPipeApplier().RenderStateCsos[slot].Live); + EXPECT_FALSE(MGPipeHandleIsNull(MGPipeApplier().BoundRenderStateCso)); + + MGPHandleOnly handle{}; + handle.Handle = MGPipeHandle{slot, 0}; + handle.Kind = static_cast(MGPipeKind::RenderStateCso); + MGPipeApplyDeleteRenderState(handle); + EXPECT_FALSE(MGPipeApplier().RenderStateCsos[slot].Live); + EXPECT_EQ(MGPipeApplier().RenderStateCsos[slot].Gen, 0u); + EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundRenderStateCso)); + } #endif } } // namespace + +int main(int argc, char** argv) { + // Before anything logs: MG_Util::Debug::InitFile() reads the variable once, on the first + // write, and caches the FILE*. The name carries this process's pid, and the file is + // removed on the way out; a forked child that aborts leaves it to us. + namespace fs = std::filesystem; + const fs::path path = + fs::temp_directory_path() / ("mobilegl-renderstatespans-test-" + std::to_string(ProcessId()) + ".log"); + std::error_code ec; + fs::remove(path, ec); + g_logPath = path.string(); +#if defined(_WIN32) + _putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str()); +#else + setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); +#endif + ::testing::InitGoogleTest(&argc, argv); + const int rc = RUN_ALL_TESTS(); + fs::remove(path, ec); + return rc; +} From d1a7c5f1595c84dccfeeb82595a6b55617350f49 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 09:51:14 -0400 Subject: [PATCH 083/529] [Fix] (Pipe): stop claiming set_pixel_pack_state supplies a field it only half writes, and fold the chunk boundaries into the subset hash's seed - Coverage.def's emitted list named GetPixelStoreParameters, but the field is PipeInputs::m_pixelStore[2] - pack AND unpack - and set_pixel_pack_state carries the pack half only, deliberately and permanently. An emitted row is a licence for the residual fill loop to skip the field, so the moment the render-state bitmask has its bit set the unpack half would be written by nothing while its poison stamp said it was published, invisible to the poison and to the verify comparator alike. The row is gone and the reason is in the file; the pack half is simply written twice until the field is split. - kMGPipeRenderStateChunkTableVersion was a promise nobody enforced: a boundary could move, the two byte-count assertions be updated, and every persisted key stay valid. The hash is now seeded with the version XOR a compile-time checksum of the boundary table, so a moved boundary invalidates the keys whether or not anyone remembered - and without a static_assert on the boundaries, which would turn G7's negative control into a build break instead of a red test. --- MobileGL/MG_Pipe/Coverage.def | 11 +++++++++- MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp | 2 +- MobileGL/MG_Pipe/MGPipeRenderStateSpans.h | 23 ++++++++++++++++++--- MobileGL/MG_Pipe/generated/PipeFilled.inc | 6 ++---- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/MobileGL/MG_Pipe/Coverage.def b/MobileGL/MG_Pipe/Coverage.def index f43eeed75..4ee3ab586 100644 --- a/MobileGL/MG_Pipe/Coverage.def +++ b/MobileGL/MG_Pipe/Coverage.def @@ -141,6 +141,16 @@ // The one row whose call differs from the accessor list's is GetPrimitiveRestartIndex: // coverage maps it onto draw_vbo because that is where a backend reads it, but the VALUE // travels in dynamic chunk D6, so set_dynamic_state is what supplies it. +// +// GetPixelStoreParameters is DELIBERATELY ABSENT, and the reason is the shape of the field +// rather than of the call. The field is PipeInputs::m_pixelStore[2] - pack AND unpack - and +// set_pixel_pack_state carries the PACK half only, deliberately and permanently (D10, +// ARCHITECTURE.md 4.6 D5: nothing on the far side of the boundary reads unpack state). A row +// here says "this field is supplied, the fill loop may skip it", which would be a half-truth: +// the moment the render-state bitmask has its bit set, the unpack half would be written by +// nothing while its poison stamp said it was published, so neither the poison nor the verify +// comparator could see it. Until the field is split, the whole of it keeps going through the +// fill loop and the pack half is simply written twice. #define MGP_COVERAGE_EMITTED_LIST(X) \ X(GetBlendColor, SetDynamicState) \ X(GetBlendEquationIndexed, CreateRenderState) \ @@ -162,7 +172,6 @@ X(GetPatchDefaultOuterLevel, SetPatchState) \ X(GetPatchVertices, SetPatchState) \ X(GetPipelineStateVersion, BindRenderState) \ - X(GetPixelStoreParameters, SetPixelPackState) \ X(GetPolygonModeFront, CreateRenderState) \ X(GetPolygonOffsetFactor, SetDynamicState) \ X(GetPolygonOffsetUnits, SetDynamicState) \ diff --git a/MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp b/MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp index c0342f09a..6a7d636b5 100755 --- a/MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp +++ b/MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp @@ -182,7 +182,7 @@ namespace MobileGL::MG_Pipe { Uint64 MGPipeHashPipelineBytes(const void* bytes) { return static_cast( - XXH64(bytes, kMGPipePipelineChunkBytes, kMGPipeRenderStateChunkTableVersion)); + XXH64(bytes, kMGPipePipelineChunkBytes, kMGPipeRenderStateChunkTableSeed)); } Uint64 MGPipeComputePipelineSubsetHash(const RenderStateParameters& params) { diff --git a/MobileGL/MG_Pipe/MGPipeRenderStateSpans.h b/MobileGL/MG_Pipe/MGPipeRenderStateSpans.h index 1922d52b6..d0453c9f3 100644 --- a/MobileGL/MG_Pipe/MGPipeRenderStateSpans.h +++ b/MobileGL/MG_Pipe/MGPipeRenderStateSpans.h @@ -135,11 +135,28 @@ namespace MobileGL::MG_Pipe { inline constexpr SizeT kMGPipePipelineChunkBytes = MGPipeRenderStateChunkDetail::BytesOfHalf(true); inline constexpr SizeT kMGPipeDynamicChunkBytes = MGPipeRenderStateChunkDetail::BytesOfHalf(false); - // Seeds MGPipeComputePipelineSubsetHash, so a chunk-table change invalidates every - // persisted key rather than silently aliasing an old one. BUMP IT whenever a boundary, - // an ordering or the halves' membership moves. + // Bumped by hand when something about the table changes that its BYTES do not show - + // the halves' membership, the meaning of a chunk, the gather order. inline constexpr Uint64 kMGPipeRenderStateChunkTableVersion = 1; + // What actually seeds MGPipeComputePipelineSubsetHash. The version above is a promise a + // reader has to keep; this is the part that keeps itself. Folding the boundary table into + // the seed means a moved boundary invalidates every persisted key whether or not anyone + // remembered to bump the version - and it does so WITHOUT a static_assert on the + // boundaries, which would turn G7's negative control (which moves a boundary on purpose + // and must still compile) into a build break. + namespace MGPipeRenderStateChunkDetail { + constexpr Uint64 BoundaryChecksum() { + Uint64 hash = 0xcbf29ce484222325ull; // FNV-1a, 64-bit + for (SizeT i = 0; i <= kMGPipeRenderStateChunkCount; ++i) { + hash = (hash ^ static_cast(kMGPipeRenderStateChunkBoundaries[i])) * 0x100000001b3ull; + } + return hash; + } + } // namespace MGPipeRenderStateChunkDetail + inline constexpr Uint64 kMGPipeRenderStateChunkTableSeed = + kMGPipeRenderStateChunkTableVersion ^ MGPipeRenderStateChunkDetail::BoundaryChecksum(); + // ---- the trip wires. A mistake in the table is a build break, here. ---- static_assert(kMGPipeRenderStateChunkBoundaries[0] == 0, "the chunk table must start at byte 0 of RenderStateParameters"); diff --git a/MobileGL/MG_Pipe/generated/PipeFilled.inc b/MobileGL/MG_Pipe/generated/PipeFilled.inc index 90ee5fb72..d24e51423 100644 --- a/MobileGL/MG_Pipe/generated/PipeFilled.inc +++ b/MobileGL/MG_Pipe/generated/PipeFilled.inc @@ -309,7 +309,6 @@ enum class MGPipeFieldEmitter : Uint8 { CreateRenderState, SetDynamicState, SetPatchState, - SetPixelPackState, SetVertexAttribDefaults, }; @@ -319,7 +318,6 @@ inline constexpr const char* kMGPipeFieldEmitterNames[] = { "CreateRenderState", "SetDynamicState", "SetPatchState", - "SetPixelPackState", "SetVertexAttribDefaults", }; @@ -354,7 +352,7 @@ inline constexpr MGPipeFieldEmitter kMGPipeFieldEmittedBy[kMGPipeInputFieldCount MGPipeFieldEmitter::SetPatchState, // GetPatchDefaultOuterLevel MGPipeFieldEmitter::SetPatchState, // GetPatchVertices MGPipeFieldEmitter::BindRenderState, // GetPipelineStateVersion - MGPipeFieldEmitter::SetPixelPackState, // GetPixelStoreParameters + MGPipeFieldEmitter::kNone, // GetPixelStoreParameters MGPipeFieldEmitter::CreateRenderState, // GetPolygonModeFront MGPipeFieldEmitter::SetDynamicState, // GetPolygonOffsetFactor MGPipeFieldEmitter::SetDynamicState, // GetPolygonOffsetUnits @@ -388,7 +386,7 @@ inline constexpr MGPipeFieldEmitter kMGPipeFieldEmittedBy[kMGPipeInputFieldCount MGPipeFieldEmitter::kNone, // GetBoundTransformFeedbackLifetimeId MGPipeFieldEmitter::kNone, // HasOpenTransformFeedbackSpan }; -inline constexpr SizeT kMGPipeEmittedFieldCount = 34; +inline constexpr SizeT kMGPipeEmittedFieldCount = 33; struct MGPipeFilledState { Uint64 CurrentVerbSerial; From 842af23331819fcddfe59b9d64954faf946f56d7 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 09:51:14 -0400 Subject: [PATCH 084/529] [Chore] (Pipe): drop the executable bit from MGPipeRenderStateSpans.cpp - A mode change on its own, so it does not ride inside a code commit. The other three of the four files created with 0755 were already corrected; this is the last one. --- MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp diff --git a/MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp b/MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp old mode 100755 new mode 100644 From f4dbea2300c38316d72e35367a67ba0395a05e7f Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:02:18 -0400 Subject: [PATCH 085/529] [Feat] (Espryt): give the backend a dense {slot, gen} twin table beside the address-keyed registry - SlotTables.h: BackendSlotTable, indexed by MGPipeHandle::Slot and validated by Gen, with the handle minted by the client MGPipeSlotAllocator off the frontend object GetLifetimeId(). A lookup is one bounds check plus one array index, and unlike the registry Find it never mutates the table, so a returned BackendPtr* is not invalidated by the next call on it. - StateBackendObjectRegistry keeps its name, its signature and all ~40 call sites, and becomes the two-arm facade ARCHITECTURE.md 9.6 asks for: the pre-handle map under MOBILEGL_PIPE_LEGACY_MEMOS, the slot table under MOBILEGL_PIPE_PUSH, chosen once per process by EsprytSlotTablesEnabled() off kMGPipeSubsystemEsprytSlots. A clear bit with MOBILEGL_PIPE_LEGACY_MEMOS=0 leaves no arm at all and is Fatal{PipeLegacyMemosDisabled}. - The kind is a template parameter only in the push build (MGB_TWIN_KIND_ARG): a third template argument would rename every instantiation and G1 wants the pull build byte identical. Pull-build symbol report is 0 added / 0 removed / 0 renamed and adds no resize beyond the three RenderState symbols the contract commit already moved. - ForEachLive replaces begin()/end() under push and hands the callee a strong reference to the state object instead of the map key, which was the raw frontend address. - Nothing switches over yet: the tables are built and reachable, and the twins still go through whichever arm the bit selects. --- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 43 ++++- MobileGL/MG_Backend/DirectGLES/Managers.h | 125 +++++++++++-- MobileGL/MG_Backend/DirectGLES/SlotTables.h | 188 ++++++++++++++++++++ 3 files changed, 334 insertions(+), 22 deletions(-) create mode 100644 MobileGL/MG_Backend/DirectGLES/SlotTables.h diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 81e4ca642..7171a2a04 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -170,6 +170,37 @@ namespace MobileGL::MG_Backend::DirectGLES { [] { std::atexit(+[] { g_processTeardown = true; }); }); } +#if MOBILEGL_PIPE_PUSH + Bool EsprytSlotTablesEnabled() { + // Latched once, not read per call: the two arms of StateBackendObjectRegistry keep + // their twins in different containers, so an answer that changed mid-run would strand + // every twin already built (and, for the driver ids those twins own, leak them). + static const Bool enabled = [] { + const Bool bitSet = + (MG_Config::Features.PipePush & MG_Pipe::kMGPipeSubsystemEsprytSlots) != 0; +#if MOBILEGL_PIPE_LEGACY_MEMOS + if (!bitSet && !MG_Config::Features.PipeLegacyMemos) { + // The operator asked for the handle arm to be OFF and the legacy arm to be + // unreachable at the same time, which leaves no arm at all. Say so at startup + // rather than silently running the thing they turned off (ARCHITECTURE.md 9.6). + MGLOG_F("MGPipe: Fatal{PipeLegacyMemosDisabled, \"kMGPipeSubsystemEsprytSlots " + "is clear but MOBILEGL_PIPE_LEGACY_MEMOS=0\"}"); + } + return bitSet; +#else + // The legacy arm is not compiled, so the handle arm is the only arm. The bit still + // decides nothing here; it is recorded so a log reader sees the mismatch. + if (!bitSet) { + MGLOG_D("MGPipe: kMGPipeSubsystemEsprytSlots is clear but this build has no " + "legacy twin registry; running the handle arm anyway"); + } + return true; +#endif + }(); + return enabled; + } +#endif + Bool VertexStageStorageBlockUsable(Int maxVertexShaderStorageBlocks) { // One block is all the indirect-params view needs, so this is a >= 1 test and not a // budget calculation. Negative is treated as unusable rather than clamped: a driver @@ -2744,7 +2775,7 @@ namespace MobileGL::MG_Backend::DirectGLES { return true; } - StateBackendObjectRegistry + StateBackendObjectRegistry g_backendVertexArrayObjects; } // namespace VertexArrayImpl @@ -5072,7 +5103,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Array, MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS> g_boundTexturesCache; - StateBackendObjectRegistry g_backendTextureObjects; + StateBackendObjectRegistry g_backendTextureObjects; } // namespace TextureImpl namespace FramebufferImpl { @@ -5825,7 +5856,7 @@ namespace MobileGL::MG_Backend::DirectGLES { return m_backendColorSlots[index]; } - StateBackendObjectRegistry + StateBackendObjectRegistry g_backendFramebufferObjects; Array g_fboSyncedSlotVersions = {0}; // Tracks the bound FBO's object version (bumped on any attachment/drawbuffer change) @@ -6136,7 +6167,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // context never answers GL_NO_ERROR, and the build runs on the thread that would // then spin forever. constexpr Int kMaxDrainedProgramErrors = 32; - StateBackendObjectRegistry g_backendProgramObjects; + StateBackendObjectRegistry g_backendProgramObjects; BackendProgramObjectImpl::BackendProgramObjectImpl() { #ifdef TRACY_ENABLE @@ -8654,7 +8685,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } Array g_boundSamplersCache; - StateBackendObjectRegistry g_backendSamplerObjects; + StateBackendObjectRegistry g_backendSamplerObjects; } // namespace SamplerImpl namespace RenderbufferImpl { @@ -8766,7 +8797,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("RBO %u sync completed. backend ID %u", stateRBOObject->GetExternalIndex(), m_backendRBOId); } - StateBackendObjectRegistry + StateBackendObjectRegistry g_backendRenderbufferObjects; } // namespace RenderbufferImpl } // namespace MobileGL::MG_Backend::DirectGLES diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index 6603f2cc3..df2907f64 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -16,6 +16,7 @@ #include #include #include +#include "SlotTables.h" namespace MobileGL::MG_Backend::DirectGLES { String EmulateBaseInstanceInVertexShader(String source, GLenum shaderType); @@ -267,7 +268,34 @@ namespace MobileGL::MG_Backend::DirectGLES { EndViewportRoutingPasses(passCount); } - template + // The backend twin table. Two arms live behind this one interface (ARCHITECTURE.md 9.6 - + // after Track H the MOBILEGL_PIPE_PUSH bitmap alone is not a valid A/B, because with a bit + // clear the backend would still be running the re-keyed code): + // + // legacy (MOBILEGL_PIPE_LEGACY_MEMOS): UnorderedMap keyed on the + // frontend heap ADDRESS, with a weak_ptr per entry as the ABA defence, an erase + // inside Find, and a garbage sweep as the only death signal. Pre-P2 code verbatim. + // handles (MOBILEGL_PIPE_PUSH and kMGPipeSubsystemEsprytSlots): BackendSlotTable, keyed + // on MGPipeHandle{Slot, Gen}. See SlotTables.h for what that buys. + // + // Which arm runs is fixed once per process (EsprytSlotTablesEnabled()): the two arms hold + // their twins in different containers, so a mid-run flip would strand every twin already + // built. Every call site below this class is arm-agnostic and unchanged. + // + // The kind is a template parameter ONLY in the push build. G1 requires the pull build's + // symbol set to be byte-for-byte the pre-P2 one, and a third template argument changes + // every instantiation's mangled name - so in the pull build the parameter, like the arm it + // selects, does not exist. MGB_TWIN_KIND_ARG spells the same thing at the six declarations + // and six definitions. +#if MOBILEGL_PIPE_PUSH +#define MGB_TWIN_KIND_PARAM , MG_Pipe::MGPipeKind kKind +#define MGB_TWIN_KIND_ARG(kind) , kind +#else +#define MGB_TWIN_KIND_PARAM +#define MGB_TWIN_KIND_ARG(kind) +#endif + + template class StateBackendObjectRegistry { public: @@ -291,8 +319,15 @@ namespace MobileGL::MG_Backend::DirectGLES { MOBILEGL_ASSERT(stateObj != nullptr, "State object must not be null"); // Twin creation is the moment a driver-owned id starts needing a guarded - // destructor; cold path, so the once-guard costs nothing per draw. + // destructor; cold path, so the once-guard costs nothing per draw. It is armed + // here, at the first insertion, on BOTH arms - a destructor hook on the table + // itself is wrong for the reason spelled out above InProcessTeardown(). EnsureProcessTeardownSentinel(); +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + return m_slotTable.GetOrCreate(stateObj); + } +#endif // Sweep BEFORE the entry reference below exists: the map is open-addressed and an // erase relocates the rest of the probe cluster, so collecting once that reference // is taken would invalidate it. The sweep is therefore owed from an earlier call @@ -324,14 +359,24 @@ namespace MobileGL::MG_Backend::DirectGLES { return entry.backend; } - // Null when no live state object owns this key. The result points into the map, so - // it stays valid only until the next GetOrCreate/Find/CollectGarbage on this registry. - // Take that literally, including for Find: the map is open-addressed and erases by - // shifting the rest of the probe cluster into the hole, so an erase relocates entries - // OTHER than the erased one - and Find erases, whenever it lands on a key whose state - // object has expired. Callers that need the twin across another registry call must copy - // the BackendPtr out (or keep only the pointee, which is heap-allocated and never moves). + // Null when no live state object owns this key. + // + // On the HANDLE arm the result is a stable array element: only a GetOrCreate that grows + // the table can move it, and nothing else on the table invalidates it. + // + // On the LEGACY arm the result points into the map, so it stays valid only until the + // next GetOrCreate/Find/CollectGarbage on this registry. Take that literally, including + // for Find: the map is open-addressed and erases by shifting the rest of the probe + // cluster into the hole, so an erase relocates entries OTHER than the erased one - and + // Find erases, whenever it lands on a key whose state object has expired. Callers that + // need the twin across another registry call must copy the BackendPtr out (or keep only + // the pointee, which is heap-allocated and never moves). BackendPtr* Find(StateObject* stateObj) { +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + return m_slotTable.Find(stateObj); + } +#endif const auto entryIt = m_entries.find(stateObj); if (entryIt == m_entries.end()) { return nullptr; @@ -352,7 +397,44 @@ namespace MobileGL::MG_Backend::DirectGLES { iterator end() { return m_entries.end(); } const_iterator end() const { return m_entries.end(); } +#if MOBILEGL_PIPE_PUSH + // The {slot, gen} this object's twin is keyed on, or the null handle. This is what a + // backend memo stores instead of a raw pointer, a GL name or a bare lifetime id. + MG_Pipe::MGPipeHandle HandleOf(const StateObject* stateObj) const { + if (EsprytSlotTablesEnabled()) { + return m_slotTable.HandleOf(stateObj); + } + return MG_Pipe::kMGPipeNullHandle; + } + + // fn(const StatePtr& state, const BackendPtr& twin) over every live entry. The legacy + // begin()/end() handed out the map key, i.e. the raw frontend address - exactly the + // identity the backend must stop reading - and handed it out for entries whose state + // object had already died, so the one caller had to test stateRef.expired() itself + // before dereferencing it. Here the state object arrives as a strong reference. + template + void ForEachLive(Fn&& fn) const { + if (EsprytSlotTablesEnabled()) { + m_slotTable.ForEachLive(fn); + return; + } + for (const auto& [stateKey, entry] : m_entries) { + (void)stateKey; + if (!entry.backend) continue; + const StatePtr state = entry.stateRef.lock(); + if (!state) continue; + fn(state, entry.backend); + } + } +#endif + void CollectGarbageIfNeeded() { +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + m_slotTable.CollectGarbageIfNeeded(); + return; + } +#endif ++m_gcTick; if (m_gcTick < kGCInterval) { return; @@ -361,7 +443,15 @@ namespace MobileGL::MG_Backend::DirectGLES { m_gcTick = 0; } - void CollectGarbageNow() { CollectGarbage(); } + void CollectGarbageNow() { +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + m_slotTable.CollectGarbageNow(); + return; + } +#endif + CollectGarbage(); + } private: void CollectGarbage() { @@ -395,6 +485,9 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint32 m_gcTick = 0; Uint32 m_creationTick = 0; Bool m_isCollecting = false; +#if MOBILEGL_PIPE_PUSH + BackendSlotTable m_slotTable; +#endif }; namespace BufferImpl { @@ -800,7 +893,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint64 m_syncedBufferIdGeneration = 0; }; - extern StateBackendObjectRegistry + extern StateBackendObjectRegistry g_backendVertexArrayObjects; // Shadowed glBindVertexArray: every backend VAO bind goes through here so a @@ -1121,7 +1214,7 @@ namespace MobileGL::MG_Backend::DirectGLES { void ActivateTextureUnit(Uint unit); void UnbindTexture(Uint unit, GLenum target); - extern StateBackendObjectRegistry + extern StateBackendObjectRegistry g_backendTextureObjects; SharedPtr& SyncTextureObjectToBackend( const SharedPtr& textureObject, @@ -1212,7 +1305,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint64 m_syncedBackendIdGeneration = 0; }; - extern StateBackendObjectRegistry + extern StateBackendObjectRegistry g_backendFramebufferObjects; // True when the read buffer names a fixed-point (norm/snorm) attachment that the // backend actually stores in a floating-point format. GL clamps a read from a @@ -1730,7 +1823,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // skip redundant rebinds. Reset to 0 wherever glUseProgram(0) is issued or the // ES context is recreated. extern Uint g_lastUsedBackendProgramId; - extern StateBackendObjectRegistry + extern StateBackendObjectRegistry g_backendProgramObjects; // Points one shader storage block of an ALREADY-LINKED backend program at @@ -1830,7 +1923,7 @@ namespace MobileGL::MG_Backend::DirectGLES { extern Array g_boundSamplersCache; - extern StateBackendObjectRegistry + extern StateBackendObjectRegistry g_backendSamplerObjects; } // namespace SamplerImpl @@ -1857,7 +1950,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Int m_cacheSamples = 0; }; - extern StateBackendObjectRegistry + extern StateBackendObjectRegistry g_backendRenderbufferObjects; } // namespace RenderbufferImpl } // namespace MobileGL::MG_Backend::DirectGLES diff --git a/MobileGL/MG_Backend/DirectGLES/SlotTables.h b/MobileGL/MG_Backend/DirectGLES/SlotTables.h new file mode 100644 index 000000000..187d2ce5b --- /dev/null +++ b/MobileGL/MG_Backend/DirectGLES/SlotTables.h @@ -0,0 +1,188 @@ +// MobileGL - MobileGL/MG_Backend/DirectGLES/SlotTables.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +#include + +#if MOBILEGL_PIPE_PUSH +#include +#endif + +// Espryt 0b, the first Track H slice: the DENSE, {slot, gen}-keyed twin table that replaces +// StateBackendObjectRegistry's UnorderedMap. +// +// What changes, and why each of them is the point: +// +// * The KEY stops being a frontend heap address. It is MGPipeHandle{Slot, Gen}, minted by the +// client's MGPipeSlotAllocator off the frontend object's GetLifetimeId(). A recycled heap +// address cannot reproduce a handle, so the weak_ptr the registry carried per entry purely +// to catch that (its Entry::stateRef, used as an IDENTITY test) stops being an identity +// mechanism, and OwnerEquals / TwinLookupMemo x3 / UnitSamplerLookupMemo's owner compare all +// lose their reason to exist. +// * The lookup stops being a hash probe into an open-addressed map and becomes one bounds +// check plus one array index, so a returned BackendPtr* is NOT invalidated by the next Find +// or sweep on the table. That kills the hazard Managers.h documents at length, and with it +// the by-value copy plus second Find that SyncTextureObjectToBackend paid to survive it. +// * Slots are dense per kind, which is what lets the server side (ARCHITECTURE.md 10.1, +// MG_Remote/Server/PipeObjectTables) be an array rather than an object graph. +// +// What has NOT changed, deliberately, and is this file's one departure from the P2 brief +// (recorded in the package result file): a frontend object's death is still discovered rather +// than announced. The brief's step e2 - a BufferBackendOps-shaped OnDestroy for the other six +// kinds - has to be installed in MG_State/GLState/{Texture,Framebuffer,Renderbuffer,Sampler, +// Program,VertexArray}State/*, and the P2 file-ownership table gives every one of those files +// to another package. So the table keeps ONE weak_ptr per entry and uses it for exactly one +// thing: ReclaimDeadSlots() frees the slot - and the twin, and the driver storage it owns - +// once the frontend object is gone. That is a liveness sweep, not an identity test, and it is +// what bumps Gen, which is precisely the ABA defence: a slot is only ever handed out again +// after it was freed. When e2 lands, ReclaimDeadSlots() becomes the fallback path of an +// explicit Destroy(handle) and the sweep call sites go away. +namespace MobileGL::MG_Backend::DirectGLES { + +#if MOBILEGL_PIPE_PUSH + + // True when this process runs the {slot, gen} arm. Fixed for the life of the process: the + // two arms hold their twins in different containers, so flipping mid-run would strand them. + Bool EsprytSlotTablesEnabled(); + + template + class BackendSlotTable { + public: + using StatePtr = SharedPtr; + using StateWeakPtr = std::weak_ptr; + using BackendPtr = SharedPtr; + + struct Entry { + BackendPtr backend; + // LIVENESS ONLY. Never compared against another object to decide identity - that is + // what Gen is for - and never dereferenced for its address. Read by + // ReclaimDeadSlots(), and locked by ForEachLive() so the callee holds a strong ref. + StateWeakPtr stateRef; + // The generation this entry's twin was built for. An entry whose Gen no longer + // matches the allocator's is a twin of the slot's PREVIOUS owner. + Uint32 Gen = 0; + Bool Live = false; + }; + + // Resolve-or-create. The handle comes from the client allocator keyed on the frontend + // object's lifetime id, so two calls for the same live object always land on the same + // slot, and a successor object at the same heap address never does. + BackendPtr& GetOrCreate(const StatePtr& stateObj) { + MOBILEGL_ASSERT(stateObj != nullptr, "State object must not be null"); + const MG_Pipe::MGPipeHandle handle = + MG_Pipe::MGPipeSlots().Acquire(kKind, stateObj->GetLifetimeId()); + MOBILEGL_ASSERT(!MG_Pipe::MGPipeHandleIsNull(handle), + "MGPipe slot space of kind %u is exhausted", + static_cast(kKind)); + Entry& entry = EntryAt(handle.Slot); + if (entry.Live && entry.Gen != handle.Gen) { + // The slot was reclaimed and handed to a new object: the twin at it describes + // driver ids the new state object never made. + entry.backend.reset(); + } + entry.Gen = handle.Gen; + entry.Live = true; + entry.stateRef = stateObj; + return entry.backend; + } + + // Null when no live twin of this object exists. Unlike the registry's Find this NEVER + // mutates the table, so the returned pointer survives any later Find or sweep on it; + // only a GetOrCreate that grows the vector can move it, and callers that hold one + // across a possible insertion still copy the BackendPtr out. + BackendPtr* Find(StateObject* stateObj) { + if (stateObj == nullptr) return nullptr; + return FindByHandle(HandleOf(stateObj)); + } + + const BackendPtr* Find(StateObject* stateObj) const { + return const_cast(this)->Find(stateObj); + } + + BackendPtr* FindByHandle(MG_Pipe::MGPipeHandle handle) { + if (MG_Pipe::MGPipeHandleIsNull(handle)) return nullptr; + if (handle.Slot >= m_slots.size()) return nullptr; + Entry& entry = m_slots[handle.Slot]; + if (!entry.Live || entry.Gen != handle.Gen) return nullptr; + return &entry.backend; + } + + // The handle this object's twin is keyed on, or the null handle. This is what a backend + // memo stores instead of a raw pointer, a GL name or a bare lifetime id. + MG_Pipe::MGPipeHandle HandleOf(const StateObject* stateObj) const { + if (stateObj == nullptr) return MG_Pipe::kMGPipeNullHandle; + return MG_Pipe::MGPipeSlots().FindByLifetimeId(kKind, stateObj->GetLifetimeId()); + } + + // Drop the twin of every slot whose frontend object is gone and return the slot to the + // allocator. Freeing is what makes the NEXT handout of that slot bump Gen. + void ReclaimDeadSlots() { + if (m_isCollecting) return; + m_isCollecting = true; + for (SizeT slot = 0; slot < m_slots.size(); ++slot) { + Entry& entry = m_slots[slot]; + if (!entry.Live || !entry.stateRef.expired()) continue; + entry.backend.reset(); + entry.stateRef.reset(); + entry.Live = false; + MG_Pipe::MGPipeSlots().Free( + kKind, MG_Pipe::MGPipeHandle{static_cast(slot), entry.Gen}); + } + m_isCollecting = false; + } + + void CollectGarbageIfNeeded() { + ++m_gcTick; + if (m_gcTick < kGCInterval) return; + m_gcTick = 0; + ReclaimDeadSlots(); + } + + void CollectGarbageNow() { ReclaimDeadSlots(); } + + // fn(const StatePtr& state, const BackendPtr& twin) over every live, still-owned entry. + // Replaces the registry's begin()/end(), whose iterator exposed the raw frontend + // address as the map key - the one place the backend read an identity it must not have. + // The state object is handed over as a STRONG reference, so the callee cannot be handed + // a dangling key the way the old iteration could. + template + void ForEachLive(Fn&& fn) const { + for (const Entry& entry : m_slots) { + if (!entry.Live || !entry.backend) continue; + const StatePtr state = entry.stateRef.lock(); + if (!state) continue; + fn(state, entry.backend); + } + } + + Uint32 LiveCount() const { + Uint32 count = 0; + for (const Entry& entry : m_slots) { + if (entry.Live) ++count; + } + return count; + } + + private: + Entry& EntryAt(Uint32 slot) { + if (slot >= m_slots.size()) m_slots.resize(static_cast(slot) + 1); + return m_slots[slot]; + } + + static constexpr Uint32 kGCInterval = 1024; + + // Indexed by MGPipeHandle::Slot; [0] is the reserved slot and is never live. + Vector m_slots; + Uint32 m_gcTick = 0; + Bool m_isCollecting = false; + }; + +#endif // MOBILEGL_PIPE_PUSH +} // namespace MobileGL::MG_Backend::DirectGLES From bd2092f4ebfe3b41294f818780a4109b4a3e1617 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:11:03 -0400 Subject: [PATCH 086/529] [Refactor] (Espryt): key every backend twin on {slot, gen} instead of the frontend object heap address - ResolveVaoTwin, SyncCurrentProgram and BindCurrentFBO stop consulting the three TwinLookupMemos on the handle arm. The memo existed to replace the registry hash probe with an array index, and the slot table Find already IS that array index; its safety argument - owner-equality of a weak snapshot against a recycled heap address - is answered by the generation instead of re-derived per lookup. OwnerEquals, the memo template and its three instances are now compiled only under MOBILEGL_PIPE_LEGACY_MEMOS. - UnitSamplerLookupMemo compares {slot, gen} instead of owner-equality, and keeps its "a miss is never cached" contract verbatim: the sampler twin is created later in the same draw by the program pass. - UnitBindingsSnapshot holds lifetime ids rather than weak_ptrs under push. It cannot hold handles: a bound-but-never-synced texture has no twin and so no handle, and two of those would read as equal. A lifetime id exists before the twin does and is never handed out twice, which is the property the weak_ptr was there for. - SyncTextureObjectToBackend keeps its by-value copy only to hold the twin alive across the nested glTextureView sync; the second Find-or-create and the put-the-twin-back repair are gone on the handle arm, because nothing there erases a live entry. - The one direct-iteration site walks ForEachLive, which hands over a strong reference to the framebuffer instead of the map key - the raw frontend address it had to null- and expiry-check by hand before dereferencing. - GetFramebufferBindingSlotFast becomes GetFramebufferBindingSlotChecked and, under push, reads MGB_CTX->GetFramebufferBindingSlot(target) every time. This closes the P1 accessor bypass: the cached raw pointer ran the checked accessor once per context change and then handed out the pointee forever, so the per-verb poison stamp and the verify read-hook were skipped at all five call sites. - Every one of these is a push-build arm; the pull build compiles the pre-P2 text and its symbol report stays 0 added / 0 removed / 0 renamed with no new resize. --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 279 ++++++++++++++++-- MobileGL/MG_Backend/DirectGLES/Managers.h | 9 + 2 files changed, 270 insertions(+), 18 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index feb59a1be..02e6401fa 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -57,9 +57,14 @@ namespace MobileGL::MG_Backend::DirectGLES { static SharedPtr g_rawDepthFetchSamplerState; static SharedPtr g_rawDepthFetchSamplerBackend; +#if MOBILEGL_PIPE_LEGACY_MEMOS // Two objects are the same binding iff they share a control block. Raw addresses lie // (a freed object's heap slot is reused), but a held weak_ptr pins the control block, // so no later object can ever owner-equal a snapshot of its predecessor. + // + // This is the pre-handle identity mechanism, and it is compiled only for the legacy arm. + // On the {slot, gen} arm nothing needs it: a handle already cannot be reproduced by a + // recycled address, so there is no snapshot to owner-compare. template static Bool OwnerEquals(const WeakPtr& snapshot, const SharedPtr& current) { return !snapshot.owner_before(current) && !current.owner_before(snapshot); @@ -130,6 +135,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // FBOs; 64 slots is plenty. static TwinLookupMemo g_fboTwinLookupMemo; +#endif // MOBILEGL_PIPE_LEGACY_MEMOS // Cached addresses of the frontend's framebuffer binding slots. The frontend // getter linear-scans its slot array per call and the draw path asks for these @@ -142,9 +148,23 @@ namespace MobileGL::MG_Backend::DirectGLES { // exactly the pointer compare below. using FbBindingSlot = std::remove_reference_tGetFramebufferBindingSlot(FramebufferTarget::Draw))>; +#if !MOBILEGL_PIPE_PUSH static const void* g_fbSlotCacheContext = nullptr; static Array g_fbSlotCache = {}; - static inline FbBindingSlot& GetFramebufferBindingSlotFast(FramebufferTarget target) { +#endif + // P2 step e4. Under push this is an ORDINARY read of pushed state and the cache above does + // not exist, which closes the P1 accessor bypass: the cached raw pointer ran the checked + // accessor once per context change and then handed out the pointee forever, so at all five + // call sites the per-verb poison stamp (MGP_INPUT_CHECK) and the verify read-hook + // (MGP_INPUT_VERIFY_READ) were skipped. A verb that legitimately never fills + // GetFramebufferBindingSlot could not be caught here, and a verify build compared the field + // only where the slow accessor was used. In the pull build MGB_CTX is the live GLContext, + // there is no poison to bypass and the frontend getter still linear-scans, so the cache is + // exactly the code it was. + static inline FbBindingSlot& GetFramebufferBindingSlotChecked(FramebufferTarget target) { +#if MOBILEGL_PIPE_PUSH + return MGB_CTX->GetFramebufferBindingSlot(target); +#else const void* ctx = MGB_CTX_IDENTITY; if (ctx != g_fbSlotCacheContext) { auto& live = *MGB_CTX; @@ -154,6 +174,7 @@ namespace MobileGL::MG_Backend::DirectGLES { g_fbSlotCacheContext = ctx; } return *g_fbSlotCache[SizeT(target)]; +#endif } static Bool IsDualSourceBlendFactor(BlendFactor v) { @@ -1203,6 +1224,21 @@ namespace MobileGL::MG_Backend::DirectGLES { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + // No memo on this arm. The memo existed to turn the registry's hash probe into + // an array index; the slot table's Find already IS the array index, and the + // memo's whole safety argument - owner-equality against a recycled heap address + // - is answered by {slot, gen} instead of re-derived per lookup. + auto* slot = g_backendVertexArrayObjects.Find(vao.get()); + auto& backendObj = slot ? *slot : g_backendVertexArrayObjects.GetOrCreate(vao); + if (!backendObj) { + backendObj = MakeShared(); + } + return backendObj.get(); + } +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS if (auto* twin = g_vaoTwinLookupMemo.Lookup(vao)) { return twin; } @@ -1213,6 +1249,9 @@ namespace MobileGL::MG_Backend::DirectGLES { } g_vaoTwinLookupMemo.Store(vao, backendObj.get()); return backendObj.get(); +#else + return nullptr; +#endif } void SyncCurrentVAO(const SharedPtr& currentVAOObject, @@ -1317,6 +1356,11 @@ namespace MobileGL::MG_Backend::DirectGLES { // relocate every entry - leaving the reference dangling. Holding the object itself // keeps the calls below working on the right twin regardless; the slot is re-resolved // at the end for the reference this function returns. + // + // The slot-table arm has no such hazard - an entry is an array element and a nested + // insert can only reallocate the vector, which the re-resolve at the tail already + // handles - so the copy is a refcount it does not need to pay. It keeps the copy for + // exactly one thing: holding the twin alive across the nested sync. const SharedPtr backendObj = backendSlot; if (imageBindableStorageRequired) { @@ -1337,6 +1381,18 @@ namespace MobileGL::MG_Backend::DirectGLES { backendObj->SyncBuiltinSamplerToBackend(textureObject); } +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + // One re-resolve, and only because a nested GetOrCreate may have GROWN the + // vector and moved the element; the entry itself cannot have been erased, since + // nothing on this arm erases a live slot. No second Find-or-create, no + // put-the-twin-back repair. + auto* slot = g_backendTextureObjects.Find(textureObject.get()); + MOBILEGL_ASSERT(slot != nullptr && *slot != nullptr, + "the texture twin resolved at entry is gone after its own sync"); + return *slot; + } +#endif auto* refreshedSlot = g_backendTextureObjects.Find(textureObject.get()); auto& refreshedBackendObj = refreshedSlot ? *refreshedSlot : g_backendTextureObjects.GetOrCreate(textureObject); @@ -1363,6 +1419,55 @@ namespace MobileGL::MG_Backend::DirectGLES { // * everything unit bindings say nothing about: the touched-unit high-water mark, // the frontend context identity, the backend ES context generation, and (for the // resolution memo) the program keys that arbitrate aliased targets. +#if MOBILEGL_PIPE_PUSH + // P2: the snapshot stops holding weak_ptrs and holds the frontend objects' LIFETIME IDs. + // The weak_ptr was here for one reason - a raw address lies once the allocator recycles + // it - and a lifetime id is a monotone per-class counter that is never handed out twice, + // so it answers the same question with an integer compare and without pinning a control + // block. 0 means "nothing bound", which no live object can collide with (the counters + // start at 1). This is not the twin table's {slot, gen}: a bound texture that has never + // been synced has no twin and therefore no handle, so a handle-keyed snapshot would read + // two never-synced textures as equal. The identity has to exist before the twin does. + struct UnitBindingsSnapshot { + Array slotObjects{}; + Uint64 samplerObject = 0; + }; + + static Uint64 LifetimeIdOf(const SharedPtr& object) { + return object ? object->GetLifetimeId() : 0; + } + + static Uint64 LifetimeIdOf(const SharedPtr& object) { + return object ? object->GetLifetimeId() : 0; + } + + static void CaptureUnitBindings(Int maxTouchedUnit, Vector& out) { + out.resize(static_cast(maxTouchedUnit + 1)); + for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); + auto& snapshot = out[static_cast(unit)]; + const auto& slots = textureUnit.GetAllBindingSlots(); + for (SizeT i = 0; i < slots.size(); ++i) { + snapshot.slotObjects[i] = LifetimeIdOf(slots[i].GetBoundObject()); + } + snapshot.samplerObject = LifetimeIdOf(textureUnit.GetSamplerObject()); + } + } + + static Bool UnitBindingsUnchanged(Int maxTouchedUnit, const Vector& snapshots) { + if (snapshots.size() != static_cast(maxTouchedUnit + 1)) return false; + for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); + const auto& snapshot = snapshots[static_cast(unit)]; + const auto& slots = textureUnit.GetAllBindingSlots(); + for (SizeT i = 0; i < slots.size(); ++i) { + if (snapshot.slotObjects[i] != LifetimeIdOf(slots[i].GetBoundObject())) return false; + } + if (snapshot.samplerObject != LifetimeIdOf(textureUnit.GetSamplerObject())) return false; + } + return true; + } +#else struct UnitBindingsSnapshot { Array, (SizeT)TextureTarget::TextureTargetCount> slotObjects{}; @@ -1395,6 +1500,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } return true; } +#endif static Vector g_observedUnitBindings; static Uint64 g_observedUnitBindingsContextId = 0; @@ -1610,7 +1716,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // registry keeps a backend object alive until its frontend texture expires, which an // attached texture cannot. A renderbuffer-only FBO - the common Minecraft frame - // reduces to the key compare and an empty loop. - const auto& drawSlot = GetFramebufferBindingSlotFast(FramebufferTarget::Draw); + const auto& drawSlot = GetFramebufferBindingSlotChecked(FramebufferTarget::Draw); const auto& currentFBO = drawSlot.GetBoundObject(); if (currentFBO) { const Uint16 fboSlotVersion = drawSlot.GetVersion(); @@ -1904,7 +2010,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MG_State::GLState::FramebufferObject* lastUpdatedFBO = nullptr; for (auto& target : fboTargets) { - auto& slot = GetFramebufferBindingSlotFast(target); + auto& slot = GetFramebufferBindingSlotChecked(target); auto& currentFBO = slot.GetBoundObject(); // The three memos together say "this target is already synced": which object is @@ -2742,7 +2848,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // only runs later in PrepareForDraw: a program compiled against a stale count // would not be relinked until the draw after the one that needed it. { - const auto& drawSlot = GetFramebufferBindingSlotFast(FramebufferTarget::Draw); + const auto& drawSlot = GetFramebufferBindingSlotChecked(FramebufferTarget::Draw); const auto& drawFBO = drawSlot.GetBoundObject(); const Uint16 slotVersion = drawSlot.GetVersion(); const Uint16 objectVersion = drawFBO ? drawFBO->GetObjectVersion() : 0; @@ -2766,16 +2872,31 @@ namespace MobileGL::MG_Backend::DirectGLES { g_fragColorBroadcastCount = g_broadcastMemoCount; } - BackendProgramObjectImpl* twin = g_programTwinLookupMemo.Lookup(currentProgram); - if (!twin) { - auto* backendProgramSlot = g_backendProgramObjects.Find(currentProgram.get()); - auto& backendObj = - backendProgramSlot ? *backendProgramSlot : g_backendProgramObjects.GetOrCreate(currentProgram); + BackendProgramObjectImpl* twin = nullptr; +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + auto* slot = g_backendProgramObjects.Find(currentProgram.get()); + auto& backendObj = slot ? *slot : g_backendProgramObjects.GetOrCreate(currentProgram); if (!backendObj) { backendObj = MakeShared(); } - g_programTwinLookupMemo.Store(currentProgram, backendObj.get()); twin = backendObj.get(); + } else +#endif + { +#if MOBILEGL_PIPE_LEGACY_MEMOS + twin = g_programTwinLookupMemo.Lookup(currentProgram); + if (!twin) { + auto* backendProgramSlot = g_backendProgramObjects.Find(currentProgram.get()); + auto& backendObj = backendProgramSlot ? *backendProgramSlot + : g_backendProgramObjects.GetOrCreate(currentProgram); + if (!backendObj) { + backendObj = MakeShared(); + } + g_programTwinLookupMemo.Store(currentProgram, backendObj.get()); + twin = backendObj.get(); + } +#endif } // A link-version mismatch means the program was relinked: the backend // shaders and every cache built by CacheResourceLocations (block @@ -2857,7 +2978,7 @@ namespace MobileGL::MG_Backend::DirectGLES { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - auto& slot = GetFramebufferBindingSlotFast(target); + auto& slot = GetFramebufferBindingSlotChecked(target); // No fast path on the binding slot's version. It is a 16-bit counter that only // ForceBindCurrentFBO ever stamps here, so the comparison was against an arbitrarily old // snapshot and any later slot version that happened to land on it - one wrap of the @@ -2871,13 +2992,26 @@ namespace MobileGL::MG_Backend::DirectGLES { // and the twin memo replaces even that with an array probe on the steady path. const auto& currentFBO = slot.GetBoundObject(); if (currentFBO && currentFBO != MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo->defaultFBO) { - FramebufferImpl::BackendFramebufferObject* twin = g_fboTwinLookupMemo.Lookup(currentFBO); - if (!twin) { - auto* backendFBOSlot = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get()); - if (backendFBOSlot && *backendFBOSlot) { - twin = backendFBOSlot->get(); - g_fboTwinLookupMemo.Store(currentFBO, twin); + FramebufferImpl::BackendFramebufferObject* twin = nullptr; +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + auto* slot = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get()); + if (slot && *slot) { + twin = slot->get(); + } + } else +#endif + { +#if MOBILEGL_PIPE_LEGACY_MEMOS + twin = g_fboTwinLookupMemo.Lookup(currentFBO); + if (!twin) { + auto* backendFBOSlot = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get()); + if (backendFBOSlot && *backendFBOSlot) { + twin = backendFBOSlot->get(); + g_fboTwinLookupMemo.Store(currentFBO, twin); + } } +#endif } if (twin) { twin->Bind(target); @@ -2932,7 +3066,7 @@ namespace MobileGL::MG_Backend::DirectGLES { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - auto& slot = GetFramebufferBindingSlotFast(target); + auto& slot = GetFramebufferBindingSlotChecked(target); const auto& fbo = slot.GetBoundObject(); SyncAndBindFramebufferObject(fbo, target); FramebufferImpl::g_fboSyncedSlotVersions[(SizeT)target] = slot.GetVersion(); @@ -3155,7 +3289,15 @@ namespace MobileGL::MG_Backend::DirectGLES { // pass creates it later in the same draw), and a cached miss would keep skipping the // bind after it appears. struct UnitSamplerLookupMemo { +#if MOBILEGL_PIPE_PUSH + // The {slot, gen} of the frontend sampler this row was resolved for. It replaces the + // weak_ptr and its owner compare: a stale row cannot match, because the successor of a + // freed sampler is handed the same slot only with a higher Gen. + MG_Pipe::MGPipeHandle frontendHandle = MG_Pipe::kMGPipeNullHandle; +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS WeakPtr frontend{}; +#endif SamplerImpl::BackendSamplerObject* backend = nullptr; }; static Array @@ -3164,6 +3306,25 @@ namespace MobileGL::MG_Backend::DirectGLES { static SamplerImpl::BackendSamplerObject* ResolveUnitSamplerBackend( Int unit, const SharedPtr& samplerObject) { auto& memo = g_unitSamplerLookupMemos[static_cast(unit)]; +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + const MG_Pipe::MGPipeHandle handle = + SamplerImpl::g_backendSamplerObjects.HandleOf(samplerObject.get()); + if (memo.backend && !MG_Pipe::MGPipeHandleIsNull(handle) && memo.frontendHandle == handle) { + return memo.backend; + } + auto* slot = SamplerImpl::g_backendSamplerObjects.FindByHandle(handle); + if (slot && *slot) { + // A MISS is still never cached: the twin may not exist yet when the unit pass + // runs, because the program pass creates it later in the same draw. + memo.frontendHandle = handle; + memo.backend = slot->get(); + return memo.backend; + } + return nullptr; + } +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS if (memo.backend && OwnerEquals(memo.frontend, samplerObject)) { return memo.backend; } @@ -3173,6 +3334,7 @@ namespace MobileGL::MG_Backend::DirectGLES { memo.backend = backendSamplerSlot->get(); return memo.backend; } +#endif return nullptr; } @@ -6410,6 +6572,86 @@ namespace MobileGL::MG_Backend::DirectGLES { } const GLuint backendTextureId = (*backendTextureSlot)->GetBackendTextureId(); + // The one direct-iteration site over a twin table. The legacy walk reads the map + // KEY, i.e. the raw frontend address, and has to test the entry's weak_ptr by hand + // before it dares dereference it. ForEachLive hands over a strong reference instead, + // so that hazard cannot arise; the body is otherwise identical, which is why it is + // lifted into a lambda both arms call. +#if MOBILEGL_PIPE_PUSH + // The push arm walks the twin table through ForEachLive, which hands over a + // STRONG reference to the state object; the legacy map walk below it reads the + // map key - the raw frontend address - and has to test the entry weak_ptr by + // hand first. The body is shared between the two arms through the lambda. + const auto detachFrom = [&](MG_State::GLState::FramebufferObject* stateFBO, + const SharedPtr& backendFBO) { + if (stateFBO == nullptr || !backendFBO || stateFBO->IsDefaultFramebuffer()) { + return; + } + + const auto& attachments = stateFBO->GetAllAttachmentObjects(); + for (SizeT i = 0; i < attachments.size(); ++i) { + const auto& attachmentObject = attachments[i]; + if (!attachmentObject.IsTexture() || attachmentObject.GetTexture().get() != texture.get()) { + continue; + } + + const auto frontendType = static_cast(i); + GLenum backendAttachment = GL_NONE; + if (frontendType >= FramebufferAttachmentType::Color0 && + frontendType <= FramebufferAttachmentType::Color31) { + backendAttachment = backendFBO->GetBackendAttachmentType(frontendType); + } else { + backendAttachment = MG_Util::ConvertFramebufferAttachmentTypeToGLEnum(frontendType); + } + if (backendAttachment == GL_NONE || backendAttachment == GL_UNKNOWN_MGL) { + continue; + } + + GLenum textureTarget = TextureImpl::ConvertTextureUploadTargetToBackendGLEnum( + attachmentObject.GetTextureUploadTarget()); + if (textureTarget == GL_UNKNOWN_MGL) { + textureTarget = TextureImpl::ConvertTextureTargetToBackendGLEnum(texture->GetTarget()); + } + + const GLuint backendFBOId = backendFBO->GetBackendFramebufferId(); + FramebufferImpl::BindFramebufferId(GL_DRAW_FRAMEBUFFER, backendFBOId); + if (attachmentObject.IsLayered()) { + g_GLESFuncs.glFramebufferTexture(GL_DRAW_FRAMEBUFFER, backendAttachment, 0, 0); + } else { + g_GLESFuncs.glFramebufferTexture2D( + GL_DRAW_FRAMEBUFFER, backendAttachment, textureTarget, 0, 0); + } + ClearGLErrors(); + m_detachedAttachments.push_back( + {backendFBOId, backendAttachment, textureTarget, backendTextureId, + static_cast(attachmentObject.GetTextureLevel()), attachmentObject.IsLayered()}); + } + }; + +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + FramebufferImpl::g_backendFramebufferObjects.ForEachLive( + [&](const SharedPtr& stateFBO, + const SharedPtr& backendFBO) { + detachFrom(stateFBO.get(), backendFBO); + }); + return; + } +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS + for (auto it = FramebufferImpl::g_backendFramebufferObjects.begin(); + it != FramebufferImpl::g_backendFramebufferObjects.end(); ++it) { + // An entry whose state object died is only waiting for the next collection; + // the key is a dangling address, so it must not be dereferenced here. + if (it->second.stateRef.expired()) { + continue; + } + detachFrom(it->first, it->second.backend); + } +#endif +#else + // Pull build: exactly the pre-P2 walk, so this translation unit generates the + // same code it did before P2 (G1). for (auto it = FramebufferImpl::g_backendFramebufferObjects.begin(); it != FramebufferImpl::g_backendFramebufferObjects.end(); ++it) { auto* stateFBO = it->first; @@ -6460,6 +6702,7 @@ namespace MobileGL::MG_Backend::DirectGLES { static_cast(attachmentObject.GetTextureLevel()), attachmentObject.IsLayered()}); } } +#endif } ~ScopedDetachedTextureFramebufferAttachments() { diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index df2907f64..ce17d7a65 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -407,6 +407,15 @@ namespace MobileGL::MG_Backend::DirectGLES { return MG_Pipe::kMGPipeNullHandle; } + // The twin at a handle, or null when the slot is free or its Gen has moved on. This is + // the lookup a backend memo that already holds a handle wants: no lifetime-id probe. + BackendPtr* FindByHandle(MG_Pipe::MGPipeHandle handle) { + if (EsprytSlotTablesEnabled()) { + return m_slotTable.FindByHandle(handle); + } + return nullptr; + } + // fn(const StatePtr& state, const BackendPtr& twin) over every live entry. The legacy // begin()/end() handed out the map key, i.e. the raw frontend address - exactly the // identity the backend must stop reading - and handed it out for entries whose state From 3160c4b85bcfacd6123d54e5caf2185e8d7037c0 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:12:45 -0400 Subject: [PATCH 087/529] [Test] (Espryt): pin the twin table identity contract - a reclaimed slot is a new handle and the stale one resolves to nothing - Five cases against BackendSlotTable directly, through a stand-in state object that carries only GetLifetimeId(), so none of them needs a GLContext, a driver or ES entry points. - The load-bearing one is the ABA: an object dies, the sweep returns its slot, the next object takes the same slot with a moved generation, and the predecessor handle answers null instead of the successor twin. That is the property the address key could only paper over with a weak_ptr. - Gen moves on reuse and only on reuse; Find never mutates the table (which is what lets SyncTextureObjectToBackend drop its second Find); a whole table saves, resets with = {} and restores, which is the shape ScopedDirectGLESTextureBindings needs; and two tables of one kind agree on a single object handle, because the identity comes from the client allocator rather than from either table. --- MobileGL/MG_Test/SanityTest.cpp | 166 ++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index 7fd2a8a23..7eafcff2f 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -3132,3 +3132,169 @@ TEST(GetterSanity, CombinedUniformComponentsSaturateInsteadOfOverflowing) { MG_State::pGLContext.reset(); } + + +#if MOBILEGL_PIPE_PUSH +namespace { + // A stand-in frontend object for the twin table. It carries the one thing the table asks of a + // state object - GetLifetimeId() - so these cases can pin the identity contract without a + // GLContext, a driver or a backend twin that would want ES entry points. + struct FakeStateObject { + explicit FakeStateObject(MobileGL::Uint64 lifetimeId): m_lifetimeId(lifetimeId) {} + MobileGL::Uint64 GetLifetimeId() const { return m_lifetimeId; } + + private: + MobileGL::Uint64 m_lifetimeId; + }; + + struct FakeBackendObject { + int marker = 0; + }; + + // Kind Query is unused by every shipping path, so these cases cannot disturb the slot space + // any real twin table allocates out of. + using FakeSlotTable = MobileGL::MG_Backend::DirectGLES:: + BackendSlotTable; +} // namespace + +// The property the whole slice exists for. The pre-P2 registry keyed twins on the frontend heap +// ADDRESS and defended the recycle with a weak_ptr; here the key is {slot, gen}, so a successor +// object landing on a slot its predecessor owned is a DIFFERENT handle, and the predecessor's +// handle resolves to nothing rather than to the successor's twin. +TEST(DirectGLESSlotTable, ARecycledSlotIsANewHandleAndTheStaleOneResolvesToNothing) { + using namespace MobileGL; + + FakeSlotTable table; + auto first = MakeShared(0xA1u); + table.GetOrCreate(first) = MakeShared(); + (*table.Find(first.get()))->marker = 1; + + const MG_Pipe::MGPipeHandle firstHandle = table.HandleOf(first.get()); + ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(firstHandle)); + EXPECT_EQ(table.LiveCount(), 1u); + + // The frontend object dies and the slot is reclaimed - which is the only moment Gen moves. + first.reset(); + table.CollectGarbageNow(); + EXPECT_EQ(table.LiveCount(), 0u); + EXPECT_EQ(table.FindByHandle(firstHandle), nullptr) + << "a handle whose object is gone still resolved to a twin"; + + auto second = MakeShared(0xA2u); + table.GetOrCreate(second) = MakeShared(); + (*table.Find(second.get()))->marker = 2; + + const MG_Pipe::MGPipeHandle secondHandle = table.HandleOf(second.get()); + EXPECT_EQ(secondHandle.Slot, firstHandle.Slot) << "the free list did not hand the slot back"; + EXPECT_NE(secondHandle.Gen, firstHandle.Gen) << "the generation did not move on slot reuse"; + EXPECT_FALSE(firstHandle == secondHandle); + + // The stale handle must not resolve to its successor's twin. This is the ABA the address key + // could only paper over. + EXPECT_EQ(table.FindByHandle(firstHandle), nullptr); + ASSERT_NE(table.FindByHandle(secondHandle), nullptr); + EXPECT_EQ((*table.FindByHandle(secondHandle))->marker, 2); +} + +// Gen moves on reuse and ONLY on reuse: a live object that is looked up again, or respecified, +// keeps the handle it was minted with (MGPipeHandles.h). +TEST(DirectGLESSlotTable, RepeatedLookupsOfALiveObjectKeepOneHandle) { + using namespace MobileGL; + + FakeSlotTable table; + auto object = MakeShared(0xB1u); + table.GetOrCreate(object) = MakeShared(); + const MG_Pipe::MGPipeHandle handle = table.HandleOf(object.get()); + + for (int i = 0; i < 8; ++i) { + auto* slot = table.GetOrCreate(object) ? table.Find(object.get()) : nullptr; + ASSERT_NE(slot, nullptr); + EXPECT_TRUE(table.HandleOf(object.get()) == handle) << "handle moved on lookup " << i; + } + EXPECT_EQ(table.LiveCount(), 1u); +} + +// The lookup does not mutate the table, which is what lets SyncTextureObjectToBackend stop paying +// a by-value copy plus a second Find to survive the registry's erase-inside-Find. A dead entry +// stays put until the sweep, and a live entry's pointer is unaffected by looking up anything else. +TEST(DirectGLESSlotTable, FindNeverMutatesTheTable) { + using namespace MobileGL; + + FakeSlotTable table; + auto kept = MakeShared(0xC1u); + auto doomed = MakeShared(0xC2u); + table.GetOrCreate(kept) = MakeShared(); + table.GetOrCreate(doomed) = MakeShared(); + + auto* keptSlot = table.Find(kept.get()); + ASSERT_NE(keptSlot, nullptr); + const FakeBackendObject* keptTwin = keptSlot->get(); + + doomed.reset(); + // The registry's Find would have erased the expired entry here and relocated the rest of the + // probe cluster, invalidating keptSlot. This one answers null and touches nothing. + EXPECT_EQ(table.Find(kept.get()), keptSlot); + EXPECT_EQ(table.LiveCount(), 2u) << "Find reclaimed a slot; only the sweep may do that"; + EXPECT_EQ(keptSlot->get(), keptTwin); + + table.CollectGarbageNow(); + EXPECT_EQ(table.LiveCount(), 1u); + EXPECT_EQ(table.Find(kept.get())->get(), keptTwin); +} + +// ScopedDirectGLESTextureBindings saves a whole twin table by value, resets it with `= {}` and +// restores it. The slot table has to keep that shape or the fixture stops isolating anything. +TEST(DirectGLESSlotTable, AWholeTableSavesResetsAndRestores) { + using namespace MobileGL; + + FakeSlotTable table; + auto object = MakeShared(0xD1u); + table.GetOrCreate(object) = MakeShared(); + (*table.Find(object.get()))->marker = 7; + + const FakeSlotTable saved = table; + table = {}; + EXPECT_EQ(table.Find(object.get()), nullptr) << "the reset left the twin reachable"; + + table = saved; + ASSERT_NE(table.Find(object.get()), nullptr); + EXPECT_EQ((*table.Find(object.get()))->marker, 7); +} + +// The whole point of routing every twin through the client allocator: a table that keeps its own +// dense array still shares ONE identity per frontend object with every other holder of it. +TEST(DirectGLESSlotTable, TwoTablesOfTheSameKindAgreeOnOneObjectsHandle) { + using namespace MobileGL; + + FakeSlotTable a; + FakeSlotTable b; + auto object = MakeShared(0xE1u); + a.GetOrCreate(object) = MakeShared(); + b.GetOrCreate(object) = MakeShared(); + + EXPECT_TRUE(a.HandleOf(object.get()) == b.HandleOf(object.get())); +} +#else +// G2 wants the pull and the push build to list the SAME ctest entries. The twin table only +// exists under MOBILEGL_PIPE_PUSH, so in the pull build each case above keeps its name and +// skips visibly - a vanishing test is exactly what that gate is there to stop. +TEST(DirectGLESSlotTable, ARecycledSlotIsANewHandleAndTheStaleOneResolvesToNothing) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} + +TEST(DirectGLESSlotTable, RepeatedLookupsOfALiveObjectKeepOneHandle) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} + +TEST(DirectGLESSlotTable, FindNeverMutatesTheTable) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} + +TEST(DirectGLESSlotTable, AWholeTableSavesResetsAndRestores) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} + +TEST(DirectGLESSlotTable, TwoTablesOfTheSameKindAgreeOnOneObjectsHandle) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} +#endif // MOBILEGL_PIPE_PUSH From 149e26a79ae4e16a70f8ceb50d38f99a773fc230 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:23:57 -0400 Subject: [PATCH 088/529] [Fix] (Espryt): do not return a reference through a null slot pointer, and stop a comment claiming a memo that is no longer there - SyncTextureObjectToBackend re-resolves its slot after the nested glTextureView sync. The assert that it is still there is right - the caller holds the frontend object, so nothing can reclaim its slot - but the function returns a REFERENCE, and in a release build the assert is gone and the deref is not. It now falls back to GetOrCreate and puts the twin the caller is about to use back. - ResolveVaoTwin documents why its raw twin pointer survives the whole draw on both arms instead of pointing at TwinLookupMemo, which the handle arm does not compile. --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 02e6401fa..dc1e7c37b 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -1218,8 +1218,10 @@ namespace MobileGL::MG_Backend::DirectGLES { // the result to the buffer sync (resolved-buffers memo host), the VAO sync and // the draw-time bind, which each used to run their own registry Find. The raw // pointer stays valid for the whole draw: the frontend VAO is pinned by the - // context binding, and a live object's registry entry is never erased nor its - // twin replaced (see TwinLookupMemo's contract). + // context binding, and a live object's twin is never erased nor replaced - on the + // legacy arm that is TwinLookupMemo's contract, and on the {slot, gen} arm it is + // simply that nothing but the sweep frees a slot and the sweep only takes slots + // whose frontend object is already gone. BackendVertexArrayObject* ResolveVaoTwin(const SharedPtr& vao) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); @@ -1390,7 +1392,17 @@ namespace MobileGL::MG_Backend::DirectGLES { auto* slot = g_backendTextureObjects.Find(textureObject.get()); MOBILEGL_ASSERT(slot != nullptr && *slot != nullptr, "the texture twin resolved at entry is gone after its own sync"); - return *slot; + if (slot != nullptr && *slot != nullptr) { + return *slot; + } + // Cannot happen - the caller holds the frontend object, so its slot cannot be + // reclaimed underneath this call - but the return is a reference, and a null + // deref in a release build is a worse way to learn that than a re-created twin. + auto& repaired = g_backendTextureObjects.GetOrCreate(textureObject); + if (!repaired) { + repaired = backendObj; + } + return repaired; } #endif auto* refreshedSlot = g_backendTextureObjects.Find(textureObject.get()); From 9a8369296e4a59507e913e16ba862a2de8781bc2 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 09:36:08 -0400 Subject: [PATCH 089/529] [Fix] (Espryt): sweep the twin table on object churn again, refuse to run an arm the operator disabled, and give the hot lookups back their array probe - The handle arm took only ONE of the registry's two sweep drivers. The map arm sweeps every 64 first-time insertions BECAUSE object churn, not draw count, is what makes the sweep urgent: a CTS-shaped case runs ~10 per-draw ticks, so the 1024-tick draw-path driver alone spans ~100 cases' worth of dead, gigabyte-sized twins. BackendSlotTable now carries the same kCreationGCInterval = 64 creation tick, swept before the entry reference exists for the same reason the map arm sweeps there. Without this the slice REGRESSED the memory it was supposed to leave unchanged. - Fatal{PipeLegacyMemosDisabled} now aborts. It logged and then returned false, which fell straight into the legacy arm the operator had just made unreachable: a green run measured on the wrong arm, and the exact lever HandleRecycleScenario's arms are selected with. It is also resolved at backend context creation now, not on the first twin lookup, so a process that twins nothing still learns its knobs leave it with no arm at all. - EsprytSlotTablesEnabled() becomes an inline latch over an out-of-line resolver. It is consulted on every Find/GetOrCreate/HandleOf/ForEachLive/CollectGarbage*, i.e. several times per draw, and as a cross-TU call with no LTO that was a PLT call per lookup. - HandleOf keeps a one-entry lifetimeId -> handle memo, so the three per-draw resolution paths whose TwinLookupMemos this slice deleted go back to an integer compare plus an array index instead of the allocator's ByLifetimeId hash - which is the "direct slot indexing" the memo removal was traded for. It cannot serve a stale answer: a lifetime id is never handed out twice, and FindByHandle compares Gen anyway. - GetOrCreate(nullptr) returns a parking slot instead of dereferencing null in a release build; the map arm inserted a null key and SyncTextureObjectToBackend documents relying on that tolerance. - ReclaimDeadSlots moves the twin out before it writes the entry, so a twin destructor that re-entered GetOrCreate and grew m_slots could not make the writes land in freed memory. - The framebuffer binding-slot cache is gated on kMGPipeSubsystemEsprytSlots rather than on the compile-time MOBILEGL_PIPE_PUSH, so MOBILEGL_PIPE_PUSH=0 stays the faithful all-subsystems-pull control ConfigLoader.cpp documents. The poison bypass stays closed on the arm that ships. - MGB_TWIN_KIND_PARAM/ARG stop leaking into every TU that includes Managers.h: the twelve declaration and definition sites name a TwinRegistry alias template that swallows the kind in the pull build, and the one remaining macro is #undef'd after the class. - The twin lookup inside BindCurrentFBO stops shadowing the framebuffer binding slot in a function whose whole subject is which "slot" is meant. --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 50 ++++-- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 39 +++-- MobileGL/MG_Backend/DirectGLES/Managers.h | 34 ++-- MobileGL/MG_Backend/DirectGLES/SlotTables.h | 146 ++++++++++++++++-- 4 files changed, 220 insertions(+), 49 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index dc1e7c37b..4905b4a7d 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -148,23 +148,31 @@ namespace MobileGL::MG_Backend::DirectGLES { // exactly the pointer compare below. using FbBindingSlot = std::remove_reference_tGetFramebufferBindingSlot(FramebufferTarget::Draw))>; -#if !MOBILEGL_PIPE_PUSH +#if !MOBILEGL_PIPE_PUSH || MOBILEGL_PIPE_LEGACY_MEMOS static const void* g_fbSlotCacheContext = nullptr; static Array g_fbSlotCache = {}; #endif - // P2 step e4. Under push this is an ORDINARY read of pushed state and the cache above does - // not exist, which closes the P1 accessor bypass: the cached raw pointer ran the checked - // accessor once per context change and then handed out the pointee forever, so at all five - // call sites the per-verb poison stamp (MGP_INPUT_CHECK) and the verify read-hook - // (MGP_INPUT_VERIFY_READ) were skipped. A verb that legitimately never fills + // P2 step e4. On the {slot, gen} arm this is an ORDINARY read of pushed state and the cache + // above is not consulted, which closes the P1 accessor bypass: the cached raw pointer ran + // the checked accessor once per context change and then handed out the pointee forever, so + // at all five call sites the per-verb poison stamp (MGP_INPUT_CHECK) and the verify + // read-hook (MGP_INPUT_VERIFY_READ) were skipped. A verb that legitimately never fills // GetFramebufferBindingSlot could not be caught here, and a verify build compared the field - // only where the slow accessor was used. In the pull build MGB_CTX is the live GLContext, - // there is no poison to bypass and the frontend getter still linear-scans, so the cache is - // exactly the code it was. + // only where the slow accessor was used. + // + // The cache stays on the LEGACY arm, gated on the same subsystem bit as the rest of this + // slice, so that MOBILEGL_PIPE_PUSH=0 keeps being the faithful all-subsystems-pull control + // ConfigLoader.cpp documents - "reproduces P1's behaviour exactly" has to include this + // path, or the integrator's A/B measures e4 on both arms and attributes it to neither. In + // the pull build MGB_CTX is the live GLContext, there is no poison to bypass and the + // frontend getter still linear-scans, so the cache is exactly the code it was. static inline FbBindingSlot& GetFramebufferBindingSlotChecked(FramebufferTarget target) { #if MOBILEGL_PIPE_PUSH - return MGB_CTX->GetFramebufferBindingSlot(target); -#else + if (EsprytSlotTablesEnabled()) { + return MGB_CTX->GetFramebufferBindingSlot(target); + } +#endif +#if !MOBILEGL_PIPE_PUSH || MOBILEGL_PIPE_LEGACY_MEMOS const void* ctx = MGB_CTX_IDENTITY; if (ctx != g_fbSlotCacheContext) { auto& live = *MGB_CTX; @@ -174,6 +182,9 @@ namespace MobileGL::MG_Backend::DirectGLES { g_fbSlotCacheContext = ctx; } return *g_fbSlotCache[SizeT(target)]; +#else + // No legacy arm compiled: EsprytSlotTablesEnabled() is unconditionally true above. + return MGB_CTX->GetFramebufferBindingSlot(target); #endif } @@ -3007,9 +3018,11 @@ namespace MobileGL::MG_Backend::DirectGLES { FramebufferImpl::BackendFramebufferObject* twin = nullptr; #if MOBILEGL_PIPE_PUSH if (EsprytSlotTablesEnabled()) { - auto* slot = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get()); - if (slot && *slot) { - twin = slot->get(); + // Not "slot": the enclosing scope's `slot` is the framebuffer BINDING slot, + // and this one is the twin table's entry. + auto* twinEntry = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get()); + if (twinEntry && *twinEntry) { + twin = twinEntry->get(); } } else #endif @@ -10156,6 +10169,15 @@ namespace MobileGL::MG_Backend::DirectGLES { static Bool InitDisplayAndContext(EGLint surfaceBit, NativeWindowType window = static_cast(0)) { DestroyEGLContext(); +#if MOBILEGL_PIPE_PUSH + // Resolve the twin-table arm HERE, at backend startup, rather than leaving it to the + // first twin lookup deep inside the first draw: Fatal{PipeLegacyMemosDisabled} has to + // reach an operator who set MOBILEGL_PIPE_PUSH and MOBILEGL_PIPE_LEGACY_MEMOS into a + // combination that leaves no arm at all, including in a process that goes on to twin + // nothing. The call is idempotent and latched. + (void)EsprytSlotTablesEnabled(); +#endif + g_Display = g_EGLFuncs.eglGetDisplay(EGL_DEFAULT_DISPLAY); if (g_Display == EGL_NO_DISPLAY) return false; diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 7171a2a04..b6dfca5cb 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -171,20 +171,30 @@ namespace MobileGL::MG_Backend::DirectGLES { } #if MOBILEGL_PIPE_PUSH - Bool EsprytSlotTablesEnabled() { - // Latched once, not read per call: the two arms of StateBackendObjectRegistry keep - // their twins in different containers, so an answer that changed mid-run would strand - // every twin already built (and, for the driver ids those twins own, leak them). - static const Bool enabled = [] { + Bool ResolveEsprytSlotTablesArm() { + // Resolved once and latched by the inline EsprytSlotTablesEnabled() in SlotTables.h: + // the two arms of StateBackendObjectRegistry keep their twins in different containers, + // so an answer that changed mid-run would strand every twin already built (and, for + // the driver ids those twins own, leak them). InitDisplayAndContext() forces the + // resolution at backend context creation, so the trap below fires before the first + // draw rather than on the first twin lookup - a short-lived process that never twins + // anything used to never learn its knobs left it with no arm at all. + { const Bool bitSet = (MG_Config::Features.PipePush & MG_Pipe::kMGPipeSubsystemEsprytSlots) != 0; #if MOBILEGL_PIPE_LEGACY_MEMOS if (!bitSet && !MG_Config::Features.PipeLegacyMemos) { // The operator asked for the handle arm to be OFF and the legacy arm to be - // unreachable at the same time, which leaves no arm at all. Say so at startup - // rather than silently running the thing they turned off (ARCHITECTURE.md 9.6). + // unreachable at the same time, which leaves no arm at all. This is a Fatal{}, + // and a Fatal{} in this codebase STOPS (MG_Impl/Pipe/PipeFill.cpp's BadKnob and + // its verify trap are both MGLOG_F + abort). Returning here instead would run + // the very arm the operator disabled and hand back a green result measured on + // it - which is exactly the lever HandleRecycleScenario's arms are selected + // with, so a mis-set A/B would be scored silently against the wrong arm + // (ARCHITECTURE.md 9.6). MGLOG_F("MGPipe: Fatal{PipeLegacyMemosDisabled, \"kMGPipeSubsystemEsprytSlots " "is clear but MOBILEGL_PIPE_LEGACY_MEMOS=0\"}"); + std::abort(); } return bitSet; #else @@ -196,8 +206,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } return true; #endif - }(); - return enabled; + } } #endif @@ -2775,7 +2784,7 @@ namespace MobileGL::MG_Backend::DirectGLES { return true; } - StateBackendObjectRegistry + TwinRegistry g_backendVertexArrayObjects; } // namespace VertexArrayImpl @@ -5103,7 +5112,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Array, MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS> g_boundTexturesCache; - StateBackendObjectRegistry g_backendTextureObjects; + TwinRegistry g_backendTextureObjects; } // namespace TextureImpl namespace FramebufferImpl { @@ -5856,7 +5865,7 @@ namespace MobileGL::MG_Backend::DirectGLES { return m_backendColorSlots[index]; } - StateBackendObjectRegistry + TwinRegistry g_backendFramebufferObjects; Array g_fboSyncedSlotVersions = {0}; // Tracks the bound FBO's object version (bumped on any attachment/drawbuffer change) @@ -6167,7 +6176,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // context never answers GL_NO_ERROR, and the build runs on the thread that would // then spin forever. constexpr Int kMaxDrainedProgramErrors = 32; - StateBackendObjectRegistry g_backendProgramObjects; + TwinRegistry g_backendProgramObjects; BackendProgramObjectImpl::BackendProgramObjectImpl() { #ifdef TRACY_ENABLE @@ -8685,7 +8694,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } Array g_boundSamplersCache; - StateBackendObjectRegistry g_backendSamplerObjects; + TwinRegistry g_backendSamplerObjects; } // namespace SamplerImpl namespace RenderbufferImpl { @@ -8797,7 +8806,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("RBO %u sync completed. backend ID %u", stateRBOObject->GetExternalIndex(), m_backendRBOId); } - StateBackendObjectRegistry + TwinRegistry g_backendRenderbufferObjects; } // namespace RenderbufferImpl } // namespace MobileGL::MG_Backend::DirectGLES diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index ce17d7a65..61edde38d 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -285,14 +285,15 @@ namespace MobileGL::MG_Backend::DirectGLES { // The kind is a template parameter ONLY in the push build. G1 requires the pull build's // symbol set to be byte-for-byte the pre-P2 one, and a third template argument changes // every instantiation's mangled name - so in the pull build the parameter, like the arm it - // selects, does not exist. MGB_TWIN_KIND_ARG spells the same thing at the six declarations - // and six definitions. + // selects, does not exist. The macro below spells that one difference; it is #undef'd + // straight after the class, and the twelve declaration and definition sites name the + // registry through the TwinRegistry alias instead, which swallows the kind in the pull + // build. (An alias template may have a parameter it does not use, and an alias emits no + // symbol of its own, so the pull build's mangled names are unchanged.) #if MOBILEGL_PIPE_PUSH #define MGB_TWIN_KIND_PARAM , MG_Pipe::MGPipeKind kKind -#define MGB_TWIN_KIND_ARG(kind) , kind #else #define MGB_TWIN_KIND_PARAM -#define MGB_TWIN_KIND_ARG(kind) #endif template @@ -499,6 +500,19 @@ namespace MobileGL::MG_Backend::DirectGLES { #endif }; +#undef MGB_TWIN_KIND_PARAM + + // One spelling for the twin registry at every declaration and definition site. In the push + // build the kind is the registry's third template argument; in the pull build the alias + // drops it, so the mangled name is the pre-P2 two-argument one. +#if MOBILEGL_PIPE_PUSH + template + using TwinRegistry = StateBackendObjectRegistry; +#else + template + using TwinRegistry = StateBackendObjectRegistry; +#endif + namespace BufferImpl { const GLenum TempBufferTarget = GL_ARRAY_BUFFER; @@ -902,7 +916,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint64 m_syncedBufferIdGeneration = 0; }; - extern StateBackendObjectRegistry + extern TwinRegistry g_backendVertexArrayObjects; // Shadowed glBindVertexArray: every backend VAO bind goes through here so a @@ -1223,7 +1237,7 @@ namespace MobileGL::MG_Backend::DirectGLES { void ActivateTextureUnit(Uint unit); void UnbindTexture(Uint unit, GLenum target); - extern StateBackendObjectRegistry + extern TwinRegistry g_backendTextureObjects; SharedPtr& SyncTextureObjectToBackend( const SharedPtr& textureObject, @@ -1314,7 +1328,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint64 m_syncedBackendIdGeneration = 0; }; - extern StateBackendObjectRegistry + extern TwinRegistry g_backendFramebufferObjects; // True when the read buffer names a fixed-point (norm/snorm) attachment that the // backend actually stores in a floating-point format. GL clamps a read from a @@ -1832,7 +1846,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // skip redundant rebinds. Reset to 0 wherever glUseProgram(0) is issued or the // ES context is recreated. extern Uint g_lastUsedBackendProgramId; - extern StateBackendObjectRegistry + extern TwinRegistry g_backendProgramObjects; // Points one shader storage block of an ALREADY-LINKED backend program at @@ -1932,7 +1946,7 @@ namespace MobileGL::MG_Backend::DirectGLES { extern Array g_boundSamplersCache; - extern StateBackendObjectRegistry + extern TwinRegistry g_backendSamplerObjects; } // namespace SamplerImpl @@ -1959,7 +1973,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Int m_cacheSamples = 0; }; - extern StateBackendObjectRegistry + extern TwinRegistry g_backendRenderbufferObjects; } // namespace RenderbufferImpl } // namespace MobileGL::MG_Backend::DirectGLES diff --git a/MobileGL/MG_Backend/DirectGLES/SlotTables.h b/MobileGL/MG_Backend/DirectGLES/SlotTables.h index 187d2ce5b..61e40b73e 100644 --- a/MobileGL/MG_Backend/DirectGLES/SlotTables.h +++ b/MobileGL/MG_Backend/DirectGLES/SlotTables.h @@ -44,13 +44,44 @@ // what bumps Gen, which is precisely the ABA defence: a slot is only ever handed out again // after it was freed. When e2 lands, ReclaimDeadSlots() becomes the fallback path of an // explicit Destroy(handle) and the sweep call sites go away. +// +// Because the sweep is still the only death signal, this table carries BOTH of the drivers +// the registry it replaces carries, and for the same reasons: +// * the draw-path tick (kGCInterval = 1024 CollectGarbageIfNeeded calls), and +// * the CREATION tick (kCreationGCInterval = 64 first-time insertions), because object CHURN +// rather than draw count is what makes the sweep urgent - a CTS-shaped case runs ~10 +// per-draw ticks, so 1024 of them span ~100 cases' worth of dead, gigabyte-sized objects. +// Dropping the second one would have made this table's memory behaviour strictly WORSE than +// the map it replaces, which is the opposite of what the slice is for. +// +// P3+ DEBT, recorded rather than hidden: this header is under MG_Backend/ and it MINTS +// handles (MGPipeSlots().Acquire below) off a frontend SharedPtr's GetLifetimeId(). +// MGPipeHandles.h:13-16 says a handle is minted by the CLIENT and never by the server, and +// under a real split neither the frontend object nor its lifetime id exists on this side of +// the wire. This is monolith glue: the minting and the lifetimeId -> handle resolution both +// belong on the client, and the backend should receive the handle in the verb payload. It is +// NOT part of "Track H done" and check_include_closure.py does not probe MG_Backend headers, +// so nothing catches it automatically. namespace MobileGL::MG_Backend::DirectGLES { #if MOBILEGL_PIPE_PUSH + // Reads the config, logs, and traps when the operator left no arm at all. Cold: called + // exactly once per process, from the latch below and from backend context creation. + Bool ResolveEsprytSlotTablesArm(); + // True when this process runs the {slot, gen} arm. Fixed for the life of the process: the // two arms hold their twins in different containers, so flipping mid-run would strand them. - Bool EsprytSlotTablesEnabled(); + // + // INLINE on purpose. Every Find / GetOrCreate / HandleOf / ForEachLive / CollectGarbage* + // on the twin tables consults it, i.e. it is on the per-draw path several times per draw. + // As an out-of-line function in Managers.cpp (no LTO in any shipped configuration) that was + // a call through the PLT per lookup; here the caller sees a guard-variable load and a + // perfectly-predicted branch, and the arm dispatch folds into the caller. + inline Bool EsprytSlotTablesEnabled() { + static const Bool enabled = ResolveEsprytSlotTablesArm(); + return enabled; + } template class BackendSlotTable { @@ -75,13 +106,34 @@ namespace MobileGL::MG_Backend::DirectGLES { // object's lifetime id, so two calls for the same live object always land on the same // slot, and a successor object at the same heap address never does. BackendPtr& GetOrCreate(const StatePtr& stateObj) { - MOBILEGL_ASSERT(stateObj != nullptr, "State object must not be null"); + // No assert on null here, unlike the map arm: null is TOLERATED, so a DEBUG build + // must not trap where the release build quietly does the documented thing. + if (stateObj == nullptr) { + // The registry this replaces inserted a null key and handed back ITS twin slot + // (DirectGLES.cpp's SyncTextureObjectToBackend documents relying on exactly + // that tolerance), so a release build never dereferenced null here. Keep the + // shape: one per-table parking slot, never live, never swept, never handed a + // handle. A null object has no identity and therefore cannot have a twin. + m_nullTwin.reset(); + return m_nullTwin; + } + + // Sweep BEFORE the entry reference below exists, for the same reason the map arm + // does it here: EntryAt may grow m_slots and move every element, so a reference + // taken first would not survive it. The sweep is owed from an earlier creation + // rather than triggered by this one. + if (m_creationTick >= kCreationGCInterval) { + m_creationTick = 0; + ReclaimDeadSlots(); + } + const MG_Pipe::MGPipeHandle handle = MG_Pipe::MGPipeSlots().Acquire(kKind, stateObj->GetLifetimeId()); MOBILEGL_ASSERT(!MG_Pipe::MGPipeHandleIsNull(handle), "MGPipe slot space of kind %u is exhausted", static_cast(kKind)); Entry& entry = EntryAt(handle.Slot); + const Bool firstInsertion = !entry.Live || entry.Gen != handle.Gen; if (entry.Live && entry.Gen != handle.Gen) { // The slot was reclaimed and handed to a new object: the twin at it describes // driver ids the new state object never made. @@ -90,6 +142,20 @@ namespace MobileGL::MG_Backend::DirectGLES { entry.Gen = handle.Gen; entry.Live = true; entry.stateRef = stateObj; + if (firstInsertion) { + // A slot this table has never held (or held for a previous owner). Nothing + // tells the backend that a texture or renderbuffer was DELETED - the twin, and + // the driver storage it owns, lives until a collection - and + // CollectGarbageIfNeeded is ticked only from the per-draw sync paths, which a + // CTS-shaped workload runs about ten times per case. 1024 of those ticks then + // span ~100 cases, so ~100 cases' worth of dead (and, for this suite, + // gigabyte-sized) objects would stay allocated at once. Object CHURN rather + // than draw count is what makes the sweep urgent, so a twin the table has + // never seen ticks it too - and it does so on the path that is about to + // allocate, which is exactly when the memory is needed. + ++m_creationTick; + } + RememberHandle(stateObj->GetLifetimeId(), handle); return entry.backend; } @@ -118,7 +184,12 @@ namespace MobileGL::MG_Backend::DirectGLES { // memo stores instead of a raw pointer, a GL name or a bare lifetime id. MG_Pipe::MGPipeHandle HandleOf(const StateObject* stateObj) const { if (stateObj == nullptr) return MG_Pipe::kMGPipeNullHandle; - return MG_Pipe::MGPipeSlots().FindByLifetimeId(kKind, stateObj->GetLifetimeId()); + const Uint64 lifetimeId = stateObj->GetLifetimeId(); + if (lifetimeId == m_memoLifetimeId) return m_memoHandle; + const MG_Pipe::MGPipeHandle handle = + MG_Pipe::MGPipeSlots().FindByLifetimeId(kKind, lifetimeId); + RememberHandle(lifetimeId, handle); + return handle; } // Drop the twin of every slot whose frontend object is gone and return the slot to the @@ -127,13 +198,25 @@ namespace MobileGL::MG_Backend::DirectGLES { if (m_isCollecting) return; m_isCollecting = true; for (SizeT slot = 0; slot < m_slots.size(); ++slot) { - Entry& entry = m_slots[slot]; - if (!entry.Live || !entry.stateRef.expired()) continue; - entry.backend.reset(); - entry.stateRef.reset(); - entry.Live = false; + Uint32 gen = 0; + // The twin's destructor is a driver call and could, in principle, re-enter + // GetOrCreate on this table and resize m_slots. So NOTHING that outlives the + // destructor may be a reference into m_slots: the twin is moved out into a + // local, the entry is finished with, and only then is the local released. + BackendPtr dead; + { + Entry& entry = m_slots[slot]; + if (!entry.Live || !entry.stateRef.expired()) continue; + gen = entry.Gen; + dead = std::move(entry.backend); + entry.backend.reset(); + entry.stateRef.reset(); + entry.Live = false; + } MG_Pipe::MGPipeSlots().Free( - kKind, MG_Pipe::MGPipeHandle{static_cast(slot), entry.Gen}); + kKind, MG_Pipe::MGPipeHandle{static_cast(slot), gen}); + if (m_memoHandle.Slot == static_cast(slot)) ForgetHandle(); + dead.reset(); } m_isCollecting = false; } @@ -142,10 +225,20 @@ namespace MobileGL::MG_Backend::DirectGLES { ++m_gcTick; if (m_gcTick < kGCInterval) return; m_gcTick = 0; + m_creationTick = 0; + ReclaimDeadSlots(); + } + + void CollectGarbageNow() { + m_creationTick = 0; ReclaimDeadSlots(); } - void CollectGarbageNow() { ReclaimDeadSlots(); } + // Test-only introspection: how many first-time insertions are owed before the + // creation-driven sweep fires. Reading it is what lets a test pin the CADENCE rather + // than only the effect of an explicit CollectGarbageNow(). + Uint32 CreationTickForTest() const { return m_creationTick; } + static constexpr Uint32 CreationGCIntervalForTest() { return kCreationGCInterval; } // fn(const StatePtr& state, const BackendPtr& twin) over every live, still-owned entry. // Replaces the registry's begin()/end(), whose iterator exposed the raw frontend @@ -176,12 +269,45 @@ namespace MobileGL::MG_Backend::DirectGLES { return m_slots[slot]; } + void RememberHandle(Uint64 lifetimeId, MG_Pipe::MGPipeHandle handle) const { + m_memoLifetimeId = lifetimeId; + m_memoHandle = handle; + } + void ForgetHandle() const { + m_memoLifetimeId = 0; + m_memoHandle = MG_Pipe::kMGPipeNullHandle; + } + static constexpr Uint32 kGCInterval = 1024; + // Creations are far rarer than draws, so this counts in a much smaller unit than + // kGCInterval does. Same value the map arm uses, so the two arms sweep at the same + // cadence under the same workload. + static constexpr Uint32 kCreationGCInterval = 64; // Indexed by MGPipeHandle::Slot; [0] is the reserved slot and is never live. Vector m_slots; Uint32 m_gcTick = 0; + Uint32 m_creationTick = 0; Bool m_isCollecting = false; + // Handed back by GetOrCreate for a null state object. Never live, never swept. + BackendPtr m_nullTwin; + + // ONE-entry resolution memo, lifetimeId -> handle. The three per-draw resolution paths + // (ResolveVaoTwin, SyncCurrentProgram, BindCurrentFBO) ask the SAME table for the SAME + // object every draw, so this turns the steady state back into an integer compare plus + // one array index - which is what the deleted TwinLookupMemos bought and what D13 + // promises ("direct slot indexing - the memo existed only to avoid the hash probe"). + // Without it every resolution went through the allocator's ByLifetimeId hash. + // + // It cannot serve a stale answer, by two independent arguments: + // * the key is a lifetime id, which MG_State never hands out twice, so a recycled + // heap address cannot hit this memo the way it could hit an address-keyed one; and + // * even a hit for a slot that has since been freed and re-handed is caught, because + // the caller resolves the handle through FindByHandle, which compares Gen. + // Cleared anyway when the sweep frees the memoised slot. 0 is never a live lifetime id + // (MG_State's counters start at 1), so a zeroed memo is a guaranteed miss. + mutable Uint64 m_memoLifetimeId = 0; + mutable MG_Pipe::MGPipeHandle m_memoHandle = MG_Pipe::kMGPipeNullHandle; }; #endif // MOBILEGL_PIPE_PUSH From 5a3c0616b7eb47c54c3dc2f880438a406277e65e Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 09:36:21 -0400 Subject: [PATCH 090/529] [Test] (Espryt): pin the churn-driven sweep cadence, the null tolerance, and the one slot per object the two Track H slices share - ObjectChurnAloneDrivesTheSweep churns 256 objects through GetOrCreate and never calls CollectGarbage{IfNeeded,Now}: nothing but the creation tick can collect them. Verified red - it is the only case that fails - when the creation-driven sweep is neutered, and green again on restore. The churn count is a fixed constant rather than a multiple of the interval so that a negative control which pushes the interval out of reach makes the case FAIL instead of running for 2^32 iterations. - TwoTablesOfTheSameKindAgreeOnOneObjectsHandle could not fail: HandleOf never reads the table, so any two tables agree for any implementation. Replaced by a case that asserts what is actually load-bearing - ONE slot of the kind is consumed however many tables hold a twin of the object (which is what makes Magma's subsystem-4 table resolve the same handle for the same VAO), and the shared handle still addresses each table's own twin. - GetOrCreateToleratesANullStateObject pins the release-build behaviour the map arm had. - The three names are registered as visible GTEST_SKIPs in the pull build, like the five before them, so the pull and push builds keep listing the same ctest entries. --- MobileGL/MG_Test/SanityTest.cpp | 91 +++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 3 deletions(-) diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index 7eafcff2f..71e0c9fe2 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -3263,16 +3263,93 @@ TEST(DirectGLESSlotTable, AWholeTableSavesResetsAndRestores) { // The whole point of routing every twin through the client allocator: a table that keeps its own // dense array still shares ONE identity per frontend object with every other holder of it. -TEST(DirectGLESSlotTable, TwoTablesOfTheSameKindAgreeOnOneObjectsHandle) { +// +// Comparing a.HandleOf(o) with b.HandleOf(o) alone would prove nothing - HandleOf never reads the +// table, so any two tables agree for any implementation. What is actually load-bearing, and what +// is asserted here, is that ONE slot of the kind is consumed for the object no matter how many +// tables hold a twin of it (a per-table allocator would pass the pure compare and fail this), and +// that the shared handle still addresses each table's OWN twin. +TEST(DirectGLESSlotTable, TwoTablesOfTheSameKindShareOneSlotAndKeepTheirOwnTwin) { using namespace MobileGL; + auto& slots = MG_Pipe::MGPipeSlots(); + const Uint32 liveBefore = slots.LiveCount(MG_Pipe::MGPipeKind::Query); + FakeSlotTable a; FakeSlotTable b; auto object = MakeShared(0xE1u); a.GetOrCreate(object) = MakeShared(); + (*a.Find(object.get()))->marker = 1; b.GetOrCreate(object) = MakeShared(); + (*b.Find(object.get()))->marker = 2; + + EXPECT_EQ(slots.LiveCount(MG_Pipe::MGPipeKind::Query), liveBefore + 1u) + << "the two tables minted a slot each; Magma's table would then resolve a different " + "handle for the same object than Espryt's"; + + const MG_Pipe::MGPipeHandle handle = a.HandleOf(object.get()); + ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(handle)); + EXPECT_TRUE(b.HandleOf(object.get()) == handle); + + ASSERT_NE(a.FindByHandle(handle), nullptr); + ASSERT_NE(b.FindByHandle(handle), nullptr); + EXPECT_EQ((*a.FindByHandle(handle))->marker, 1); + EXPECT_EQ((*b.FindByHandle(handle))->marker, 2); + // Deliberately no sweep: two tables of ONE kind both hold this slot, and whichever swept + // first would return it to the allocator while the other still names it. The six shipping + // registries are one table per kind, so that cannot arise outside this case. +} + +// The sweep has TWO drivers and the table must carry both. Nothing below calls +// CollectGarbageIfNeeded() or CollectGarbageNow(): this is the CREATION-driven half, and it is +// the one that matters for a workload that churns objects without drawing much. The draw-path +// tick is 1024 CollectGarbageIfNeeded calls, i.e. ~100 CTS-shaped cases at ~10 per-draw ticks +// each, which is how the registry this replaces came to hold ~100 cases' worth of dead, +// gigabyte-sized twins at once before the creation tick was added to fix it. +TEST(DirectGLESSlotTable, ObjectChurnAloneDrivesTheSweep) { + using namespace MobileGL; + + auto& slots = MG_Pipe::MGPipeSlots(); + const Uint32 interval = FakeSlotTable::CreationGCIntervalForTest(); + // The churn count is a FIXED constant, not a multiple of the interval: a negative control + // that pushes the interval out of reach must make this case go red, not make it run for + // 2^32 iterations. + constexpr Uint32 kChurn = 256u; + ASSERT_GT(interval, 0u); + ASSERT_LT(interval, kChurn) << "the creation sweep can no longer fire inside this case"; + const Uint32 highWaterBefore = slots.HighWater(MG_Pipe::MGPipeKind::Query); + + FakeSlotTable table; + Uint32 peakLive = 0; + for (Uint32 i = 0; i < kChurn; ++i) { + auto object = MakeShared(0xF0000000ull + i); + table.GetOrCreate(object) = MakeShared(); + peakLive = std::max(peakLive, table.LiveCount()); + // The object dies here. NOTHING announces that to the table (step e2 is not landed); + // the only thing that can notice is a sweep. + } + + EXPECT_LE(peakLive, interval + 2u) + << peakLive << " dead twins accumulated at once with " << kChurn + << " objects churned - object churn stopped driving the sweep"; + EXPECT_LE(table.LiveCount(), interval + 2u); + EXPECT_LE(slots.HighWater(MG_Pipe::MGPipeKind::Query) - highWaterBefore, interval + 2u) + << "the slot space grew with the churn instead of being recycled"; +} - EXPECT_TRUE(a.HandleOf(object.get()) == b.HandleOf(object.get())); +// The map arm inserted a null key and handed back that entry's twin, and +// SyncTextureObjectToBackend documents relying on it. Release builds compile the assert out, so +// on the handle arm this has to be a defined answer rather than a dereference of null. +TEST(DirectGLESSlotTable, GetOrCreateToleratesANullStateObject) { + using namespace MobileGL; + + FakeSlotTable table; + const SharedPtr none; + auto& twin = table.GetOrCreate(none); + EXPECT_EQ(twin, nullptr); + EXPECT_EQ(table.LiveCount(), 0u) << "a null object took a slot"; + EXPECT_EQ(table.Find(nullptr), nullptr); + EXPECT_TRUE(MG_Pipe::MGPipeHandleIsNull(table.HandleOf(nullptr))); } #else // G2 wants the pull and the push build to list the SAME ctest entries. The twin table only @@ -3294,7 +3371,15 @@ TEST(DirectGLESSlotTable, AWholeTableSavesResetsAndRestores) { GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; } -TEST(DirectGLESSlotTable, TwoTablesOfTheSameKindAgreeOnOneObjectsHandle) { +TEST(DirectGLESSlotTable, TwoTablesOfTheSameKindShareOneSlotAndKeepTheirOwnTwin) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} + +TEST(DirectGLESSlotTable, ObjectChurnAloneDrivesTheSweep) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} + +TEST(DirectGLESSlotTable, GetOrCreateToleratesANullStateObject) { GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; } #endif // MOBILEGL_PIPE_PUSH From e10f5d675084a48c831deaa97a859ce5df71965c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 11:01:18 -0400 Subject: [PATCH 091/529] [Test] (Espryt): run the sanity binary on the twin arm it was compiled for, and pin that it does - SanityTest never calls MG_ConfigLoader::Init, so MG_Config::Features.PipePush kept its static default of 0 - the value a PULL build ships - and every case in the binary took StateBackendObjectRegistry's legacy UnorderedMap arm. In build-linux, build-push AND build-verify alike, the whole D13 "must not break" list (the scratch-FBO scrub, the three context-generation guards on the texture/framebuffer/renderbuffer twins, the sampled-set staleness walk and the whole-registry ScopedDirectGLESTextureBindings fixture) was therefore evidence about code this package did not change; a gdb breakpoint on MGPipeSlotAllocator::Acquire was the only way to see it. - A gtest global Environment now seeds Features.PipePush with ConfigLoader's own push-build default, so the binary runs the arm that SHIPS in the build it was compiled for: legacy in build-linux (where the handle arm is not compiled and every DirectGLESSlotTable case skips visibly), handles in build-push and build-verify. MOBILEGL_PIPE_PUSH in the environment overrides it with ConfigLoader's decimal/0x contract, so the legacy-arm run of the same binary is one env var. It is an Environment and not a static initializer because MG_Config::Features has a String member and is dynamically initialised. - TheTwinRegistryCasesInThisBinaryRunOnTheHandleArm is that gdb probe made falsifiable: delete the environment and it goes red naming the arm rather than a symptom. --- MobileGL/MG_Test/SanityTest.cpp | 69 +++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index 71e0c9fe2..7c83f907b 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -37,9 +37,54 @@ #include #include #include +#include +#include #include #include +#if MOBILEGL_PIPE_PUSH +namespace { + // Which arm of the twin table this binary runs on. + // + // SanityTest never calls MG_ConfigLoader::Init, so MG_Config::Features keeps its static + // defaults - and Features.PipePush's static default is 0 ("pull everything", Config.h), + // which is the value a PULL build ships. Without this the D13 "must not break" cases + // (the scratch-FBO scrub, the three context-generation guards on the texture / + // framebuffer / renderbuffer twins, the sampled-set staleness walk and the whole-registry + // ScopedDirectGLESTextureBindings fixture) exercised the legacy UnorderedMap arm in EVERY + // build directory - so a push build's 82 sanity cases said nothing about the code this + // package actually changed. + // + // The default here is therefore ConfigLoader's own push-build default + // (kMGPipeSubsystemsMigratedAtP2, ConfigLoader.cpp), i.e. this binary runs the arm that + // SHIPS in the build it was compiled for: legacy in build-linux (where the handle arm is + // not compiled at all and every DirectGLESSlotTable case skips), handles in build-push and + // build-verify. MOBILEGL_PIPE_PUSH in the environment overrides it with the same + // decimal/0x contract ConfigLoader.cpp:172-192 gives it, so `MOBILEGL_PIPE_PUSH=0 + // ctest -R Sanity` is the legacy-arm run of the same binary and the A/B is one env var. + // + // It runs as a gtest Environment rather than a static initializer on purpose: + // MG_Config::Features has a String member, so it is dynamically initialised, and writing + // to it from another TU's static initializer would be an initialisation-order race. + // SetUp() runs inside RUN_ALL_TESTS, long after every static initializer, and before the + // first test - hence before anything can latch EsprytSlotTablesEnabled(). + class EsprytSlotArmEnvironment final : public ::testing::Environment { + public: + void SetUp() override { + MobileGL::Uint64 bits = MobileGL::MG_Pipe::kMGPipeSubsystemsMigratedAtP2; + const char* knob = std::getenv("MOBILEGL_PIPE_PUSH"); + if (knob != nullptr && *knob != '\0') { + bits = std::strtoull(knob, nullptr, 0); + } + MobileGL::MG_Config::Features.PipePush = bits; + } + }; + + const ::testing::Environment* g_esprytSlotArmEnvironment = + ::testing::AddGlobalTestEnvironment(new EsprytSlotArmEnvironment()); +} // namespace +#endif // MOBILEGL_PIPE_PUSH + namespace { class DynamicParameterBackend final : public MobileGL::MG_Backend::BackendObject { public: @@ -3351,6 +3396,26 @@ TEST(DirectGLESSlotTable, GetOrCreateToleratesANullStateObject) { EXPECT_EQ(table.Find(nullptr), nullptr); EXPECT_TRUE(MG_Pipe::MGPipeHandleIsNull(table.HandleOf(nullptr))); } +// The gate on MAJOR 1 of the round-2 review: this binary's OTHER 82 cases - among them every +// D13 "must not break" item - are only evidence about this package if they run on the arm this +// package wrote. Before EsprytSlotArmEnvironment existed they did not, in any build directory +// the P2 brief defines, and nothing said so; a gdb breakpoint on MGPipeSlotAllocator::Acquire +// was the only way to find out. This case is that breakpoint, made falsifiable: delete the +// environment and it goes red, and it goes red naming the arm rather than the symptom. +TEST(DirectGLESSlotTable, TheTwinRegistryCasesInThisBinaryRunOnTheHandleArm) { + const char* knob = std::getenv("MOBILEGL_PIPE_PUSH"); + if (knob != nullptr && *knob != '\0') { + GTEST_SKIP() << "the operator pinned the arm with MOBILEGL_PIPE_PUSH=" << knob; + } + EXPECT_TRUE(MobileGL::MG_Backend::DirectGLES::EsprytSlotTablesEnabled()) + << "a push build of SanityTest resolved the LEGACY twin registry, so every case in this " + "binary that builds a twin - the scratch-FBO scrub, the three context-generation " + "guards, the sampled-set staleness walk, ScopedDirectGLESTextureBindings - is " + "exercising code this package did not change"; + EXPECT_NE(MobileGL::MG_Config::Features.PipePush & MobileGL::MG_Pipe::kMGPipeSubsystemEsprytSlots, + 0ull); +} + #else // G2 wants the pull and the push build to list the SAME ctest entries. The twin table only // exists under MOBILEGL_PIPE_PUSH, so in the pull build each case above keeps its name and @@ -3382,4 +3447,8 @@ TEST(DirectGLESSlotTable, ObjectChurnAloneDrivesTheSweep) { TEST(DirectGLESSlotTable, GetOrCreateToleratesANullStateObject) { GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; } + +TEST(DirectGLESSlotTable, TheTwinRegistryCasesInThisBinaryRunOnTheHandleArm) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} #endif // MOBILEGL_PIPE_PUSH From eb817051308ff98a2d16611bde9659399653d2c7 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 11:02:12 -0400 Subject: [PATCH 092/529] [Fix] (Espryt): tell the backend when a frontend object dies instead of discovering it in a garbage sweep - P2 step e2, as far as the file-ownership table lets one package take it. New frontend header MG_State/GLState/StateObjectDeathNotice.h carries BufferBackendOps' shape for the other six kinds: an ops table the backend fills in, and one entry point that takes {kind, lifetimeId} rather than the object, because by the time the last SharedPtr has dropped there is no object left to pass and the lifetime id is exactly what the client slot allocator resolves a handle from. Declared only under MOBILEGL_PIPE_PUSH, so the pull build's symbol set is untouched. - BackendSlotTable::DestroyByLifetimeId drops the twin and returns the slot at the moment the object goes, instead of at the next sweep - which for a renderbuffer or a texture atlas is the difference between freeing the driver allocation now and freeing it 64 creations from now. It returns the slot only when THIS table holds it: two holders of one kind already exist (the ScopedDirectGLESTextureBindings fixture; Magma's subsystem-4 table shares the VertexElementsCso kind), and a table that never twinned the object must not free a slot the other one still names. The legacy arm keys on the frontend heap address, cannot answer a notice at all, and keeps the sweep - which is the announced- versus-discovered half of the A/B the compile-time arm exists for. - Managers.cpp registers one dispatcher for all six kinds from ResolveEsprytSlotTablesArm(), i.e. exactly when the arm that can answer a notice is the arm that runs, and drops a notice that arrives after exit() has begun. - FIRING it needs a destructor per class, and the P2 ownership table gives {Texture,Framebuffer,Sampler,VertexArray}State/* to other packages, so only ProgramObject and RenderbufferObject raise it here. The other four still rely on the sweep; their four one-line calls retire it entirely. - Three cases pin the three halves: the slot comes back with no sweep and the notice is idempotent and does not free another holder's slot; a program and a renderbuffer announce their own death when the last SharedPtr drops and not before; and the handle arm actually installs a consumer, rather than the two halves each being fine on their own. --- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 50 +++++++++ MobileGL/MG_Backend/DirectGLES/Managers.h | 13 +++ MobileGL/MG_Backend/DirectGLES/SlotTables.h | 58 ++++++++-- .../GLState/ProgramState/ProgramObject.cpp | 16 ++- .../RenderbufferState/RenderbufferObject.cpp | 15 +++ .../RenderbufferState/RenderbufferObject.h | 6 + .../MG_State/GLState/StateObjectDeathNotice.h | 62 +++++++++++ MobileGL/MG_Test/SanityTest.cpp | 104 ++++++++++++++++++ 8 files changed, 312 insertions(+), 12 deletions(-) create mode 100644 MobileGL/MG_State/GLState/StateObjectDeathNotice.h diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index b6dfca5cb..e49a19b7a 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -12,6 +12,7 @@ #include "DirectGLES.h" #include "BackendObject_DirectGLES.h" #include +#include #include #include @@ -171,6 +172,48 @@ namespace MobileGL::MG_Backend::DirectGLES { } #if MOBILEGL_PIPE_PUSH + namespace { + // P2 step e2's dispatcher: the frontend told us an object died, so free its slot and + // drop its twin NOW. One entry point for all six kinds, because the answer is the same + // for all six - which is why the notice carries the kind rather than there being six + // ops tables. + // + // A notice that arrives after exit() has begun is dropped: past that point the twin's + // destructor must not call into the driver (see InProcessTeardown()), and the process + // is about to hand every GPU object back anyway. + void OnFrontendStateObjectDestroyed(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) { + if (InProcessTeardown()) return; + switch (kind) { + case MG_Pipe::MGPipeKind::Texture: + TextureImpl::g_backendTextureObjects.DestroyByLifetimeId(lifetimeId); + break; + case MG_Pipe::MGPipeKind::Framebuffer: + FramebufferImpl::g_backendFramebufferObjects.DestroyByLifetimeId(lifetimeId); + break; + case MG_Pipe::MGPipeKind::Renderbuffer: + RenderbufferImpl::g_backendRenderbufferObjects.DestroyByLifetimeId(lifetimeId); + break; + case MG_Pipe::MGPipeKind::SamplerCso: + SamplerImpl::g_backendSamplerObjects.DestroyByLifetimeId(lifetimeId); + break; + case MG_Pipe::MGPipeKind::ShaderCso: + PrgramImpl::g_backendProgramObjects.DestroyByLifetimeId(lifetimeId); + break; + case MG_Pipe::MGPipeKind::VertexElementsCso: + VertexArrayImpl::g_backendVertexArrayObjects.DestroyByLifetimeId(lifetimeId); + break; + default: + // Buffer already has its own death signal (BufferBackendOps::OnDestroy) and + // every other kind has no backend twin table here. + break; + } + } + + const MG_State::GLState::StateObjectDeathOps g_glesStateObjectDeathOps = { + .OnDestroyed = OnFrontendStateObjectDestroyed, + }; + } // namespace + Bool ResolveEsprytSlotTablesArm() { // Resolved once and latched by the inline EsprytSlotTablesEnabled() in SlotTables.h: // the two arms of StateBackendObjectRegistry keep their twins in different containers, @@ -196,6 +239,12 @@ namespace MobileGL::MG_Backend::DirectGLES { "is clear but MOBILEGL_PIPE_LEGACY_MEMOS=0\"}"); std::abort(); } + if (bitSet) { + // The notice is only consumable on the handle arm (the legacy registry keys on + // the frontend ADDRESS, which is gone by the time a destructor speaks), so it is + // installed exactly where it can be answered. Once per process, cold. + MG_State::GLState::SetStateObjectDeathOps(&g_glesStateObjectDeathOps); + } return bitSet; #else // The legacy arm is not compiled, so the handle arm is the only arm. The bit still @@ -204,6 +253,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("MGPipe: kMGPipeSubsystemEsprytSlots is clear but this build has no " "legacy twin registry; running the handle arm anyway"); } + MG_State::GLState::SetStateObjectDeathOps(&g_glesStateObjectDeathOps); return true; #endif } diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index 61edde38d..587a27829 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -417,6 +417,19 @@ namespace MobileGL::MG_Backend::DirectGLES { return nullptr; } + // P2 step e2. The legacy arm cannot answer this at all - its key is the frontend heap + // ADDRESS and the object is already gone by the time the notice arrives - so there it + // is a no-op and the garbage sweep stays its only death signal. That asymmetry is not + // an oversight: it is the A/B the compile-time arm exists to make measurable + // (ARCHITECTURE.md 9.6), and announced-versus-discovered death is one of the things + // being measured. + Bool DestroyByLifetimeId(Uint64 lifetimeId) { + if (EsprytSlotTablesEnabled()) { + return m_slotTable.DestroyByLifetimeId(lifetimeId); + } + return false; + } + // fn(const StatePtr& state, const BackendPtr& twin) over every live entry. The legacy // begin()/end() handed out the map key, i.e. the raw frontend address - exactly the // identity the backend must stop reading - and handed it out for entries whose state diff --git a/MobileGL/MG_Backend/DirectGLES/SlotTables.h b/MobileGL/MG_Backend/DirectGLES/SlotTables.h index 61e40b73e..8d3819574 100644 --- a/MobileGL/MG_Backend/DirectGLES/SlotTables.h +++ b/MobileGL/MG_Backend/DirectGLES/SlotTables.h @@ -33,17 +33,19 @@ // * Slots are dense per kind, which is what lets the server side (ARCHITECTURE.md 10.1, // MG_Remote/Server/PipeObjectTables) be an array rather than an object graph. // -// What has NOT changed, deliberately, and is this file's one departure from the P2 brief -// (recorded in the package result file): a frontend object's death is still discovered rather -// than announced. The brief's step e2 - a BufferBackendOps-shaped OnDestroy for the other six -// kinds - has to be installed in MG_State/GLState/{Texture,Framebuffer,Renderbuffer,Sampler, -// Program,VertexArray}State/*, and the P2 file-ownership table gives every one of those files -// to another package. So the table keeps ONE weak_ptr per entry and uses it for exactly one -// thing: ReclaimDeadSlots() frees the slot - and the twin, and the driver storage it owns - -// once the frontend object is gone. That is a liveness sweep, not an identity test, and it is -// what bumps Gen, which is precisely the ABA defence: a slot is only ever handed out again -// after it was freed. When e2 lands, ReclaimDeadSlots() becomes the fallback path of an -// explicit Destroy(handle) and the sweep call sites go away. +// Death: announced where the package may announce it, discovered everywhere else. +// DestroyByLifetimeId() below is step e2's backend half and it is complete - it drops the twin +// and returns the slot the moment the frontend object's last SharedPtr goes - and the notice +// that drives it (MG_State/GLState/StateObjectDeathNotice.h, BufferBackendOps' shape) is +// registered for all six kinds. What is only PARTLY wired is the firing side: a destructor has +// to raise the notice, and of the six object classes the P2 file-ownership table gives +// {Texture,Framebuffer,Sampler,VertexArray}State/* to other packages, so only ProgramObject +// and RenderbufferObject fire it here. The four that do not still rely on the sweep, which is +// why the table keeps ONE weak_ptr per entry and uses it for exactly one thing: +// ReclaimDeadSlots() frees the slot - and the twin, and the driver storage it owns - once the +// frontend object is gone. That is a liveness sweep, not an identity test, and it is what +// bumps Gen, which is precisely the ABA defence: a slot is only ever handed out again after it +// was freed. The four remaining one-line destructor calls retire the sweep entirely. // // Because the sweep is still the only death signal, this table carries BOTH of the drivers // the registry it replaces carries, and for the same reasons: @@ -221,6 +223,40 @@ namespace MobileGL::MG_Backend::DirectGLES { m_isCollecting = false; } + // P2 step e2's backend half: the frontend object with this lifetime id has just been + // DESTROYED, so drop its twin and return its slot now rather than waiting for a sweep + // to notice the weak_ptr expired. Announced death is what the sweep is a stand-in for; + // it frees the driver storage the twin owns at the moment the application let go of + // the object, which is what Managers.h's "dead gigabytes" note is about. + // + // Returns whether this table held the slot. The slot goes back to the allocator ONLY + // then, and this is not defensive: two holders of one kind already exist (the + // ScopedDirectGLESTextureBindings fixture's saved copy is a second live table of kind + // Texture; Magma's subsystem-4 table shares the VertexElementsCso kind), and a table + // that never twinned the object must not free a slot the other one still names. + Bool DestroyByLifetimeId(Uint64 lifetimeId) { + const MG_Pipe::MGPipeHandle handle = + MG_Pipe::MGPipeSlots().FindByLifetimeId(kKind, lifetimeId); + if (MG_Pipe::MGPipeHandleIsNull(handle)) return false; + if (handle.Slot >= m_slots.size()) return false; + // Same ordering rule as ReclaimDeadSlots(): the twin's destructor is a driver call + // and could re-enter GetOrCreate and resize m_slots, so nothing that outlives it + // may be a reference into the vector. + BackendPtr dead; + { + Entry& entry = m_slots[handle.Slot]; + if (!entry.Live || entry.Gen != handle.Gen) return false; + dead = std::move(entry.backend); + entry.backend.reset(); + entry.stateRef.reset(); + entry.Live = false; + } + if (m_memoHandle.Slot == handle.Slot) ForgetHandle(); + MG_Pipe::MGPipeSlots().Free(kKind, handle); + dead.reset(); + return true; + } + void CollectGarbageIfNeeded() { ++m_gcTick; if (m_gcTick < kGCInterval) return; diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp index 76c7e1cbc..f2ace10c2 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp @@ -14,6 +14,7 @@ #include #include #include +#include const char* kDefaultFragmentShaderSource = R"(#version 460 core layout(location = 0) out vec4 FragColor; @@ -28,7 +29,20 @@ namespace MobileGL::MG_State::GLState { return s_nextProgramLifetimeId.fetch_add(1, std::memory_order_relaxed); } - ProgramObject::~ProgramObject() { CancelLink(); } + ProgramObject::~ProgramObject() { + CancelLink(); +#if MOBILEGL_PIPE_PUSH + // P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a + // garbage sweep. This is the last SharedPtr to this object dropping - not + // glDeleteProgram, which only marks the name and leaves a still-bound object very much + // alive - so it is the exact moment the backend's twin, and the driver storage that + // twin owns, stop being reachable. The notice carries the lifetime id because the + // object no longer exists to be passed, and because the lifetime id is what the client + // slot allocator resolves the handle from. No-op unless a backend registered the ops + // (a pull build declares none at all). + NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::ShaderCso, m_lifetimeId); +#endif + } // EnsureLinkJoined() is defined inline in ProgramObject.h (see the comment there for // why: ~1200 call sites, no LTO). Only its blocking half lives here. diff --git a/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.cpp b/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.cpp index 91bfca505..4e68b9d72 100644 --- a/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.cpp +++ b/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.cpp @@ -8,6 +8,7 @@ #include "RenderbufferObject.h" #include +#include #include @@ -26,6 +27,20 @@ namespace MobileGL { RenderbufferObject::RenderbufferObject(Uint externalIndex) : m_externalIndex(externalIndex) {} +#if MOBILEGL_PIPE_PUSH + RenderbufferObject::~RenderbufferObject() { + // P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a + // garbage sweep. This is the last SharedPtr to this object dropping - not + // glDeleteRenderbuffers, which only marks the name and leaves a still-bound object very much + // alive - so it is the exact moment the backend's twin, and the driver storage that + // twin owns, stop being reachable. The notice carries the lifetime id because the + // object no longer exists to be passed, and because the lifetime id is what the client + // slot allocator resolves the handle from. No-op unless a backend registered the ops + // (a pull build declares none at all). + NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::Renderbuffer, m_lifetimeId); + } +#endif + Uint RenderbufferObject::GetExternalIndex() const { return m_externalIndex; } diff --git a/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.h b/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.h index 1e1f304bd..8b8a30035 100644 --- a/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.h +++ b/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.h @@ -25,6 +25,12 @@ namespace MobileGL { using TargetEnum = RenderbufferTarget; RenderbufferObject(Uint externalIndex); +#if MOBILEGL_PIPE_PUSH + // P2 step e2. Out of line, and declared only where there is a notice to raise: + // in a pull build this class stays trivially destructible, which is what keeps + // the pull build's symbol set byte-for-byte the pre-P2 one (G1). + ~RenderbufferObject(); +#endif Uint GetExternalIndex() const; void SetInternalFormat(TextureInternalFormat format); diff --git a/MobileGL/MG_State/GLState/StateObjectDeathNotice.h b/MobileGL/MG_State/GLState/StateObjectDeathNotice.h new file mode 100644 index 000000000..ba7ce54e7 --- /dev/null +++ b/MobileGL/MG_State/GLState/StateObjectDeathNotice.h @@ -0,0 +1,62 @@ +// MobileGL - MobileGL/MG_State/GLState/StateObjectDeathNotice.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +#if MOBILEGL_PIPE_PUSH +#include + +// P2 step e2, the frontend half: TELL the backend that a state object died, instead of +// leaving it to discover the death in a garbage sweep. +// +// Today the only kind that announces its own death is Buffer, through BufferBackendOps +// (BufferState/BufferObject.h) - a frontend-declared ops table that the backend fills in at +// context bring-up. This is the same shape for the other six kinds, with two differences that +// follow from what the notice is for: +// +// * it carries {kind, lifetimeId} and NOT the object, because by the time the last +// SharedPtr has dropped there is no object left to pass, and the lifetime id is exactly +// the key the client slot allocator resolves a handle from (ARCHITECTURE.md 4.2); +// * it is one entry point for every kind rather than one ops table per kind, because the +// backend's answer is the same for all six: free the slot, drop the twin. +// +// It exists only under MOBILEGL_PIPE_PUSH. A pull build has no slot allocator, no handle and +// nothing that could consume the notice, and G1 requires its symbol set to be byte-for-byte +// the pre-P2 one - so in that build this header declares nothing at all and the call sites +// compile to nothing. +// +// The pointer is written once, at backend bring-up, and read from state-object destructors. +// It is deliberately a plain pointer and not an atomic: the destructors and the bring-up run +// on the context thread, exactly as BufferBackendOps' g_bufferBackendOps does. +namespace MobileGL::MG_State::GLState { + + struct StateObjectDeathOps { + // The last SharedPtr to the frontend object with this lifetime id has dropped. + // Called from the object's destructor, so the object must NOT be touched. + void (*OnDestroyed)(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) = nullptr; + }; + + inline const StateObjectDeathOps* g_stateObjectDeathOps = nullptr; + + inline void SetStateObjectDeathOps(const StateObjectDeathOps* ops) { + g_stateObjectDeathOps = ops; + } + + inline const StateObjectDeathOps* GetStateObjectDeathOps() { + return g_stateObjectDeathOps; + } + + inline void NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) { + const StateObjectDeathOps* ops = g_stateObjectDeathOps; + if (ops == nullptr || ops->OnDestroyed == nullptr) return; + ops->OnDestroyed(kind, lifetimeId); + } + +} // namespace MobileGL::MG_State::GLState +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index 7c83f907b..59fd3c1bc 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -39,6 +39,9 @@ #include #include #include +#include +#include +#include #include #include @@ -3396,6 +3399,95 @@ TEST(DirectGLESSlotTable, GetOrCreateToleratesANullStateObject) { EXPECT_EQ(table.Find(nullptr), nullptr); EXPECT_TRUE(MG_Pipe::MGPipeHandleIsNull(table.HandleOf(nullptr))); } +// P2 step e2, the backend half. A sweep is a stand-in for a death notice; this is the notice. +// Nothing below calls CollectGarbage*: the slot comes back, and the twin goes, at the moment +// the frontend says the object is gone - which for a texture atlas or a renderbuffer is the +// difference between freeing a driver allocation now and freeing it 64 creations from now. +TEST(DirectGLESSlotTable, AnAnnouncedDeathReturnsTheSlotWithoutASweep) { + using namespace MobileGL; + + auto& slots = MG_Pipe::MGPipeSlots(); + const Uint32 liveBefore = slots.LiveCount(MG_Pipe::MGPipeKind::Query); + + FakeSlotTable table; + auto object = MakeShared(0x1E2A0001ull); + table.GetOrCreate(object) = MakeShared(); + const MG_Pipe::MGPipeHandle handle = table.HandleOf(object.get()); + ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(handle)); + EXPECT_EQ(slots.LiveCount(MG_Pipe::MGPipeKind::Query), liveBefore + 1u); + + // The object is STILL ALIVE here, which is the point: the sweep's weak_ptr test cannot + // fire, so anything that changes is the notice's doing and nothing else's. + EXPECT_TRUE(table.DestroyByLifetimeId(object->GetLifetimeId())); + EXPECT_EQ(table.LiveCount(), 0u) << "the twin survived its own destroy notice"; + EXPECT_EQ(table.FindByHandle(handle), nullptr); + EXPECT_EQ(table.Find(object.get()), nullptr); + EXPECT_EQ(slots.LiveCount(MG_Pipe::MGPipeKind::Query), liveBefore) + << "the slot was not returned to the allocator"; + + // Idempotent, and a table that does not hold the slot says so rather than freeing it out + // from under whoever does. Both matter once two holders of one kind exist. + EXPECT_FALSE(table.DestroyByLifetimeId(object->GetLifetimeId())); + FakeSlotTable other; + EXPECT_FALSE(other.DestroyByLifetimeId(object->GetLifetimeId())); +} + +// The firing side of e2, on the two of the six object classes whose files this package owns. +// The notice has to arrive when the LAST SharedPtr drops - not when glDeleteProgram marks the +// name, because a still-bound object goes on living - so the object is simply dropped here. +TEST(DirectGLESSlotTable, AProgramAndARenderbufferAnnounceTheirOwnDeath) { + using namespace MobileGL; + + struct Notice { + MG_Pipe::MGPipeKind kind = MG_Pipe::MGPipeKind::None; + Uint64 lifetimeId = 0; + }; + static Vector notices; + notices.clear(); + const MG_State::GLState::StateObjectDeathOps recording = { + .OnDestroyed = [](MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) { + notices.push_back(Notice{kind, lifetimeId}); + }, + }; + const MG_State::GLState::StateObjectDeathOps* previous = + MG_State::GLState::GetStateObjectDeathOps(); + MG_State::GLState::SetStateObjectDeathOps(&recording); + + Uint64 programId = 0; + Uint64 renderbufferId = 0; + { + auto program = MakeShared(0u); + programId = program->GetLifetimeId(); + auto renderbuffer = MakeShared(0u); + renderbufferId = renderbuffer->GetLifetimeId(); + EXPECT_TRUE(notices.empty()) << "a live object announced its own death"; + } + + MG_State::GLState::SetStateObjectDeathOps(previous); + + ASSERT_EQ(notices.size(), 2u); + // Destruction is reverse of construction, so the renderbuffer speaks first. + EXPECT_EQ(notices[0].kind, MG_Pipe::MGPipeKind::Renderbuffer); + EXPECT_EQ(notices[0].lifetimeId, renderbufferId); + EXPECT_EQ(notices[1].kind, MG_Pipe::MGPipeKind::ShaderCso); + EXPECT_EQ(notices[1].lifetimeId, programId); +} + +// ... and that the backend actually installs a consumer for it, rather than the two halves +// each being fine on their own. Registered from ResolveEsprytSlotTablesArm(), i.e. exactly +// when the arm that can answer a notice is the arm that runs. +TEST(DirectGLESSlotTable, TheHandleArmInstallsTheDeathNoticeConsumer) { + using namespace MobileGL; + + if (!MG_Backend::DirectGLES::EsprytSlotTablesEnabled()) { + GTEST_SKIP() << "the legacy arm keys on the frontend address and cannot answer a notice"; + } + ASSERT_NE(MG_State::GLState::GetStateObjectDeathOps(), nullptr) + << "the handle arm runs but nothing consumes a death notice, so every twin still waits " + "for a garbage sweep"; + EXPECT_NE(MG_State::GLState::GetStateObjectDeathOps()->OnDestroyed, nullptr); +} + // The gate on MAJOR 1 of the round-2 review: this binary's OTHER 82 cases - among them every // D13 "must not break" item - are only evidence about this package if they run on the arm this // package wrote. Before EsprytSlotArmEnvironment existed they did not, in any build directory @@ -3451,4 +3543,16 @@ TEST(DirectGLESSlotTable, GetOrCreateToleratesANullStateObject) { TEST(DirectGLESSlotTable, TheTwinRegistryCasesInThisBinaryRunOnTheHandleArm) { GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; } + +TEST(DirectGLESSlotTable, AnAnnouncedDeathReturnsTheSlotWithoutASweep) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} + +TEST(DirectGLESSlotTable, AProgramAndARenderbufferAnnounceTheirOwnDeath) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} + +TEST(DirectGLESSlotTable, TheHandleArmInstallsTheDeathNoticeConsumer) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} #endif // MOBILEGL_PIPE_PUSH From caa0a7221b8628bdad7c155475fbbfdf2f1e7890 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 11:02:29 -0400 Subject: [PATCH 093/529] [Fix] (Espryt): pick the unit-bindings debounce by the runtime arm, and stop two slot cases sharing one kind - UnitBindingsSnapshot was split by #if MOBILEGL_PIPE_PUSH, so a push build ran P2's lifetime-id debounce on the MOBILEGL_PIPE_PUSH=0 arm too. That arm has to reproduce P1 (ConfigLoader.cpp), or the integrator's A/B measures this slice's mechanism on both sides and attributes it to neither - the same complaint g_fbSlotCache was already fixed for. The snapshot now carries P1's WeakPtr fields beside the lifetime ids whenever the legacy arm is compiled, and Capture/Unchanged pick by EsprytSlotTablesEnabled(). The two answers are equivalent (OwnerEquals on two empty pointers is true and LifetimeIdOf(nullptr) == 0 == 0; a live-versus-expired control block and two distinct lifetime ids both compare unequal), so this is A/B fidelity, not a behaviour change, and a build with no legacy arm carries neither the fields nor the branch. - TwoTablesOfTheSameKindShareOneSlotAndKeepTheirOwnTwin deliberately never swept, so it left a live MGPipeKind::Query slot behind for good, and ObjectChurnAloneDrivesTheSweep reads HighWater/LiveCount of that same process-global kind. Deltas made them pass today, but --gtest_shuffle or a third case on kind Query would have made them interact. The two-holder case now has a kind to itself and returns its slot at the end. - Its comment claimed two live tables of one kind "cannot arise outside this case". They can and do: ScopedDirectGLESTextureBindings holds a second live table of kind Texture, and package D's subsystem 4 re-keys VaoDrawMemo out of the same per-kind allocator. The comment now states the real hazard (whichever holder frees first orphans the other's entry; safe, because Free is generation-guarded and FindByHandle compares Gen, but not free) and flags it for the integrator. --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 61 +++++++++++++++++-- MobileGL/MG_Test/SanityTest.cpp | 44 ++++++++++--- 2 files changed, 92 insertions(+), 13 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 4905b4a7d..571252d29 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -1451,9 +1451,25 @@ namespace MobileGL::MG_Backend::DirectGLES { // start at 1). This is not the twin table's {slot, gen}: a bound texture that has never // been synced has no twin and therefore no handle, so a handle-keyed snapshot would read // two never-synced textures as equal. The identity has to exist before the twin does. + // + // Split by the RUNTIME arm, not by the build, for exactly the reason g_fbSlotCache is: + // MOBILEGL_PIPE_PUSH=0 has to reproduce P1's behaviour (ConfigLoader.cpp), and a + // legacy-arm run that debounced on lifetime ids would be running P2's mechanism while + // the A/B attributed the result to P1. The two answers are equivalent - OwnerEquals on + // two empty pointers is true and LifetimeIdOf(nullptr) == 0 == 0; a live-versus-expired + // control block and two distinct lifetime ids both compare unequal - so keeping the + // legacy fields costs that arm nothing but the words, and gives the control back its + // fidelity. A build with no legacy arm compiled carries neither the fields nor the + // branch. struct UnitBindingsSnapshot { Array slotObjects{}; Uint64 samplerObject = 0; +#if MOBILEGL_PIPE_LEGACY_MEMOS + // P1's identity, kept verbatim for the legacy arm only. + Array, (SizeT)TextureTarget::TextureTargetCount> + legacySlotObjects{}; + WeakPtr legacySamplerObject{}; +#endif }; static Uint64 LifetimeIdOf(const SharedPtr& object) { @@ -1464,32 +1480,69 @@ namespace MobileGL::MG_Backend::DirectGLES { return object ? object->GetLifetimeId() : 0; } +#if MOBILEGL_PIPE_LEGACY_MEMOS +#define MGB_UNIT_BINDINGS_HANDLE_ARM (EsprytSlotTablesEnabled()) +#else +#define MGB_UNIT_BINDINGS_HANDLE_ARM (true) +#endif + static void CaptureUnitBindings(Int maxTouchedUnit, Vector& out) { + const Bool handleArm = MGB_UNIT_BINDINGS_HANDLE_ARM; out.resize(static_cast(maxTouchedUnit + 1)); for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); auto& snapshot = out[static_cast(unit)]; const auto& slots = textureUnit.GetAllBindingSlots(); for (SizeT i = 0; i < slots.size(); ++i) { - snapshot.slotObjects[i] = LifetimeIdOf(slots[i].GetBoundObject()); + if (handleArm) { + snapshot.slotObjects[i] = LifetimeIdOf(slots[i].GetBoundObject()); + } +#if MOBILEGL_PIPE_LEGACY_MEMOS + else { + snapshot.legacySlotObjects[i] = slots[i].GetBoundObject(); + } +#endif + } + if (handleArm) { + snapshot.samplerObject = LifetimeIdOf(textureUnit.GetSamplerObject()); } - snapshot.samplerObject = LifetimeIdOf(textureUnit.GetSamplerObject()); +#if MOBILEGL_PIPE_LEGACY_MEMOS + else { + snapshot.legacySamplerObject = textureUnit.GetSamplerObject(); + } +#endif } } static Bool UnitBindingsUnchanged(Int maxTouchedUnit, const Vector& snapshots) { if (snapshots.size() != static_cast(maxTouchedUnit + 1)) return false; + const Bool handleArm = MGB_UNIT_BINDINGS_HANDLE_ARM; for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); const auto& snapshot = snapshots[static_cast(unit)]; const auto& slots = textureUnit.GetAllBindingSlots(); for (SizeT i = 0; i < slots.size(); ++i) { - if (snapshot.slotObjects[i] != LifetimeIdOf(slots[i].GetBoundObject())) return false; + if (handleArm) { + if (snapshot.slotObjects[i] != LifetimeIdOf(slots[i].GetBoundObject())) return false; + } +#if MOBILEGL_PIPE_LEGACY_MEMOS + else if (!OwnerEquals(snapshot.legacySlotObjects[i], slots[i].GetBoundObject())) { + return false; + } +#endif + } + if (handleArm) { + if (snapshot.samplerObject != LifetimeIdOf(textureUnit.GetSamplerObject())) return false; + } +#if MOBILEGL_PIPE_LEGACY_MEMOS + else if (!OwnerEquals(snapshot.legacySamplerObject, textureUnit.GetSamplerObject())) { + return false; } - if (snapshot.samplerObject != LifetimeIdOf(textureUnit.GetSamplerObject())) return false; +#endif } return true; } +#undef MGB_UNIT_BINDINGS_HANDLE_ARM #else struct UnitBindingsSnapshot { Array, (SizeT)TextureTarget::TextureTargetCount> diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index 59fd3c1bc..7c0957582 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -3199,10 +3199,18 @@ namespace { int marker = 0; }; - // Kind Query is unused by every shipping path, so these cases cannot disturb the slot space - // any real twin table allocates out of. + // Kinds Query and Fence are unused by every shipping path, so these cases cannot disturb + // the slot space any real twin table allocates out of. + // + // TWO of them, because MGPipeSlots() is a process-global singleton and three of the cases + // below read its per-kind LiveCount / HighWater. The two-holder case is the one that can + // perturb another, so it gets a kind of its own rather than a promise about gtest's + // registration order: --gtest_shuffle, --gtest_filter and a future case are all free to + // reorder them, and a shared kind would make that a flake. using FakeSlotTable = MobileGL::MG_Backend::DirectGLES:: BackendSlotTable; + using FakeSharedKindSlotTable = MobileGL::MG_Backend::DirectGLES:: + BackendSlotTable; } // namespace // The property the whole slice exists for. The pre-P2 registry keyed twins on the frontend heap @@ -3320,18 +3328,19 @@ TEST(DirectGLESSlotTable, AWholeTableSavesResetsAndRestores) { TEST(DirectGLESSlotTable, TwoTablesOfTheSameKindShareOneSlotAndKeepTheirOwnTwin) { using namespace MobileGL; + constexpr MG_Pipe::MGPipeKind kKind = MG_Pipe::MGPipeKind::Fence; auto& slots = MG_Pipe::MGPipeSlots(); - const Uint32 liveBefore = slots.LiveCount(MG_Pipe::MGPipeKind::Query); + const Uint32 liveBefore = slots.LiveCount(kKind); - FakeSlotTable a; - FakeSlotTable b; + FakeSharedKindSlotTable a; + FakeSharedKindSlotTable b; auto object = MakeShared(0xE1u); a.GetOrCreate(object) = MakeShared(); (*a.Find(object.get()))->marker = 1; b.GetOrCreate(object) = MakeShared(); (*b.Find(object.get()))->marker = 2; - EXPECT_EQ(slots.LiveCount(MG_Pipe::MGPipeKind::Query), liveBefore + 1u) + EXPECT_EQ(slots.LiveCount(kKind), liveBefore + 1u) << "the two tables minted a slot each; Magma's table would then resolve a different " "handle for the same object than Espryt's"; @@ -3343,9 +3352,26 @@ TEST(DirectGLESSlotTable, TwoTablesOfTheSameKindShareOneSlotAndKeepTheirOwnTwin) ASSERT_NE(b.FindByHandle(handle), nullptr); EXPECT_EQ((*a.FindByHandle(handle))->marker, 1); EXPECT_EQ((*b.FindByHandle(handle))->marker, 2); - // Deliberately no sweep: two tables of ONE kind both hold this slot, and whichever swept - // first would return it to the allocator while the other still names it. The six shipping - // registries are one table per kind, so that cannot arise outside this case. + + // TWO HOLDERS OF ONE SLOT is a real configuration, not a test artefact, and this is where + // the sharp edge is: whichever holder sweeps (or is told of the death) first returns the + // slot to the allocator while the other still names it. The allocator makes that SAFE - + // Free is generation-guarded and idempotent, and FindByHandle's Gen compare turns the + // other holder's now-stale handle into a miss - but not free: if the object is still alive + // the next resolution re-Acquires it onto a NEW slot, orphaning the first table's entry + // until its own sweep. Two such configurations exist or are landing: the + // ScopedDirectGLESTextureBindings fixture above holds a second live table of kind Texture + // for the length of a test, and package D's subsystem 4 re-keys VaoDrawMemo out of this + // same per-kind allocator. Flagged for the integrator rather than defended here, because + // the fix (a refcounted slot-ownership token) belongs with whoever owns both holders. + // + // The cleanup below is what keeps that out of the OTHER cases: object first, then both + // tables, so the slot is back on the free list and this kind's LiveCount is where it was. + object.reset(); + a.CollectGarbageNow(); + b.CollectGarbageNow(); + EXPECT_EQ(slots.LiveCount(kKind), liveBefore) + << "the shared slot outlived both holders and the object"; } // The sweep has TWO drivers and the table must carry both. Nothing below calls From 7c97fcfee361a67d8446044504a47819699307b4 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 13:03:10 -0400 Subject: [PATCH 094/529] [Fix] (Espryt): stop the armless knob pair inside the test that needs an arm, not inside the EGL bring-up a forked pre-flight swallows - Fatal{PipeLegacyMemosDisabled} was raised from InitDisplayAndContext(), i.e. from inside eglMakeCurrent. The integration harness pre-flights that exact sequence in a forked child (MG_IntegrationTest/Harness/HeadlessGL.cpp) and reports a child that dies on a signal as "no usable GPU/display/ICD", so every scenario SKIPPED and ctest called the lane 100% passed while running nothing - on the very pair of env vars the D14/D18 A/B is driven with. ROADMAP.md:7 forbids a gate that cannot go red for the reason it exists. - The arm decision becomes a pure function of the two knobs, ClassifyEsprytSlotArm(), with three verdicts. Bring-up now calls DiagnoseEsprytSlotArm(), which names both knobs at ERROR and RETURNS; the stop stays in ResolveEsprytSlotTablesArm(), which the inline latch reaches at the first twin lookup - a scenario body, where a crash is a test failure. - A process that never looks a twin up never needs an arm and is no longer stopped by one it would not have used. That is the only behaviour this moves. - SanityTest gains an always-on case: the four knob combinations of the pure classifier, that the diagnosis does not stop, and that the stop is SIGABRT whose log line names PipeLegacyMemosDisabled, MOBILEGL_PIPE_PUSH, MOBILEGL_PIPE_LEGACY_MEMOS=0 and the bit - the message and not merely the signal, because "Subprocess aborted" alone tells an operator nothing. It skips visibly in the pull and no-legacy builds (G2 name parity). --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 19 ++- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 109 ++++++++++++------ MobileGL/MG_Backend/DirectGLES/SlotTables.h | 32 ++++- MobileGL/MG_Test/SanityTest.cpp | 81 +++++++++++++ 4 files changed, 195 insertions(+), 46 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 571252d29..3b72d8411 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -10223,12 +10223,19 @@ namespace MobileGL::MG_Backend::DirectGLES { DestroyEGLContext(); #if MOBILEGL_PIPE_PUSH - // Resolve the twin-table arm HERE, at backend startup, rather than leaving it to the - // first twin lookup deep inside the first draw: Fatal{PipeLegacyMemosDisabled} has to - // reach an operator who set MOBILEGL_PIPE_PUSH and MOBILEGL_PIPE_LEGACY_MEMOS into a - // combination that leaves no arm at all, including in a process that goes on to twin - // nothing. The call is idempotent and latched. - (void)EsprytSlotTablesEnabled(); + // DIAGNOSE the twin-table arm here, at backend startup, so an operator who set + // MOBILEGL_PIPE_PUSH and MOBILEGL_PIPE_LEGACY_MEMOS into a combination that leaves no + // arm at all is told so by name, in the log, before the first draw. + // + // Diagnose, and deliberately NOT resolve: resolving raises + // Fatal{PipeLegacyMemosDisabled}, and this function runs inside eglMakeCurrent, which + // the integration harness pre-flights in a FORKED CHILD + // (MG_IntegrationTest/Harness/HeadlessGL.cpp). A child that dies on a signal is reported + // to the parent as "no usable GPU/display/ICD" and every scenario in the lane is + // SKIPPED - so the stop became a green lane that ran nothing, on exactly the two env + // vars the D14/D18 A/B is driven with (ROADMAP.md:7). The stop now belongs to the first + // twin lookup, which happens in a scenario body where a crash IS a test failure. + DiagnoseEsprytSlotArm(); #endif g_Display = g_EGLFuncs.eglGetDisplay(EGL_DEFAULT_DISPLAY); diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index e49a19b7a..ca1ca6698 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -214,49 +214,82 @@ namespace MobileGL::MG_Backend::DirectGLES { }; } // namespace + // The one sentence that decides the arm, written once so that a test can drive every + // combination of the two knobs and so that bring-up and first-use cannot disagree. + EsprytSlotArmVerdict ClassifyEsprytSlotArm(Bool subsystemBitSet, Bool legacyMemosEnabled) { +#if MOBILEGL_PIPE_LEGACY_MEMOS + if (subsystemBitSet) return EsprytSlotArmVerdict::Handles; + return legacyMemosEnabled ? EsprytSlotArmVerdict::Legacy : EsprytSlotArmVerdict::NoArm; +#else + // The legacy arm is not compiled, so the handle arm is the only arm and neither knob + // can produce an armless configuration. + (void)subsystemBitSet; + (void)legacyMemosEnabled; + return EsprytSlotArmVerdict::Handles; +#endif + } + + EsprytSlotArmVerdict CurrentEsprytSlotArmVerdict() { + return ClassifyEsprytSlotArm( + (MG_Config::Features.PipePush & MG_Pipe::kMGPipeSubsystemEsprytSlots) != 0, + MG_Config::Features.PipeLegacyMemos); + } + + void DiagnoseEsprytSlotArm() { + if (CurrentEsprytSlotArmVerdict() != EsprytSlotArmVerdict::NoArm) return; + // Loud, named, and NOT a stop - see the comment on this function in SlotTables.h for + // why a stop raised from inside EGL bring-up is swallowed into a skipped lane. + MGLOG_E("MGPipe: PipeLegacyMemosDisabled - MOBILEGL_PIPE_PUSH leaves " + "kMGPipeSubsystemEsprytSlots (bit 5) clear and MOBILEGL_PIPE_LEGACY_MEMOS=0 " + "makes the legacy twin registry unreachable, so this context has no twin table " + "arm at all; the first twin lookup will stop the process"); + } + Bool ResolveEsprytSlotTablesArm() { // Resolved once and latched by the inline EsprytSlotTablesEnabled() in SlotTables.h: // the two arms of StateBackendObjectRegistry keep their twins in different containers, // so an answer that changed mid-run would strand every twin already built (and, for - // the driver ids those twins own, leak them). InitDisplayAndContext() forces the - // resolution at backend context creation, so the trap below fires before the first - // draw rather than on the first twin lookup - a short-lived process that never twins - // anything used to never learn its knobs left it with no arm at all. - { - const Bool bitSet = - (MG_Config::Features.PipePush & MG_Pipe::kMGPipeSubsystemEsprytSlots) != 0; -#if MOBILEGL_PIPE_LEGACY_MEMOS - if (!bitSet && !MG_Config::Features.PipeLegacyMemos) { - // The operator asked for the handle arm to be OFF and the legacy arm to be - // unreachable at the same time, which leaves no arm at all. This is a Fatal{}, - // and a Fatal{} in this codebase STOPS (MG_Impl/Pipe/PipeFill.cpp's BadKnob and - // its verify trap are both MGLOG_F + abort). Returning here instead would run - // the very arm the operator disabled and hand back a green result measured on - // it - which is exactly the lever HandleRecycleScenario's arms are selected - // with, so a mis-set A/B would be scored silently against the wrong arm - // (ARCHITECTURE.md 9.6). - MGLOG_F("MGPipe: Fatal{PipeLegacyMemosDisabled, \"kMGPipeSubsystemEsprytSlots " - "is clear but MOBILEGL_PIPE_LEGACY_MEMOS=0\"}"); - std::abort(); - } - if (bitSet) { - // The notice is only consumable on the handle arm (the legacy registry keys on - // the frontend ADDRESS, which is gone by the time a destructor speaks), so it is - // installed exactly where it can be answered. Once per process, cold. - MG_State::GLState::SetStateObjectDeathOps(&g_glesStateObjectDeathOps); - } - return bitSet; -#else - // The legacy arm is not compiled, so the handle arm is the only arm. The bit still - // decides nothing here; it is recorded so a log reader sees the mismatch. - if (!bitSet) { - MGLOG_D("MGPipe: kMGPipeSubsystemEsprytSlots is clear but this build has no " - "legacy twin registry; running the handle arm anyway"); - } - MG_State::GLState::SetStateObjectDeathOps(&g_glesStateObjectDeathOps); - return true; -#endif + // the driver ids those twins own, leak them). + // + // This runs at the FIRST TWIN LOOKUP, not at backend bring-up. That is deliberate and + // it is the fix for a lane that went green by skipping: bring-up runs inside + // eglMakeCurrent, which the integration harness pre-flights in a forked child, and a + // child that aborts is reported as "no usable GPU" and skips every scenario. Here the + // stop lands in the caller of the twin lookup - a scenario body, a sync path, a test - + // where ctest reports it as a failure. A process that never looks a twin up never needs + // an arm and is never stopped by this. + const EsprytSlotArmVerdict verdict = CurrentEsprytSlotArmVerdict(); + if (verdict == EsprytSlotArmVerdict::NoArm) { + // The operator asked for the handle arm to be OFF and the legacy arm to be + // unreachable at the same time, which leaves no arm at all. This is a Fatal{}, + // and a Fatal{} in this codebase STOPS (MG_Impl/Pipe/PipeFill.cpp's BadKnob and + // its verify trap are both MGLOG_F + abort). Returning here instead would run + // the very arm the operator disabled and hand back a green result measured on + // it - which is exactly the lever HandleRecycleScenario's arms are selected + // with, so a mis-set A/B would be scored silently against the wrong arm + // (ARCHITECTURE.md 9.6). + MGLOG_F("MGPipe: Fatal{PipeLegacyMemosDisabled, \"MOBILEGL_PIPE_PUSH leaves " + "kMGPipeSubsystemEsprytSlots (bit 5) clear and MOBILEGL_PIPE_LEGACY_MEMOS=0 " + "makes the legacy twin registry unreachable, so there is no twin table arm " + "to run\"}"); + std::abort(); + } + if (verdict == EsprytSlotArmVerdict::Legacy) { + return false; + } +#if !MOBILEGL_PIPE_LEGACY_MEMOS + if ((MG_Config::Features.PipePush & MG_Pipe::kMGPipeSubsystemEsprytSlots) == 0) { + // The bit is clear but this build has no legacy twin registry to fall back to, so + // the bit decides nothing. Recorded so a log reader sees the mismatch. + MGLOG_D("MGPipe: kMGPipeSubsystemEsprytSlots is clear but this build has no " + "legacy twin registry; running the handle arm anyway"); } +#endif + // The notice is only consumable on the handle arm (the legacy registry keys on the + // frontend ADDRESS, which is gone by the time a destructor speaks), so it is installed + // exactly where it can be answered. Once per process, cold. + MG_State::GLState::SetStateObjectDeathOps(&g_glesStateObjectDeathOps); + return true; } #endif diff --git a/MobileGL/MG_Backend/DirectGLES/SlotTables.h b/MobileGL/MG_Backend/DirectGLES/SlotTables.h index 8d3819574..614006515 100644 --- a/MobileGL/MG_Backend/DirectGLES/SlotTables.h +++ b/MobileGL/MG_Backend/DirectGLES/SlotTables.h @@ -68,8 +68,36 @@ namespace MobileGL::MG_Backend::DirectGLES { #if MOBILEGL_PIPE_PUSH - // Reads the config, logs, and traps when the operator left no arm at all. Cold: called - // exactly once per process, from the latch below and from backend context creation. + // What the two knobs add up to. Split out as a PURE function of them so a test can drive + // every combination without needing a process per combination. + enum class EsprytSlotArmVerdict { + Handles, // kMGPipeSubsystemEsprytSlots is set: the {slot, gen} tables run. + Legacy, // the bit is clear and the legacy address-keyed registry is reachable. + NoArm, // the bit is clear AND MOBILEGL_PIPE_LEGACY_MEMOS=0 made the legacy arm + // unreachable, so the operator asked for a configuration with no arm at all. + }; + + EsprytSlotArmVerdict ClassifyEsprytSlotArm(Bool subsystemBitSet, Bool legacyMemosEnabled); + + // This process's verdict, read off MG_Config::Features. Latches nothing and stops nothing. + EsprytSlotArmVerdict CurrentEsprytSlotArmVerdict(); + + // Says, at backend bring-up, that the knobs leave no arm - and does NOT stop. + // + // The stop cannot live here, and that is the whole point of the split. Backend context + // creation runs inside eglMakeCurrent, and the integration harness pre-flights exactly that + // sequence in a FORKED CHILD (MG_IntegrationTest/Harness/HeadlessGL.cpp): a child that dies + // on a signal is reported as "no usable GPU/display/ICD" and every scenario in the lane is + // SKIPPED - i.e. the lane goes green having run nothing, on the very pair of env vars the + // D14/D18 A/B is driven with, which is what ROADMAP.md:7 forbids. So bring-up only + // DIAGNOSES; the stop is raised by ResolveEsprytSlotTablesArm() at the first twin lookup, + // which happens in the test body where the harness reports it as a failure. + void DiagnoseEsprytSlotArm(); + + // Reads the config, logs, installs the death-notice consumer, and STOPS when the operator + // left no arm at all. Cold: called exactly once per process, from the latch below - i.e. at + // the first twin lookup, which is the first moment an arm is actually needed. A process + // that never twins anything needs no arm and is not stopped. Bool ResolveEsprytSlotTablesArm(); // True when this process runs the {slot, gen} arm. Fixed for the life of the process: the diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index 7c0957582..933b5b72c 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -42,6 +42,7 @@ #include #include #include +#include #include #include @@ -3534,6 +3535,82 @@ TEST(DirectGLESSlotTable, TheTwinRegistryCasesInThisBinaryRunOnTheHandleArm) { 0ull); } +// The gate on MAJOR 1 of the round-3 review. Commit d89fb684 raised +// Fatal{PipeLegacyMemosDisabled} from inside InitDisplayAndContext(), i.e. from inside EGL +// bring-up - and the integration harness pre-flights EGL bring-up in a FORKED CHILD, converting +// any child that dies on a signal into "no usable GPU/display/ICD" and SKIPPING every scenario. +// So `MOBILEGL_PIPE_PUSH=0 MOBILEGL_PIPE_LEGACY_MEMOS=0 ctest -L integration-gpu -R DirectGLES` +// reported 100% tests passed while running nothing at all, on the exact pair of env vars the +// D14/D18 A/B is driven with. ROADMAP.md:7 forbids a gate that cannot go red for the reason it +// exists, and a lane that goes green by skipping is the worst version of that. +// +// The split this case pins: bring-up DIAGNOSES (and returns), first twin lookup STOPS. It +// checks the message and not only the signal, because an operator who is handed a bare +// "Subprocess aborted" has been told nothing about which two knobs they set. +TEST(DirectGLESSlotTable, AnArmlessKnobCombinationStopsInsteadOfSkippingTheLane) { +#if !MOBILEGL_PIPE_LEGACY_MEMOS + GTEST_SKIP() << "this build compiles no legacy twin registry, so no knob combination can " + "leave the process without an arm"; +#else + using namespace MobileGL; + namespace fs = std::filesystem; + using MG_Backend::DirectGLES::EsprytSlotArmVerdict; + + // The pure half: all four knob combinations, no process required. + EXPECT_EQ(MG_Backend::DirectGLES::ClassifyEsprytSlotArm(true, true), EsprytSlotArmVerdict::Handles); + EXPECT_EQ(MG_Backend::DirectGLES::ClassifyEsprytSlotArm(true, false), EsprytSlotArmVerdict::Handles); + EXPECT_EQ(MG_Backend::DirectGLES::ClassifyEsprytSlotArm(false, true), EsprytSlotArmVerdict::Legacy); + EXPECT_EQ(MG_Backend::DirectGLES::ClassifyEsprytSlotArm(false, false), EsprytSlotArmVerdict::NoArm); + + const Uint64 savedPush = MG_Config::Features.PipePush; + const Bool savedLegacy = MG_Config::Features.PipeLegacyMemos; + MG_Config::Features.PipePush = savedPush & ~MG_Pipe::kMGPipeSubsystemEsprytSlots; + MG_Config::Features.PipeLegacyMemos = false; + ASSERT_EQ(MG_Backend::DirectGLES::CurrentEsprytSlotArmVerdict(), EsprytSlotArmVerdict::NoArm); + + const fs::path logPath = fs::temp_directory_path() / "mobilegl-espryt-armless-knobs.log"; + fs::remove(logPath); + MG_Util::Debug::Close(); + SetEnvVar("MOBILEGL_LOG_FILE_PATH", logPath.string().c_str()); + + // Bring-up's half of the split. It must NAME the knobs and it must RETURN: this call is the + // one InitDisplayAndContext() makes, and it runs inside the harness's forked pre-flight + // child. If it ever stops again, this line takes the whole binary down and the case is red. + MG_Backend::DirectGLES::DiagnoseEsprytSlotArm(); + +#if !defined(_WIN32) + // First-use's half: the stop, raised in a forked child so it is a datum rather than the end + // of this process. In production the caller is a twin lookup inside a scenario body, where + // ctest reports the crash as a FAILING test rather than as a missing GPU. + EXPECT_EXIT((void)MG_Backend::DirectGLES::ResolveEsprytSlotTablesArm(), + ::testing::KilledBySignal(SIGABRT), ""); +#endif + + MG_Util::Debug::Close(); + UnsetEnvVar("MOBILEGL_LOG_FILE_PATH"); + MG_Config::Features.PipePush = savedPush; + MG_Config::Features.PipeLegacyMemos = savedLegacy; + + std::string contents; + { + std::ifstream logFile(logPath); + ASSERT_TRUE(logFile.good()) << "neither the diagnosis nor the fatal wrote a line an " + "operator could read"; + contents.assign(std::istreambuf_iterator(logFile), std::istreambuf_iterator()); + } + fs::remove(logPath); + + EXPECT_NE(contents.find("PipeLegacyMemosDisabled"), std::string::npos) << contents; + EXPECT_NE(contents.find("MOBILEGL_PIPE_PUSH"), std::string::npos) << contents; + EXPECT_NE(contents.find("MOBILEGL_PIPE_LEGACY_MEMOS=0"), std::string::npos) << contents; + EXPECT_NE(contents.find("kMGPipeSubsystemEsprytSlots"), std::string::npos) << contents; +#if !defined(_WIN32) + EXPECT_NE(contents.find("Fatal{"), std::string::npos) + << "the diagnosis was logged but the first-use stop was not: " << contents; +#endif +#endif // MOBILEGL_PIPE_LEGACY_MEMOS +} + #else // G2 wants the pull and the push build to list the SAME ctest entries. The twin table only // exists under MOBILEGL_PIPE_PUSH, so in the pull build each case above keeps its name and @@ -3581,4 +3658,8 @@ TEST(DirectGLESSlotTable, AProgramAndARenderbufferAnnounceTheirOwnDeath) { TEST(DirectGLESSlotTable, TheHandleArmInstallsTheDeathNoticeConsumer) { GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; } + +TEST(DirectGLESSlotTable, AnArmlessKnobCombinationStopsInsteadOfSkippingTheLane) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} #endif // MOBILEGL_PIPE_PUSH From 6cb7d1b83b4276bd75a8a78a6052e6ab58c18aef Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 13:11:14 -0400 Subject: [PATCH 095/529] [Fix] (Espryt, State): let the last four object classes announce their own death and delete the twin table's garbage collector - e2 was landed for two of six kinds, so Texture, Framebuffer, SamplerCso and VertexElementsCso still discovered death in a sweep and ROADMAP.md:18's "delete the GC" was undelivered. TextureObjectBase (the one base every concrete texture derives from), FramebufferObject, SamplerObject and VertexArrayObject now raise NotifyStateObjectDestroyed from their destructor, on RenderbufferObject's pattern: out of line, declared only under MOBILEGL_PIPE_PUSH, so the pull build keeps its implicit destructor and its symbol set (G1 still 0 added / 0 removed / 0 renamed and 0 resized against p2/contract). - With all six announcing, BackendSlotTable loses BOTH sweep drivers: no draw tick, no creation tick, no kGCInterval / kCreationGCInterval / m_gcTick / m_creationTick. CollectGarbageIfNeeded() is empty on this arm; CollectGarbageNow() stays as an EXPLICIT collection and is the backstop for a notice that InProcessTeardown() drops. - The seven CollectGarbageIfNeeded call sites in DirectGLES.cpp keep their spelling because they are the legacy registry's driver and that arm is still compiled beside this one; the registry's body is now guarded on MOBILEGL_PIPE_LEGACY_MEMOS, so a build without the legacy arm has no collector at all. On the handle arm each site is a predicted branch. - The weak_ptr per entry stays for exactly two jobs it is honest about: ForEachLive()'s strong hand-over to ScopedDetachedTextureFramebufferAttachments, and the explicit collection. It is never an identity test; Gen is. - Tests: AProgramAndARenderbufferAnnounceTheirOwnDeath becomes EveryReKeyedObjectClassAnnouncesItsOwnDeath and drives all six classes, by membership rather than count because every texture owns a private sampler that also announces; ObjectChurnAloneDrivesTheSweep becomes AnnouncedDeathKeepsObjectChurnFromAccumulatingWithoutASweep and pins that 256 churned objects hold one live twin at a time with no CollectGarbage* call anywhere. --- MobileGL/MG_Backend/DirectGLES/Managers.h | 8 ++ MobileGL/MG_Backend/DirectGLES/SlotTables.h | 104 ++++++------------ .../FramebufferState/FramebufferObject.cpp | 15 +++ .../FramebufferState/FramebufferObject.h | 6 + .../GLState/SamplerState/SamplerObject.cpp | 15 +++ .../GLState/SamplerState/SamplerObject.h | 6 + .../GLState/TextureState/TextureObject.cpp | 15 +++ .../GLState/TextureState/TextureObject.h | 8 ++ .../VertexArrayState/VertexArrayObject.cpp | 16 +++ .../VertexArrayState/VertexArrayObject.h | 6 + MobileGL/MG_Test/SanityTest.cpp | 101 ++++++++++------- 11 files changed, 189 insertions(+), 111 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index 587a27829..840ef3094 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -451,6 +451,12 @@ namespace MobileGL::MG_Backend::DirectGLES { } #endif + // The seven DirectGLES.cpp call sites drive the LEGACY arm and nothing else. On the + // handle arm death is announced by the frontend object's destructor + // (MG_State/GLState/StateObjectDeathNotice.h), so there is no garbage to collect on a + // tick and this is the predicted branch plus a return - which is how ROADMAP.md:18's + // "delete the GC" is delivered without deleting the legacy arm's own collector while + // that arm is still compiled beside it. void CollectGarbageIfNeeded() { #if MOBILEGL_PIPE_PUSH if (EsprytSlotTablesEnabled()) { @@ -458,12 +464,14 @@ namespace MobileGL::MG_Backend::DirectGLES { return; } #endif +#if MOBILEGL_PIPE_LEGACY_MEMOS ++m_gcTick; if (m_gcTick < kGCInterval) { return; } CollectGarbage(); m_gcTick = 0; +#endif } void CollectGarbageNow() { diff --git a/MobileGL/MG_Backend/DirectGLES/SlotTables.h b/MobileGL/MG_Backend/DirectGLES/SlotTables.h index 614006515..b2c9751e9 100644 --- a/MobileGL/MG_Backend/DirectGLES/SlotTables.h +++ b/MobileGL/MG_Backend/DirectGLES/SlotTables.h @@ -33,28 +33,26 @@ // * Slots are dense per kind, which is what lets the server side (ARCHITECTURE.md 10.1, // MG_Remote/Server/PipeObjectTables) be an array rather than an object graph. // -// Death: announced where the package may announce it, discovered everywhere else. -// DestroyByLifetimeId() below is step e2's backend half and it is complete - it drops the twin -// and returns the slot the moment the frontend object's last SharedPtr goes - and the notice -// that drives it (MG_State/GLState/StateObjectDeathNotice.h, BufferBackendOps' shape) is -// registered for all six kinds. What is only PARTLY wired is the firing side: a destructor has -// to raise the notice, and of the six object classes the P2 file-ownership table gives -// {Texture,Framebuffer,Sampler,VertexArray}State/* to other packages, so only ProgramObject -// and RenderbufferObject fire it here. The four that do not still rely on the sweep, which is -// why the table keeps ONE weak_ptr per entry and uses it for exactly one thing: -// ReclaimDeadSlots() frees the slot - and the twin, and the driver storage it owns - once the -// frontend object is gone. That is a liveness sweep, not an identity test, and it is what -// bumps Gen, which is precisely the ABA defence: a slot is only ever handed out again after it -// was freed. The four remaining one-line destructor calls retire the sweep entirely. +// Death is ANNOUNCED, and that is what lets this table have no garbage collector - the +// deliverable ROADMAP.md:18 spells "GC" in and the one D13 makes a precondition of the switch- +// over. All six re-keyed object classes raise MG_State::GLState::NotifyStateObjectDestroyed() +// from their destructor (BufferBackendOps' shape, one entry point for six kinds), the backend +// consumes it in Managers.cpp, and DestroyByLifetimeId() below drops the twin and returns the +// slot at the moment the frontend object's last SharedPtr goes. So: +// * there is NO draw-path tick and NO creation tick on this arm. CollectGarbageIfNeeded() is +// an empty call, and the seven call sites in DirectGLES.cpp drive the LEGACY registry only; +// * a twin, and the driver storage it owns, is freed when the application lets go of the +// object rather than up to 64 creations or 1024 draw ticks later. That is what +// Managers.h's "dead gigabytes" note asked for. // -// Because the sweep is still the only death signal, this table carries BOTH of the drivers -// the registry it replaces carries, and for the same reasons: -// * the draw-path tick (kGCInterval = 1024 CollectGarbageIfNeeded calls), and -// * the CREATION tick (kCreationGCInterval = 64 first-time insertions), because object CHURN -// rather than draw count is what makes the sweep urgent - a CTS-shaped case runs ~10 -// per-draw ticks, so 1024 of them span ~100 cases' worth of dead, gigabyte-sized objects. -// Dropping the second one would have made this table's memory behaviour strictly WORSE than -// the map it replaces, which is the opposite of what the slice is for. +// The weak_ptr per entry survives, and only for what it is honest about: +// * ForEachLive() hands the callee a STRONG reference to the frontend object, which the one +// direct-iteration site (ScopedDetachedTextureFramebufferAttachments) needs; and +// * ReclaimDeadSlots() is kept as the body of the EXPLICIT CollectGarbageNow(), i.e. a +// collection someone asks for, never a periodic one. It is the backstop for the one case +// the notice cannot cover: a destructor that runs after exit() has begun, where +// InProcessTeardown() drops the notice because a twin destructor must not call the driver. +// It is never an identity test - that is what Gen is for. // // P3+ DEBT, recorded rather than hidden: this header is under MG_Backend/ and it MINTS // handles (MGPipeSlots().Acquire below) off a frontend SharedPtr's GetLifetimeId(). @@ -148,22 +146,12 @@ namespace MobileGL::MG_Backend::DirectGLES { return m_nullTwin; } - // Sweep BEFORE the entry reference below exists, for the same reason the map arm - // does it here: EntryAt may grow m_slots and move every element, so a reference - // taken first would not survive it. The sweep is owed from an earlier creation - // rather than triggered by this one. - if (m_creationTick >= kCreationGCInterval) { - m_creationTick = 0; - ReclaimDeadSlots(); - } - const MG_Pipe::MGPipeHandle handle = MG_Pipe::MGPipeSlots().Acquire(kKind, stateObj->GetLifetimeId()); MOBILEGL_ASSERT(!MG_Pipe::MGPipeHandleIsNull(handle), "MGPipe slot space of kind %u is exhausted", static_cast(kKind)); Entry& entry = EntryAt(handle.Slot); - const Bool firstInsertion = !entry.Live || entry.Gen != handle.Gen; if (entry.Live && entry.Gen != handle.Gen) { // The slot was reclaimed and handed to a new object: the twin at it describes // driver ids the new state object never made. @@ -172,19 +160,11 @@ namespace MobileGL::MG_Backend::DirectGLES { entry.Gen = handle.Gen; entry.Live = true; entry.stateRef = stateObj; - if (firstInsertion) { - // A slot this table has never held (or held for a previous owner). Nothing - // tells the backend that a texture or renderbuffer was DELETED - the twin, and - // the driver storage it owns, lives until a collection - and - // CollectGarbageIfNeeded is ticked only from the per-draw sync paths, which a - // CTS-shaped workload runs about ten times per case. 1024 of those ticks then - // span ~100 cases, so ~100 cases' worth of dead (and, for this suite, - // gigabyte-sized) objects would stay allocated at once. Object CHURN rather - // than draw count is what makes the sweep urgent, so a twin the table has - // never seen ticks it too - and it does so on the path that is about to - // allocate, which is exactly when the memory is needed. - ++m_creationTick; - } + // No creation tick and no sweep here. The registry this replaces needed both, + // because nothing told it a texture or a renderbuffer had been DELETED and object + // CHURN rather than draw count is what made that urgent. Every one of the six kinds + // now announces its own death from its destructor, so a dead twin's slot is already + // back before the next creation asks for one. RememberHandle(stateObj->GetLifetimeId(), handle); return entry.backend; } @@ -285,24 +265,18 @@ namespace MobileGL::MG_Backend::DirectGLES { return true; } - void CollectGarbageIfNeeded() { - ++m_gcTick; - if (m_gcTick < kGCInterval) return; - m_gcTick = 0; - m_creationTick = 0; - ReclaimDeadSlots(); - } + // Deliberately EMPTY, and this is the P2 deliverable rather than an omission: on this + // arm death is announced, so there is nothing for a periodic sweep to discover. The + // seven DirectGLES.cpp call sites keep their spelling because they are the legacy + // registry's driver and that arm is still compiled beside this one; on this arm they + // cost the predicted branch in StateBackendObjectRegistry and return. + void CollectGarbageIfNeeded() {} - void CollectGarbageNow() { - m_creationTick = 0; - ReclaimDeadSlots(); - } - - // Test-only introspection: how many first-time insertions are owed before the - // creation-driven sweep fires. Reading it is what lets a test pin the CADENCE rather - // than only the effect of an explicit CollectGarbageNow(). - Uint32 CreationTickForTest() const { return m_creationTick; } - static constexpr Uint32 CreationGCIntervalForTest() { return kCreationGCInterval; } + // An EXPLICIT collection - someone asked, so it runs. Not a driver: nothing calls this + // on a tick. It is the backstop for a notice that could not be delivered (see the + // InProcessTeardown() note in the file header) and the tests' way of forcing the + // liveness sweep without waiting for one. + void CollectGarbageNow() { ReclaimDeadSlots(); } // fn(const StatePtr& state, const BackendPtr& twin) over every live, still-owned entry. // Replaces the registry's begin()/end(), whose iterator exposed the raw frontend @@ -342,16 +316,8 @@ namespace MobileGL::MG_Backend::DirectGLES { m_memoHandle = MG_Pipe::kMGPipeNullHandle; } - static constexpr Uint32 kGCInterval = 1024; - // Creations are far rarer than draws, so this counts in a much smaller unit than - // kGCInterval does. Same value the map arm uses, so the two arms sweep at the same - // cadence under the same workload. - static constexpr Uint32 kCreationGCInterval = 64; - // Indexed by MGPipeHandle::Slot; [0] is the reserved slot and is never live. Vector m_slots; - Uint32 m_gcTick = 0; - Uint32 m_creationTick = 0; Bool m_isCollecting = false; // Handed back by GetOrCreate for a null state object. Never live, never swept. BackendPtr m_nullTwin; diff --git a/MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.cpp b/MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.cpp index b8e54d196..92402f063 100644 --- a/MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.cpp +++ b/MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.cpp @@ -7,6 +7,7 @@ // End of Source File Header #include "FramebufferObject.h" +#include "MG_State/GLState/StateObjectDeathNotice.h" #include "MG_Util/Types.h" #include @@ -21,6 +22,20 @@ namespace MobileGL::MG_State::GLState { return s_nextFramebufferLifetimeId.fetch_add(1, std::memory_order_relaxed); } +#if MOBILEGL_PIPE_PUSH + FramebufferObject::~FramebufferObject() { + // P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a + // garbage sweep. This is the last SharedPtr to this object dropping - not the + // glDelete* that only marks the name and leaves a still-bound object very much + // alive - so it is the exact moment the backend's twin, and the driver storage + // that twin owns, stop being reachable. The notice carries the lifetime id + // because the object no longer exists to be passed, and because the lifetime id + // is what the client slot allocator resolves the handle from. No-op unless a + // backend registered the ops (a pull build declares none at all). + NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::Framebuffer, m_lifetimeId); + } +#endif + // FramebufferAttachmentObject FramebufferAttachmentObject::FramebufferAttachmentObject( const SharedPtr& texture, TextureUploadTarget textureUploadTarget, Int level, diff --git a/MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.h b/MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.h index 8e3bf1569..d9153cf96 100644 --- a/MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.h +++ b/MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.h @@ -115,6 +115,12 @@ namespace MobileGL { Array(FramebufferAttachmentType::FramebufferAttachmentTypeCount)>; FramebufferObject(Uint externalIndex); +#if MOBILEGL_PIPE_PUSH + // P2 step e2. Out of line, and declared only where there is a notice to raise: + // in a pull build this class keeps its implicit destructor, which is what keeps + // the pull build's symbol set byte-for-byte the pre-P2 one (G1). + ~FramebufferObject(); +#endif void AttachTexture(FramebufferAttachmentType type, const SharedPtr& texture, TextureUploadTarget textureUploadTarget = TextureUploadTarget::Unknown, int level = 0, diff --git a/MobileGL/MG_State/GLState/SamplerState/SamplerObject.cpp b/MobileGL/MG_State/GLState/SamplerState/SamplerObject.cpp index c1010888f..3f91b4ad7 100644 --- a/MobileGL/MG_State/GLState/SamplerState/SamplerObject.cpp +++ b/MobileGL/MG_State/GLState/SamplerState/SamplerObject.cpp @@ -9,6 +9,7 @@ #include "SamplerObject.h" #include +#include #include @@ -24,6 +25,20 @@ namespace MobileGL { SamplerObject::SamplerObject(Uint externalIndex) : m_externalIndex(externalIndex), m_lifetimeId(AllocateLifetimeId()) {} +#if MOBILEGL_PIPE_PUSH + SamplerObject::~SamplerObject() { + // P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a + // garbage sweep. This is the last SharedPtr to this object dropping - not the + // glDelete* that only marks the name and leaves a still-bound object very much + // alive - so it is the exact moment the backend's twin, and the driver storage + // that twin owns, stop being reachable. The notice carries the lifetime id + // because the object no longer exists to be passed, and because the lifetime id + // is what the client slot allocator resolves the handle from. No-op unless a + // backend registered the ops (a pull build declares none at all). + NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::SamplerCso, m_lifetimeId); + } +#endif + void SamplerObject::BumpVersion() { ++m_version; // Every setter early-outs on an unchanged value, so this only runs on a real diff --git a/MobileGL/MG_State/GLState/SamplerState/SamplerObject.h b/MobileGL/MG_State/GLState/SamplerState/SamplerObject.h index 90b4619c7..b5dafc23c 100644 --- a/MobileGL/MG_State/GLState/SamplerState/SamplerObject.h +++ b/MobileGL/MG_State/GLState/SamplerState/SamplerObject.h @@ -16,6 +16,12 @@ namespace MobileGL { class SamplerObject { public: SamplerObject(Uint externalIndex); +#if MOBILEGL_PIPE_PUSH + // P2 step e2. Out of line, and declared only where there is a notice to raise: + // in a pull build this class keeps its implicit destructor, which is what keeps + // the pull build's symbol set byte-for-byte the pre-P2 one (G1). + ~SamplerObject(); +#endif void SetWrapS(SamplerWrapMode mode); void SetWrapT(SamplerWrapMode mode); diff --git a/MobileGL/MG_State/GLState/TextureState/TextureObject.cpp b/MobileGL/MG_State/GLState/TextureState/TextureObject.cpp index 039e301e9..000f4d93f 100644 --- a/MobileGL/MG_State/GLState/TextureState/TextureObject.cpp +++ b/MobileGL/MG_State/GLState/TextureState/TextureObject.cpp @@ -8,6 +8,7 @@ #include "TextureObject.h" #include "MG_State/GLState/Core.h" +#include "MG_State/GLState/StateObjectDeathNotice.h" #include "MG_Util/Types.h" #include @@ -25,6 +26,20 @@ namespace MobileGL { return s_nextTextureLifetimeId.fetch_add(1, std::memory_order_relaxed); } +#if MOBILEGL_PIPE_PUSH + TextureObjectBase::~TextureObjectBase() { + // P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a + // garbage sweep. This is the last SharedPtr to this object dropping - not the + // glDelete* that only marks the name and leaves a still-bound object very much + // alive - so it is the exact moment the backend's twin, and the driver storage + // that twin owns, stop being reachable. The notice carries the lifetime id + // because the object no longer exists to be passed, and because the lifetime id + // is what the client slot allocator resolves the handle from. No-op unless a + // backend registered the ops (a pull build declares none at all). + NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::Texture, m_lifetimeId); + } +#endif + void TextureObjectBase::BumpShapeVersion() { ++m_shapeVersion; // Shape is what mipmap-completeness is computed from, and completeness decides diff --git a/MobileGL/MG_State/GLState/TextureState/TextureObject.h b/MobileGL/MG_State/GLState/TextureState/TextureObject.h index 48ef2777a..1cb33cca7 100644 --- a/MobileGL/MG_State/GLState/TextureState/TextureObject.h +++ b/MobileGL/MG_State/GLState/TextureState/TextureObject.h @@ -116,7 +116,15 @@ namespace MobileGL::MG_State::GLState { class TextureObjectBase : public ITextureObject { public: TextureObjectBase(TextureTarget target, Uint externalIndex); +#if MOBILEGL_PIPE_PUSH + // P2 step e2. Out of line, and declared only where there is a notice to raise: in a + // pull build this stays the implicit `= default` the pre-P2 tree had, which is what + // keeps the pull build's symbol set byte-for-byte the pre-P2 one (G1). Declared on the + // BASE, so every concrete texture class - 2D, 3D, cube, buffer, view - announces once. + virtual ~TextureObjectBase(); +#else virtual ~TextureObjectBase() = default; +#endif TextureInternalFormat GetFormat() const override; TextureTarget GetTarget() const override; diff --git a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.cpp b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.cpp index db784caf0..02a0e5f8b 100644 --- a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.cpp +++ b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.cpp @@ -8,6 +8,8 @@ #include "VertexArrayObject.h" +#include + #include namespace MobileGL::MG_State::GLState { @@ -37,6 +39,20 @@ namespace MobileGL::MG_State::GLState { } } +#if MOBILEGL_PIPE_PUSH + VertexArrayObject::~VertexArrayObject() { + // P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a + // garbage sweep. This is the last SharedPtr to this object dropping - not the + // glDelete* that only marks the name and leaves a still-bound object very much + // alive - so it is the exact moment the backend's twin, and the driver storage + // that twin owns, stop being reachable. The notice carries the lifetime id + // because the object no longer exists to be passed, and because the lifetime id + // is what the client slot allocator resolves the handle from. No-op unless a + // backend registered the ops (a pull build declares none at all). + NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::VertexElementsCso, m_lifetimeId); + } +#endif + void VertexArrayObject::EnableAttribute(Uint index) { if (index >= MAX_VERTEX_ATTRIBS) return; diff --git a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h index 8a6d22f67..41c5065b7 100644 --- a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h +++ b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h @@ -25,6 +25,12 @@ namespace MobileGL { static constexpr int MAX_VERTEX_ATTRIB_BINDINGS = 32; VertexArrayObject(Uint externIndex); +#if MOBILEGL_PIPE_PUSH + // P2 step e2. Out of line, and declared only where there is a notice to raise: + // in a pull build this class keeps its implicit destructor, which is what keeps + // the pull build's symbol set byte-for-byte the pre-P2 one (G1). + ~VertexArrayObject(); +#endif void EnableAttribute(Uint index); void DisableAttribute(Uint index); diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index 933b5b72c..3ae3531a5 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -41,7 +41,10 @@ #include #include #include +#include #include +#include +#include #include #include #include @@ -3375,24 +3378,20 @@ TEST(DirectGLESSlotTable, TwoTablesOfTheSameKindShareOneSlotAndKeepTheirOwnTwin) << "the shared slot outlived both holders and the object"; } -// The sweep has TWO drivers and the table must carry both. Nothing below calls -// CollectGarbageIfNeeded() or CollectGarbageNow(): this is the CREATION-driven half, and it is -// the one that matters for a workload that churns objects without drawing much. The draw-path -// tick is 1024 CollectGarbageIfNeeded calls, i.e. ~100 CTS-shaped cases at ~10 per-draw ticks -// each, which is how the registry this replaces came to hold ~100 cases' worth of dead, -// gigabyte-sized twins at once before the creation tick was added to fix it. -TEST(DirectGLESSlotTable, ObjectChurnAloneDrivesTheSweep) { +// The sweep and both of its drivers are RETIRED on this arm (ROADMAP.md:18's "delete the GC"), +// and this is the property that replaces them. The registry this table replaces learned of a +// death only by finding an expired weak_ptr, so it needed a 1024-call draw tick AND a +// 64-creation tick and still held up to 64 dead, gigabyte-sized twins at once. An announced +// death returns the slot before the next creation asks for one, so NOTHING accumulates - +// nothing below calls CollectGarbageIfNeeded() or CollectGarbageNow(), and on this arm the +// former does nothing at all. +TEST(DirectGLESSlotTable, AnnouncedDeathKeepsObjectChurnFromAccumulatingWithoutASweep) { using namespace MobileGL; auto& slots = MG_Pipe::MGPipeSlots(); - const Uint32 interval = FakeSlotTable::CreationGCIntervalForTest(); - // The churn count is a FIXED constant, not a multiple of the interval: a negative control - // that pushes the interval out of reach must make this case go red, not make it run for - // 2^32 iterations. constexpr Uint32 kChurn = 256u; - ASSERT_GT(interval, 0u); - ASSERT_LT(interval, kChurn) << "the creation sweep can no longer fire inside this case"; const Uint32 highWaterBefore = slots.HighWater(MG_Pipe::MGPipeKind::Query); + const Uint32 liveBefore = slots.LiveCount(MG_Pipe::MGPipeKind::Query); FakeSlotTable table; Uint32 peakLive = 0; @@ -3400,15 +3399,21 @@ TEST(DirectGLESSlotTable, ObjectChurnAloneDrivesTheSweep) { auto object = MakeShared(0xF0000000ull + i); table.GetOrCreate(object) = MakeShared(); peakLive = std::max(peakLive, table.LiveCount()); - // The object dies here. NOTHING announces that to the table (step e2 is not landed); - // the only thing that can notice is a sweep. + // What a real object's destructor raises. FakeStateObject is not one of the six + // re-keyed frontend classes, so the firing half is driven by hand here; that those six + // classes really do fire it is EveryReKeyedObjectClassAnnouncesItsOwnDeath below, and + // that the registries answer it per kind is + // EverySwitchedOverKindResolvesItsTwinThroughTheHandleArm. + EXPECT_TRUE(table.DestroyByLifetimeId(object->GetLifetimeId())); } - EXPECT_LE(peakLive, interval + 2u) - << peakLive << " dead twins accumulated at once with " << kChurn - << " objects churned - object churn stopped driving the sweep"; - EXPECT_LE(table.LiveCount(), interval + 2u); - EXPECT_LE(slots.HighWater(MG_Pipe::MGPipeKind::Query) - highWaterBefore, interval + 2u) + EXPECT_EQ(peakLive, 1u) + << peakLive << " twins were live at once with " << kChurn + << " objects churned and every death announced - the notice stopped freeing the twin"; + EXPECT_EQ(table.LiveCount(), 0u); + EXPECT_EQ(slots.LiveCount(MG_Pipe::MGPipeKind::Query), liveBefore) + << "the churn leaked slots the announced deaths should have returned"; + EXPECT_LE(slots.HighWater(MG_Pipe::MGPipeKind::Query) - highWaterBefore, 1u) << "the slot space grew with the churn instead of being recycled"; } @@ -3459,45 +3464,57 @@ TEST(DirectGLESSlotTable, AnAnnouncedDeathReturnsTheSlotWithoutASweep) { EXPECT_FALSE(other.DestroyByLifetimeId(object->GetLifetimeId())); } -// The firing side of e2, on the two of the six object classes whose files this package owns. -// The notice has to arrive when the LAST SharedPtr drops - not when glDeleteProgram marks the -// name, because a still-bound object goes on living - so the object is simply dropped here. -TEST(DirectGLESSlotTable, AProgramAndARenderbufferAnnounceTheirOwnDeath) { +// The firing side of e2, on ALL SIX re-keyed object classes - the round-3 review's MAJOR 3. +// Until this round only ProgramObject and RenderbufferObject raised the notice and the other +// four discovered their death in a sweep; the sweep is now retired, so a class that stopped +// announcing would leak its twin and the driver storage that twin owns for the life of the +// process. The notice has to arrive when the LAST SharedPtr drops - not when glDelete* marks +// the name, because a still-bound object goes on living - so the objects are simply dropped. +TEST(DirectGLESSlotTable, EveryReKeyedObjectClassAnnouncesItsOwnDeath) { using namespace MobileGL; - struct Notice { - MG_Pipe::MGPipeKind kind = MG_Pipe::MGPipeKind::None; - Uint64 lifetimeId = 0; - }; - static Vector notices; + static Vector> notices; notices.clear(); const MG_State::GLState::StateObjectDeathOps recording = { .OnDestroyed = [](MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) { - notices.push_back(Notice{kind, lifetimeId}); + notices.emplace_back(kind, lifetimeId); }, }; const MG_State::GLState::StateObjectDeathOps* previous = MG_State::GLState::GetStateObjectDeathOps(); MG_State::GLState::SetStateObjectDeathOps(&recording); - Uint64 programId = 0; - Uint64 renderbufferId = 0; + Vector> expected; { auto program = MakeShared(0u); - programId = program->GetLifetimeId(); auto renderbuffer = MakeShared(0u); - renderbufferId = renderbuffer->GetLifetimeId(); + auto texture = MakeShared(0u); + auto framebuffer = MakeShared(1u); + auto sampler = MakeShared(0u); + auto vertexArray = MakeShared(0u); + + expected.emplace_back(MG_Pipe::MGPipeKind::ShaderCso, program->GetLifetimeId()); + expected.emplace_back(MG_Pipe::MGPipeKind::Renderbuffer, renderbuffer->GetLifetimeId()); + expected.emplace_back(MG_Pipe::MGPipeKind::Texture, texture->GetLifetimeId()); + expected.emplace_back(MG_Pipe::MGPipeKind::Framebuffer, framebuffer->GetLifetimeId()); + expected.emplace_back(MG_Pipe::MGPipeKind::SamplerCso, sampler->GetLifetimeId()); + expected.emplace_back(MG_Pipe::MGPipeKind::VertexElementsCso, vertexArray->GetLifetimeId()); + EXPECT_TRUE(notices.empty()) << "a live object announced its own death"; } MG_State::GLState::SetStateObjectDeathOps(previous); - ASSERT_EQ(notices.size(), 2u); - // Destruction is reverse of construction, so the renderbuffer speaks first. - EXPECT_EQ(notices[0].kind, MG_Pipe::MGPipeKind::Renderbuffer); - EXPECT_EQ(notices[0].lifetimeId, renderbufferId); - EXPECT_EQ(notices[1].kind, MG_Pipe::MGPipeKind::ShaderCso); - EXPECT_EQ(notices[1].lifetimeId, programId); + // Membership rather than a count or an order: every TextureObjectBase owns a private + // SamplerObject (TextureObject.cpp), so tearing a texture down legitimately raises a + // SamplerCso notice as well. What must hold is that each of the six classes announced its + // OWN id under its OWN kind. + for (const auto& want : expected) { + EXPECT_NE(std::find(notices.begin(), notices.end(), want), notices.end()) + << "kind " << static_cast(want.first) << " lifetime id " << want.second + << " was destroyed without announcing it, so its twin would wait for a sweep that " + "this arm no longer runs"; + } } // ... and that the backend actually installs a consumer for it, rather than the two halves @@ -3635,7 +3652,7 @@ TEST(DirectGLESSlotTable, TwoTablesOfTheSameKindShareOneSlotAndKeepTheirOwnTwin) GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; } -TEST(DirectGLESSlotTable, ObjectChurnAloneDrivesTheSweep) { +TEST(DirectGLESSlotTable, AnnouncedDeathKeepsObjectChurnFromAccumulatingWithoutASweep) { GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; } @@ -3651,7 +3668,7 @@ TEST(DirectGLESSlotTable, AnAnnouncedDeathReturnsTheSlotWithoutASweep) { GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; } -TEST(DirectGLESSlotTable, AProgramAndARenderbufferAnnounceTheirOwnDeath) { +TEST(DirectGLESSlotTable, EveryReKeyedObjectClassAnnouncesItsOwnDeath) { GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; } From f5bd1a041238ef00e5fbc1890128c41527cf219b Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 13:12:59 -0400 Subject: [PATCH 096/529] [Test] (Espryt): drive the acquire / look up / delete / re-acquire walk through the real registry for all six re-keyed kinds - The D13 "must not break" pins made ZERO slot acquisitions: the scratch-FBO scrub and the three context-generation guards build their twins with MakeShared directly or under ScopedStateGuardMocks, so they never reach StateBackendObjectRegistry and pass identically on both arms. The four acquisitions in the binary were all kind Texture, and the eleven DirectGLESSlotTable cases drive BackendSlotTable directly on the throwaway kinds Query and Fence - so they would pass had the six registries never been re-keyed. Framebuffer, Renderbuffer, SamplerCso, ShaderCso and VertexElementsCso had no case that could go red for the switch-over. - EverySwitchedOverKindResolvesItsTwinThroughTheHandleArm walks all six through the real registry global the shipping paths call: GetOrCreate mints a non-null {slot, gen}, Find and FindByHandle name the same twin storage, the object's own destructor notice frees the slot with no sweep, and the successor lands on the freed slot with a moved Gen while the predecessor's handle resolves to nothing. A kind still on the legacy arm answers the null handle and fails the assertion by name. - Evidence: gdb breakpoint on MGPipeSlotAllocator::Acquire counts 12 hits for this case alone in build-push - six kinds times the two objects each - against 4 for the whole rest of the binary. It skips visibly on the legacy arm and in the pull build (G2 parity). --- MobileGL/MG_Test/SanityTest.cpp | 98 +++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index 3ae3531a5..93cc31542 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -3552,6 +3552,100 @@ TEST(DirectGLESSlotTable, TheTwinRegistryCasesInThisBinaryRunOnTheHandleArm) { 0ull); } +namespace { + // One kind's worth of the walk the re-key exists for - acquire, look up by object, look up + // by handle, delete, re-acquire - driven through the REAL registry global that every + // shipping path uses, and therefore through StateBackendObjectRegistry's arm dispatch. + // + // That "through the real registry" is the whole point of this helper. The eleven + // DirectGLESSlotTable cases above drive BackendSlotTable directly on the throwaway kinds + // Query and Fence, so they would pass unchanged had the six registries never been re-keyed; + // and of the D13 "must not break" cases only the sampled-set staleness walk makes a twin at + // all, all four of them of kind Texture. Five of the six re-keyed kinds therefore had no + // case that could go red for the switch-over. This is that case. + // + // No backend twin is constructed: every one of the six twin classes generates a driver id + // in its constructor, and none of that is what was re-keyed. What is asserted instead is + // that GetOrCreate, Find(object) and FindByHandle(handle) all name the SAME twin storage, + // that the handle is a real {slot, gen} rather than the null handle the legacy arm answers, + // and that a successor object landing on the freed slot gets a different Gen while the + // predecessor's handle resolves to nothing. + template + void ExpectTheHandleArmDrivesThisKind(const char* kindName, Registry& registry, MakeObject make) { + using namespace MobileGL; + + auto first = make(); + ASSERT_NE(first, nullptr) << kindName; + auto& firstTwin = registry.GetOrCreate(first); + ASSERT_NE(MG_State::GLState::GetStateObjectDeathOps(), nullptr) + << kindName << ": twinning an object did not install the death-notice consumer"; + + const MG_Pipe::MGPipeHandle firstHandle = registry.HandleOf(first.get()); + ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(firstHandle)) + << kindName << ": GetOrCreate on the real registry minted no handle, so this kind is " + "not running on the {slot, gen} arm at all"; + EXPECT_EQ(registry.Find(first.get()), &firstTwin) + << kindName << ": Find resolved a different twin slot than GetOrCreate handed back"; + EXPECT_EQ(registry.FindByHandle(firstHandle), &firstTwin) + << kindName << ": the handle does not address the twin GetOrCreate handed back"; + + // The frontend object dies. NOTHING below sweeps - the destructor's own notice is the + // only thing that can free the slot, which is what makes this the e2/e3 pair end to end. + const Uint64 firstLifetimeId = first->GetLifetimeId(); + first.reset(); + EXPECT_EQ(registry.FindByHandle(firstHandle), nullptr) + << kindName << ": the twin outlived the announced death of its object"; + EXPECT_FALSE(registry.DestroyByLifetimeId(firstLifetimeId)) + << kindName << ": the slot was still held after its object announced its death"; + + auto second = make(); + ASSERT_NE(second, nullptr) << kindName; + auto& secondTwin = registry.GetOrCreate(second); + const MG_Pipe::MGPipeHandle secondHandle = registry.HandleOf(second.get()); + ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(secondHandle)) << kindName; + EXPECT_EQ(secondHandle.Slot, firstHandle.Slot) + << kindName << ": the freed slot was not handed back, so this walk did not exercise " + "the recycle it exists to test"; + EXPECT_NE(secondHandle.Gen, firstHandle.Gen) + << kindName << ": Gen did not move on slot reuse - the predecessor's handle would " + "resolve to the successor's twin, which is the ABA the address key " + "could only paper over"; + EXPECT_EQ(registry.FindByHandle(firstHandle), nullptr) + << kindName << ": the STALE handle resolved to a twin"; + EXPECT_EQ(registry.FindByHandle(secondHandle), &secondTwin) << kindName; + + second.reset(); + EXPECT_EQ(registry.FindByHandle(secondHandle), nullptr) << kindName; + } +} // namespace + +// The gate on MAJOR 2 of the round-3 review: every kind this package re-keyed, exercised +// through the registry the shipping code calls, on the arm this package wrote. +TEST(DirectGLESSlotTable, EverySwitchedOverKindResolvesItsTwinThroughTheHandleArm) { + using namespace MobileGL; + using namespace MobileGL::MG_Backend::DirectGLES; + using namespace MobileGL::MG_State::GLState; + + if (!EsprytSlotTablesEnabled()) { + GTEST_SKIP() << "the legacy arm keys twins on the frontend heap address and answers the " + "null handle, so there is no {slot, gen} walk to drive"; + } + + ExpectTheHandleArmDrivesThisKind("Texture", TextureImpl::g_backendTextureObjects, [] { + return SharedPtr(MakeShared(0u)); + }); + ExpectTheHandleArmDrivesThisKind("Framebuffer", FramebufferImpl::g_backendFramebufferObjects, + [] { return MakeShared(1u); }); + ExpectTheHandleArmDrivesThisKind("Renderbuffer", RenderbufferImpl::g_backendRenderbufferObjects, + [] { return MakeShared(0u); }); + ExpectTheHandleArmDrivesThisKind("SamplerCso", SamplerImpl::g_backendSamplerObjects, + [] { return MakeShared(0u); }); + ExpectTheHandleArmDrivesThisKind("ShaderCso", PrgramImpl::g_backendProgramObjects, + [] { return MakeShared(0u); }); + ExpectTheHandleArmDrivesThisKind("VertexElementsCso", VertexArrayImpl::g_backendVertexArrayObjects, + [] { return MakeShared(0u); }); +} + // The gate on MAJOR 1 of the round-3 review. Commit d89fb684 raised // Fatal{PipeLegacyMemosDisabled} from inside InitDisplayAndContext(), i.e. from inside EGL // bring-up - and the integration harness pre-flights EGL bring-up in a FORKED CHILD, converting @@ -3679,4 +3773,8 @@ TEST(DirectGLESSlotTable, TheHandleArmInstallsTheDeathNoticeConsumer) { TEST(DirectGLESSlotTable, AnArmlessKnobCombinationStopsInsteadOfSkippingTheLane) { GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; } + +TEST(DirectGLESSlotTable, EverySwitchedOverKindResolvesItsTwinThroughTheHandleArm) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} #endif // MOBILEGL_PIPE_PUSH From c9dd173201c2a3d4290bf9e5aa46fee0eeeb363c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 13:21:07 -0400 Subject: [PATCH 097/529] [Fix] (Espryt): close four review minors on the twin table - a walk that can outlive its vector, a null call that is not arm-equivalent, and two comments that claimed more than the code does - ForEachLive walked with a range-for and handed fn a reference INTO m_slots, so a callee that reached GetOrCreate on the same table would resize the vector under both. Index loop and a copied twin, the shape ReclaimDeadSlots already uses. The one caller today happens not to insert; that is not a property the walk should depend on. - GetOrCreate(nullptr) reset the parking twin on EVERY call, so a second null call destroyed what the first was handed. The map arm kept its null-keyed entry until a sweep, so this was an arm difference in the one path (SyncTextureObjectToBackend) that documents relying on the tolerance. It now keeps the parked twin, and the case makes a second call. - The one-entry memo's comment claimed the three per-draw resolution paths ask for the same object every draw. Two of them do not: BindCurrentFBO resolves both targets in a frame and ResolveUnitSamplerBackend asks per texture unit, so both thrash a single-entry memo and pay a probe P1 did not. The comment now says so and names the fix (per-unit / per-target) and the gate that would price it (G11, device-side, owed). - HandleOf caches a NULL answer too - deliberate, because a bound-but-never-synced object would otherwise re-probe every draw - and what makes it safe is that GetOrCreate refreshes the memo. Nothing pinned that; RepeatedLookupsOfALiveObjectKeepOneHandle now does. - Removed the dead #if MOBILEGL_PIPE_PUSH nested inside #if MOBILEGL_PIPE_PUSH in ScopedDetachedTextureFramebufferAttachments. --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 4 +- MobileGL/MG_Backend/DirectGLES/SlotTables.h | 44 +++++++++++++------ MobileGL/MG_Test/SanityTest.cpp | 26 ++++++++++- 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 3b72d8411..1946bd412 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -6706,7 +6706,8 @@ namespace MobileGL::MG_Backend::DirectGLES { } }; -#if MOBILEGL_PIPE_PUSH + // Already inside #if MOBILEGL_PIPE_PUSH, so no second guard here: the arm choice + // below is the RUNTIME one. if (EsprytSlotTablesEnabled()) { FramebufferImpl::g_backendFramebufferObjects.ForEachLive( [&](const SharedPtr& stateFBO, @@ -6715,7 +6716,6 @@ namespace MobileGL::MG_Backend::DirectGLES { }); return; } -#endif #if MOBILEGL_PIPE_LEGACY_MEMOS for (auto it = FramebufferImpl::g_backendFramebufferObjects.begin(); it != FramebufferImpl::g_backendFramebufferObjects.end(); ++it) { diff --git a/MobileGL/MG_Backend/DirectGLES/SlotTables.h b/MobileGL/MG_Backend/DirectGLES/SlotTables.h index b2c9751e9..078e24666 100644 --- a/MobileGL/MG_Backend/DirectGLES/SlotTables.h +++ b/MobileGL/MG_Backend/DirectGLES/SlotTables.h @@ -137,12 +137,15 @@ namespace MobileGL::MG_Backend::DirectGLES { // No assert on null here, unlike the map arm: null is TOLERATED, so a DEBUG build // must not trap where the release build quietly does the documented thing. if (stateObj == nullptr) { - // The registry this replaces inserted a null key and handed back ITS twin slot - // (DirectGLES.cpp's SyncTextureObjectToBackend documents relying on exactly - // that tolerance), so a release build never dereferenced null here. Keep the - // shape: one per-table parking slot, never live, never swept, never handed a - // handle. A null object has no identity and therefore cannot have a twin. - m_nullTwin.reset(); + // The registry this replaces inserted a null KEY and handed back that entry's + // twin (DirectGLES.cpp's SyncTextureObjectToBackend documents relying on + // exactly that tolerance), so a release build never dereferenced null here. + // Keep the shape exactly, INCLUDING across calls: the map kept its null-keyed + // entry, so a second null call was handed the same twin the first one got. + // Resetting here instead would have destroyed it - an arm difference in the one + // path that documents relying on this. One per-table parking slot, never live, + // never swept, never handed a handle, because a null object has no identity and + // therefore cannot have a {slot, gen}. return m_nullTwin; } @@ -285,11 +288,18 @@ namespace MobileGL::MG_Backend::DirectGLES { // a dangling key the way the old iteration could. template void ForEachLive(Fn&& fn) const { - for (const Entry& entry : m_slots) { + // Index loop and a COPIED twin, not a range-for over references: fn is arbitrary + // backend code, and a nested GetOrCreate on this table would resize m_slots and + // invalidate both the iterator and any reference into the vector that outlives the + // call. ReclaimDeadSlots walks by index for the same reason. The one caller today + // happens not to insert; that is not a property the walk should depend on. + for (SizeT slot = 0; slot < m_slots.size(); ++slot) { + const Entry& entry = m_slots[slot]; if (!entry.Live || !entry.backend) continue; const StatePtr state = entry.stateRef.lock(); if (!state) continue; - fn(state, entry.backend); + const BackendPtr twin = entry.backend; + fn(state, twin); } } @@ -322,12 +332,18 @@ namespace MobileGL::MG_Backend::DirectGLES { // Handed back by GetOrCreate for a null state object. Never live, never swept. BackendPtr m_nullTwin; - // ONE-entry resolution memo, lifetimeId -> handle. The three per-draw resolution paths - // (ResolveVaoTwin, SyncCurrentProgram, BindCurrentFBO) ask the SAME table for the SAME - // object every draw, so this turns the steady state back into an integer compare plus - // one array index - which is what the deleted TwinLookupMemos bought and what D13 - // promises ("direct slot indexing - the memo existed only to avoid the hash probe"). - // Without it every resolution went through the allocator's ByLifetimeId hash. + // ONE-entry resolution memo, lifetimeId -> handle. It exists because without it every + // resolution goes through the allocator's ByLifetimeId hash, which the deleted + // TwinLookupMemos existed to avoid and which D13 promises to replace with "direct slot + // indexing". + // + // It is one entry and therefore only helps a caller that asks for the SAME object twice + // running - ResolveVaoTwin and SyncCurrentProgram do, once per draw each. Two callers + // it does NOT help, recorded rather than claimed away: BindCurrentFBO resolves BOTH + // targets in a frame, and ResolveUnitSamplerBackend asks for a different sampler per + // texture unit, so both thrash a single-entry memo and pay the probe P1 did not (P1 had + // a per-unit memo and a direct-mapped 6-slot array there). Making the memo per-unit / + // per-target is the fix, and G11 - the device-side gate that would price it - is owed. // // It cannot serve a stale answer, by two independent arguments: // * the key is a lifetime id, which MG_State never hands out twice, so a recycled diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index 93cc31542..c7e5adafc 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -3263,8 +3263,16 @@ TEST(DirectGLESSlotTable, RepeatedLookupsOfALiveObjectKeepOneHandle) { FakeSlotTable table; auto object = MakeShared(0xB1u); + // HandleOf caches whatever the allocator answered, INCLUDING the null handle - an object + // that is bound but never synced has no twin, and re-probing the hash for it every draw is + // exactly what the memo exists to avoid. What makes that safe is that GetOrCreate refreshes + // the memo, so a cached "no handle" can never outlive the twin's creation. Nothing pinned + // that; this does. + EXPECT_TRUE(MG_Pipe::MGPipeHandleIsNull(table.HandleOf(object.get()))); table.GetOrCreate(object) = MakeShared(); const MG_Pipe::MGPipeHandle handle = table.HandleOf(object.get()); + ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(handle)) + << "the memo went on answering the null handle it cached before the twin existed"; for (int i = 0; i < 8; ++i) { auto* slot = table.GetOrCreate(object) ? table.Find(object.get()) : nullptr; @@ -3413,7 +3421,11 @@ TEST(DirectGLESSlotTable, AnnouncedDeathKeepsObjectChurnFromAccumulatingWithoutA EXPECT_EQ(table.LiveCount(), 0u); EXPECT_EQ(slots.LiveCount(MG_Pipe::MGPipeKind::Query), liveBefore) << "the churn leaked slots the announced deaths should have returned"; - EXPECT_LE(slots.HighWater(MG_Pipe::MGPipeKind::Query) - highWaterBefore, 1u) + // 2 and not 1: on a cold allocator the high-water mark counts the RESERVED slot 0 + // (kMGPipeFirstAllocatableSlot is 1) as well as the one slot this loop recycles, and ctest + // runs every case in its own process, so this case sees a cold allocator. What the bound + // rules out is the thing that matters - 256 churned objects growing the space by 256. + EXPECT_LE(slots.HighWater(MG_Pipe::MGPipeKind::Query) - highWaterBefore, 2u) << "the slot space grew with the churn instead of being recycled"; } @@ -3430,6 +3442,18 @@ TEST(DirectGLESSlotTable, GetOrCreateToleratesANullStateObject) { EXPECT_EQ(table.LiveCount(), 0u) << "a null object took a slot"; EXPECT_EQ(table.Find(nullptr), nullptr); EXPECT_TRUE(MG_Pipe::MGPipeHandleIsNull(table.HandleOf(nullptr))); + + // ...and a SECOND null call is handed the same parking slot rather than destroying what the + // first one was given. The map arm kept its null-keyed entry until a sweep, so a table that + // reset here would answer differently on the two arms in the one path that documents + // relying on this tolerance. + twin = MakeShared(); + twin->marker = 5; + auto& again = table.GetOrCreate(none); + ASSERT_NE(again, nullptr) << "the second null call destroyed the first one's parked twin"; + EXPECT_EQ(again->marker, 5); + EXPECT_EQ(&again, &twin); + EXPECT_EQ(table.LiveCount(), 0u); } // P2 step e2, the backend half. A sweep is a stand-in for a death notice; this is the notice. // Nothing below calls CollectGarbage*: the slot comes back, and the twin goes, at the moment From df784c6752c6b8a711289faff81fd4ee22f80dd5 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Mon, 7 Sep 2026 22:28:58 -0400 Subject: [PATCH 098/529] [Fix, Test] (Espryt): deliver a death notice to every holder of a kind, retire the twin table's last sweep, and pin the bring-up call site under the armless knob pair - BackendSlotTable links every instance into a per-type holder list; OnFrontendObjectDestroyed resolves the handle once, drops the twin in each holder by handle and frees the slot once, last - a notice delivered to one registry left the fixture's saved copy holding a dead twin, and its driver storage, for the life of the process (review v4 minor 2) - ReclaimDeadSlots and CollectGarbageNow leave the slot table: nothing called them, so the header's "backstop" was a claim without a caller; the registry's pre-P2 CollectGarbageNow is a no-op on the handle arm (minor 1) - HandleOf no longer memoises a null answer, which a second holder's acquire could never refresh (minor 8) - EnsureProcessTeardownSentinel is armed by the slot table's first insertion, as D13 says; the registry arms it only on the legacy arm (minor 9) - new SanityTest cases: OneDeathNoticeDropsTheTwinInEveryHolderOfTheKind, ASavedCopyOfARealRegistryDropsTheTwinOnTheSameNotice, ANegativeLookupIsNotCachedAcrossAnotherHoldersAcquire, and EglBringUpUnderTheArmlessKnobPairReturnsInsteadOfStopping, which runs InitPbufferSurface under the pair in a forked child and fails naming both knobs if InitDisplayAndContext ever stops there again (minor 4) - AnArmlessKnobCombinationStopsInsteadOfSkippingTheLane writes a per-process, per-case log path and restores MOBILEGL_LOG_FILE_PATH and MG_Config::Features through RAII guards on every exit path (minor 5) --- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 9 +- MobileGL/MG_Backend/DirectGLES/Managers.h | 46 ++- MobileGL/MG_Backend/DirectGLES/SlotTables.h | 300 +++++++++----- MobileGL/MG_Test/SanityTest.cpp | 426 +++++++++++++++++--- 4 files changed, 602 insertions(+), 179 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index ca1ca6698..448f9f926 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -178,9 +178,16 @@ namespace MobileGL::MG_Backend::DirectGLES { // for all six - which is why the notice carries the kind rather than there being six // ops tables. // + // Each arm below names the registry GLOBAL of its kind, but DestroyByLifetimeId is + // static: it is answered by every table of that kind that exists at the moment - the + // global's own and any by-value copy a fixture or a context reset is holding - and + // the slot goes back once, after all of them have let go (SlotTables.h, the holder + // list). Naming one instance here is a spelling, not a choice of holder. + // // A notice that arrives after exit() has begun is dropped: past that point the twin's // destructor must not call into the driver (see InProcessTeardown()), and the process - // is about to hand every GPU object back anyway. + // is about to hand every GPU object back anyway. That twin is a deliberate leak, not + // garbage for a later collection - there is none on this arm. void OnFrontendStateObjectDestroyed(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) { if (InProcessTeardown()) return; switch (kind) { diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index 840ef3094..a061774bb 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -315,20 +315,27 @@ namespace MobileGL::MG_Backend::DirectGLES { using BackendMap = UnorderedMap; using iterator = typename BackendMap::iterator; using const_iterator = typename BackendMap::const_iterator; +#if MOBILEGL_PIPE_PUSH + using SlotTable = BackendSlotTable; +#endif BackendPtr& GetOrCreate(const StatePtr& stateObj) { MOBILEGL_ASSERT(stateObj != nullptr, "State object must not be null"); - // Twin creation is the moment a driver-owned id starts needing a guarded - // destructor; cold path, so the once-guard costs nothing per draw. It is armed - // here, at the first insertion, on BOTH arms - a destructor hook on the table - // itself is wrong for the reason spelled out above InProcessTeardown(). - EnsureProcessTeardownSentinel(); #if MOBILEGL_PIPE_PUSH if (EsprytSlotTablesEnabled()) { + // The slot table arms the teardown sentinel itself, at its own first + // insertion (D13; SlotTables.h) - so a table used outside a registry arms + // it too, which is right: it is the twin, not the registry, that owns the + // driver id a guarded destructor exists for. return m_slotTable.GetOrCreate(stateObj); } #endif + // Twin creation is the moment a driver-owned id starts needing a guarded + // destructor; cold path, so the once-guard costs nothing per draw. It is armed + // here, at the first insertion - a destructor hook on the table itself is wrong + // for the reason spelled out above InProcessTeardown(). + EnsureProcessTeardownSentinel(); // Sweep BEFORE the entry reference below exists: the map is open-addressed and an // erase relocates the rest of the probe cluster, so collecting once that reference // is taken would invalidate it. The sweep is therefore owed from an earlier call @@ -417,15 +424,20 @@ namespace MobileGL::MG_Backend::DirectGLES { return nullptr; } - // P2 step e2. The legacy arm cannot answer this at all - its key is the frontend heap - // ADDRESS and the object is already gone by the time the notice arrives - so there it - // is a no-op and the garbage sweep stays its only death signal. That asymmetry is not - // an oversight: it is the A/B the compile-time arm exists to make measurable + // P2 step e2. STATIC, because a death notice is about an object and not about a + // registry instance: it is answered by EVERY table of this kind that exists - this + // registry's own, and any by-value copy of it a fixture or a context reset is holding + // (SlotTables.h explains the holder list and why one holder was a leak). + // + // The legacy arm cannot answer this at all - its key is the frontend heap ADDRESS and + // the object is already gone by the time the notice arrives - so there it is a no-op + // and the garbage sweep stays its only death signal. That asymmetry is not an + // oversight: it is the A/B the compile-time arm exists to make measurable // (ARCHITECTURE.md 9.6), and announced-versus-discovered death is one of the things // being measured. - Bool DestroyByLifetimeId(Uint64 lifetimeId) { + static Bool DestroyByLifetimeId(Uint64 lifetimeId) { if (EsprytSlotTablesEnabled()) { - return m_slotTable.DestroyByLifetimeId(lifetimeId); + return SlotTable::OnFrontendObjectDestroyed(lifetimeId); } return false; } @@ -454,13 +466,13 @@ namespace MobileGL::MG_Backend::DirectGLES { // The seven DirectGLES.cpp call sites drive the LEGACY arm and nothing else. On the // handle arm death is announced by the frontend object's destructor // (MG_State/GLState/StateObjectDeathNotice.h), so there is no garbage to collect on a - // tick and this is the predicted branch plus a return - which is how ROADMAP.md:18's - // "delete the GC" is delivered without deleting the legacy arm's own collector while - // that arm is still compiled beside it. + // tick, the slot table has no collector to forward to, and this is the predicted + // branch plus a return - which is how ROADMAP.md:18's "delete the GC" is delivered + // without deleting the legacy arm's own collector while that arm is still compiled + // beside it. void CollectGarbageIfNeeded() { #if MOBILEGL_PIPE_PUSH if (EsprytSlotTablesEnabled()) { - m_slotTable.CollectGarbageIfNeeded(); return; } #endif @@ -474,10 +486,12 @@ namespace MobileGL::MG_Backend::DirectGLES { #endif } + // Pre-P2 API, kept for the legacy arm. On the handle arm there is nothing it could + // collect: a twin leaves with its object's death notice, and a notice dropped during + // process teardown is a deliberate leak (SlotTables.h), not garbage awaiting a call. void CollectGarbageNow() { #if MOBILEGL_PIPE_PUSH if (EsprytSlotTablesEnabled()) { - m_slotTable.CollectGarbageNow(); return; } #endif diff --git a/MobileGL/MG_Backend/DirectGLES/SlotTables.h b/MobileGL/MG_Backend/DirectGLES/SlotTables.h index 078e24666..620d8385d 100644 --- a/MobileGL/MG_Backend/DirectGLES/SlotTables.h +++ b/MobileGL/MG_Backend/DirectGLES/SlotTables.h @@ -28,8 +28,8 @@ // lose their reason to exist. // * The lookup stops being a hash probe into an open-addressed map and becomes one bounds // check plus one array index, so a returned BackendPtr* is NOT invalidated by the next Find -// or sweep on the table. That kills the hazard Managers.h documents at length, and with it -// the by-value copy plus second Find that SyncTextureObjectToBackend paid to survive it. +// on the table. That kills the hazard Managers.h documents at length, and with it the +// by-value copy plus second Find that SyncTextureObjectToBackend paid to survive it. // * Slots are dense per kind, which is what lets the server side (ARCHITECTURE.md 10.1, // MG_Remote/Server/PipeObjectTables) be an array rather than an object graph. // @@ -37,22 +37,35 @@ // deliverable ROADMAP.md:18 spells "GC" in and the one D13 makes a precondition of the switch- // over. All six re-keyed object classes raise MG_State::GLState::NotifyStateObjectDestroyed() // from their destructor (BufferBackendOps' shape, one entry point for six kinds), the backend -// consumes it in Managers.cpp, and DestroyByLifetimeId() below drops the twin and returns the -// slot at the moment the frontend object's last SharedPtr goes. So: -// * there is NO draw-path tick and NO creation tick on this arm. CollectGarbageIfNeeded() is -// an empty call, and the seven call sites in DirectGLES.cpp drive the LEGACY registry only; +// consumes it in Managers.cpp, and OnFrontendObjectDestroyed() below drops the twin in EVERY +// table of the kind and returns the slot, at the moment the frontend object's last SharedPtr +// goes. So: +// * there is NO draw-path tick, NO creation tick and NO sweep of any kind on this arm. The +// seven CollectGarbageIfNeeded call sites in DirectGLES.cpp drive the LEGACY registry only; // * a twin, and the driver storage it owns, is freed when the application lets go of the // object rather than up to 64 creations or 1024 draw ticks later. That is what // Managers.h's "dead gigabytes" note asked for. // -// The weak_ptr per entry survives, and only for what it is honest about: -// * ForEachLive() hands the callee a STRONG reference to the frontend object, which the one -// direct-iteration site (ScopedDetachedTextureFramebufferAttachments) needs; and -// * ReclaimDeadSlots() is kept as the body of the EXPLICIT CollectGarbageNow(), i.e. a -// collection someone asks for, never a periodic one. It is the backstop for the one case -// the notice cannot cover: a destructor that runs after exit() has begun, where -// InProcessTeardown() drops the notice because a twin destructor must not call the driver. -// It is never an identity test - that is what Gen is for. +// EVERY HOLDER OF THE KIND, not one. Two live tables of one kind is a real configuration - the +// ScopedDirectGLESTextureBindings fixture keeps a by-value copy of the Texture registry for the +// length of a test, and a context reset does the same in reverse - and the slot allocator +// erases its lifetimeId -> slot mapping on Free, so a notice delivered to one holder and +// resolved again by the next would find nothing to resolve. Every table therefore links itself +// into a per-table-type list at construction and out at destruction, and one notice resolves +// the handle ONCE, drops the twin in each holder BY HANDLE, and frees the slot once, last. No +// holder can be left naming a live entry for a dead object, and there is nothing a sweep could +// still find. (The list is per table TYPE; the kind is the type's template parameter, and each +// of the six kinds has exactly one table type in this backend. Magma's subsystem-4 table mints +// out of its own per-renderer allocator, not MGPipeSlots(), so it is not a holder here.) +// +// The weak_ptr per entry survives for exactly one reason: ForEachLive() hands the callee a +// STRONG reference to the frontend object, which the one direct-iteration site +// (ScopedDetachedTextureFramebufferAttachments) needs. It is never an identity test - that is +// what Gen is for - and it is never read to decide whether an entry is dead: a destructor that +// runs after exit() has begun has its notice dropped by InProcessTeardown(), and that twin is +// then a DELIBERATE leak (the process is exiting, the driver reclaims the object, and a twin +// destructor must not call into a driver that may already be unloaded), not something to be +// collected later. // // P3+ DEBT, recorded rather than hidden: this header is under MG_Backend/ and it MINTS // handles (MGPipeSlots().Acquire below) off a frontend SharedPtr's GetLifetimeId(). @@ -66,6 +79,11 @@ namespace MobileGL::MG_Backend::DirectGLES { #if MOBILEGL_PIPE_PUSH + // Declared in Managers.h as well; repeated here because this header is included from it + // before that declaration, and the table below is the arming site on this arm (D13: "the + // arming site moves to the slot table's first insertion"). + void EnsureProcessTeardownSentinel(); + // What the two knobs add up to. Split out as a PURE function of them so a test can drive // every combination without needing a process per combination. enum class EsprytSlotArmVerdict { @@ -90,6 +108,11 @@ namespace MobileGL::MG_Backend::DirectGLES { // D14/D18 A/B is driven with, which is what ROADMAP.md:7 forbids. So bring-up only // DIAGNOSES; the stop is raised by ResolveEsprytSlotTablesArm() at the first twin lookup, // which happens in the test body where the harness reports it as a failure. + // + // The CALL SITE (InitDisplayAndContext in DirectGLES.cpp) is pinned by + // DirectGLESSlotTable.EglBringUpUnderTheArmlessKnobPairReturnsInsteadOfStopping, which runs + // the real bring-up entry point under the pair in a forked child: edit that site back to + // ResolveEsprytSlotTablesArm() and the case fails naming both knobs. void DiagnoseEsprytSlotArm(); // Reads the config, logs, installs the death-notice consumer, and STOPS when the operator @@ -101,11 +124,11 @@ namespace MobileGL::MG_Backend::DirectGLES { // True when this process runs the {slot, gen} arm. Fixed for the life of the process: the // two arms hold their twins in different containers, so flipping mid-run would strand them. // - // INLINE on purpose. Every Find / GetOrCreate / HandleOf / ForEachLive / CollectGarbage* - // on the twin tables consults it, i.e. it is on the per-draw path several times per draw. - // As an out-of-line function in Managers.cpp (no LTO in any shipped configuration) that was - // a call through the PLT per lookup; here the caller sees a guard-variable load and a - // perfectly-predicted branch, and the arm dispatch folds into the caller. + // INLINE on purpose. Every Find / GetOrCreate / HandleOf / ForEachLive on the twin tables + // consults it, i.e. it is on the per-draw path several times per draw. As an out-of-line + // function in Managers.cpp (no LTO in any shipped configuration) that was a call through + // the PLT per lookup; here the caller sees a guard-variable load and a perfectly-predicted + // branch, and the arm dispatch folds into the caller. inline Bool EsprytSlotTablesEnabled() { static const Bool enabled = ResolveEsprytSlotTablesArm(); return enabled; @@ -120,9 +143,10 @@ namespace MobileGL::MG_Backend::DirectGLES { struct Entry { BackendPtr backend; - // LIVENESS ONLY. Never compared against another object to decide identity - that is - // what Gen is for - and never dereferenced for its address. Read by - // ReclaimDeadSlots(), and locked by ForEachLive() so the callee holds a strong ref. + // LIVENESS ONLY, and only for ForEachLive(), which locks it so the callee holds a + // strong ref. Never compared against another object to decide identity - that is + // what Gen is for - never dereferenced for its address, and never read to decide + // whether the slot is dead: death is announced, not discovered. StateWeakPtr stateRef; // The generation this entry's twin was built for. An entry whose Gen no longer // matches the allocator's is a twin of the slot's PREVIOUS owner. @@ -130,6 +154,49 @@ namespace MobileGL::MG_Backend::DirectGLES { Bool Live = false; }; + // Every constructor links the table into the per-type holder list and the destructor + // unlinks it, so a by-value copy (the ScopedDirectGLESTextureBindings fixture's saved + // registry) is a holder for exactly as long as it exists. Copy and move carry the + // ENTRIES and the memo; the links are the table's own and are never copied. + BackendSlotTable() { LinkHolder(); } + BackendSlotTable(const BackendSlotTable& other): + m_slots(other.m_slots), + m_nullTwin(other.m_nullTwin), + m_memoLifetimeId(other.m_memoLifetimeId), + m_memoHandle(other.m_memoHandle) { + LinkHolder(); + } + BackendSlotTable(BackendSlotTable&& other) noexcept: + m_slots(std::move(other.m_slots)), + m_nullTwin(std::move(other.m_nullTwin)), + m_memoLifetimeId(other.m_memoLifetimeId), + m_memoHandle(other.m_memoHandle) { + other.m_slots.clear(); + other.ForgetHandle(); + LinkHolder(); + } + BackendSlotTable& operator=(const BackendSlotTable& other) { + if (this != &other) { + m_slots = other.m_slots; + m_nullTwin = other.m_nullTwin; + m_memoLifetimeId = other.m_memoLifetimeId; + m_memoHandle = other.m_memoHandle; + } + return *this; + } + BackendSlotTable& operator=(BackendSlotTable&& other) noexcept { + if (this != &other) { + m_slots = std::move(other.m_slots); + m_nullTwin = std::move(other.m_nullTwin); + m_memoLifetimeId = other.m_memoLifetimeId; + m_memoHandle = other.m_memoHandle; + other.m_slots.clear(); + other.ForgetHandle(); + } + return *this; + } + ~BackendSlotTable() { UnlinkHolder(); } + // Resolve-or-create. The handle comes from the client allocator keyed on the frontend // object's lifetime id, so two calls for the same live object always land on the same // slot, and a successor object at the same heap address never does. @@ -144,11 +211,17 @@ namespace MobileGL::MG_Backend::DirectGLES { // entry, so a second null call was handed the same twin the first one got. // Resetting here instead would have destroyed it - an arm difference in the one // path that documents relying on this. One per-table parking slot, never live, - // never swept, never handed a handle, because a null object has no identity and + // never handed a handle, because a null object has no identity and // therefore cannot have a {slot, gen}. return m_nullTwin; } + // D13: the teardown sentinel is armed by the slot table's first insertion. Twin + // creation is the moment a driver-owned id starts needing a guarded destructor; + // this is the cold path, so the once-guard costs nothing per draw. On the legacy + // arm StateBackendObjectRegistry::GetOrCreate arms it itself. + EnsureProcessTeardownSentinel(); + const MG_Pipe::MGPipeHandle handle = MG_Pipe::MGPipeSlots().Acquire(kKind, stateObj->GetLifetimeId()); MOBILEGL_ASSERT(!MG_Pipe::MGPipeHandleIsNull(handle), @@ -173,9 +246,9 @@ namespace MobileGL::MG_Backend::DirectGLES { } // Null when no live twin of this object exists. Unlike the registry's Find this NEVER - // mutates the table, so the returned pointer survives any later Find or sweep on it; - // only a GetOrCreate that grows the vector can move it, and callers that hold one - // across a possible insertion still copy the BackendPtr out. + // mutates the table, so the returned pointer survives any later Find on it; only a + // GetOrCreate that grows the vector can move it, and callers that hold one across a + // possible insertion still copy the BackendPtr out. BackendPtr* Find(StateObject* stateObj) { if (stateObj == nullptr) return nullptr; return FindByHandle(HandleOf(stateObj)); @@ -195,91 +268,62 @@ namespace MobileGL::MG_Backend::DirectGLES { // The handle this object's twin is keyed on, or the null handle. This is what a backend // memo stores instead of a raw pointer, a GL name or a bare lifetime id. + // + // A NULL answer is never memoised. The memo is per table and the allocator is per + // kind, so with two holders of one kind the OTHER table can be the one that acquires; + // a cached "no handle" here would then outlive the twin's creation over there, and + // nothing on this table's own acquire path would ever refresh it. A miss costs the + // allocator probe it always cost; a hit is refreshed the moment anyone acquires. MG_Pipe::MGPipeHandle HandleOf(const StateObject* stateObj) const { if (stateObj == nullptr) return MG_Pipe::kMGPipeNullHandle; const Uint64 lifetimeId = stateObj->GetLifetimeId(); if (lifetimeId == m_memoLifetimeId) return m_memoHandle; const MG_Pipe::MGPipeHandle handle = MG_Pipe::MGPipeSlots().FindByLifetimeId(kKind, lifetimeId); - RememberHandle(lifetimeId, handle); + if (!MG_Pipe::MGPipeHandleIsNull(handle)) RememberHandle(lifetimeId, handle); return handle; } - // Drop the twin of every slot whose frontend object is gone and return the slot to the - // allocator. Freeing is what makes the NEXT handout of that slot bump Gen. - void ReclaimDeadSlots() { - if (m_isCollecting) return; - m_isCollecting = true; - for (SizeT slot = 0; slot < m_slots.size(); ++slot) { - Uint32 gen = 0; - // The twin's destructor is a driver call and could, in principle, re-enter - // GetOrCreate on this table and resize m_slots. So NOTHING that outlives the - // destructor may be a reference into m_slots: the twin is moved out into a - // local, the entry is finished with, and only then is the local released. - BackendPtr dead; - { - Entry& entry = m_slots[slot]; - if (!entry.Live || !entry.stateRef.expired()) continue; - gen = entry.Gen; - dead = std::move(entry.backend); - entry.backend.reset(); - entry.stateRef.reset(); - entry.Live = false; - } - MG_Pipe::MGPipeSlots().Free( - kKind, MG_Pipe::MGPipeHandle{static_cast(slot), gen}); - if (m_memoHandle.Slot == static_cast(slot)) ForgetHandle(); - dead.reset(); - } - m_isCollecting = false; - } - - // P2 step e2's backend half: the frontend object with this lifetime id has just been - // DESTROYED, so drop its twin and return its slot now rather than waiting for a sweep - // to notice the weak_ptr expired. Announced death is what the sweep is a stand-in for; - // it frees the driver storage the twin owns at the moment the application let go of - // the object, which is what Managers.h's "dead gigabytes" note is about. + // P2 step e2's backend half. The frontend object with this lifetime id has just been + // DESTROYED: resolve its handle ONCE, drop its twin in EVERY table of this type, and + // return the slot to the allocator - in that order, because the allocator forgets the + // lifetime id on Free and a holder told second could no longer resolve it. // - // Returns whether this table held the slot. The slot goes back to the allocator ONLY - // then, and this is not defensive: two holders of one kind already exist (the - // ScopedDirectGLESTextureBindings fixture's saved copy is a second live table of kind - // Texture; Magma's subsystem-4 table shares the VertexElementsCso kind), and a table - // that never twinned the object must not free a slot the other one still names. - Bool DestroyByLifetimeId(Uint64 lifetimeId) { + // The slot is returned whether or not any holder still had a twin at it: the lifetime + // id is dead and MG_State never hands one out twice, so nothing can acquire it again, + // and a slot minted for it that no table holds (a table reset with `= {}` drops its + // entries without freeing) would otherwise stay allocated for the life of the process. + // + // STATIC, and deliberately so: a notice is about an object, not about a table, and + // "which table holds it" is exactly the question that produced the two-holder leak. + // Returns whether the object had a slot of this kind, i.e. whether anything was freed; + // a second call for the same id answers false because the allocator no longer maps it. + static Bool OnFrontendObjectDestroyed(Uint64 lifetimeId) { const MG_Pipe::MGPipeHandle handle = MG_Pipe::MGPipeSlots().FindByLifetimeId(kKind, lifetimeId); if (MG_Pipe::MGPipeHandleIsNull(handle)) return false; - if (handle.Slot >= m_slots.size()) return false; - // Same ordering rule as ReclaimDeadSlots(): the twin's destructor is a driver call - // and could re-enter GetOrCreate and resize m_slots, so nothing that outlives it - // may be a reference into the vector. - BackendPtr dead; - { - Entry& entry = m_slots[handle.Slot]; - if (!entry.Live || entry.Gen != handle.Gen) return false; - dead = std::move(entry.backend); - entry.backend.reset(); - entry.stateRef.reset(); - entry.Live = false; + for (BackendSlotTable* holder = s_firstHolder; holder != nullptr;) { + // The successor is read BEFORE the release: ReleaseTwinAt runs the twin's + // destructor, which is a driver call, and nothing that outlives it may be a + // reference into this holder. + BackendSlotTable* const next = holder->m_nextHolder; + holder->ReleaseTwinAt(handle); + holder = next; } - if (m_memoHandle.Slot == handle.Slot) ForgetHandle(); MG_Pipe::MGPipeSlots().Free(kKind, handle); - dead.reset(); return true; } - // Deliberately EMPTY, and this is the P2 deliverable rather than an omission: on this - // arm death is announced, so there is nothing for a periodic sweep to discover. The - // seven DirectGLES.cpp call sites keep their spelling because they are the legacy - // registry's driver and that arm is still compiled beside this one; on this arm they - // cost the predicted branch in StateBackendObjectRegistry and return. - void CollectGarbageIfNeeded() {} - - // An EXPLICIT collection - someone asked, so it runs. Not a driver: nothing calls this - // on a tick. It is the backstop for a notice that could not be delivered (see the - // InProcessTeardown() note in the file header) and the tests' way of forcing the - // liveness sweep without waiting for one. - void CollectGarbageNow() { ReclaimDeadSlots(); } + // How many tables of this type exist right now. For the tests that pin the holder + // list; nothing on a shipping path asks. + static Uint32 HolderCount() { + Uint32 count = 0; + for (const BackendSlotTable* holder = s_firstHolder; holder != nullptr; + holder = holder->m_nextHolder) { + ++count; + } + return count; + } // fn(const StatePtr& state, const BackendPtr& twin) over every live, still-owned entry. // Replaces the registry's begin()/end(), whose iterator exposed the raw frontend @@ -291,8 +335,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // Index loop and a COPIED twin, not a range-for over references: fn is arbitrary // backend code, and a nested GetOrCreate on this table would resize m_slots and // invalidate both the iterator and any reference into the vector that outlives the - // call. ReclaimDeadSlots walks by index for the same reason. The one caller today - // happens not to insert; that is not a property the walk should depend on. + // call. The one caller today happens not to insert; that is not a property the + // walk should depend on. for (SizeT slot = 0; slot < m_slots.size(); ++slot) { const Entry& entry = m_slots[slot]; if (!entry.Live || !entry.backend) continue; @@ -312,6 +356,32 @@ namespace MobileGL::MG_Backend::DirectGLES { } private: + // Drop the twin at `handle` if THIS table holds it. Frees nothing: the slot belongs to + // the kind, not to the table, and OnFrontendObjectDestroyed returns it once, after + // every holder has let go. + Bool ReleaseTwinAt(MG_Pipe::MGPipeHandle handle) { + // Forget the memo whenever it names this slot, even if this table has no entry + // there: a memo can be a handle learned from the allocator for an object another + // holder twinned, and it must not survive the slot's next handout. + if (m_memoHandle.Slot == handle.Slot) ForgetHandle(); + if (handle.Slot >= m_slots.size()) return false; + // The twin's destructor is a driver call and could, in principle, re-enter + // GetOrCreate on this table and resize m_slots. So NOTHING that outlives the + // destructor may be a reference into m_slots: the twin is moved out into a local, + // the entry is finished with, and only then is the local released. + BackendPtr dead; + { + Entry& entry = m_slots[handle.Slot]; + if (!entry.Live || entry.Gen != handle.Gen) return false; + dead = std::move(entry.backend); + entry.backend.reset(); + entry.stateRef.reset(); + entry.Live = false; + } + dead.reset(); + return true; + } + Entry& EntryAt(Uint32 slot) { if (slot >= m_slots.size()) m_slots.resize(static_cast(slot) + 1); return m_slots[slot]; @@ -326,10 +396,36 @@ namespace MobileGL::MG_Backend::DirectGLES { m_memoHandle = MG_Pipe::kMGPipeNullHandle; } + // The holder list: intrusive and doubly linked, so registering and unregistering are + // two pointer writes with no allocation, and its head is a constant-initialised + // static - which is what lets the process-lifetime registry globals in Managers.cpp + // link themselves in from their own constructors with no initialisation-order + // question to answer. Single-threaded, like every table it links (the tables live and + // die on the context thread, as the notice they answer does). + void LinkHolder() { + m_prevHolder = nullptr; + m_nextHolder = s_firstHolder; + if (s_firstHolder != nullptr) s_firstHolder->m_prevHolder = this; + s_firstHolder = this; + } + void UnlinkHolder() { + if (m_prevHolder != nullptr) { + m_prevHolder->m_nextHolder = m_nextHolder; + } else { + s_firstHolder = m_nextHolder; + } + if (m_nextHolder != nullptr) m_nextHolder->m_prevHolder = m_prevHolder; + m_prevHolder = nullptr; + m_nextHolder = nullptr; + } + + static inline BackendSlotTable* s_firstHolder = nullptr; + BackendSlotTable* m_prevHolder = nullptr; + BackendSlotTable* m_nextHolder = nullptr; + // Indexed by MGPipeHandle::Slot; [0] is the reserved slot and is never live. Vector m_slots; - Bool m_isCollecting = false; - // Handed back by GetOrCreate for a null state object. Never live, never swept. + // Handed back by GetOrCreate for a null state object. Never live, never handed a handle. BackendPtr m_nullTwin; // ONE-entry resolution memo, lifetimeId -> handle. It exists because without it every @@ -345,13 +441,15 @@ namespace MobileGL::MG_Backend::DirectGLES { // a per-unit memo and a direct-mapped 6-slot array there). Making the memo per-unit / // per-target is the fix, and G11 - the device-side gate that would price it - is owed. // - // It cannot serve a stale answer, by two independent arguments: + // It cannot serve a stale answer, by three independent arguments: // * the key is a lifetime id, which MG_State never hands out twice, so a recycled - // heap address cannot hit this memo the way it could hit an address-keyed one; and + // heap address cannot hit this memo the way it could hit an address-keyed one; + // * a null answer is never stored, so another holder's acquire cannot be hidden by + // a "no handle" this table remembered earlier; and // * even a hit for a slot that has since been freed and re-handed is caught, because // the caller resolves the handle through FindByHandle, which compares Gen. - // Cleared anyway when the sweep frees the memoised slot. 0 is never a live lifetime id - // (MG_State's counters start at 1), so a zeroed memo is a guaranteed miss. + // Cleared anyway when a death notice names the memoised slot. 0 is never a live + // lifetime id (MG_State's counters start at 1), so a zeroed memo is a guaranteed miss. mutable Uint64 m_memoLifetimeId = 0; mutable MG_Pipe::MGPipeHandle m_memoHandle = MG_Pipe::kMGPipeNullHandle; }; diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index c7e5adafc..a9a126860 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -46,8 +47,14 @@ #include #include #include +#include #include #include +#if defined(_WIN32) +#include +#else +#include +#endif #if MOBILEGL_PIPE_PUSH namespace { @@ -3215,6 +3222,86 @@ namespace { BackendSlotTable; using FakeSharedKindSlotTable = MobileGL::MG_Backend::DirectGLES:: BackendSlotTable; + + // A log file path no other process and no other case can be writing to: the pid keeps two + // SanityTest processes on one host apart, the counter keeps two cases in one process apart. + std::filesystem::path UniqueScratchLogPath(const char* stem) { + static int counter = 0; +#if defined(_WIN32) + const long pid = static_cast(_getpid()); +#else + const long pid = static_cast(::getpid()); +#endif + const auto ticks = std::chrono::steady_clock::now().time_since_epoch().count(); + return std::filesystem::temp_directory_path() / + (std::string(stem) + "-" + std::to_string(pid) + "-" + std::to_string(++counter) + + "-" + std::to_string(ticks) + ".log"); + } + + // Points MobileGL's file log at `path` for the life of the guard and puts back whatever the + // operator had - the previous MOBILEGL_LOG_FILE_PATH, or none - on EVERY exit path, + // including a failed ASSERT. The log is closed on both sides of the switch, because Log.cpp + // reads the variable only when it opens the file. + struct ScopedLogFileRedirect { + explicit ScopedLogFileRedirect(const std::filesystem::path& path): m_path(path) { + if (const char* previous = std::getenv("MOBILEGL_LOG_FILE_PATH")) { + m_hadPrevious = true; + m_previous = previous; + } + std::filesystem::remove(m_path); + MobileGL::MG_Util::Debug::Close(); + SetEnvVar("MOBILEGL_LOG_FILE_PATH", m_path.string().c_str()); + } + ~ScopedLogFileRedirect() { + MobileGL::MG_Util::Debug::Close(); + if (m_hadPrevious) { + SetEnvVar("MOBILEGL_LOG_FILE_PATH", m_previous.c_str()); + } else { + UnsetEnvVar("MOBILEGL_LOG_FILE_PATH"); + } + std::error_code ignored; + std::filesystem::remove(m_path, ignored); + } + ScopedLogFileRedirect(const ScopedLogFileRedirect&) = delete; + ScopedLogFileRedirect& operator=(const ScopedLogFileRedirect&) = delete; + + // Everything written so far. Closes the log first so the last line is on disk. + std::string Contents() const { + MobileGL::MG_Util::Debug::Close(); + std::ifstream logFile(m_path); + if (!logFile.good()) return {}; + return std::string(std::istreambuf_iterator(logFile), std::istreambuf_iterator()); + } + + private: + std::filesystem::path m_path; + bool m_hadPrevious = false; + std::string m_previous; + }; + + // Sets the two knobs into the combination that leaves no twin-table arm at all - + // kMGPipeSubsystemEsprytSlots clear and PipeLegacyMemos false, which is what + // MOBILEGL_PIPE_PUSH=0 MOBILEGL_PIPE_LEGACY_MEMOS=0 in the environment produces - and + // restores MG_Config::Features on every exit path. + struct ScopedArmlessKnobPair { + ScopedArmlessKnobPair(): + m_savedPush(MobileGL::MG_Config::Features.PipePush), + m_savedLegacy(MobileGL::MG_Config::Features.PipeLegacyMemos) { + MobileGL::MG_Config::Features.PipePush = + m_savedPush & ~MobileGL::MG_Pipe::kMGPipeSubsystemEsprytSlots; + MobileGL::MG_Config::Features.PipeLegacyMemos = false; + } + ~ScopedArmlessKnobPair() { + MobileGL::MG_Config::Features.PipePush = m_savedPush; + MobileGL::MG_Config::Features.PipeLegacyMemos = m_savedLegacy; + } + ScopedArmlessKnobPair(const ScopedArmlessKnobPair&) = delete; + ScopedArmlessKnobPair& operator=(const ScopedArmlessKnobPair&) = delete; + + private: + MobileGL::Uint64 m_savedPush; + MobileGL::Bool m_savedLegacy; + }; } // namespace // The property the whole slice exists for. The pre-P2 registry keyed twins on the frontend heap @@ -3233,9 +3320,11 @@ TEST(DirectGLESSlotTable, ARecycledSlotIsANewHandleAndTheStaleOneResolvesToNothi ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(firstHandle)); EXPECT_EQ(table.LiveCount(), 1u); - // The frontend object dies and the slot is reclaimed - which is the only moment Gen moves. + // The frontend object dies and announces it (FakeStateObject is not one of the six re-keyed + // classes, so the notice its destructor would raise is raised by hand), and the slot is + // reclaimed - which is the only moment Gen moves. first.reset(); - table.CollectGarbageNow(); + EXPECT_TRUE(FakeSlotTable::OnFrontendObjectDestroyed(0xA1u)); EXPECT_EQ(table.LiveCount(), 0u); EXPECT_EQ(table.FindByHandle(firstHandle), nullptr) << "a handle whose object is gone still resolved to a twin"; @@ -3254,6 +3343,9 @@ TEST(DirectGLESSlotTable, ARecycledSlotIsANewHandleAndTheStaleOneResolvesToNothi EXPECT_EQ(table.FindByHandle(firstHandle), nullptr); ASSERT_NE(table.FindByHandle(secondHandle), nullptr); EXPECT_EQ((*table.FindByHandle(secondHandle))->marker, 2); + + second.reset(); + EXPECT_TRUE(FakeSlotTable::OnFrontendObjectDestroyed(0xA2u)); } // Gen moves on reuse and ONLY on reuse: a live object that is looked up again, or respecified, @@ -3263,16 +3355,12 @@ TEST(DirectGLESSlotTable, RepeatedLookupsOfALiveObjectKeepOneHandle) { FakeSlotTable table; auto object = MakeShared(0xB1u); - // HandleOf caches whatever the allocator answered, INCLUDING the null handle - an object - // that is bound but never synced has no twin, and re-probing the hash for it every draw is - // exactly what the memo exists to avoid. What makes that safe is that GetOrCreate refreshes - // the memo, so a cached "no handle" can never outlive the twin's creation. Nothing pinned - // that; this does. + // An object that is bound but never synced has no twin and no handle; asking is a miss. EXPECT_TRUE(MG_Pipe::MGPipeHandleIsNull(table.HandleOf(object.get()))); table.GetOrCreate(object) = MakeShared(); const MG_Pipe::MGPipeHandle handle = table.HandleOf(object.get()); ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(handle)) - << "the memo went on answering the null handle it cached before the twin existed"; + << "the memo went on answering a null handle after the twin was created"; for (int i = 0; i < 8; ++i) { auto* slot = table.GetOrCreate(object) ? table.Find(object.get()) : nullptr; @@ -3280,11 +3368,46 @@ TEST(DirectGLESSlotTable, RepeatedLookupsOfALiveObjectKeepOneHandle) { EXPECT_TRUE(table.HandleOf(object.get()) == handle) << "handle moved on lookup " << i; } EXPECT_EQ(table.LiveCount(), 1u); + + object.reset(); + EXPECT_TRUE(FakeSlotTable::OnFrontendObjectDestroyed(0xB1u)); +} + +// The round-4 review's minor 8: HandleOf used to memoise a NULL answer, on the argument that +// GetOrCreate refreshes the memo. That holds for ONE table. The allocator is per kind and the +// memo is per table, so once a second holder of the kind can be the one that acquires, the first +// table's cached "no handle" outlives the twin's creation and nothing on its own acquire path +// ever corrects it - every Find through it is a miss for a twin that exists. This is that +// configuration, and it must resolve. +TEST(DirectGLESSlotTable, ANegativeLookupIsNotCachedAcrossAnotherHoldersAcquire) { + using namespace MobileGL; + + FakeSharedKindSlotTable first; + FakeSharedKindSlotTable second; + auto object = MakeShared(0xB2u); + + // `first` asks before anyone has acquired: a miss, which must NOT be remembered. + EXPECT_TRUE(MG_Pipe::MGPipeHandleIsNull(first.HandleOf(object.get()))); + + // `second` is the holder that acquires. + second.GetOrCreate(object) = MakeShared(); + const MG_Pipe::MGPipeHandle handle = second.HandleOf(object.get()); + ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(handle)); + + // `first` never acquired, so nothing on its own path refreshed its memo; it must still + // answer the handle the kind now has for this object. + EXPECT_TRUE(first.HandleOf(object.get()) == handle) + << "the first holder kept answering the null handle it cached before the second holder " + "acquired, so every lookup through it misses a twin that exists"; + + object.reset(); + EXPECT_TRUE(FakeSharedKindSlotTable::OnFrontendObjectDestroyed(0xB2u)); } // The lookup does not mutate the table, which is what lets SyncTextureObjectToBackend stop paying -// a by-value copy plus a second Find to survive the registry's erase-inside-Find. A dead entry -// stays put until the sweep, and a live entry's pointer is unaffected by looking up anything else. +// a by-value copy plus a second Find to survive the registry's erase-inside-Find. An entry whose +// object has gone stays put until the death notice arrives, and a live entry's pointer is +// unaffected by looking up anything else. TEST(DirectGLESSlotTable, FindNeverMutatesTheTable) { using namespace MobileGL; @@ -3298,35 +3421,58 @@ TEST(DirectGLESSlotTable, FindNeverMutatesTheTable) { ASSERT_NE(keptSlot, nullptr); const FakeBackendObject* keptTwin = keptSlot->get(); + // The object goes but its notice is deliberately withheld for a moment, so that whatever + // changes between here and the notice is Find's doing. The registry's Find would have + // erased the expired entry here and relocated the rest of the probe cluster, invalidating + // keptSlot. This one answers what it answers and touches nothing. doomed.reset(); - // The registry's Find would have erased the expired entry here and relocated the rest of the - // probe cluster, invalidating keptSlot. This one answers null and touches nothing. EXPECT_EQ(table.Find(kept.get()), keptSlot); - EXPECT_EQ(table.LiveCount(), 2u) << "Find reclaimed a slot; only the sweep may do that"; + EXPECT_EQ(table.LiveCount(), 2u) << "Find reclaimed a slot; only a death notice may do that"; EXPECT_EQ(keptSlot->get(), keptTwin); - table.CollectGarbageNow(); + EXPECT_TRUE(FakeSlotTable::OnFrontendObjectDestroyed(0xC2u)); EXPECT_EQ(table.LiveCount(), 1u); EXPECT_EQ(table.Find(kept.get())->get(), keptTwin); + + kept.reset(); + EXPECT_TRUE(FakeSlotTable::OnFrontendObjectDestroyed(0xC1u)); } // ScopedDirectGLESTextureBindings saves a whole twin table by value, resets it with `= {}` and -// restores it. The slot table has to keep that shape or the fixture stops isolating anything. +// restores it. The slot table has to keep that shape or the fixture stops isolating anything - +// and the saved copy is a HOLDER for as long as it exists, so a death announced while it is +// held reaches it too (the round-4 review's minor 2, in the fixture's own shape). TEST(DirectGLESSlotTable, AWholeTableSavesResetsAndRestores) { using namespace MobileGL; + const Uint32 holdersBefore = FakeSlotTable::HolderCount(); FakeSlotTable table; auto object = MakeShared(0xD1u); table.GetOrCreate(object) = MakeShared(); (*table.Find(object.get()))->marker = 7; + const MG_Pipe::MGPipeHandle handle = table.HandleOf(object.get()); const FakeSlotTable saved = table; + EXPECT_EQ(FakeSlotTable::HolderCount(), holdersBefore + 2u) + << "the by-value copy did not register as a holder"; table = {}; + EXPECT_EQ(FakeSlotTable::HolderCount(), holdersBefore + 2u) + << "the reset changed the holder count - a temporary's registration leaked or the " + "table's own was lost"; EXPECT_EQ(table.Find(object.get()), nullptr) << "the reset left the twin reachable"; table = saved; ASSERT_NE(table.Find(object.get()), nullptr); EXPECT_EQ((*table.Find(object.get()))->marker, 7); + + // The object dies while BOTH the working table and the saved copy hold its twin. One + // notice, and neither may keep a live entry. + object.reset(); + EXPECT_TRUE(FakeSlotTable::OnFrontendObjectDestroyed(0xD1u)); + EXPECT_EQ(table.FindByHandle(handle), nullptr); + EXPECT_EQ(table.LiveCount(), 0u); + EXPECT_EQ(saved.LiveCount(), 0u) + << "the saved copy kept the dead object's twin - the notice reached one holder only"; } // The whole point of routing every twin through the client allocator: a table that keeps its own @@ -3365,27 +3511,76 @@ TEST(DirectGLESSlotTable, TwoTablesOfTheSameKindShareOneSlotAndKeepTheirOwnTwin) EXPECT_EQ((*a.FindByHandle(handle))->marker, 1); EXPECT_EQ((*b.FindByHandle(handle))->marker, 2); - // TWO HOLDERS OF ONE SLOT is a real configuration, not a test artefact, and this is where - // the sharp edge is: whichever holder sweeps (or is told of the death) first returns the - // slot to the allocator while the other still names it. The allocator makes that SAFE - - // Free is generation-guarded and idempotent, and FindByHandle's Gen compare turns the - // other holder's now-stale handle into a miss - but not free: if the object is still alive - // the next resolution re-Acquires it onto a NEW slot, orphaning the first table's entry - // until its own sweep. Two such configurations exist or are landing: the + // TWO HOLDERS OF ONE SLOT is a real configuration, not a test artefact (the // ScopedDirectGLESTextureBindings fixture above holds a second live table of kind Texture - // for the length of a test, and package D's subsystem 4 re-keys VaoDrawMemo out of this - // same per-kind allocator. Flagged for the integrator rather than defended here, because - // the fix (a refcounted slot-ownership token) belongs with whoever owns both holders. - // - // The cleanup below is what keeps that out of the OTHER cases: object first, then both - // tables, so the slot is back on the free list and this kind's LiveCount is where it was. + // for the length of a test), and the death of the object is what makes it sharp: the + // allocator forgets the lifetime id on Free, so a notice delivered to ONE holder leaves + // the other with a live entry - and its twin's driver storage - that nothing can resolve + // and nothing sweeps. OneDeathNoticeDropsTheTwinInEveryHolderOfTheKind below is the case + // for that; here the cleanup only has to leave the kind's LiveCount where it was. object.reset(); - a.CollectGarbageNow(); - b.CollectGarbageNow(); + EXPECT_TRUE(FakeSharedKindSlotTable::OnFrontendObjectDestroyed(0xE1u)); EXPECT_EQ(slots.LiveCount(kKind), liveBefore) << "the shared slot outlived both holders and the object"; } +// The round-4 review's minor 2, closed: one notice, EVERY holder. Before this the dispatcher +// told one table per kind and DestroyByLifetimeId freed only a slot THIS table held, so with +// the sweep retired the second holder kept a live entry, and the twin, for the life of the +// process. Three holders here - two independent tables and a by-value copy, which is exactly +// what the fixture makes - and a fourth that never twinned the object and must be untouched. +TEST(DirectGLESSlotTable, OneDeathNoticeDropsTheTwinInEveryHolderOfTheKind) { + using namespace MobileGL; + + constexpr MG_Pipe::MGPipeKind kKind = MG_Pipe::MGPipeKind::Fence; + auto& slots = MG_Pipe::MGPipeSlots(); + const Uint32 liveBefore = slots.LiveCount(kKind); + const Uint32 holdersBefore = FakeSharedKindSlotTable::HolderCount(); + + FakeSharedKindSlotTable a; + FakeSharedKindSlotTable b; + FakeSharedKindSlotTable bystander; + auto object = MakeShared(0xE2u); + auto uninvolved = MakeShared(0xE3u); + + a.GetOrCreate(object) = MakeShared(); + b.GetOrCreate(object) = MakeShared(); + bystander.GetOrCreate(uninvolved) = MakeShared(); + const FakeSharedKindSlotTable copyOfA = a; // the fixture's saved registry + EXPECT_EQ(FakeSharedKindSlotTable::HolderCount(), holdersBefore + 4u); + + const MG_Pipe::MGPipeHandle handle = a.HandleOf(object.get()); + ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(handle)); + // Observers on the twins, so "dropped" means destroyed and not merely unreachable. + const std::weak_ptr twinA = *a.FindByHandle(handle); + const std::weak_ptr twinB = *b.FindByHandle(handle); + EXPECT_EQ(slots.LiveCount(kKind), liveBefore + 2u); + + // ONE notice. The object is still alive so that nothing but the notice can be at work. + EXPECT_TRUE(FakeSharedKindSlotTable::OnFrontendObjectDestroyed(object->GetLifetimeId())); + + EXPECT_EQ(a.FindByHandle(handle), nullptr) << "holder a kept the twin"; + EXPECT_EQ(b.FindByHandle(handle), nullptr) << "holder b kept the twin"; + EXPECT_EQ(copyOfA.LiveCount(), 0u) << "the by-value copy kept the twin"; + EXPECT_EQ(a.LiveCount(), 0u); + EXPECT_EQ(b.LiveCount(), 0u); + EXPECT_TRUE(twinA.expired()) << "a's twin is unreachable but still allocated"; + EXPECT_TRUE(twinB.expired()) << "b's twin is unreachable but still allocated"; + EXPECT_EQ(slots.LiveCount(kKind), liveBefore + 1u) << "the slot was not returned exactly once"; + EXPECT_FALSE(FakeSharedKindSlotTable::OnFrontendObjectDestroyed(object->GetLifetimeId())) + << "a second notice for the same object found a slot to free"; + + // The holder that never twinned the object is exactly as it was. + EXPECT_EQ(bystander.LiveCount(), 1u); + ASSERT_NE(bystander.Find(uninvolved.get()), nullptr); + + object.reset(); + uninvolved.reset(); + EXPECT_TRUE(FakeSharedKindSlotTable::OnFrontendObjectDestroyed(0xE3u)); + EXPECT_EQ(bystander.LiveCount(), 0u); + EXPECT_EQ(slots.LiveCount(kKind), liveBefore); +} + // The sweep and both of its drivers are RETIRED on this arm (ROADMAP.md:18's "delete the GC"), // and this is the property that replaces them. The registry this table replaces learned of a // death only by finding an expired weak_ptr, so it needed a 1024-call draw tick AND a @@ -3412,7 +3607,7 @@ TEST(DirectGLESSlotTable, AnnouncedDeathKeepsObjectChurnFromAccumulatingWithoutA // classes really do fire it is EveryReKeyedObjectClassAnnouncesItsOwnDeath below, and // that the registries answer it per kind is // EverySwitchedOverKindResolvesItsTwinThroughTheHandleArm. - EXPECT_TRUE(table.DestroyByLifetimeId(object->GetLifetimeId())); + EXPECT_TRUE(FakeSlotTable::OnFrontendObjectDestroyed(object->GetLifetimeId())); } EXPECT_EQ(peakLive, 1u) @@ -3472,20 +3667,34 @@ TEST(DirectGLESSlotTable, AnAnnouncedDeathReturnsTheSlotWithoutASweep) { ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(handle)); EXPECT_EQ(slots.LiveCount(MG_Pipe::MGPipeKind::Query), liveBefore + 1u); - // The object is STILL ALIVE here, which is the point: the sweep's weak_ptr test cannot - // fire, so anything that changes is the notice's doing and nothing else's. - EXPECT_TRUE(table.DestroyByLifetimeId(object->GetLifetimeId())); + // The object is STILL ALIVE here, which is the point: there is no weak_ptr test anywhere + // that could fire, so anything that changes is the notice's doing and nothing else's. + EXPECT_TRUE(FakeSlotTable::OnFrontendObjectDestroyed(object->GetLifetimeId())); EXPECT_EQ(table.LiveCount(), 0u) << "the twin survived its own destroy notice"; EXPECT_EQ(table.FindByHandle(handle), nullptr); EXPECT_EQ(table.Find(object.get()), nullptr); EXPECT_EQ(slots.LiveCount(MG_Pipe::MGPipeKind::Query), liveBefore) << "the slot was not returned to the allocator"; - // Idempotent, and a table that does not hold the slot says so rather than freeing it out - // from under whoever does. Both matter once two holders of one kind exist. - EXPECT_FALSE(table.DestroyByLifetimeId(object->GetLifetimeId())); - FakeSlotTable other; - EXPECT_FALSE(other.DestroyByLifetimeId(object->GetLifetimeId())); + // Idempotent: the allocator no longer maps the id, so a repeated notice frees nothing and + // says so - and it says so for every holder, since the notice is about the object and not + // about a table. + EXPECT_FALSE(FakeSlotTable::OnFrontendObjectDestroyed(object->GetLifetimeId())); + + // A slot minted for an object that NO table holds any more - the fixture's `table = {}` + // drops entries without freeing - still goes back when the object dies: the id is dead and + // cannot be acquired again, so keeping the slot would be the process-lifetime leak the + // review named. + auto orphaned = MakeShared(0x1E2A0002ull); + { + FakeSlotTable transient; + transient.GetOrCreate(orphaned) = MakeShared(); + } + EXPECT_EQ(slots.LiveCount(MG_Pipe::MGPipeKind::Query), liveBefore + 1u); + orphaned.reset(); + EXPECT_TRUE(FakeSlotTable::OnFrontendObjectDestroyed(0x1E2A0002ull)) + << "a slot no holder had an entry for was left allocated"; + EXPECT_EQ(slots.LiveCount(MG_Pipe::MGPipeKind::Query), liveBefore); } // The firing side of e2, on ALL SIX re-keyed object classes - the round-3 review's MAJOR 3. @@ -3670,6 +3879,39 @@ TEST(DirectGLESSlotTable, EverySwitchedOverKindResolvesItsTwinThroughTheHandleAr [] { return MakeShared(0u); }); } +// The two-holder fix, end to end through the REAL Texture registry and the REAL destructor: +// a by-value copy of TextureImpl::g_backendTextureObjects - which is precisely what +// ScopedDirectGLESTextureBindings keeps in `previousRegistry` for the length of a test - must +// drop the twin on the same notice the registry global does, with no sweep and no second call. +TEST(DirectGLESSlotTable, ASavedCopyOfARealRegistryDropsTheTwinOnTheSameNotice) { + using namespace MobileGL; + using namespace MobileGL::MG_Backend::DirectGLES; + using namespace MobileGL::MG_State::GLState; + + if (!EsprytSlotTablesEnabled()) { + GTEST_SKIP() << "the legacy arm keys twins on the frontend heap address and cannot " + "answer a death notice"; + } + + auto& registry = TextureImpl::g_backendTextureObjects; + SharedPtr texture = MakeShared(0u); + auto& twin = registry.GetOrCreate(texture); + (void)twin; + const MG_Pipe::MGPipeHandle handle = registry.HandleOf(texture.get()); + ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(handle)); + + // The fixture's shape: copy the registry while the twin is live. + auto saved = registry; + ASSERT_NE(saved.FindByHandle(handle), nullptr) << "the copy did not carry the live entry"; + + // The real destructor raises the real notice; nothing else runs. + texture.reset(); + EXPECT_EQ(registry.FindByHandle(handle), nullptr) << "the registry global kept the twin"; + EXPECT_EQ(saved.FindByHandle(handle), nullptr) + << "the saved copy kept the dead texture's twin - the dispatcher told one holder only"; + EXPECT_FALSE(registry.DestroyByLifetimeId(0)) << "sanity: a null id frees nothing"; +} + // The gate on MAJOR 1 of the round-3 review. Commit d89fb684 raised // Fatal{PipeLegacyMemosDisabled} from inside InitDisplayAndContext(), i.e. from inside EGL // bring-up - and the integration harness pre-flights EGL bring-up in a FORKED CHILD, converting @@ -3697,20 +3939,18 @@ TEST(DirectGLESSlotTable, AnArmlessKnobCombinationStopsInsteadOfSkippingTheLane) EXPECT_EQ(MG_Backend::DirectGLES::ClassifyEsprytSlotArm(false, true), EsprytSlotArmVerdict::Legacy); EXPECT_EQ(MG_Backend::DirectGLES::ClassifyEsprytSlotArm(false, false), EsprytSlotArmVerdict::NoArm); - const Uint64 savedPush = MG_Config::Features.PipePush; - const Bool savedLegacy = MG_Config::Features.PipeLegacyMemos; - MG_Config::Features.PipePush = savedPush & ~MG_Pipe::kMGPipeSubsystemEsprytSlots; - MG_Config::Features.PipeLegacyMemos = false; + // Both guards restore on every exit path - a failed ASSERT included - so no later case in + // this binary runs on a mutated config or without the operator's file log, and the log + // path is unique per process and per case (the round-4 review's minor 5). + const ScopedArmlessKnobPair knobs; ASSERT_EQ(MG_Backend::DirectGLES::CurrentEsprytSlotArmVerdict(), EsprytSlotArmVerdict::NoArm); - - const fs::path logPath = fs::temp_directory_path() / "mobilegl-espryt-armless-knobs.log"; - fs::remove(logPath); - MG_Util::Debug::Close(); - SetEnvVar("MOBILEGL_LOG_FILE_PATH", logPath.string().c_str()); + const ScopedLogFileRedirect log(UniqueScratchLogPath("mobilegl-espryt-armless-knobs")); // Bring-up's half of the split. It must NAME the knobs and it must RETURN: this call is the // one InitDisplayAndContext() makes, and it runs inside the harness's forked pre-flight // child. If it ever stops again, this line takes the whole binary down and the case is red. + // (That the call SITE still makes this call and not the stopping one is + // EglBringUpUnderTheArmlessKnobPairReturnsInsteadOfStopping below.) MG_Backend::DirectGLES::DiagnoseEsprytSlotArm(); #if !defined(_WIN32) @@ -3721,19 +3961,9 @@ TEST(DirectGLESSlotTable, AnArmlessKnobCombinationStopsInsteadOfSkippingTheLane) ::testing::KilledBySignal(SIGABRT), ""); #endif - MG_Util::Debug::Close(); - UnsetEnvVar("MOBILEGL_LOG_FILE_PATH"); - MG_Config::Features.PipePush = savedPush; - MG_Config::Features.PipeLegacyMemos = savedLegacy; - - std::string contents; - { - std::ifstream logFile(logPath); - ASSERT_TRUE(logFile.good()) << "neither the diagnosis nor the fatal wrote a line an " - "operator could read"; - contents.assign(std::istreambuf_iterator(logFile), std::istreambuf_iterator()); - } - fs::remove(logPath); + const std::string contents = log.Contents(); + ASSERT_FALSE(contents.empty()) << "neither the diagnosis nor the fatal wrote a line an " + "operator could read"; EXPECT_NE(contents.find("PipeLegacyMemosDisabled"), std::string::npos) << contents; EXPECT_NE(contents.find("MOBILEGL_PIPE_PUSH"), std::string::npos) << contents; @@ -3746,6 +3976,64 @@ TEST(DirectGLESSlotTable, AnArmlessKnobCombinationStopsInsteadOfSkippingTheLane) #endif // MOBILEGL_PIPE_LEGACY_MEMOS } +// The round-4 review's minor 4: the case above pins the two FUNCTIONS, and nothing failed if +// InitDisplayAndContext() (DirectGLES.cpp) was edited back to call the stopping one - which is +// exactly the regression that produced the round-3 major. This pins the CALL SITE, by running +// the real bring-up entry point under the armless pair. +// +// No display is needed: InitDisplayAndContext's twin-arm call is its first statement after +// the context teardown, ahead of eglGetDisplay, so an EGL table whose eglGetDisplay answers +// EGL_NO_DISPLAY takes bring-up through that call and straight back out with `false`. The +// child must then EXIT with the code below. If the site stops again it dies of SIGABRT +// instead, and the death test fails - naming both knobs - rather than skipping: in the +// integration harness that same abort is what turned into a green lane that ran nothing. +// +// Not skipped in a build without the legacy arm either: there the verdict is Handles and +// bring-up has nothing to diagnose, but it must still return, and this says so. +TEST(DirectGLESSlotTable, EglBringUpUnderTheArmlessKnobPairReturnsInsteadOfStopping) { +#if defined(_WIN32) + GTEST_SKIP() << "needs a forked death test"; +#else + using namespace MobileGL; + + constexpr int kBringUpReturnedFalse = 0x51; + constexpr int kBringUpReturnedTrue = 0x52; + + // The redirect is set up in the PARENT: the child inherits the environment and writes the + // file, the parent reads it once the child has gone, and the guard restores the operator's + // log path either way. + const ScopedLogFileRedirect log(UniqueScratchLogPath("mobilegl-espryt-armless-bringup")); + + EXPECT_EXIT( + { + const ScopedArmlessKnobPair knobs; + MG_External::EGLFunctionsTable egl{}; + egl.eglGetDisplay = +[](EGLNativeDisplayType) -> EGLDisplay { return EGL_NO_DISPLAY; }; + MG_Backend::DirectGLES::SetEGLFuncsTable(egl); + const Bool ok = MG_Backend::DirectGLES::InitPbufferSurface(1, 1); + MG_Util::Debug::Close(); + std::exit(ok ? kBringUpReturnedTrue : kBringUpReturnedFalse); + }, + ::testing::ExitedWithCode(kBringUpReturnedFalse), "") + << "EGL bring-up under MOBILEGL_PIPE_PUSH with kMGPipeSubsystemEsprytSlots clear and " + "MOBILEGL_PIPE_LEGACY_MEMOS=0 did not RETURN: InitDisplayAndContext() is stopping " + "on the armless knob pair again instead of diagnosing it, and the integration " + "harness's forked pre-flight turns that stop into a lane that skips every scenario"; + +#if MOBILEGL_PIPE_LEGACY_MEMOS + // And it diagnosed, by name, on the way through - the operator is told which two knobs + // they set before the first draw - without a Fatal{} anywhere in bring-up. + const std::string contents = log.Contents(); + EXPECT_NE(contents.find("PipeLegacyMemosDisabled"), std::string::npos) + << "bring-up returned but did not diagnose the armless pair: " << contents; + EXPECT_NE(contents.find("MOBILEGL_PIPE_PUSH"), std::string::npos) << contents; + EXPECT_NE(contents.find("MOBILEGL_PIPE_LEGACY_MEMOS=0"), std::string::npos) << contents; + EXPECT_EQ(contents.find("Fatal{"), std::string::npos) + << "bring-up wrote a Fatal{} - the stop is back inside EGL bring-up: " << contents; +#endif +#endif +} + #else // G2 wants the pull and the push build to list the SAME ctest entries. The twin table only // exists under MOBILEGL_PIPE_PUSH, so in the pull build each case above keeps its name and @@ -3801,4 +4089,20 @@ TEST(DirectGLESSlotTable, AnArmlessKnobCombinationStopsInsteadOfSkippingTheLane) TEST(DirectGLESSlotTable, EverySwitchedOverKindResolvesItsTwinThroughTheHandleArm) { GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; } + +TEST(DirectGLESSlotTable, ANegativeLookupIsNotCachedAcrossAnotherHoldersAcquire) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} + +TEST(DirectGLESSlotTable, OneDeathNoticeDropsTheTwinInEveryHolderOfTheKind) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} + +TEST(DirectGLESSlotTable, ASavedCopyOfARealRegistryDropsTheTwinOnTheSameNotice) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} + +TEST(DirectGLESSlotTable, EglBringUpUnderTheArmlessKnobPairReturnsInsteadOfStopping) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} #endif // MOBILEGL_PIPE_PUSH From 3d1a866e828eb7249b7ecb244457049f6cf20a88 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Mon, 7 Sep 2026 22:46:02 -0400 Subject: [PATCH 099/529] [Test] (Espryt): pin that the armless cases put the operator's log path and MG_Config::Features back as they found them - TheArmlessCasesLeaveTheLogPathAndTheConfigAsTheyFoundThem drives ScopedLogFileRedirect and ScopedArmlessKnobPair with and without a pre-set MOBILEGL_LOG_FILE_PATH and asserts the restore byte for byte - in a build without the legacy arm the verdict is Handles whatever the knobs say, so the case pins the knob values rather than the NoArm verdict there --- MobileGL/MG_Test/SanityTest.cpp | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index a9a126860..9d53b6ea1 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -3976,6 +3976,57 @@ TEST(DirectGLESSlotTable, AnArmlessKnobCombinationStopsInsteadOfSkippingTheLane) #endif // MOBILEGL_PIPE_LEGACY_MEMOS } +// The two guards the armless cases stand on, pinned on their own: whatever an operator had in +// MOBILEGL_LOG_FILE_PATH - a path, or nothing - and whatever MG_Config::Features held are back, +// byte for byte, once the guards go out of scope, with or without a failure inside. Before +// this the armless case unset the variable for every later case in the binary and restored +// the config only on its success path (the round-4 review's minor 5). +TEST(DirectGLESSlotTable, TheArmlessCasesLeaveTheLogPathAndTheConfigAsTheyFoundThem) { + using namespace MobileGL; + + const Uint64 push = MG_Config::Features.PipePush; + const Bool legacy = MG_Config::Features.PipeLegacyMemos; + std::string previousPath; + const bool hadPreviousPath = std::getenv("MOBILEGL_LOG_FILE_PATH") != nullptr; + if (hadPreviousPath) previousPath = std::getenv("MOBILEGL_LOG_FILE_PATH"); + + // With an operator path in place... + const std::filesystem::path operatorPath = UniqueScratchLogPath("mobilegl-espryt-operator"); + SetEnvVar("MOBILEGL_LOG_FILE_PATH", operatorPath.string().c_str()); + { + const ScopedArmlessKnobPair knobs; + const ScopedLogFileRedirect redirect(UniqueScratchLogPath("mobilegl-espryt-guard")); +#if MOBILEGL_PIPE_LEGACY_MEMOS + // Only a build with the legacy arm can be left armless; without it the verdict is + // Handles whatever the knobs say, and what is pinned here is the restore, not the arm. + EXPECT_EQ(MG_Backend::DirectGLES::CurrentEsprytSlotArmVerdict(), + MG_Backend::DirectGLES::EsprytSlotArmVerdict::NoArm); +#endif + EXPECT_EQ(MG_Config::Features.PipePush & MG_Pipe::kMGPipeSubsystemEsprytSlots, 0ull); + EXPECT_FALSE(MG_Config::Features.PipeLegacyMemos); + EXPECT_STRNE(std::getenv("MOBILEGL_LOG_FILE_PATH"), operatorPath.string().c_str()); + } + ASSERT_NE(std::getenv("MOBILEGL_LOG_FILE_PATH"), nullptr) << "the operator's log path was unset"; + EXPECT_STREQ(std::getenv("MOBILEGL_LOG_FILE_PATH"), operatorPath.string().c_str()); + EXPECT_EQ(MG_Config::Features.PipePush, push); + EXPECT_EQ(MG_Config::Features.PipeLegacyMemos, legacy); + + // ...and with none. + UnsetEnvVar("MOBILEGL_LOG_FILE_PATH"); + { + const ScopedLogFileRedirect redirect(UniqueScratchLogPath("mobilegl-espryt-guard")); + EXPECT_NE(std::getenv("MOBILEGL_LOG_FILE_PATH"), nullptr); + } + EXPECT_EQ(std::getenv("MOBILEGL_LOG_FILE_PATH"), nullptr) + << "a log path was left behind where the operator had none"; + + if (hadPreviousPath) { + SetEnvVar("MOBILEGL_LOG_FILE_PATH", previousPath.c_str()); + } + std::error_code ignored; + std::filesystem::remove(operatorPath, ignored); +} + // The round-4 review's minor 4: the case above pins the two FUNCTIONS, and nothing failed if // InitDisplayAndContext() (DirectGLES.cpp) was edited back to call the stopping one - which is // exactly the regression that produced the round-3 major. This pins the CALL SITE, by running @@ -4105,4 +4156,8 @@ TEST(DirectGLESSlotTable, ASavedCopyOfARealRegistryDropsTheTwinOnTheSameNotice) TEST(DirectGLESSlotTable, EglBringUpUnderTheArmlessKnobPairReturnsInsteadOfStopping) { GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; } + +TEST(DirectGLESSlotTable, TheArmlessCasesLeaveTheLogPathAndTheConfigAsTheyFoundThem) { + GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH"; +} #endif // MOBILEGL_PIPE_PUSH From de532f55a9b1308fd3fd7f0f0ed14deb51091df8 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:06:12 -0400 Subject: [PATCH 100/529] [Feat] (State): give the frontend six aggregate generations so a tracker can answer "did any bound texture, buffer, attachment or attribute move" with one Uint64 compare - MGP_NOTE_AGGREGATE(Aggregate) next to MGP_NOTE_MUTATION in MG_Pipe/PipeMutation.h, ((void)0) in the pull build for the same reason and with the same shape. It answers a DIFFERENT question from MGP_NOTE_MUTATION - "did any object of this class move since the tracker last looked", not "did a backend move a frontend value inside its own verb" - which is why it is a second macro rather than an overload. - The counters are members of the owning MG_State container (VertexArrayState, FramebufferState, TextureState x2, BufferState) and are reached through a push-only GLContext facade, because the bump points sit on OBJECTS and an object has no back-pointer to the state that owns it. That is the free-function form P2 brief D4 allows, and it costs a global load on a path that has just written object state. - A SIXTH aggregate, VertexAttribDefault on GLContext, which D4 does not list. Its bit (NEW_VERTEX_ATTRIB_DEFAULTS) is specified there with a ContentHash over all 32 CurrentVertexAttributeValues, and hashing 768 bytes on every draw does not fit inside the T1 ceiling the same brief pins. The hash still decides whether to EMIT (D11's set-hash suppressor); the generation decides whether to hash at all. - 30 bump points: 3 VertexArrayObject config-version sites, 3 FramebufferObject object-version sites (one of them inside MOBILEGL_DEFINE_FRAMEBUFFER_DEFAULT_SETTER, so the statement carries its own line continuation), 5 texture content-version sites, 12 texture params-version sites, SamplerObject::BumpVersion as the sampler choke point, 7 BufferObject change-serial sites and the 3 glVertexAttrib* defaults. - Every counter is deliberately COARSER than the state it guards: over-firing costs one extra push, under-firing renders stale, and under-firing is the direction ARCHITECTURE.md 13.2 names as the dangerous one and the P1 verify comparator cannot see for object-class state. - TrackerTest: each bump point moves ITS aggregate and no other, plus a null-context note. - G1: the pull build is 0 added / 0 removed / 0 renamed and the four resized symbols are the contract commit's own, unchanged by this commit. --- MobileGL/MG_Impl/Pipe/PipeFill.cpp | 33 ++++ MobileGL/MG_Pipe/PipeMutation.h | 45 +++++ .../GLState/BufferState/BufferObject.cpp | 8 + .../GLState/BufferState/BufferState.h | 12 ++ MobileGL/MG_State/GLState/Core.cpp | 4 + MobileGL/MG_State/GLState/Core.h | 40 +++++ .../FramebufferState/FramebufferObject.cpp | 4 + .../FramebufferState/FramebufferState.h | 12 ++ .../GLState/SamplerState/SamplerObject.cpp | 4 + .../GLState/TextureState/TextureObject.cpp | 15 ++ .../GLState/TextureState/TextureObject.h | 2 + .../TextureState/TextureObject2DCube.cpp | 3 + .../GLState/TextureState/TextureState.h | 15 ++ .../VertexArrayState/VertexArrayObject.cpp | 4 + .../VertexArrayState/VertexArrayState.h | 13 ++ MobileGL/MG_Test/Pipe/TrackerTest.cpp | 163 ++++++++++++++++-- 16 files changed, 366 insertions(+), 11 deletions(-) diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 1276cb4c1..6ffeea0cc 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -490,6 +490,39 @@ namespace MobileGL::MG_Pipe { MGPipeFillAccess::CopyField(inputs, *ctx, field); } + // ---- the aggregate generations (P2 brief D4) ---- + // MGP_NOTE_AGGREGATE lands here. The bump points are on OBJECTS, which have no + // back-pointer to the state container that owns them, so the note finds the live + // context - the same shape, and for the same reason, as MGPipeNoteFrontendMutation + // above. No verb has to be in flight and no field is stamped: an aggregate generation + // is not a PipeInputs field, it is what the tracker's shutter compares against. + void MGPipeNoteAggregate(MGPipeAggregate aggregate) { + auto* ctx = LiveContext(); + if (ctx == nullptr) return; + switch (aggregate) { + case MGPipeAggregate::VaoAttribute: + ctx->NoteVaoAttributeChanged(); + break; + case MGPipeAggregate::FramebufferAttachment: + ctx->NoteFramebufferAttachmentChanged(); + break; + case MGPipeAggregate::TextureContent: + ctx->NoteTextureContentChanged(); + break; + case MGPipeAggregate::TextureParams: + ctx->NoteTextureParamsChanged(); + break; + case MGPipeAggregate::BufferChange: + ctx->NoteBufferChanged(); + break; + case MGPipeAggregate::VertexAttribDefault: + ctx->NoteVertexAttribDefaultChanged(); + break; + case MGPipeAggregate::Count: + break; + } + } + void MGPipeSetPoisonOmission(const char* verb, const char* field) { if (verb == nullptr || field == nullptr) { g_omission = PoisonOmission{}; diff --git a/MobileGL/MG_Pipe/PipeMutation.h b/MobileGL/MG_Pipe/PipeMutation.h index fcbdc03e9..7fd438a71 100644 --- a/MobileGL/MG_Pipe/PipeMutation.h +++ b/MobileGL/MG_Pipe/PipeMutation.h @@ -33,10 +33,55 @@ namespace MobileGL::MG_Pipe { // A no-op unless a context is live, a verb has been filled, and `field` is in that verb // class's may-read mask; a forwarded (sticky) field has no storage and is never copied. void MGPipeNoteFrontendMutation(MGPipeInputField field); + + // ---- the aggregate generations (P2 brief D4, ARCHITECTURE.md 5.2) ---- + // + // MGP_NOTE_MUTATION answers "a backend moved a frontend value INSIDE its own verb". + // MGP_NOTE_AGGREGATE answers a different question, which is why it is a second macro + // and not an overload: "did ANY object of this class move since the last time the + // tracker looked", collapsed onto one monotonic Uint64 per class so a per-verb dirty + // walk is a handful of compares rather than a scan over 32 attributes, 16 attachments, + // 32 texture units and 84 binding points. + // + // The counters are members of the owning MG_State container, all guarded by + // MOBILEGL_PIPE_PUSH so the pull build's state objects do not change size (G1). The + // bump points sit on OBJECTS, which have no back-pointer to their state, so the macro + // goes through a free function that finds the live GLContext - the same shape, and for + // the same reason, as MGP_NOTE_MUTATION (MG_Impl/Pipe/PipeFill.cpp). It costs a global + // load on a path that has just written object state. + // + // Monotonic and never reset: the tracker widens and compares, it never subtracts. + // Over-firing is free (one extra re-push); under-firing renders stale, which is why + // every counter here is deliberately COARSER than the state it guards. + enum class MGPipeAggregate : Uint32 { + // VertexArrayState: any VAO attribute format / buffer / enable moved. + VaoAttribute = 0, + // FramebufferState: any FBO attachment or default-geometry write, or a bind. + FramebufferAttachment, + // TextureState: any texture object CONTENT moved (an upload, a dirty region). + TextureContent, + // TextureState: any texture object or sampler object PARAMETER moved. + TextureParams, + // BufferState: any buffer object contents moved. + BufferChange, + // GLContext: a glVertexAttrib* default value moved. Not one of D4 five: the bit it + // shutters (NEW_VERTEX_ATTRIB_DEFAULTS) is specified there as a ContentHash over + // all 32 CurrentVertexAttributeValues, and hashing 768 bytes on EVERY draw does not + // fit inside the T1 ceiling. The hash still decides whether to EMIT (D11 set-hash + // suppressor); this decides whether to hash at all. + VertexAttribDefault, + Count, + }; + + // MG_Impl/Pipe/PipeFill.cpp. A no-op unless a context is live. + void MGPipeNoteAggregate(MGPipeAggregate aggregate); } // namespace MobileGL::MG_Pipe #define MGP_NOTE_MUTATION(Field) \ ::MobileGL::MG_Pipe::MGPipeNoteFrontendMutation(::MobileGL::MG_Pipe::MGPipeInputField::Field) +#define MGP_NOTE_AGGREGATE(Aggregate) \ + ::MobileGL::MG_Pipe::MGPipeNoteAggregate(::MobileGL::MG_Pipe::MGPipeAggregate::Aggregate) #else #define MGP_NOTE_MUTATION(Field) ((void)0) +#define MGP_NOTE_AGGREGATE(Aggregate) ((void)0) #endif #endif diff --git a/MobileGL/MG_State/GLState/BufferState/BufferObject.cpp b/MobileGL/MG_State/GLState/BufferState/BufferObject.cpp index 8bfe428bb..7109f2427 100644 --- a/MobileGL/MG_State/GLState/BufferState/BufferObject.cpp +++ b/MobileGL/MG_State/GLState/BufferState/BufferObject.cpp @@ -11,6 +11,7 @@ #include #include +#include namespace MobileGL::MG_State::GLState { namespace { @@ -43,6 +44,7 @@ namespace MobileGL::MG_State::GLState { void BufferObject::NotifyRespecify() { ++m_changeSerial; + MGP_NOTE_AGGREGATE(BufferChange); if (g_bufferBackendOps && g_bufferBackendOps->Respecify) { g_bufferBackendOps->Respecify(*this); } @@ -50,6 +52,7 @@ namespace MobileGL::MG_State::GLState { void BufferObject::NotifySubData(SizeT offset, SizeT size) { ++m_changeSerial; + MGP_NOTE_AGGREGATE(BufferChange); if (size == 0) return; m_hasDefinedContent = true; if (g_bufferBackendOps && g_bufferBackendOps->SubData) { @@ -59,6 +62,7 @@ namespace MobileGL::MG_State::GLState { void BufferObject::NotifyFlushMappedRange(Range1D range, Flags appAccess) { ++m_changeSerial; + MGP_NOTE_AGGREGATE(BufferChange); if (range.start >= range.end) return; m_hasDefinedContent = true; if (g_bufferBackendOps && g_bufferBackendOps->FlushMappedRange) { @@ -73,6 +77,7 @@ namespace MobileGL::MG_State::GLState { // undefined store to "has content" - that would cost the next orphaning // respecification a full-size upload of bytes the application never wrote. ++m_changeSerial; + MGP_NOTE_AGGREGATE(BufferChange); return; } m_hasDefinedContent = true; @@ -80,6 +85,7 @@ namespace MobileGL::MG_State::GLState { // The write already landed in coherent GPU memory; the backend has no separate // copy to sync. Only bump the serial so cached transient slices invalidate. ++m_changeSerial; + MGP_NOTE_AGGREGATE(BufferChange); return; } NotifySubData(offset, size); @@ -302,6 +308,7 @@ namespace MobileGL::MG_State::GLState { data.size, m_size); Memcpy(m_resource.Bytes() + atOffset, data.data, data.size); ++m_changeSerial; + MGP_NOTE_AGGREGATE(BufferChange); } void BufferObject::MarkGpuWritten() { @@ -366,6 +373,7 @@ namespace MobileGL::MG_State::GLState { g_bufferBackendOps->ResidentSubData(*this, offset, bytes); m_hasDefinedContent = true; ++m_changeSerial; + MGP_NOTE_AGGREGATE(BufferChange); m_gpuWritePending = true; return; } diff --git a/MobileGL/MG_State/GLState/BufferState/BufferState.h b/MobileGL/MG_State/GLState/BufferState/BufferState.h index 51d3b1d60..82ab6dc6f 100644 --- a/MobileGL/MG_State/GLState/BufferState/BufferState.h +++ b/MobileGL/MG_State/GLState/BufferState/BufferState.h @@ -64,7 +64,19 @@ namespace MobileGL::MG_State::GLState { Bool ValidateName(Uint index) const; Bool ValidateBufferObject(Uint index) const; +#if MOBILEGL_PIPE_PUSH + // P2 brief D4: "did the contents of ANY buffer object move". One counter for every + // BufferObject ++m_changeSerial site, which is what NEW_VERTEX_BUFFERS / + // NEW_INDEX_BUFFER / NEW_CONST_BUFFERS / NEW_SHADER_BUFFERS / NEW_SO_TARGETS all + // shutter on in P2 - five bits over one aggregate until P3b splits them. + void NoteBufferChanged() { ++m_anyBufferChangeGeneration; } + Uint64 GetAnyBufferChangeGeneration() const { return m_anyBufferChangeGeneration; } +#endif + private: +#if MOBILEGL_PIPE_PUSH + Uint64 m_anyBufferChangeGeneration = 0; +#endif UnorderedMap> m_bufferObjects; IndexGenerator m_indexGenerator; Array, GlobalBufferTargets.size()> m_bindingSlots; diff --git a/MobileGL/MG_State/GLState/Core.cpp b/MobileGL/MG_State/GLState/Core.cpp index 952e1da8e..c814f921e 100644 --- a/MobileGL/MG_State/GLState/Core.cpp +++ b/MobileGL/MG_State/GLState/Core.cpp @@ -15,6 +15,7 @@ #include #include +#include namespace MobileGL::MG_State { void Init() { @@ -213,6 +214,7 @@ namespace MobileGL::MG_State { current.intValue[component] = static_cast(value[component]); current.uintValue[component] = static_cast(value[component]); } + MGP_NOTE_AGGREGATE(VertexAttribDefault); } void GLContext::SetCurrentVertexAttributeInt(Uint index, const Array& value) { @@ -227,6 +229,7 @@ namespace MobileGL::MG_State { current.floatValue[component] = static_cast(value[component]); current.uintValue[component] = static_cast(value[component]); } + MGP_NOTE_AGGREGATE(VertexAttribDefault); } void GLContext::SetCurrentVertexAttributeUint(Uint index, const Array& value) { @@ -241,6 +244,7 @@ namespace MobileGL::MG_State { current.floatValue[component] = static_cast(value[component]); current.intValue[component] = static_cast(value[component]); } + MGP_NOTE_AGGREGATE(VertexAttribDefault); } const CurrentVertexAttributeValue& GLContext::GetCurrentVertexAttribute(Uint index) const { diff --git a/MobileGL/MG_State/GLState/Core.h b/MobileGL/MG_State/GLState/Core.h index 3b7e4802a..a72398acd 100644 --- a/MobileGL/MG_State/GLState/Core.h +++ b/MobileGL/MG_State/GLState/Core.h @@ -198,6 +198,43 @@ namespace MobileGL { Uint GetBoundProgramPipelineName() const { return m_boundProgramPipeline; } const SharedPtr& GetBoundProgramPipeline() const; +#if MOBILEGL_PIPE_PUSH + // ---- the aggregate generations (P2 brief D4) ---- + // + // The bump points sit on OBJECTS - a VertexArrayObject, a TextureObject, a + // BufferObject - which have no back-pointer to the state container that owns + // them, so MGP_NOTE_AGGREGATE goes through MGPipeNoteAggregate, which finds + // the live context and lands here. This facade is the whole reason the + // objects need no back-pointer, and it is push-only so the pull build's + // GLContext is byte-identical (G1). + void NoteVaoAttributeChanged() { m_vertexArrayState.NoteAttributeChanged(); } + Uint64 GetAnyVaoAttributeGeneration() const { + return m_vertexArrayState.GetAnyAttributeGeneration(); + } + void NoteFramebufferAttachmentChanged() { m_framebufferState.NoteAttachmentChanged(); } + Uint64 GetAnyFramebufferAttachmentGeneration() const { + return m_framebufferState.GetAnyAttachmentGeneration(); + } + void NoteTextureContentChanged() { m_textureState.NoteTextureContentChanged(); } + Uint64 GetAnyTextureContentGeneration() const { + return m_textureState.GetAnyTextureContentGeneration(); + } + void NoteTextureParamsChanged() { m_textureState.NoteTextureParamsChanged(); } + Uint64 GetAnyTextureParamsGeneration() const { + return m_textureState.GetAnyTextureParamsGeneration(); + } + void NoteBufferChanged() { m_bufferState.NoteBufferChanged(); } + Uint64 GetAnyBufferChangeGeneration() const { + return m_bufferState.GetAnyBufferChangeGeneration(); + } + // The sixth aggregate lives here rather than on a state container because + // the values it guards do too (m_currentVertexAttributes). + void NoteVertexAttribDefaultChanged() { ++m_anyVertexAttribDefaultGeneration; } + Uint64 GetAnyVertexAttribDefaultGeneration() const { + return m_anyVertexAttribDefaultGeneration; + } +#endif + // RenderState Uint GetRenderStateParametersVersion() const; // Only the pipeline-relevant subset - see RenderState::m_pipelineStateVersion. @@ -515,6 +552,9 @@ namespace MobileGL { Bool m_transformFeedbackPaused = false; GLenum m_transformFeedbackPrimitiveMode = GL_POINTS; SharedPtr m_transformFeedbackProgram; +#if MOBILEGL_PIPE_PUSH + Uint64 m_anyVertexAttribDefaultGeneration = 0; +#endif Uint64 m_transformFeedbackGeneration = 0; // Source of the per-span ids above; never rolls back with an object switch. Uint64 m_transformFeedbackNextGeneration = 0; diff --git a/MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.cpp b/MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.cpp index 92402f063..1261f604a 100644 --- a/MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.cpp +++ b/MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.cpp @@ -11,6 +11,7 @@ #include "MG_Util/Types.h" #include +#include namespace MobileGL::MG_State::GLState { // Starts at 1 so a zero-initialized memo slot can never carry a live object's id. @@ -205,6 +206,7 @@ namespace MobileGL::MG_State::GLState { if (m_readBuffer == buf) return; m_readBuffer = buf; ++m_objectVersion; + MGP_NOTE_AGGREGATE(FramebufferAttachment); } Uint FramebufferObject::GetExternalIndex() const { @@ -216,6 +218,7 @@ namespace MobileGL::MG_State::GLState { if (member == value) return; \ member = value; \ ++m_objectVersion; \ + MGP_NOTE_AGGREGATE(FramebufferAttachment); \ } MOBILEGL_DEFINE_FRAMEBUFFER_DEFAULT_SETTER(DefaultWidth, m_defaultWidth, Int) @@ -228,5 +231,6 @@ namespace MobileGL::MG_State::GLState { void FramebufferObject::BumpAttachmentVersion(FramebufferAttachmentType type) { ++m_attachmentVersions[static_cast(type)]; ++m_objectVersion; + MGP_NOTE_AGGREGATE(FramebufferAttachment); } } // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/FramebufferState/FramebufferState.h b/MobileGL/MG_State/GLState/FramebufferState/FramebufferState.h index 3e822be77..94dd27c46 100644 --- a/MobileGL/MG_State/GLState/FramebufferState/FramebufferState.h +++ b/MobileGL/MG_State/GLState/FramebufferState/FramebufferState.h @@ -25,7 +25,19 @@ namespace MobileGL::MG_State::GLState { Bool ValidateName(Uint index) const; Bool ValidateFramebufferObject(Uint index) const; +#if MOBILEGL_PIPE_PUSH + // P2 brief D4: "did the attachment set or the default geometry of ANY framebuffer + // move". It does NOT cover a BIND - a bind writes a BindingSlot, not the object - so + // MGPipeTracker pairs this counter with the bound draw framebuffer identity, which + // is one extra load and keeps the bump points on the object where they belong. + void NoteAttachmentChanged() { ++m_anyAttachmentGeneration; } + Uint64 GetAnyAttachmentGeneration() const { return m_anyAttachmentGeneration; } +#endif + private: +#if MOBILEGL_PIPE_PUSH + Uint64 m_anyAttachmentGeneration = 0; +#endif UnorderedMap> m_framebufferObjects; IndexGenerator m_indexGenerator; Array, static_cast(FramebufferTarget::FramebufferTargetCount)> diff --git a/MobileGL/MG_State/GLState/SamplerState/SamplerObject.cpp b/MobileGL/MG_State/GLState/SamplerState/SamplerObject.cpp index 3f91b4ad7..a5c301b91 100644 --- a/MobileGL/MG_State/GLState/SamplerState/SamplerObject.cpp +++ b/MobileGL/MG_State/GLState/SamplerState/SamplerObject.cpp @@ -12,6 +12,7 @@ #include #include +#include namespace MobileGL { namespace MG_State { @@ -47,6 +48,9 @@ namespace MobileGL { // bindings must never miss an invalidation, and over-invalidating on a wrap-mode // write costs one re-resolve. if (pGLContext) pGLContext->BumpSamplingResolutionGeneration(); + // Every sampler parameter is a texture PARAMETER as far as the dirty walk is + // concerned, and BumpVersion is the one choke point every setter reaches. + MGP_NOTE_AGGREGATE(TextureParams); } void SamplerObject::SetWrapS(SamplerWrapMode mode) { diff --git a/MobileGL/MG_State/GLState/TextureState/TextureObject.cpp b/MobileGL/MG_State/GLState/TextureState/TextureObject.cpp index 000f4d93f..0c4c05754 100644 --- a/MobileGL/MG_State/GLState/TextureState/TextureObject.cpp +++ b/MobileGL/MG_State/GLState/TextureState/TextureObject.cpp @@ -11,6 +11,7 @@ #include "MG_State/GLState/StateObjectDeathNotice.h" #include "MG_Util/Types.h" #include +#include namespace MobileGL { namespace MG_State { @@ -114,6 +115,7 @@ namespace MobileGL { m_internalFormat = format; BumpShapeVersion(); ++m_textureParamsVersion; + MGP_NOTE_AGGREGATE(TextureParams); } Uint TextureObjectBase::GetExternalIndex() const { @@ -139,6 +141,7 @@ namespace MobileGL { m_sampler->SetBorderColor(color); ++m_textureParamsVersion; + MGP_NOTE_AGGREGATE(TextureParams); } const IntVec4& TextureObjectBase::GetBorderColorI() const { @@ -153,6 +156,7 @@ namespace MobileGL { m_sampler->SetBorderColorI(color); ++m_textureParamsVersion; + MGP_NOTE_AGGREGATE(TextureParams); } const UintVec4& TextureObjectBase::GetBorderColorUI() const { @@ -167,6 +171,7 @@ namespace MobileGL { m_sampler->SetBorderColorUI(color); ++m_textureParamsVersion; + MGP_NOTE_AGGREGATE(TextureParams); } BorderColorForm TextureObjectBase::GetBorderColorForm() const { @@ -216,6 +221,7 @@ namespace MobileGL { break; } ++m_textureParamsVersion; + MGP_NOTE_AGGREGATE(TextureParams); } void TextureObjectBase::SetSwizzleParamRGBA(const Vec4& values) { @@ -223,6 +229,7 @@ namespace MobileGL { m_swizzleParams = values; ++m_textureParamsVersion; + MGP_NOTE_AGGREGATE(TextureParams); } const UintVec2& TextureObjectBase::GetLevelRange() const { @@ -240,6 +247,7 @@ namespace MobileGL { m_levelRange.y() = m_levelRange.x(); } ++m_textureParamsVersion; + MGP_NOTE_AGGREGATE(TextureParams); BumpShapeVersion(); } @@ -251,6 +259,7 @@ namespace MobileGL { m_levelRange.y() = maxLevel; ++m_textureParamsVersion; + MGP_NOTE_AGGREGATE(TextureParams); BumpShapeVersion(); } @@ -271,6 +280,7 @@ namespace MobileGL { m_levelRange.y() = std::min(std::max(m_levelRange.y(), m_levelRange.x()), m_immutableLevels - 1); } ++m_textureParamsVersion; + MGP_NOTE_AGGREGATE(TextureParams); } Uint16 TextureObjectBase::GetTextureParamsVersion() const { @@ -298,6 +308,7 @@ namespace MobileGL { void TextureObjectBase::BumpContentVersion() { ++m_contentVersion; + MGP_NOTE_AGGREGATE(TextureContent); } Int TextureObjectBase::GetSamples() const { @@ -307,6 +318,7 @@ namespace MobileGL { void TextureObjectBase::SetSamples(Int samples) { m_samples = samples; ++m_textureParamsVersion; + MGP_NOTE_AGGREGATE(TextureParams); } Bool TextureObjectBase::HasFixedSampleLocations() const { @@ -316,6 +328,7 @@ namespace MobileGL { void TextureObjectBase::SetFixedSampleLocations(Bool fixedSampleLocations) { m_fixedSampleLocations = fixedSampleLocations; ++m_textureParamsVersion; + MGP_NOTE_AGGREGATE(TextureParams); } Uint64 TextureObjectBase::GetLifetimeId() const { @@ -367,6 +380,7 @@ namespace MobileGL { Bool dirty) { if (dirty) { ++m_contentVersion; + MGP_NOTE_AGGREGATE(TextureContent); } m_textureStorage.MarkDirty(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, dirty); } @@ -378,6 +392,7 @@ namespace MobileGL { void TextureObjectWithOneMipmap::MarkStorageDirtyRegion(TextureUploadTarget uploadTarget, Uint mipmapLevel, IntVec3 offset, IntVec3 size) { ++m_contentVersion; + MGP_NOTE_AGGREGATE(TextureContent); m_textureStorage.MarkDirtyRegion(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, offset, size); } diff --git a/MobileGL/MG_State/GLState/TextureState/TextureObject.h b/MobileGL/MG_State/GLState/TextureState/TextureObject.h index 1cb33cca7..efab72c78 100644 --- a/MobileGL/MG_State/GLState/TextureState/TextureObject.h +++ b/MobileGL/MG_State/GLState/TextureState/TextureObject.h @@ -13,6 +13,7 @@ #include "../SamplerState/SamplerObject.h" #include #include +#include namespace MobileGL::MG_State::GLState { // Texture objects are always SharedPtr-owned (TextureState creates every instance via @@ -188,6 +189,7 @@ namespace MobileGL::MG_State::GLState { if (m_depthStencilTextureMode == mode) return; m_depthStencilTextureMode = mode; ++m_textureParamsVersion; + MGP_NOTE_AGGREGATE(TextureParams); } protected: diff --git a/MobileGL/MG_State/GLState/TextureState/TextureObject2DCube.cpp b/MobileGL/MG_State/GLState/TextureState/TextureObject2DCube.cpp index 765477c5b..b326d3a5c 100644 --- a/MobileGL/MG_State/GLState/TextureState/TextureObject2DCube.cpp +++ b/MobileGL/MG_State/GLState/TextureState/TextureObject2DCube.cpp @@ -7,6 +7,7 @@ // End of Source File Header #include "TextureObject2DCube.h" +#include namespace MobileGL { namespace MG_State { @@ -49,6 +50,7 @@ namespace MobileGL { void TextureObject2DCube::MarkStorageDirty(TextureUploadTarget uploadTarget, Uint mipmapLevel, bool dirty) { if (dirty) { ++m_contentVersion; + MGP_NOTE_AGGREGATE(TextureContent); } m_textureStorage.MarkDirty(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, dirty); } @@ -60,6 +62,7 @@ namespace MobileGL { void TextureObject2DCube::MarkStorageDirtyRegion(TextureUploadTarget uploadTarget, Uint mipmapLevel, IntVec3 offset, IntVec3 size) { ++m_contentVersion; + MGP_NOTE_AGGREGATE(TextureContent); m_textureStorage.MarkDirtyRegion(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, offset, size); } diff --git a/MobileGL/MG_State/GLState/TextureState/TextureState.h b/MobileGL/MG_State/GLState/TextureState/TextureState.h index ab7af05a4..4186b6d38 100644 --- a/MobileGL/MG_State/GLState/TextureState/TextureState.h +++ b/MobileGL/MG_State/GLState/TextureState/TextureState.h @@ -140,7 +140,22 @@ namespace MobileGL::MG_State::GLState { // again (the unit tests do exactly that between cases). Uint64 GetContextId() const { return m_contextId; } +#if MOBILEGL_PIPE_PUSH + // P2 brief D4, the two texture aggregates. CONTENT is an upload or a dirty region; + // PARAMS is a glTexParameter or a glSamplerParameter. They are separate because + // NEW_SAMPLER_VIEWS and NEW_SAMPLERS are separate dirty bits and a Minecraft frame + // moves them at wildly different rates. + void NoteTextureContentChanged() { ++m_anyTextureContentGeneration; } + Uint64 GetAnyTextureContentGeneration() const { return m_anyTextureContentGeneration; } + void NoteTextureParamsChanged() { ++m_anyTextureParamsGeneration; } + Uint64 GetAnyTextureParamsGeneration() const { return m_anyTextureParamsGeneration; } +#endif + private: +#if MOBILEGL_PIPE_PUSH + Uint64 m_anyTextureContentGeneration = 0; + Uint64 m_anyTextureParamsGeneration = 0; +#endif static Uint64 AllocateContextId(); const Uint64 m_contextId; diff --git a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.cpp b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.cpp index 02a0e5f8b..64aef8961 100644 --- a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.cpp +++ b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.cpp @@ -11,6 +11,7 @@ #include #include +#include namespace MobileGL::MG_State::GLState { // Starts at 1 so a zero-initialized memo slot can never carry a live object's id. @@ -313,18 +314,21 @@ namespace MobileGL::MG_State::GLState { if (index >= MAX_VERTEX_ATTRIBS) return; ++m_attributeVersions[index].FormatVersion; ++m_configVersion; + MGP_NOTE_AGGREGATE(VaoAttribute); } void VertexArrayObject::BumpAttributeBufferVersion(Uint index) { if (index >= MAX_VERTEX_ATTRIBS) return; ++m_attributeVersions[index].BufferVersion; ++m_configVersion; + MGP_NOTE_AGGREGATE(VaoAttribute); } void VertexArrayObject::BumpAttributeSwitchVersion(Uint index) { if (index >= MAX_VERTEX_ATTRIBS) return; ++m_attributeVersions[index].SwitchVersion; ++m_configVersion; + MGP_NOTE_AGGREGATE(VaoAttribute); } const VertexAttributeVersion& VertexArrayObject::GetAttributeVersion(Uint index) const { diff --git a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayState.h b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayState.h index 418813c43..c3ce3207b 100644 --- a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayState.h +++ b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayState.h @@ -28,7 +28,20 @@ namespace MobileGL { const SharedPtr& GetBoundVertexArray(); Vector>& GetAllVertexArrays(); +#if MOBILEGL_PIPE_PUSH + // P2 brief D4: "did the attribute configuration of ANY vertex array move". + // Bumped from every VertexArrayObject::BumpAttribute*Version through + // MGP_NOTE_AGGREGATE(VaoAttribute), which is coarser than the per-object + // m_configVersion on purpose - the tracker wants one compare, and an extra + // re-push costs a push while a missed one renders stale. + void NoteAttributeChanged() { ++m_anyVaoAttributeGeneration; } + Uint64 GetAnyAttributeGeneration() const { return m_anyVaoAttributeGeneration; } +#endif + private: +#if MOBILEGL_PIPE_PUSH + Uint64 m_anyVaoAttributeGeneration = 0; +#endif // "Nothing bound" (an out-of-range or never-created name was bound). Distinct from // being bound to a live slot so that a slot filled AFTER such a bind does not // retroactively become the bound VAO. diff --git a/MobileGL/MG_Test/Pipe/TrackerTest.cpp b/MobileGL/MG_Test/Pipe/TrackerTest.cpp index b83d6aba0..2bdda5783 100644 --- a/MobileGL/MG_Test/Pipe/TrackerTest.cpp +++ b/MobileGL/MG_Test/Pipe/TrackerTest.cpp @@ -6,27 +6,168 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header -// The frontend state tracker: the dirty walk, the five aggregate generations, the per-bit fire counters (P2 brief D4). +// The frontend state tracker: the dirty walk, the aggregate generations, the per-bit fire +// counters (P2 brief D4). Owned by P2 package B (p2/tracker); the file and its CMake +// registration are the contract commit's. // -// STUB, and deliberately one. It is created by the P2 CONTRACT commit together with its -// CMakeLists.txt registration, so that the package which owns its CONTENTS -// (P2 package B, p2/tracker) never has to touch MG_Test/Pipe/CMakeLists.txt - no two P2 -// packages edit the same file, which is what keeps the integrator's rebases clean. -// -// The placeholder case is not decoration: without it the binary has no test, and -// gtest_discover_tests on a binary with no test is a silently green lane. +// Needs the push sources, so every case is a visible SKIP in a pull build rather than a +// vanishing test - the shape PipeInputsTest.cpp established. #include #include "Includes.h" #include +#if MOBILEGL_PIPE_PUSH +#include +#include +#endif + using namespace MobileGL; using namespace MobileGL::MG_Pipe; namespace { - // The tracker does not exist yet; what is true in every build is that the subsystem - // bitmask it dispatches on is allocated and does not overlap the behaviour bit. - TEST(Tracker, PlaceholderUntilTheOwningPackageFillsThisIn) { + // The subsystem bitmask the tracker dispatches on is allocated in every build. + TEST(Tracker, SubsystemBitsDoNotOverlapTheBehaviourBit) { EXPECT_EQ(kMGPipeSubsystemsMigratedAtP2 & kMGPipeBehaviourNoCsoContentAddressing, 0ull); } + +#if !MOBILEGL_PIPE_PUSH + TEST(Tracker, SkippedInAPullBuild) { + GTEST_SKIP() << "the tracker is compiled only under MOBILEGL_PIPE_PUSH"; + } +#else + using GLContext = MG_State::GLState::GLContext; + + using MG_State::GLState::TextureObjectBase; + using MobileGL::TextureTarget; + + constexpr SizeT kAggregateCount = static_cast(MGPipeAggregate::Count); + + // A live frontend context for the bump points to find, restored on the way out so the + // cases stay independent (PipeInputsTest's idiom). + class TrackerAggregates : public ::testing::Test { + protected: + void SetUp() override { + m_previous = Move(MG_State::pGLContext); + MG_State::pGLContext = MakeUnique(); + } + void TearDown() override { MG_State::pGLContext = Move(m_previous); } + + static GLContext& Ctx() { return *MG_State::pGLContext; } + + struct Snapshot { + Uint64 Values[kAggregateCount]; + Uint64 operator[](MGPipeAggregate a) const { return Values[static_cast(a)]; } + }; + + static Snapshot Snap() { + GLContext& c = Ctx(); + Snapshot s{}; + s.Values[static_cast(MGPipeAggregate::VaoAttribute)] = c.GetAnyVaoAttributeGeneration(); + s.Values[static_cast(MGPipeAggregate::FramebufferAttachment)] = + c.GetAnyFramebufferAttachmentGeneration(); + s.Values[static_cast(MGPipeAggregate::TextureContent)] = c.GetAnyTextureContentGeneration(); + s.Values[static_cast(MGPipeAggregate::TextureParams)] = c.GetAnyTextureParamsGeneration(); + s.Values[static_cast(MGPipeAggregate::BufferChange)] = c.GetAnyBufferChangeGeneration(); + s.Values[static_cast(MGPipeAggregate::VertexAttribDefault)] = + c.GetAnyVertexAttribDefaultGeneration(); + return s; + } + + // The whole contract of an aggregate generation in one assertion: the bump point + // moved ITS counter and moved NO other. The second half is what stops a bump point + // being wired to the wrong aggregate, which would over-fire one dirty bit and + // under-fire another - and under-firing is the direction that renders stale. + static void ExpectOnly(MGPipeAggregate moved, const Snapshot& before, const Snapshot& after) { + for (SizeT i = 0; i < kAggregateCount; ++i) { + const auto which = static_cast(i); + if (which == moved) { + EXPECT_GT(after.Values[i], before.Values[i]) << "aggregate " << i << " did not move"; + } else { + EXPECT_EQ(after.Values[i], before.Values[i]) << "aggregate " << i << " moved and must not"; + } + } + } + + UniquePtr m_previous; + }; + + TEST_F(TrackerAggregates, EveryAggregateStartsAtZero) { + const Snapshot s = Snap(); + for (SizeT i = 0; i < kAggregateCount; ++i) EXPECT_EQ(s.Values[i], 0ull); + } + + TEST_F(TrackerAggregates, AVertexArrayAttributeMovesOnlyTheVaoAggregate) { + const auto& vao = Ctx().CreateVertexArrayObject(1); + ASSERT_TRUE(vao != nullptr); + const Snapshot before = Snap(); + vao->EnableAttribute(3); + ExpectOnly(MGPipeAggregate::VaoAttribute, before, Snap()); + } + + TEST_F(TrackerAggregates, AFramebufferObjectWriteMovesOnlyTheFramebufferAggregate) { + const auto& fbo = Ctx().CreateFramebufferObject(1); + ASSERT_TRUE(fbo != nullptr); + fbo->SetReadBuffer(FramebufferAttachmentType::Color0); + const Snapshot before = Snap(); + fbo->SetReadBuffer(FramebufferAttachmentType::Color1); + ExpectOnly(MGPipeAggregate::FramebufferAttachment, before, Snap()); + } + + TEST_F(TrackerAggregates, AFramebufferDefaultSetterMovesOnlyTheFramebufferAggregate) { + const auto& fbo = Ctx().CreateFramebufferObject(2); + ASSERT_TRUE(fbo != nullptr); + const Snapshot before = Snap(); + // The five MOBILEGL_DEFINE_FRAMEBUFFER_DEFAULT_SETTER bodies are one macro, so the + // bump statement inside it has to carry its own line continuation or the macro + // silently swallows the next line. This case is what says it did not. + fbo->SetDefaultWidth(64); + ExpectOnly(MGPipeAggregate::FramebufferAttachment, before, Snap()); + } + + TEST_F(TrackerAggregates, ATextureContentWriteMovesOnlyTheContentAggregate) { + const auto& tex = Ctx().CreateTextureObject(1, TextureTarget::Texture2D); + ASSERT_TRUE(tex != nullptr); + const Snapshot before = Snap(); + static_cast(tex.get())->BumpContentVersion(); + ExpectOnly(MGPipeAggregate::TextureContent, before, Snap()); + } + + TEST_F(TrackerAggregates, ATextureParameterMovesOnlyTheParamsAggregate) { + const auto& tex = Ctx().CreateTextureObject(2, TextureTarget::Texture2D); + ASSERT_TRUE(tex != nullptr); + const Snapshot before = Snap(); + tex->SetMaxLevel(4); + ExpectOnly(MGPipeAggregate::TextureParams, before, Snap()); + } + + TEST_F(TrackerAggregates, ASamplerParameterMovesOnlyTheParamsAggregate) { + const auto& sampler = Ctx().CreateSamplerObject(1); + ASSERT_TRUE(sampler != nullptr); + const Snapshot before = Snap(); + sampler->SetWrapS(MobileGL::SamplerWrapMode::ClampToEdge); + ExpectOnly(MGPipeAggregate::TextureParams, before, Snap()); + } + + TEST_F(TrackerAggregates, ABufferRespecifyMovesOnlyTheBufferAggregate) { + const auto& buffer = Ctx().CreateBufferObject(1); + ASSERT_TRUE(buffer != nullptr); + const Snapshot before = Snap(); + buffer->Respecify(64, nullptr); + ExpectOnly(MGPipeAggregate::BufferChange, before, Snap()); + } + + TEST_F(TrackerAggregates, AVertexAttribDefaultMovesOnlyItsOwnAggregate) { + const Snapshot before = Snap(); + Ctx().SetCurrentVertexAttributeFloat(2, Array{1.0f, 2.0f, 3.0f, 4.0f}); + ExpectOnly(MGPipeAggregate::VertexAttribDefault, before, Snap()); + } + + TEST_F(TrackerAggregates, ANoteWithoutALiveContextIsANoOp) { + UniquePtr held = Move(MG_State::pGLContext); + MGP_NOTE_AGGREGATE(BufferChange); // must not dereference a null context + MG_State::pGLContext = Move(held); + SUCCEED(); + } +#endif // MOBILEGL_PIPE_PUSH } // namespace From fcd4ad37997e39f5cebfd32ef3e705234c2bab88 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:15:38 -0400 Subject: [PATCH 101/529] [Feat] (Pipe): compute the per-verb dirty mask at the validate point and count how often each bit fires - MGPipeFillForVerb becomes MGPipeValidateForVerb and MGP_FILL expands to the new name. The macro spelling, the 83 call sites and the verb enum do not change: the dispatch is kMGPipeVerbClass's nine classes, which is the same code as nine named ValidateFor* entry points with one call site per verb instead of nine (P2 brief D1, against ARCHITECTURE.md's eight - FillPoints.def argues in its own comment for splitting kProgramOp out, and the landed table is what runs). - MG_Impl/Pipe/Tracker.h: MGPipeDirty's 18 bits, the widened Uint16 shutters, the per-verb walk and the per-bit-per-verb-class fire tallies. The widening happens in the TRACKER and MG_State is not changed for it; a wrap costs one extra re-push and never a missed one. - NOTHING IS EMITTED YET. The mask is computed, latched and counted, and the full P1 residual fill runs after it unchanged. That is the point of this step: it says the walk is semantically free before any field stops being pulled, so a regression in the next commit cannot be blamed on the walk. - Every shutter over-fires on purpose. Bits 2 and 3 are BYTE compares, not value compares, because a NaN patch level is a legal glPatchParameterfv value and has to equal itself; bits 5..17 are composed with a mixing hash, which can in principle collide, and that is stated in the file and is acceptable only because nothing consumes those bits in P2. - The shutter for bits 6..8 reads the current program's version counters WITHOUT GetProgramForDraw, so the walk never joins a pending link to answer "did the shader move". - Header-only rather than Tracker.{h,cpp}: the root CMakeLists.txt that would have to name a new .cpp belongs to package A and is frozen behind the p2/contract tag. One translation unit in the library includes it, so inline costs nothing, and splitting it out is one list(APPEND) line whenever the ownership allows. - MG_Test/ScopedPipeVerb.h and MG_Test/Pipe/PipeInputsTest.cpp follow the rename. Five comment references to the old name live in package A's files (MGPipe.h, the two generated .inc, and gen_pipe.py) and are deliberately left for their owner. --- MobileGL/MG_Impl/Pipe/PipeFill.cpp | 17 +- MobileGL/MG_Impl/Pipe/PipeFill.h | 22 +- MobileGL/MG_Impl/Pipe/Tracker.h | 375 +++++++++++++++++++++++ MobileGL/MG_Test/Pipe/PipeInputsTest.cpp | 50 +-- MobileGL/MG_Test/ScopedPipeVerb.h | 4 +- 5 files changed, 432 insertions(+), 36 deletions(-) create mode 100644 MobileGL/MG_Impl/Pipe/Tracker.h diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 6ffeea0cc..89ae1e01b 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -595,8 +596,8 @@ namespace MobileGL::MG_Pipe { MGPipeFillAccess::SetVerb(inputs, MGPipeVerb::kVerbCount); } - // ---- the filler ---- - void MGPipeFillForVerb(MGPipeVerb verb) { + // ---- the validate point (P2 brief D1) ---- + void MGPipeValidateForVerb(MGPipeVerb verb) { PipeInputs& inputs = gPipeInputs; ParsePoisonOmissionKnob(); #if MOBILEGL_PIPE_VERIFY @@ -620,7 +621,17 @@ namespace MobileGL::MG_Pipe { auto* ctx = LiveContext(); MGPipeFillAccess::SetIdentity(inputs, ctx); if (ctx == nullptr) return; - const MGPipeFieldMask& mask = kMGPipeClassFieldMask[static_cast(kMGPipeVerbClass[static_cast(verb)])]; + const MGPipeVerbClass verbClass = kMGPipeVerbClass[static_cast(verb)]; + const MGPipeFieldMask& mask = kMGPipeClassFieldMask[static_cast(verbClass)]; + + // ---- step 2: the dirty walk (P2 brief D1, D4) ---- + // The mask is computed, latched and counted here and nothing is emitted from it + // yet: this commit is the safety net that says the walk is semantically free + // before any field stops being pulled. The emission steps land on top of it. + const Uint32 dirty = MGPipeTrackerInstance().Update(*ctx, verbClass); + (void)dirty; + + // ---- step 4: the residual fill ---- for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { const auto field = static_cast(i); if (!MGPipeFieldMaskHas(mask, field)) continue; diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.h b/MobileGL/MG_Impl/Pipe/PipeFill.h index f03a3b824..f9be638d7 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.h +++ b/MobileGL/MG_Impl/Pipe/PipeFill.h @@ -18,11 +18,21 @@ namespace MobileGL::MG_Pipe { struct PipeInputs; - // PipeFill.cpp. Bumps the per-verb serial, records the verb and the context identity, - // and copies every field in the verb class's may-read mask (kMGPipeClassFieldMask) out - // of the live GLContext, stamping each with the new serial. In a verify build it then - // runs the entry compare against a second snapshot (P1 brief D8). - void MGPipeFillForVerb(MGPipeVerb verb); + // PipeFill.cpp. THE VALIDATE POINT (ARCHITECTURE.md 5.1, P2 brief D1). In order: + // 1. bump the per-verb serial, record the verb and the context identity; + // 2. run the tracker's DIRTY WALK for this verb's class (MG_Impl/Pipe/Tracker.h); + // 3. EMIT, for each set dirty bit whose subsystem bit is on in the runtime + // MOBILEGL_PIPE_PUSH bitmask, the P2 call that carries it; + // 4. run the P1 residual fill for every field an emitted call did NOT supply, + // stamping each with the new serial exactly as before; + // 5. in a verify build, the entry compare against a second snapshot (P1 brief D8) - + // which stops being a tautology the moment step 3 supplies a field step 4 skips. + // + // It was MGPipeFillForVerb through P1, when steps 2 and 3 did not exist. The macro + // spelling, the 83 call sites and the verb enum are unchanged: the dispatch is + // kMGPipeVerbClass's nine classes, which is the same code as nine named ValidateFor* + // entry points with one call site per verb instead of nine. + void MGPipeValidateForVerb(MGPipeVerb verb); // Ends the verb in flight without starting another: bumps the serial, so every field the // verb stamped goes stale, and puts the current verb back to "none", so a read made after @@ -49,7 +59,7 @@ namespace MobileGL::MG_Pipe { void SnapshotFromGLContext(PipeInputs& snapshot, const MGPipeFieldMask& mask); #endif } // namespace MobileGL::MG_Pipe -#define MGP_FILL(Verb) ::MobileGL::MG_Pipe::MGPipeFillForVerb(::MobileGL::MG_Pipe::MGPipeVerb::Verb) +#define MGP_FILL(Verb) ::MobileGL::MG_Pipe::MGPipeValidateForVerb(::MobileGL::MG_Pipe::MGPipeVerb::Verb) #else #define MGP_FILL(Verb) ((void)0) #endif diff --git a/MobileGL/MG_Impl/Pipe/Tracker.h b/MobileGL/MG_Impl/Pipe/Tracker.h new file mode 100644 index 000000000..a01e21a67 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/Tracker.h @@ -0,0 +1,375 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/Tracker.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +// The frontend state tracker (ARCHITECTURE.md 5.2, P2 brief D4). +// +// WHERE IT RUNS. Not above MGP_FILL and not in the GL setter: MGPipeValidateForVerb, the +// one statement MGP_FILL already expands to before every gBackendFunctionsTable.GL call +// (PipeFill.h). Blaze3D brackets every batch with glEnable/glDisable(GL_BLEND), so a +// setter that pushed would push twice per batch for a state the batch may not even read; +// the validate point coalesces the whole bracket into the two draws that observe it +// (ARCHITECTURE.md 5.1). +// +// WHAT IT DOES. One Uint32 dirty mask per verb, one bit per row of ARCHITECTURE.md 5.2, +// computed by comparing a shutter against what the tracker last pushed. P2 EMITS for bits +// 0..4 only (the value-class ones); bits 5..17 are computed, latched and counted so the +// per-bit fire rate is a measurement rather than a plan, and their fields keep going +// through the residual fill until P3a/P3b/P4a/P4b. +// +// WHY EVERY SHUTTER OVER-FIRES. A bit that fires too often costs one extra push. A bit +// that fires too rarely renders stale, and ARCHITECTURE.md 13.2 names that as the +// dangerous direction precisely because the P1 verify comparator cannot see it for +// object-class state (it compares those by identity only). So each shutter below is +// deliberately coarser than the state it guards - five bits share one buffer aggregate, +// the framebuffer bit fires on any attachment write anywhere - and the narrowing is P3's +// work, paid for with the fire rates this file publishes. +// +// NO TIMER LIVES HERE. ROADMAP.md forbids committing hot-path instrumentation; the +// absolute ns/draw comes from DriverBench, which times whole frames from outside the +// library (P2 brief D17). The only counting is the per-bit fire tally, behind +// PipeStats::Enabled() like every other counting site in the tree. +// +// HEADER-ONLY, and that is an ownership decision rather than a design one: the P2 brief +// asks for Tracker.{h,cpp}, but the root CMakeLists.txt that would have to name a new .cpp +// belongs to package A and is frozen behind the p2/contract tag. Everything here is +// included by exactly one translation unit in the library (MG_Impl/Pipe/PipeFill.cpp) plus +// the unit tests, so inline costs nothing. Splitting it back out is one list(APPEND) line. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include + +#include + +namespace MobileGL::MG_Pipe { + + // One bit per row of the ARCHITECTURE.md 5.2 table, hand-written rather than generated: + // the list is design, not derived data, and the generator has nothing to derive it from. + enum class MGPipeDirty : Uint32 { + // ---- value class: P2 emits for these five ---- + NewRenderState = 0, // RenderState::m_version -> set_dynamic_state + NewPipelineState, // RenderState::m_pipelineStateVersion -> create/bind_render_state + NewPixelPack, // PixelStoreParameters (pack) -> set_pixel_pack_state + NewPatchState, // the patch trio, NaN legal -> set_patch_state + NewVertexAttribDefaults, // glVertexAttrib* defaults -> set_vertex_attrib_defaults + // ---- value class: computed and counted, emitted from P3a on ---- + NewVertexElements, // the bound VAO's attribute configuration + NewShader, // the current program's link version + NewShaderBindings, // image units, block bindings, uniform write set + NewGlobalConstants, // the default-uniform-block image + // ---- object class: computed and counted, emitted from P3b/P4b on ---- + NewVertexBuffers, + NewIndexBuffer, + NewFramebuffer, + NewSamplerViews, + NewSamplers, + NewShaderImages, + NewConstBuffers, + NewShaderBuffers, + NewSoTargets, + Count, + }; + + inline constexpr SizeT kMGPipeDirtyCount = static_cast(MGPipeDirty::Count); + static_assert(kMGPipeDirtyCount <= 32, "the dirty mask is a Uint32"); + + inline constexpr Uint32 MGPipeDirtyBit(MGPipeDirty bit) { + return Uint32{1} << static_cast(bit); + } + + // The five P2 emits for. + inline constexpr Uint32 kMGPipeDirtyEmittedAtP2 = + MGPipeDirtyBit(MGPipeDirty::NewRenderState) | MGPipeDirtyBit(MGPipeDirty::NewPipelineState) | + MGPipeDirtyBit(MGPipeDirty::NewPixelPack) | MGPipeDirtyBit(MGPipeDirty::NewPatchState) | + MGPipeDirtyBit(MGPipeDirty::NewVertexAttribDefaults); + + inline constexpr const char* kMGPipeDirtyNames[kMGPipeDirtyCount] = { + "NEW_RENDER_STATE", + "NEW_PIPELINE_STATE", + "NEW_PIXEL_PACK", + "NEW_PATCH_STATE", + "NEW_VERTEX_ATTRIB_DEFAULTS", + "NEW_VERTEX_ELEMENTS", + "NEW_SHADER", + "NEW_SHADER_BINDINGS", + "NEW_GLOBAL_CONSTANTS", + "NEW_VERTEX_BUFFERS", + "NEW_INDEX_BUFFER", + "NEW_FRAMEBUFFER", + "NEW_SAMPLER_VIEWS", + "NEW_SAMPLERS", + "NEW_SHADER_IMAGES", + "NEW_CONST_BUFFERS", + "NEW_SHADER_BUFFERS", + "NEW_SO_TARGETS", + }; + + // Which runtime MOBILEGL_PIPE_PUSH subsystem bit gates a dirty bit's emission. Zero for + // a bit P2 does not emit, which is what makes "the bitmask is a true per-subsystem A/B" + // literally true rather than approximately. + inline constexpr Uint64 MGPipeSubsystemForDirty(MGPipeDirty bit) { + switch (bit) { + case MGPipeDirty::NewRenderState: + case MGPipeDirty::NewPipelineState: + return kMGPipeSubsystemRenderState; + case MGPipeDirty::NewPixelPack: + return kMGPipeSubsystemPixelPack; + case MGPipeDirty::NewPatchState: + return kMGPipeSubsystemPatchState; + case MGPipeDirty::NewVertexAttribDefaults: + return kMGPipeSubsystemVertexAttribDefaults; + default: + // Bits 5..17 have no call of their own until P3a/P3b/P4a/P4b, so there is no + // subsystem to switch and the residual fill keeps supplying their fields. + return 0; + } + } + + // A COMPOSITE shutter, for the bits whose "did anything move" is more than one counter. + // It is a hash, so two different states can in principle collide and cost a MISSED fire. + // That is acceptable for bits 5..17 and only for them: nothing consumes those bits in + // P2, and P3 replaces each with its own exact shutter as it takes the subsystem over. + // The five bits P2 EMITS for are never composed - they are widened counters and byte + // compares, neither of which can collide. + inline constexpr Uint64 MGPipeMixShutter(Uint64 accumulator, Uint64 value) { + accumulator ^= value + 0x9e3779b97f4a7c15ull + (accumulator << 6) + (accumulator >> 2); + return accumulator; + } + + // A Uint16 counter widened at the TRACKER boundary, never in MG_State + // (ARCHITECTURE.md 5.2: MG_State is not changed for this). A decrease is a wrap and adds + // 65536. A wrap is harmless locally - one extra re-push, never a missed one - which is + // exactly what TrackerTest.WrapAroundRePushesButNeverMisses pins. + class MGPipeWidenedCounter { + public: + Uint64 Observe(Uint16 now) { + if (m_started && now < m_last) m_high += 0x10000ull; + m_started = true; + m_last = now; + return m_high + now; + } + void Reset() { + m_high = 0; + m_last = 0; + m_started = false; + } + + private: + Uint64 m_high = 0; + Uint16 m_last = 0; + Bool m_started = false; + }; + + class MGPipeTracker { + public: + using GLContext = MG_State::GLState::GLContext; + + // The dirty walk. Compares every shutter against what was last pushed, LATCHES the + // new values, counts the fires per verb class, and returns the mask. Latching here + // rather than after emission is deliberate: a bit whose subsystem is switched off is + // not emitted, but its fields are then still pulled by the residual fill, so the + // pushed block is correct either way and a bit can never fire twice for one change. + Uint32 Update(GLContext& ctx, MGPipeVerbClass verbClass) { + // A different context is a different server: nothing the tracker latched about + // the old one says anything about this one, and the first walk on a fresh + // context must publish a COMPLETE state rather than an increment. + if (m_context != &ctx) { + Reset(); + m_context = &ctx; + } + + Uint64 now[kMGPipeDirtyCount]; + const RenderStateParameters& render = ctx.GetRenderStateParameters(); + + // ---- bits 0..1: the two Uint16 render-state counters, widened HERE ---- + now[Index(MGPipeDirty::NewRenderState)] = + m_renderStateVersion.Observe(static_cast(ctx.GetRenderStateParametersVersion())); + now[Index(MGPipeDirty::NewPipelineState)] = + m_pipelineStateVersion.Observe(static_cast(ctx.GetPipelineStateVersion())); + + // ---- bit 4 and the value-class bits 5..8 ---- + now[Index(MGPipeDirty::NewVertexAttribDefaults)] = ctx.GetAnyVertexAttribDefaultGeneration(); + + const auto& vao = ctx.GetBoundVertexArray(); + const Uint64 vaoIdentity = + vao ? MGPipeMixShutter(vao->GetLifetimeId(), vao->GetConfigVersion()) : 0; + now[Index(MGPipeDirty::NewVertexElements)] = vaoIdentity; + + // Deliberately NOT GetProgramForDraw: that joins a pending link, and the tracker + // must not force a compile just to answer "did the shader move". These version + // counters are plain members and are exactly what the backends already read + // without joining (Core.cpp, the glUseProgram half of join site J1). + const auto& program = ctx.GetCurrentProgram(); + Uint64 shader = 0; + Uint64 bindings = 0; + Uint64 constants = 0; + Uint64 programImages = 0; + if (program) { + shader = MGPipeMixShutter(program->GetLifetimeId(), program->GetLinkVersion()); + bindings = MGPipeMixShutter( + MGPipeMixShutter(MGPipeMixShutter(program->GetImageUnitVersion(), + program->GetBackendStateVersion()), + program->GetBlockBindingVersion()), + program->GetUniformWriteSetVersion()); + constants = MGPipeMixShutter(program->GetLifetimeId(), program->GetUBOContentVersion()); + programImages = program->GetImageUnitVersion(); + } + now[Index(MGPipeDirty::NewShader)] = shader; + now[Index(MGPipeDirty::NewShaderBindings)] = bindings; + now[Index(MGPipeDirty::NewGlobalConstants)] = constants; + + // ---- the object-class bits 9..17 ---- + const Uint64 textureContent = ctx.GetAnyTextureContentGeneration(); + const Uint64 textureParams = ctx.GetAnyTextureParamsGeneration(); + const Uint64 buffers = ctx.GetAnyBufferChangeGeneration(); + + now[Index(MGPipeDirty::NewVertexBuffers)] = + MGPipeMixShutter(ctx.GetAnyVaoAttributeGeneration(), vaoIdentity); + // The index buffer lives in the bound VAO's element slot and P2 has no cheap + // shutter for that slot alone, so it shares the buffer aggregate and over-fires + // on any buffer write anywhere. P3b narrows it when it takes the subsystem over. + now[Index(MGPipeDirty::NewIndexBuffer)] = MGPipeMixShutter(buffers, vaoIdentity); + now[Index(MGPipeDirty::NewFramebuffer)] = MGPipeMixShutter( + ctx.GetAnyFramebufferAttachmentGeneration(), + m_framebufferBind.Observe( + ctx.GetFramebufferBindingSlot(FramebufferTarget::Draw).GetVersion())); + now[Index(MGPipeDirty::NewSamplerViews)] = + MGPipeMixShutter(textureContent, ctx.GetTextureBindGeneration()); + now[Index(MGPipeDirty::NewSamplers)] = + MGPipeMixShutter(textureParams, ctx.GetSamplingResolutionGeneration()); + now[Index(MGPipeDirty::NewShaderImages)] = + MGPipeMixShutter(MGPipeMixShutter(textureContent, textureParams), programImages); + now[Index(MGPipeDirty::NewConstBuffers)] = buffers; + now[Index(MGPipeDirty::NewShaderBuffers)] = buffers; + now[Index(MGPipeDirty::NewSoTargets)] = + MGPipeMixShutter(buffers, ctx.GetTransformFeedbackGeneration()); + + Uint32 dirty = 0; + for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) { + // Bits 2 and 3 are handled below: they are BitwiseEqual shutters, not + // counters, so they have no entry in `now`. + if (i == Index(MGPipeDirty::NewPixelPack) || i == Index(MGPipeDirty::NewPatchState)) { + continue; + } + if (!m_primed || now[i] != m_lastPushed[i]) dirty |= Uint32{1} << static_cast(i); + m_lastPushed[i] = now[i]; + } + + // ---- bit 2: the PACK half of the pixel store, BitwiseEqual ---- + const PixelStoreParameters pack = ctx.GetPixelStoreParameters(false); + if (!m_primed || std::memcmp(&pack, &m_pack, sizeof(pack)) != 0) { + dirty |= MGPipeDirtyBit(MGPipeDirty::NewPixelPack); + m_pack = pack; + } + + // ---- bit 3: the patch trio, BitwiseEqual, and NaN IS LEGAL ---- + // A NaN outer level is a legal glPatchParameterfv value and must compare equal to + // itself (ARCHITECTURE.md 5.2). Float equality says it is not; memcmp says it is, + // which is the whole reason this is a byte compare. + PatchTrio patch{}; + patch.PatchVertices = render.PatchVertices; + for (SizeT i = 0; i < 4; ++i) patch.Outer[i] = render.PatchDefaultOuterLevel[i]; + for (SizeT i = 0; i < 2; ++i) patch.Inner[i] = render.PatchDefaultInnerLevel[i]; + if (!m_primed || std::memcmp(&patch, &m_patch, sizeof(patch)) != 0) { + dirty |= MGPipeDirtyBit(MGPipeDirty::NewPatchState); + m_patch = patch; + } + + m_primed = true; + m_lastDirty = dirty; + + if (MG_Util::PipeStats::Enabled()) { + const SizeT cls = static_cast(verbClass); + ++m_walks[cls]; + for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) { + if (dirty & (Uint32{1} << static_cast(i))) ++m_fires[i][cls]; + } + } + return dirty; + } + + // Context teardown, server reset, a unit test's fixture. The next Update returns + // every bit set, which is what makes the first verb on a fresh context publish a + // complete state rather than an increment. Deliberately does NOT clear the fire + // tallies: they are a per-run measurement, not per-context state. + void Reset() { + std::memset(m_lastPushed, 0, sizeof(m_lastPushed)); + m_renderStateVersion.Reset(); + m_pipelineStateVersion.Reset(); + m_framebufferBind.Reset(); + m_pack = PixelStoreParameters{}; + m_patch = PatchTrio{}; + m_context = nullptr; + m_lastDirty = 0; + m_primed = false; + } + + void ResetCounters() { + std::memset(m_fires, 0, sizeof(m_fires)); + std::memset(m_walks, 0, sizeof(m_walks)); + } + + Uint64 FireCount(MGPipeDirty bit, MGPipeVerbClass verbClass) const { + return m_fires[Index(bit)][static_cast(verbClass)]; + } + Uint64 FireCount(MGPipeDirty bit) const { + Uint64 total = 0; + for (SizeT i = 0; i < kMGPipeVerbClassCount; ++i) total += m_fires[Index(bit)][i]; + return total; + } + Uint64 WalkCount(MGPipeVerbClass verbClass) const { + return m_walks[static_cast(verbClass)]; + } + Uint64 WalkCount() const { + Uint64 total = 0; + for (SizeT i = 0; i < kMGPipeVerbClassCount; ++i) total += m_walks[i]; + return total; + } + + Uint32 LastDirty() const { return m_lastDirty; } + Bool Primed() const { return m_primed; } + + private: + static constexpr SizeT Index(MGPipeDirty bit) { return static_cast(bit); } + + struct PatchTrio { + Uint PatchVertices; + Float Outer[4]; + Float Inner[2]; + }; + + Uint64 m_lastPushed[kMGPipeDirtyCount]{}; + MGPipeWidenedCounter m_renderStateVersion; + MGPipeWidenedCounter m_pipelineStateVersion; + // The draw framebuffer BINDING slot version, widened for the same reason: a Uint16 + // that wrapped would let a composite shutter repeat and cost a missed fire. + MGPipeWidenedCounter m_framebufferBind; + // Bits 2 and 3 are BitwiseEqual shutters, not counters. + PixelStoreParameters m_pack{}; + PatchTrio m_patch{}; + + const void* m_context = nullptr; + Uint32 m_lastDirty = 0; + Bool m_primed = false; + + Uint64 m_fires[kMGPipeDirtyCount][kMGPipeVerbClassCount]{}; + Uint64 m_walks[kMGPipeVerbClassCount]{}; + }; + + // The monolith's one tracker. Under split there is one per client context; the context + // identity check inside Update is what makes the single instance safe today. + inline MGPipeTracker& MGPipeTrackerInstance() { + static MGPipeTracker tracker; + return tracker; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp b/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp index 3d2df3a19..4f42aa97f 100644 --- a/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp +++ b/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp @@ -190,12 +190,12 @@ TEST_F(PipeInputsTest, OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale) { #if !MOBILEGL_PIPE_POISON GTEST_SKIP() << "poison not compiled in (MOBILEGL_PIPE_POISON=0)"; #else - MGPipeFillForVerb(MGPipeVerb::GenerateMipmap); + MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap); EXPECT_TRUE(Fresh(MGPipeInputField::GetActiveTextureUnit)); EXPECT_TRUE(Fresh(MGPipeInputField::GetTextureUnitObject)); MGPipeSetPoisonOmission("GenerateMipmap", "GetActiveTextureUnit"); - MGPipeFillForVerb(MGPipeVerb::GenerateMipmap); + MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap); EXPECT_TRUE(Fresh(MGPipeInputField::GetTextureUnitObject)); EXPECT_FALSE(Fresh(MGPipeInputField::GetActiveTextureUnit)); // The value was still copied: only the stamp is withheld. @@ -212,7 +212,7 @@ TEST_F(PipeInputsTest, OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale) { } } - MGPipeFillForVerb(MGPipeVerb::DrawArrays); + MGPipeValidateForVerb(MGPipeVerb::DrawArrays); EXPECT_FALSE(Fresh(MGPipeInputField::GetActiveTextureUnit)); EXPECT_TRUE(Fresh(MGPipeInputField::GetBoundVertexArray)); EXPECT_TRUE(Fresh(MGPipeInputField::GetRenderStateParameters)); @@ -220,7 +220,7 @@ TEST_F(PipeInputsTest, OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale) { EXPECT_TRUE(Fresh(MGPipeInputField::RecordError)); // And the omission is scoped to its verb: a different verb of the same class keeps it. - MGPipeFillForVerb(MGPipeVerb::BindImageTexture); + MGPipeValidateForVerb(MGPipeVerb::BindImageTexture); EXPECT_TRUE(Fresh(MGPipeInputField::GetActiveTextureUnit)); #endif } @@ -237,9 +237,9 @@ TEST_F(PipeInputsTest, ReadingAnOmittedFieldAbortsNamingTheVerb) { ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; const ChildResult r = RunInChild([] { MGPipeSetPoisonOmission("GenerateMipmap", "GetActiveTextureUnit"); - MGPipeFillForVerb(MGPipeVerb::DrawArrays); + MGPipeValidateForVerb(MGPipeVerb::DrawArrays); (void)gPipeInputs.GetRenderStateParameters(); // a filled field of the preceding draw: must not abort - MGPipeFillForVerb(MGPipeVerb::GenerateMipmap); + MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap); (void)gPipeInputs.GetTextureUnitObject(0); // the sibling field: filled, must not abort (void)gPipeInputs.GetActiveTextureUnit(); // the omitted field: Fatal ::_exit(3); // reached only if the poison failed @@ -258,9 +258,9 @@ TEST_F(PipeInputsTest, ReadingAFilledFieldCompletes) { #else ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; const ChildResult r = RunInChild([] { - MGPipeFillForVerb(MGPipeVerb::DrawArrays); + MGPipeValidateForVerb(MGPipeVerb::DrawArrays); (void)gPipeInputs.GetRenderStateParameters(); - MGPipeFillForVerb(MGPipeVerb::GenerateMipmap); + MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap); (void)gPipeInputs.GetTextureUnitObject(0); (void)gPipeInputs.GetActiveTextureUnit(); }); @@ -309,10 +309,10 @@ TEST_F(PipeInputsTest, PoisonOmitKnobArmsTheOmission) { GTEST_SKIP() << "no fork() on this platform"; #else ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; - MGPipeFillForVerb(MGPipeVerb::DrawArrays); // the parent's parse saw an empty knob + MGPipeValidateForVerb(MGPipeVerb::DrawArrays); // the parent's parse saw an empty knob const ChildResult r = RunInChild([] { MG_Config::Features.PipePoisonOmit = kOmissionKnob; - MGPipeFillForVerb(MGPipeVerb::GenerateMipmap); + MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap); (void)gPipeInputs.GetTextureUnitObject(0); // the sibling field: filled, must not abort (void)gPipeInputs.GetActiveTextureUnit(); // the omitted field: Fatal ::_exit(3); @@ -334,7 +334,7 @@ TEST_F(PipeInputsTest, BadPoisonOmitKnobIsFatalNamingTheKnob) { ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; const ChildResult r = RunInChild([] { MG_Config::Features.PipePoisonOmit = "NoSuchVerb:GetActiveTextureUnit"; - MGPipeFillForVerb(MGPipeVerb::GenerateMipmap); + MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap); ::_exit(3); }); ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; @@ -354,7 +354,7 @@ TEST_F(PipeInputsTest, CorruptedSnapshotFieldIsNamedWithItsSerial) { GTEST_SKIP() << "verify not compiled in (MOBILEGL_PIPE_VERIFY=OFF)"; #else const Uint64 serialBefore = gPipeInputs.FilledState().CurrentVerbSerial; - MGPipeFillForVerb(MGPipeVerb::DrawArrays); + MGPipeValidateForVerb(MGPipeVerb::DrawArrays); const Uint64 serial = gPipeInputs.FilledState().CurrentVerbSerial; EXPECT_EQ(serial, serialBefore + 1); const MGPipeFieldMask& mask = kMGPipeClassFieldMask[static_cast(MGPipeVerbClass::kDraw)]; @@ -392,11 +392,11 @@ TEST_F(PipeInputsTest, MutatedFieldIsNamedAtRead) { GTEST_SKIP() << "no fork() on this platform"; #else ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; - MGPipeFillForVerb(MGPipeVerb::Clear); // the parent armed nothing: Features.PipeVerify is false here + MGPipeValidateForVerb(MGPipeVerb::Clear); // the parent armed nothing: Features.PipeVerify is false here const Uint64 serial = gPipeInputs.FilledState().CurrentVerbSerial + 1; // the child's DrawArrays fill const ChildResult r = RunInChild([] { MG_Config::Features.PipeVerify = true; - MGPipeFillForVerb(MGPipeVerb::DrawArrays); + MGPipeValidateForVerb(MGPipeVerb::DrawArrays); const Float boundary = gPipeInputs.GetLineWidth(); // boundary == live: completes (void)gPipeInputs.GetRenderStateParameters(); MG_State::pGLContext->SetLineWidth(boundary + 1.0f); @@ -426,12 +426,12 @@ TEST_F(PipeInputsTest, VerifyCorruptKnobNamesTheFieldAtEntry) { GTEST_SKIP() << "no fork() on this platform"; #else ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; - MGPipeFillForVerb(MGPipeVerb::Clear); + MGPipeValidateForVerb(MGPipeVerb::Clear); const Uint64 serial = gPipeInputs.FilledState().CurrentVerbSerial + 1; const ChildResult r = RunInChild([] { MG_Config::Features.PipeVerify = true; MG_Config::Features.PipeVerifyCorrupt = "GetRenderStateParameters"; - MGPipeFillForVerb(MGPipeVerb::DrawArrays); + MGPipeValidateForVerb(MGPipeVerb::DrawArrays); ::_exit(3); }); ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; @@ -455,7 +455,7 @@ TEST_F(PipeInputsTest, BadVerifyCorruptKnobIsFatalNamingTheKnob) { const ChildResult r = RunInChild([] { MG_Config::Features.PipeVerify = true; MG_Config::Features.PipeVerifyCorrupt = "NoSuchField"; - MGPipeFillForVerb(MGPipeVerb::DrawArrays); + MGPipeValidateForVerb(MGPipeVerb::DrawArrays); ::_exit(3); }); ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; @@ -479,14 +479,14 @@ TEST_F(PipeInputsTest, VerifyFatalOffLogsTheDivergenceAndContinues) { GTEST_SKIP() << "no fork() on this platform"; #else ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; - MGPipeFillForVerb(MGPipeVerb::Clear); + MGPipeValidateForVerb(MGPipeVerb::Clear); const Uint64 serial = gPipeInputs.FilledState().CurrentVerbSerial + 1; const ChildResult r = RunInChild([] { MG_Config::Features.PipeVerify = true; MG_Config::Features.PipeVerifyFatal = false; MG_Config::Features.PipeVerifyCorrupt = "GetRenderStateParameters"; - MGPipeFillForVerb(MGPipeVerb::DrawArrays); - MGPipeFillForVerb(MGPipeVerb::DrawElements); + MGPipeValidateForVerb(MGPipeVerb::DrawArrays); + MGPipeValidateForVerb(MGPipeVerb::DrawElements); std::exit(0); }); ASSERT_TRUE(ExitedWith(r, 0)) << DescribeStatus(r) << "\n" << r.Log; @@ -511,7 +511,7 @@ TEST_F(PipeInputsTest, EveryVerbFillsItsClassAndNothingElse) { #else for (SizeT v = 0; v < kMGPipeVerbCount; ++v) { const auto verb = static_cast(v); - MGPipeFillForVerb(verb); + MGPipeValidateForVerb(verb); const MGPipeFieldMask& mask = kMGPipeClassFieldMask[static_cast(kMGPipeVerbClass[v])]; for (SizeT f = 0; f < kMGPipeInputFieldCount; ++f) { const auto field = static_cast(f); @@ -532,7 +532,7 @@ TEST_F(PipeInputsTest, EveryVerbFillsItsClassAndNothingElse) { // Without the notice the two reads below differ and the pushed value is the stale one. TEST_F(PipeInputsTest, AFrontendMutationInsideAVerbRefreshesThePushedField) { auto& ctx = *MG_State::pGLContext; - MGPipeFillForVerb(MGPipeVerb::DrawArrays); + MGPipeValidateForVerb(MGPipeVerb::DrawArrays); ASSERT_EQ(gPipeInputs.GetSamplingResolutionGeneration(), ctx.GetSamplingResolutionGeneration()); ASSERT_EQ(gPipeInputs.GetTextureBindGeneration(), ctx.GetTextureBindGeneration()); ASSERT_EQ(gPipeInputs.GetMaxTouchedTextureUnit(), ctx.GetMaxTouchedTextureUnit()); @@ -566,7 +566,7 @@ TEST_F(PipeInputsTest, TheMutationNoticeRefreshesTheValueButNotTheStamp) { #else auto& ctx = *MG_State::pGLContext; MGPipeSetPoisonOmission("DrawArrays", "GetSamplingResolutionGeneration"); - MGPipeFillForVerb(MGPipeVerb::DrawArrays); + MGPipeValidateForVerb(MGPipeVerb::DrawArrays); ASSERT_FALSE(Fresh(MGPipeInputField::GetSamplingResolutionGeneration)); ctx.BumpSamplingResolutionGeneration(); EXPECT_FALSE(Fresh(MGPipeInputField::GetSamplingResolutionGeneration)) @@ -575,7 +575,7 @@ TEST_F(PipeInputsTest, TheMutationNoticeRefreshesTheValueButNotTheStamp) { // FenceSync is a kQuery verb: its mask holds no texture field at all, so the notice must // leave the generation unfilled and a read of it Fatal{UnmigratedPipeInput}. MGPipeSetPoisonOmission(nullptr, nullptr); - MGPipeFillForVerb(MGPipeVerb::FenceSync); + MGPipeValidateForVerb(MGPipeVerb::FenceSync); ASSERT_FALSE(Fresh(MGPipeInputField::GetSamplingResolutionGeneration)); ctx.BumpSamplingResolutionGeneration(); EXPECT_FALSE(Fresh(MGPipeInputField::GetSamplingResolutionGeneration)) @@ -598,7 +598,7 @@ TEST_F(PipeInputsTest, AFrontendMutationInsideAVerbDoesNotDivergeAtRead) { ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; const ChildResult r = RunInChild([] { MG_Config::Features.PipeVerify = true; - MGPipeFillForVerb(MGPipeVerb::DrawArrays); + MGPipeValidateForVerb(MGPipeVerb::DrawArrays); (void)gPipeInputs.GetSamplingResolutionGeneration(); // boundary == live: completes auto& ctx = *MG_State::pGLContext; const Uint64 before = ctx.GetSamplingResolutionGeneration(); diff --git a/MobileGL/MG_Test/ScopedPipeVerb.h b/MobileGL/MG_Test/ScopedPipeVerb.h index aa0d90fcf..df8c5d9b1 100644 --- a/MobileGL/MG_Test/ScopedPipeVerb.h +++ b/MobileGL/MG_Test/ScopedPipeVerb.h @@ -45,7 +45,7 @@ namespace MobileGL::MG_Test { explicit ScopedPipeVerb([[maybe_unused]] MG_Pipe::MGPipeVerb verb) #if MOBILEGL_PIPE_PUSH : m_verb(verb) { - MG_Pipe::MGPipeFillForVerb(m_verb); + MG_Pipe::MGPipeValidateForVerb(m_verb); } #else { @@ -60,7 +60,7 @@ namespace MobileGL::MG_Test { // entry point's MGP_FILL would. void Renew() { #if MOBILEGL_PIPE_PUSH - MG_Pipe::MGPipeFillForVerb(m_verb); + MG_Pipe::MGPipeValidateForVerb(m_verb); #endif } From aa64c910522059e3535c9cb46c30396138f025cd Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:28:25 -0400 Subject: [PATCH 102/529] [Feat] (Pipe): mint render-state CSOs on the pipeline subset and send only the dynamic chunks that moved - the steady-state cost of the whole render-state family is now two Uint16 compares - MG_Impl/Pipe/CsoCache.h: 64 entries, LRU, hash -> probe -> MEMCMP -> handle. The memcmp is not optional: a bare 64-bit hash equality would let a collision alias two different render states onto one CSO, which is silent wrong pixels with no gate that can see it, and Mesa's cso_cache memcmps for exactly that reason. It runs only when the pipeline version moved, so never in the steady state. Eviction emits delete_render_state and frees the client slot. - kMGPipeBehaviourNoCsoContentAddressing (bit 63) turns off the PROBE and the handle reuse, not the records: every pipeline-version change then mints, binds and evicts, which is the whole-block content addressing the design is measured against. - The validate point's step 3: bind_render_state when the pipeline version moved (12 bytes, no hashing, no blob when the cache hits), set_dynamic_state when m_version moved, carrying only the dynamic chunks that differ from the tracker's staging mirror. An EMPTY chunk mask still sends the 32-byte header, because the version is what Magma's dynamic tail gates on and it moved. - The residual fill now skips a field a P2 call supplies, driven by the generated kMGPipeFieldEmittedBy[] and gated per subsystem on the runtime MOBILEGL_PIPE_PUSH bitmask, so the bitmask is a true per-subsystem A/B. THE STAMP IS UNCHANGED: a stamp says "this verb published this field", which is as true of an emitted field as of a copied one, and withholding it would abort every backend read of the fields the migration just took over. - PipeStats::RecordDrawPayloadBytes has been implemented, unit-tested and called by nothing since P0. This is its first emitter. - A field that reaches PipeInputs only through MGPipeDeriveRenderStateFields is skipped only when that derivation is really there. It is package A's and is a declared stub on the p2/contract tag this branch starts from, so rather than hard-code which branch this is, the filler probes once: a sentinel in a scratch block, the mirror cleared, the derivation run, the answer latched. It stays useful after A lands - if the derivation is ever deleted the filler degrades to PULLING those fields rather than rendering a default. - integration-verify, 818 entries, green: the comparator re-reads every field from the live context at every backend read, so "the assembled block equals the live context" is now proven rather than asserted, and the entry compare has stopped being a tautology. --- MobileGL/MG_Impl/Pipe/CsoCache.h | 185 ++++++++++++++++++++++++++++ MobileGL/MG_Impl/Pipe/PipeFill.cpp | 188 ++++++++++++++++++++++++++++- MobileGL/MG_Impl/Pipe/Tracker.h | 17 +++ 3 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 MobileGL/MG_Impl/Pipe/CsoCache.h diff --git a/MobileGL/MG_Impl/Pipe/CsoCache.h b/MobileGL/MG_Impl/Pipe/CsoCache.h new file mode 100644 index 000000000..6a26b08d9 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/CsoCache.h @@ -0,0 +1,185 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/CsoCache.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +// The render-state CSO cache (ARCHITECTURE.md 4.5.2 / 5.3, P2 brief D7). +// +// THE LOOKUP, and the first step is the whole point: +// 1. m_pipelineStateVersion (widened) did not move -> reuse the last handle. ZERO hashing, +// zero probing, and nothing is emitted unless m_version also moved. That is the steady +// state of every frame, and it is why the tracker asks the cache at all only when the +// dirty walk says the pipeline version moved. +// 2. moved -> hash the 396 pipeline bytes, probe, and on a hit CONFIRM WITH A MEMCMP +// before reusing the handle. ARCHITECTURE.md 4.1 says content addressing on an +// xxHash; a bare 64-bit equality would let a collision alias two different render +// states onto one CSO, which is silent wrong pixels with no gate that can see it. +// Mesa's cso_cache memcmps for the same reason. The memcmp only ever runs on a +// pipeline-version change, i.e. never in the steady state. +// 3. miss -> mint a slot, emit create_render_state with every pipeline chunk, then bind. +// +// CAPACITY 64 (ROADMAP.md P2). 64 x (8 + 8 + 396 + 8) = about 26 KB per context. ROADMAP.md +// open question 4 says 64 is provisional and the counters retune it at P13; this ships 64 +// and publishes the mint / bind / evict counters that retune reads. +// +// THE NEGATIVE CONTROL. kMGPipeBehaviourNoCsoContentAddressing (bit 63 of the runtime +// MOBILEGL_PIPE_PUSH bitmask) turns off the PROBE and the handle reuse, not the records: +// every pipeline-version change then mints a fresh CSO, binds it and evicts, which is +// precisely "whole-block content addressing" and reproduces the regression +// RenderState.h records. It is what separates "push is slower" from "the CSO design is +// slower", and CsoContentAddressingScenario (package E) is the always-on ctest that stops +// the switch from rotting. +// +// Header-only for the same ownership reason as Tracker.h: the root CMakeLists.txt that +// would name a new .cpp is package A's and is frozen behind the p2/contract tag. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include +#include + +#include + +namespace MobileGL::MG_Pipe { + + inline constexpr SizeT kMGPipeCsoCacheCapacity = 64; + + class MGPipeCsoCache { + public: + struct Counters { + Uint64 Mints = 0; // create_render_state emissions + Uint64 Binds = 0; // bind_render_state emissions, mint or reuse + Uint64 Hits = 0; // a probe that found a live entry and passed the memcmp + Uint64 Collisions = 0; // a hash hit the memcmp REJECTED - the reason it exists + Uint64 Evictions = 0; // LRU evictions, each one a delete_render_state + }; + + // The handle for `params`' pipeline subset. Mints and emits create_render_state on a + // miss; emits delete_render_state for whatever it evicts to make room. `payloadBytes` + // accumulates what went on the wire, for PipeStats::RecordDrawPayloadBytes. + MGPipeHandle Acquire(const RenderStateParameters& params, Uint64& payloadBytes) { + Array bytes; + MGPipeGatherPipelineBytes(params, bytes.data()); + + const Bool contentAddressed = + (MG_Config::Features.PipePush & kMGPipeBehaviourNoCsoContentAddressing) == 0; + if (contentAddressed) { + const Uint64 hash = MGPipeHashPipelineBytes(bytes.data()); + for (SizeT i = 0; i < m_entries.size(); ++i) { + if (m_entries[i].Hash != hash) continue; + if (std::memcmp(m_entries[i].Bytes.data(), bytes.data(), bytes.size()) != 0) { + // A 64-bit collision between two DIFFERENT render states. Reusing the + // handle here would render one state with the other's pipeline, so the + // entry is dropped and the caller mints - correctness first, and the + // counter says how often it happened. + ++m_counters.Collisions; + Evict(i); + break; + } + m_entries[i].LastUsed = ++m_clock; + ++m_counters.Hits; + return m_entries[i].Cso; + } + return Mint(hash, bytes, payloadBytes); + } + // Content addressing OFF: never probe, always mint. The records still exist, so + // the arm differs from the default one in exactly one thing - whether a handle is + // reused - which is what makes it a control rather than a different design. + return Mint(0, bytes, payloadBytes); + } + + // Context teardown, a server reset, a unit test's fixture. Emits nothing: the applier + // is reset alongside, and a delete for a record that is about to be dropped anyway + // would be a wire message with no reader. + void Reset() { + for (auto& entry : m_entries) MGPipeSlots().Free(MGPipeKind::RenderStateCso, entry.Cso); + m_entries.clear(); + m_clock = 0; + } + + void ResetCounters() { m_counters = Counters{}; } + + SizeT Size() const { return m_entries.size(); } + const Counters& GetCounters() const { return m_counters; } + + private: + struct Entry { + Uint64 Hash = 0; + Uint64 LastUsed = 0; + MGPipeHandle Cso = kMGPipeNullHandle; + Array Bytes{}; + }; + + MGPipeHandle Mint(Uint64 hash, const Array& bytes, + Uint64& payloadBytes) { + if (m_entries.size() >= kMGPipeCsoCacheCapacity) { + SizeT victim = 0; + for (SizeT i = 1; i < m_entries.size(); ++i) { + if (m_entries[i].LastUsed < m_entries[victim].LastUsed) victim = i; + } + Evict(victim); + } + + const MGPipeHandle cso = MGPipeSlots().Allocate(MGPipeKind::RenderStateCso); + MGPRenderStateDesc desc{}; + desc.Cso = cso; + desc.BaseCso = kMGPipeNullHandle; + // A brand-new CSO names every pipeline chunk; the incremental form against a + // BaseCso is what the applier's assertion allows and P3 will use once a CSO is + // minted from a neighbour rather than from nothing. + desc.ChunkMask = kAllPipelineChunks; + desc.Blob.Size = kMGPipePipelineChunkBytes; + MGPipeApplyCreateRenderState(desc, bytes.data()); + payloadBytes += sizeof(MGPRenderStateDesc) + kMGPipePipelineChunkBytes; + + Entry entry; + entry.Hash = hash; + entry.LastUsed = ++m_clock; + entry.Cso = cso; + entry.Bytes = bytes; + m_entries.push_back(entry); + + ++m_counters.Mints; + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::RenderStateCsoMints, 1); + } + return cso; + } + + void Evict(SizeT index) { + MGPHandleOnly handle{}; + handle.Handle = m_entries[index].Cso; + handle.Kind = static_cast(MGPipeKind::RenderStateCso); + MGPipeApplyDeleteRenderState(handle); + MGPipeSlots().Free(MGPipeKind::RenderStateCso, m_entries[index].Cso); + m_entries[index] = m_entries.back(); + m_entries.pop_back(); + ++m_counters.Evictions; + } + + static constexpr Uint32 kAllPipelineChunks = + static_cast((Uint64{1} << kMGPipePipelineChunkCount) - 1); + + Vector m_entries; + Uint64 m_clock = 0; + Counters m_counters; + }; + + // The monolith's one cache, held beside the tracker. A Vector scan rather than a hash + // map on purpose: 64 entries of Uint64 is a handful of cache lines, it is probed only + // when the pipeline version moved, and it keeps the eviction order in the same array as + // the content - a map would need a second structure to answer "which is oldest". + inline MGPipeCsoCache& MGPipeCsoCacheInstance() { + static MGPipeCsoCache cache; + return cache; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 89ae1e01b..40b3845ba 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -15,8 +15,11 @@ #include #include #include +#include #include #include +#include +#include #include #include @@ -34,6 +37,12 @@ namespace MobileGL::MG_Pipe { // accessor of the same name (P1 brief D4: no derivation logic is re-implemented // here, which is what keeps the copy semantically identical by construction). // A forwarded field has no storage and copies nothing. + // The two doors the P2 emission step needs into PipeInputs' storage. They exist + // only for ApplierDerivesRenderStateFields' one-shot probe below; nothing on the hot + // path writes through them. + static RenderStateParameters& RenderStateOf(PipeInputs& inputs) { return inputs.m_renderState; } + static Uint32& ClearStencilOf(PipeInputs& inputs) { return inputs.m_clearStencil; } + static void CopyField(PipeInputs& dst, GLContext& ctx, MGPipeInputField field) { using F = MGPipeInputField; using MG_State::GLState::BufferBindPointTargets; @@ -596,6 +605,150 @@ namespace MobileGL::MG_Pipe { MGPipeFillAccess::SetVerb(inputs, MGPipeVerb::kVerbCount); } + + // ================================================================================ + // The emission step (P2 brief D1 step 3, D5, D6, D7) + // ================================================================================ + namespace { + // Which runtime MOBILEGL_PIPE_PUSH subsystem owns a field, through the call that now + // supplies it. Zero means "still pulled". + constexpr Uint64 SubsystemForEmitter(MGPipeFieldEmitter emitter) { + switch (emitter) { + case MGPipeFieldEmitter::BindRenderState: + case MGPipeFieldEmitter::CreateRenderState: + case MGPipeFieldEmitter::SetDynamicState: + return kMGPipeSubsystemRenderState; + case MGPipeFieldEmitter::SetPixelPackState: + return kMGPipeSubsystemPixelPack; + case MGPipeFieldEmitter::SetPatchState: + return kMGPipeSubsystemPatchState; + case MGPipeFieldEmitter::SetVertexAttribDefaults: + return kMGPipeSubsystemVertexAttribDefaults; + case MGPipeFieldEmitter::kNone: + break; + } + return 0; + } + + // Which of those subsystems THIS BUILD actually emits for. It grows one commit at a + // time, and a field whose emitter is not wired here keeps being pulled - so adding a + // row to Coverage.def can never silently drop a field on the floor before the call + // that carries it exists. + constexpr Uint64 kMGPipeWiredSubsystems = kMGPipeSubsystemRenderState; + + // The fields the applier writes DIRECTLY, out of the chunk bytes it scattered. Every + // other emitted field reaches PipeInputs only through + // MGPipeDeriveRenderStateFields, which is why the probe below exists. + constexpr Bool AppliedWithoutDerivation(MGPipeInputField field) { + switch (field) { + case MGPipeInputField::GetRenderStateParameters: + case MGPipeInputField::GetRenderStateParametersVersion: + case MGPipeInputField::GetPipelineStateVersion: + case MGPipeInputField::GetPixelStoreParameters: + case MGPipeInputField::GetPatchVertices: + case MGPipeInputField::GetPatchDefaultOuterLevel: + case MGPipeInputField::GetPatchDefaultInnerLevel: + case MGPipeInputField::GetCurrentVertexAttribute: + return true; + default: + return false; + } + } + + // DOES THIS TREE'S APPLIER ACTUALLY DERIVE? + // + // MGPipeDeriveRenderStateFields is package A's, and on the P2 contract tag it is a + // declared stub whose body lands in A's follow-on commit. A field that reaches + // PipeInputs only through that derivation must NOT be skipped by the residual fill + // while the derivation is a stub: skipping it would leave the mirror unwritten and + // the backend reading a default. + // + // Rather than hard-code which branch this is, the filler asks once: it puts a + // sentinel in a scratch block's working RenderStateParameters, clears the mirror the + // derivation is supposed to recompute, runs the derivation, and looks. The answer is + // latched for the process and costs one compare, once. + // + // It stays useful after A lands: if the derivation is ever deleted or gated off, the + // filler degrades to PULLING those fields instead of rendering a default, which is + // the safe direction. The verify lane and RenderStateSpansTest are what say the + // derivation is CORRECT; this only says it is THERE. + Bool ApplierDerivesRenderStateFields() { + static const Bool answer = [] { + static PipeInputs probe; + constexpr Uint32 kSentinel = 0x5a5a5a5au; + MGPipeFillAccess::RenderStateOf(probe).ClearStencil = kSentinel; + MGPipeFillAccess::ClearStencilOf(probe) = 0u; + MGPipeDeriveRenderStateFields(probe); + const Bool derives = MGPipeFillAccess::ClearStencilOf(probe) == kSentinel; + if (!derives) { + MGLOG_W_ONCE("MGPipe: MGPipeDeriveRenderStateFields does not derive on this " + "build - the render-state mirrors stay on the pull path"); + } + return derives; + }(); + return answer; + } + + constexpr Uint32 kAllDynamicChunks = + static_cast((Uint64{1} << kMGPipeDynamicChunkCount) - 1); + + // create/bind_render_state and set_dynamic_state. Returns the bytes that went on the + // wire, for the payload histogram. + Uint64 EmitRenderState(GLContext& ctx, Uint32 dirty, Bool freshlyPrimed) { + MGPipeTracker& tracker = MGPipeTrackerInstance(); + const RenderStateParameters& live = ctx.GetRenderStateParameters(); + const auto version = static_cast(ctx.GetRenderStateParametersVersion()); + const auto pipelineVersion = static_cast(ctx.GetPipelineStateVersion()); + Uint64 payloadBytes = 0; + + if (freshlyPrimed) { + // A fresh context is a fresh server: the cache's handles name slots this + // client's allocator is about to hand out again, so both sides start over + // together rather than one of them remembering the other's objects. + MGPipeCsoCacheInstance().Reset(); + MGPipeApplierReset(); + } + + if (dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState)) { + const MGPipeHandle cso = MGPipeCsoCacheInstance().Acquire(live, payloadBytes); + MGPBindRenderState bind{}; + bind.Cso = cso; + bind.Version = version; + bind.PipelineVersion = pipelineVersion; + MGPipeApplyBindRenderState(bind); + payloadBytes += sizeof(MGPBindRenderState); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::RenderStateCsoBinds, 1); + } + } + + if (dirty & MGPipeDirtyBit(MGPipeDirty::NewRenderState)) { + // The chunk-level suppressor: only the dynamic chunks that differ from what + // the server has. A glViewport sends chunk D0 and nothing else; a + // glClearColor sends D2. An EMPTY mask still sends the 32-byte header, + // because the VERSION is what Magma's dynamic tail gates on and it moved. + const Uint32 chunkMask = + freshlyPrimed ? kAllDynamicChunks + : MGPipeDynamicChunksThatMoved(live, tracker.Staged()); + Array blob; + const SizeT blobBytes = MGPipeDynamicChunkBlobBytes(chunkMask); + MGPipeGatherDynamicChunks(live, chunkMask, blob.data()); + MGPDynamicState dyn{}; + dyn.ChunkMask = chunkMask; + dyn.Version = version; + dyn.Blob.Size = blobBytes; + MGPipeApplySetDynamicState(dyn, blob.data()); + payloadBytes += sizeof(MGPDynamicState) + blobBytes; + } + + if (dirty & (MGPipeDirtyBit(MGPipeDirty::NewPipelineState) | + MGPipeDirtyBit(MGPipeDirty::NewRenderState))) { + tracker.Staged() = live; + } + return payloadBytes; + } + } // namespace + // ---- the validate point (P2 brief D1) ---- void MGPipeValidateForVerb(MGPipeVerb verb) { PipeInputs& inputs = gPipeInputs; @@ -628,10 +781,27 @@ namespace MobileGL::MG_Pipe { // The mask is computed, latched and counted here and nothing is emitted from it // yet: this commit is the safety net that says the walk is semantically free // before any field stops being pulled. The emission steps land on top of it. - const Uint32 dirty = MGPipeTrackerInstance().Update(*ctx, verbClass); - (void)dirty; + MGPipeTracker& tracker = MGPipeTrackerInstance(); + const Uint32 dirty = tracker.Update(*ctx, verbClass); + + // ---- step 3: emission ---- + const Uint64 pushMask = MG_Config::Features.PipePush; + Uint64 payloadBytes = 0; + if ((pushMask & kMGPipeSubsystemRenderState) != 0 && + (dirty & (MGPipeDirtyBit(MGPipeDirty::NewPipelineState) | + MGPipeDirtyBit(MGPipeDirty::NewRenderState))) != 0) { + payloadBytes += EmitRenderState(*ctx, dirty, tracker.FreshlyPrimed()); + } + if (payloadBytes != 0 && MG_Util::PipeStats::Enabled()) { + // PipeStats::RecordDrawPayloadBytes has been implemented and unit-tested since + // P0 and called by nothing; this is its first emitter, and the 24-bucket + // histogram is what answers ROADMAP.md open question 4's chunk-granularity + // retune with data instead of a guess. + MG_Util::PipeStats::RecordDrawPayloadBytes(payloadBytes); + } - // ---- step 4: the residual fill ---- + // ---- step 4: the residual fill, for what an emitted call did NOT supply ---- + const Bool applierDerives = ApplierDerivesRenderStateFields(); for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { const auto field = static_cast(i); if (!MGPipeFieldMaskHas(mask, field)) continue; @@ -645,7 +815,17 @@ namespace MobileGL::MG_Pipe { #else if (kMGPipeInputFieldSticky[i]) continue; #endif - MGPipeFillAccess::CopyField(inputs, *ctx, field); + // A field a P2 call now supplies is not pulled again - that second pull is + // exactly the cost P2 exists to remove. THE STAMP IS UNCHANGED either way: a + // stamp says "this verb published this field", which is as true of an emitted + // field as of a copied one, and withholding it would abort every backend read of + // the very fields the migration just took over. + const MGPipeFieldEmitter emitter = kMGPipeFieldEmittedBy[i]; + const Uint64 subsystem = SubsystemForEmitter(emitter); + const Bool supplied = subsystem != 0 && (subsystem & kMGPipeWiredSubsystems) != 0 && + (pushMask & subsystem) != 0 && + (applierDerives || AppliedWithoutDerivation(field)); + if (!supplied) MGPipeFillAccess::CopyField(inputs, *ctx, field); #if MOBILEGL_PIPE_POISON // The value is copied either way; only the stamp is withheld for the omitted pair. if (!IsOmitted(verb, field)) filled.FilledGen[i] = filled.CurrentVerbSerial; diff --git a/MobileGL/MG_Impl/Pipe/Tracker.h b/MobileGL/MG_Impl/Pipe/Tracker.h index a01e21a67..f2684a8ec 100644 --- a/MobileGL/MG_Impl/Pipe/Tracker.h +++ b/MobileGL/MG_Impl/Pipe/Tracker.h @@ -186,6 +186,7 @@ namespace MobileGL::MG_Pipe { Reset(); m_context = &ctx; } + const Bool wasPrimed = m_primed; Uint64 now[kMGPipeDirtyCount]; const RenderStateParameters& render = ctx.GetRenderStateParameters(); @@ -285,6 +286,7 @@ namespace MobileGL::MG_Pipe { } m_primed = true; + m_freshlyPrimed = !wasPrimed; m_lastDirty = dirty; if (MG_Util::PipeStats::Enabled()) { @@ -308,9 +310,11 @@ namespace MobileGL::MG_Pipe { m_framebufferBind.Reset(); m_pack = PixelStoreParameters{}; m_patch = PatchTrio{}; + m_staged = RenderStateParameters{}; m_context = nullptr; m_lastDirty = 0; m_primed = false; + m_freshlyPrimed = false; } void ResetCounters() { @@ -337,6 +341,16 @@ namespace MobileGL::MG_Pipe { Uint32 LastDirty() const { return m_lastDirty; } Bool Primed() const { return m_primed; } + // True when the LAST Update was the first one after a Reset - a fresh context, or a + // server reset. The emission step reads it to send a COMPLETE state rather than an + // increment against a staging mirror that describes a context that is gone. + Bool FreshlyPrimed() const { return m_freshlyPrimed; } + + // "What the server has" (P2 brief D8). set_dynamic_state sends the dynamic chunks + // that differ from this, which is the chunk-level suppressor; a chunk that + // memcmp-matches is not sent at all. + RenderStateParameters& Staged() { return m_staged; } + const RenderStateParameters& Staged() const { return m_staged; } private: static constexpr SizeT Index(MGPipeDirty bit) { return static_cast(bit); } @@ -357,9 +371,12 @@ namespace MobileGL::MG_Pipe { PixelStoreParameters m_pack{}; PatchTrio m_patch{}; + RenderStateParameters m_staged{}; + const void* m_context = nullptr; Uint32 m_lastDirty = 0; Bool m_primed = false; + Bool m_freshlyPrimed = false; Uint64 m_fires[kMGPipeDirtyCount][kMGPipeVerbClassCount]{}; Uint64 m_walks[kMGPipeVerbClassCount]{}; From dcfa5ad31170afe975484d8377005fc483545921 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:34:38 -0400 Subject: [PATCH 103/529] [Feat] (Pipe): push pixel-pack, patch and vertex-attribute-default state as their own calls, the last one behind the set-hash suppressor - set_pixel_pack_state on NEW_PIXEL_PACK, set_patch_state on NEW_PATCH_STATE, set_vertex_attrib_defaults on NEW_VERTEX_ATTRIB_DEFAULTS, each gated on its own runtime subsystem bit so the bitmask stays a per-subsystem A/B. - MG_Impl/Pipe/SetHashSuppressor.h: seven slots, one per kVarTail set_*, with SetVertexAttribDefaults the one P2 wires. 0 is reserved for "never emitted" and a computed 0 is remapped to 1, so the first emission always goes out. The other six are the carrier for the ~175 lines of backend debounce that move in P3b/P4b; landing the mechanism now means the shape is pinned by a test rather than by a plan. - The var-tail carries only the attributes that differ from the tracker's mirror, underneath the set-hash suppression of the whole resolved set - the two suppressors answer different questions and both are cheap. Two rows of Coverage.def's emitted list CANNOT yet retire their pull, and each says why in the code rather than being silently absent: - GetPixelStoreParameters is BOTH halves of the pixel store and set_pixel_pack_state deliberately carries only PACK, so the unpack half has no carrier at all. The field keeps being pulled and the verify comparator keeps proving it. - GetCurrentVertexAttribute's three views are not bit-identical - GLContext CONVERTS between them - while MGPipeApplySetVertexAttribDefaults memcpys one Data[4] into all three and ignores MGPAttribValue::ValueClass, which the wire type carries precisely so it does not have to. Until that applier reads ValueClass the carrier cannot reproduce the frontend value. The call is still emitted, so the wire shape, the payload bytes and the suppressor are all real; the residual fill runs after emission, so the mirror ends up correct either way. Both are contract-side defects in files this package does not own; they are reported to the integrator with the exact fix rather than worked around here. --- MobileGL/MG_Impl/Pipe/PipeFill.cpp | 114 +++++++++++++++++++++- MobileGL/MG_Impl/Pipe/SetHashSuppressor.h | 85 ++++++++++++++++ MobileGL/MG_Impl/Pipe/Tracker.h | 10 ++ 3 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 MobileGL/MG_Impl/Pipe/SetHashSuppressor.h diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 40b3845ba..4cd43fcaf 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -634,7 +635,38 @@ namespace MobileGL::MG_Pipe { // time, and a field whose emitter is not wired here keeps being pulled - so adding a // row to Coverage.def can never silently drop a field on the floor before the call // that carries it exists. - constexpr Uint64 kMGPipeWiredSubsystems = kMGPipeSubsystemRenderState; + constexpr Uint64 kMGPipeWiredSubsystems = kMGPipeSubsystemRenderState | + kMGPipeSubsystemPixelPack | + kMGPipeSubsystemPatchState | + kMGPipeSubsystemVertexAttribDefaults; + + // A field an emitted call supplies COMPLETELY, so the residual fill may stop pulling + // it. Two rows of Coverage.def's emitted list do not qualify and each has its reason + // recorded here rather than a silent absence: + // + // GetPixelStoreParameters is BOTH halves of the pixel store (m_pixelStore[0] pack + // and [1] unpack) and set_pixel_pack_state deliberately carries only PACK + // (ARCHITECTURE.md 4.6 D5, MGPipeTypes.h). The unpack half has no carrier at all, + // so the field keeps being pulled and the verify comparator keeps proving it. + // + // GetCurrentVertexAttribute's three views are NOT bit-identical: GLContext + // CONVERTS between them (SetCurrentVertexAttributeFloat writes (Int32)value into + // intValue), while MGPipeApplySetVertexAttribDefaults memcpys one Data[4] into + // all three and ignores MGPAttribValue::ValueClass, which the wire type carries + // precisely so it does not have to. Until that applier reads ValueClass the + // carrier cannot reproduce the frontend value, so the field keeps being pulled. + // The call is still emitted: the wire shape, the payload bytes and the set-hash + // suppressor are all real, and the residual fill runs AFTER emission, so the + // mirror ends up with the frontend's value either way. + constexpr Bool EmittedCallSuppliesTheWholeField(MGPipeInputField field) { + switch (field) { + case MGPipeInputField::GetPixelStoreParameters: + case MGPipeInputField::GetCurrentVertexAttribute: + return false; + default: + return true; + } + } // The fields the applier writes DIRECTLY, out of the chunk bytes it scattered. Every // other emitted field reaches PipeInputs only through @@ -689,6 +721,69 @@ namespace MobileGL::MG_Pipe { return answer; } + + // set_pixel_pack_state. PACK only, deliberately: nothing on the far side of the + // boundary reads unpack state, and the staged-repack upload path does not even issue + // glPixelStorei (ARCHITECTURE.md 4.6 D5). + Uint64 EmitPixelPackState(GLContext& ctx) { + MGPPixelPackState pack{}; + pack.Pack = ctx.GetPixelStoreParameters(false); + MGPipeApplySetPixelPackState(pack); + return sizeof(MGPPixelPackState); + } + + // set_patch_state. The trio ALSO travels in pipeline chunk P0, and that redundancy is + // a trip wire rather than waste: the applier asserts under verify that the two + // carriers agree. 28 bytes on a state that changes about once per program. + Uint64 EmitPatchState(GLContext& ctx) { + const RenderStateParameters& live = ctx.GetRenderStateParameters(); + MGPPatchState patch{}; + patch.Vertices = live.PatchVertices; + for (SizeT i = 0; i < 4; ++i) patch.Outer[i] = live.PatchDefaultOuterLevel[i]; + for (SizeT i = 0; i < 2; ++i) patch.Inner[i] = live.PatchDefaultInnerLevel[i]; + MGPipeApplySetPatchState(patch); + return sizeof(MGPPatchState); + } + + // set_vertex_attrib_defaults, behind D11's set-hash suppressor: the RESOLVED set - all + // 32 values, all three views - is hashed on the client and the call does not go out + // when the hash has not moved. That is coalescing rule 4, and this is its one wired + // consumer in P2. + Uint64 EmitVertexAttribDefaults(GLContext& ctx) { + MGPipeTracker& tracker = MGPipeTrackerInstance(); + auto& staged = tracker.StagedAttribDefaults(); + constexpr SizeT kAttribs = MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS; + static_assert(kAttribs <= 32, "MGPVertexAttribDefaults::Mask is a Uint32"); + + Array resolved; + for (SizeT i = 0; i < kAttribs; ++i) resolved[i] = ctx.GetCurrentVertexAttribute(static_cast(i)); + + const Uint64 contentHash = XXH64(resolved.data(), sizeof(resolved), 0); + if (!MGPipeSetHashSuppressorInstance().ShouldEmit(MGPipeSuppressorSlot::SetVertexAttribDefaults, + contentHash)) { + return 0; + } + + Array tail{}; + MGPVertexAttribDefaults header{}; + for (SizeT i = 0; i < kAttribs; ++i) { + if (std::memcmp(&resolved[i], &staged[i], sizeof(resolved[i])) == 0) continue; + MGPAttribValue& value = tail[header.Count]; + value.Location = static_cast(i); + // ClassifyVertexAttribType resolves the float/int/uint view on the CLIENT + // (MGPipeTypes.h); the frontend keeps all three populated, so the class the + // shader input consumes is what decides which one is authoritative. + value.ValueClass = 0; + std::memcpy(value.Data, resolved[i].floatValue.data(), sizeof(value.Data)); + header.Mask |= Uint32{1} << static_cast(i); + ++header.Count; + staged[i] = resolved[i]; + } + if (header.Count == 0) return 0; + MGPipeApplySetVertexAttribDefaults(header, tail.data()); + return sizeof(MGPVertexAttribDefaults) + header.Count * sizeof(MGPAttribValue); + } + constexpr Uint32 kAllDynamicChunks = static_cast((Uint64{1} << kMGPipeDynamicChunkCount) - 1); @@ -792,6 +887,22 @@ namespace MobileGL::MG_Pipe { MGPipeDirtyBit(MGPipeDirty::NewRenderState))) != 0) { payloadBytes += EmitRenderState(*ctx, dirty, tracker.FreshlyPrimed()); } + if (tracker.FreshlyPrimed()) { + // A fresh context: what the server has is no longer what any slot last emitted. + MGPipeSetHashSuppressorInstance().InvalidateAll(); + } + if ((pushMask & kMGPipeSubsystemPixelPack) != 0 && + (dirty & MGPipeDirtyBit(MGPipeDirty::NewPixelPack)) != 0) { + payloadBytes += EmitPixelPackState(*ctx); + } + if ((pushMask & kMGPipeSubsystemPatchState) != 0 && + (dirty & MGPipeDirtyBit(MGPipeDirty::NewPatchState)) != 0) { + payloadBytes += EmitPatchState(*ctx); + } + if ((pushMask & kMGPipeSubsystemVertexAttribDefaults) != 0 && + (dirty & MGPipeDirtyBit(MGPipeDirty::NewVertexAttribDefaults)) != 0) { + payloadBytes += EmitVertexAttribDefaults(*ctx); + } if (payloadBytes != 0 && MG_Util::PipeStats::Enabled()) { // PipeStats::RecordDrawPayloadBytes has been implemented and unit-tested since // P0 and called by nothing; this is its first emitter, and the 24-bucket @@ -824,6 +935,7 @@ namespace MobileGL::MG_Pipe { const Uint64 subsystem = SubsystemForEmitter(emitter); const Bool supplied = subsystem != 0 && (subsystem & kMGPipeWiredSubsystems) != 0 && (pushMask & subsystem) != 0 && + EmittedCallSuppliesTheWholeField(field) && (applierDerives || AppliedWithoutDerivation(field)); if (!supplied) MGPipeFillAccess::CopyField(inputs, *ctx, field); #if MOBILEGL_PIPE_POISON diff --git a/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h b/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h new file mode 100644 index 000000000..20081bd2f --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h @@ -0,0 +1,85 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/SetHashSuppressor.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +// Coalescing rule 4 (ARCHITECTURE.md 5.4, P2 brief D11): every kVarTail set_* hashes the +// RESOLVED set on the client and does not emit when the hash has not moved. +// +// This is the carrier for the ~175 lines of debounce that move off the backends in P3b and +// P4b - Espryt's UnitBindingsSnapshot / CaptureUnitBindings / UnitBindingsUnchanged and +// Magma's equivalents all answer "is this set the same set as last time", and every one of +// them answers it against a shape the backend rediscovered. P2 lands the MECHANISM and ONE +// real consumer (SetVertexAttribDefaults) so the shape is pinned by a test rather than by a +// plan; the other six slots exist, are unit-tested, and are wired by the phase that moves +// the set they name. +// +// A hash of 0 is reserved for "never emitted", so the first emission always goes out; a +// computed 0 is remapped to 1, which costs one collision in 2^64 an extra emission and +// never a missed one. +// +// Header-only for the same ownership reason as Tracker.h and CsoCache.h: the root +// CMakeLists.txt that would name a new .cpp belongs to package A and is frozen behind the +// p2/contract tag. +#if MOBILEGL_PIPE_PUSH +#include + +namespace MobileGL::MG_Pipe { + + // One slot per kVarTail set_* (ARCHITECTURE.md 5.1's call list). + enum class MGPipeSuppressorSlot : Uint32 { + SetVertexBuffers = 0, // P3b + SetSamplerViews, // P3b + BindSamplerStates, // P3b + SetShaderImages, // P4b + SetShaderBuffers, // P4b + SetStreamOutputTargets, // P4b + SetVertexAttribDefaults, // P2 - the one consumer that is wired + Count, + }; + + inline constexpr SizeT kMGPipeSuppressorSlotCount = static_cast(MGPipeSuppressorSlot::Count); + + class MGPipeSetHashSuppressor { + public: + // True when `contentHash` differs from what this slot last emitted, and LATCHES it. + // False means the resolved set has not moved and the call must not go out. + Bool ShouldEmit(MGPipeSuppressorSlot slot, Uint64 contentHash) { + const Uint64 latched = contentHash == 0 ? 1 : contentHash; + const SizeT index = static_cast(slot); + if (m_lastEmitted[index] == latched) return false; + m_lastEmitted[index] = latched; + return true; + } + + // A context change or a server reset: what the server has is no longer what this + // slot last emitted, so the next resolved set must go out whatever it hashes to. + void Invalidate(MGPipeSuppressorSlot slot) { m_lastEmitted[static_cast(slot)] = 0; } + + void InvalidateAll() { + for (SizeT i = 0; i < kMGPipeSuppressorSlotCount; ++i) m_lastEmitted[i] = 0; + } + + // 0 == "never emitted". Exposed for the unit test, which is what pins that the + // reserved value really is reserved. + Uint64 LastEmitted(MGPipeSuppressorSlot slot) const { + return m_lastEmitted[static_cast(slot)]; + } + + private: + Array m_lastEmitted{}; + }; + + // The monolith's one suppressor, beside the tracker and the CSO cache. + inline MGPipeSetHashSuppressor& MGPipeSetHashSuppressorInstance() { + static MGPipeSetHashSuppressor suppressor; + return suppressor; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/Tracker.h b/MobileGL/MG_Impl/Pipe/Tracker.h index f2684a8ec..493fea124 100644 --- a/MobileGL/MG_Impl/Pipe/Tracker.h +++ b/MobileGL/MG_Impl/Pipe/Tracker.h @@ -311,6 +311,7 @@ namespace MobileGL::MG_Pipe { m_pack = PixelStoreParameters{}; m_patch = PatchTrio{}; m_staged = RenderStateParameters{}; + m_stagedAttribs = AttribDefaults{}; m_context = nullptr; m_lastDirty = 0; m_primed = false; @@ -352,6 +353,14 @@ namespace MobileGL::MG_Pipe { RenderStateParameters& Staged() { return m_staged; } const RenderStateParameters& Staged() const { return m_staged; } + // The same mirror for the 32 glVertexAttrib* defaults: set_vertex_attrib_defaults + // names only the attributes that differ from it, which is the var-tail's own + // suppressor underneath D11's set-hash one. + using AttribDefaults = Array; + AttribDefaults& StagedAttribDefaults() { return m_stagedAttribs; } + const AttribDefaults& StagedAttribDefaults() const { return m_stagedAttribs; } + private: static constexpr SizeT Index(MGPipeDirty bit) { return static_cast(bit); } @@ -372,6 +381,7 @@ namespace MobileGL::MG_Pipe { PatchTrio m_patch{}; RenderStateParameters m_staged{}; + AttribDefaults m_stagedAttribs{}; const void* m_context = nullptr; Uint32 m_lastDirty = 0; From 7dec32a57423b6501dc56165fcc2df2550d086ca Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:44:58 -0400 Subject: [PATCH 104/529] [Feat] (Pipe): carry what has no call of its own in the residual value block and abort when it disagrees with the assembled state - set_residual_value_state emits the 35 capability bits, read from the FRONTEND's own IsCapabilityEnabled rather than from the assembled mirror. That direction is the whole design: the applier then compares the carried answer against the assembled one, so the block is an independent oracle instead of a tautology - which is the failure the P1 entry compare had and P2 is paying to remove. - It goes out AFTER the residual fill, not with the other emissions: the mirror the trip wire compares against is written either by the applier's derivation or by that fill, so before it the block would be compared against the previous verb's answer. - It is HELD, not dropped, when the verb's class does not carry IsCapabilityEnabled. kQuery and kXfbSpan do not read it, so at those verbs the mirror is stale by construction; a capability that moved between two queries would silently disarm the wire if the emission were skipped instead of deferred. - ByteClass::ResidualValueBlock has been a placeholder that "stays at 0 until P2" since P0. This makes it non-zero, which is half of G10. - The wire is not theoretical: the first version of this commit fired it for real - Fatal{PipeResidualDiverged, "Dither"} carried=1 assembled=0, on every verb whose class does not read the capability mirror - and that is what the holding latch above is for. GL_DITHER defaults to enabled, so the very first mismatch the block could have found is the one it found. - integration-verify 818 green, integration-gpu 878 green under the default bitmask and again under MOBILEGL_PIPE_PUSH=0, unit 1499 green. --- MobileGL/MG_Impl/Pipe/PipeFill.cpp | 76 +++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 4cd43fcaf..efb1fb712 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -784,6 +784,48 @@ namespace MobileGL::MG_Pipe { return sizeof(MGPVertexAttribDefaults) + header.Count * sizeof(MGPAttribValue); } + + // set_residual_value_state (P2 brief D9, ARCHITECTURE.md 9.4). + // + // Since P2 the block is one Uint64 of capability bits, and every one of the 35 is + // ALSO answerable from the assembled working block now that the contract closed the + // FramebufferSrgb / DepthClamp / TextureCubeMapSeamless storage holes. That + // redundancy is the whole point: the bits are read HERE from the frontend, and the + // applier compares them against the assembled answer, so the day a later call takes + // a capability over and forgets to carry it the block says so on the next draw + // (Fatal{PipeResidualDiverged, ""}). + // + // Building the carried bits from the ASSEMBLED block instead would make the trip + // wire a tautology, which is exactly the failure P1's entry compare had and P2 is + // paying to remove. + // + // Emitted once per context and again whenever the capability set may have moved, + // which is whenever the pipeline version moved: every SET_CAPABILITY arm calls + // BumpVersions, so that shutter cannot miss one. + Uint64 EmitResidualValueState(GLContext& ctx) { + ResidualValueBlock block{}; + constexpr SizeT kCapabilityCount = static_cast(CapabilityInput::CapabilityInputCount); + static_assert(kCapabilityCount <= 64, "CapabilityBits is a Uint64"); + for (SizeT i = 0; i < kCapabilityCount; ++i) { + if (ctx.IsCapabilityEnabled(static_cast(i))) { + block.CapabilityBits |= Uint64{1} << i; + } + } + MGPipeApplySetResidualValueState(block); + if (MG_Util::PipeStats::Enabled()) { + // ByteClass::ResidualValueBlock has been a placeholder that "stays at 0 + // until P2" since P0. This is what makes it non-zero. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::ResidualValueBlock, + sizeof(ResidualValueBlock)); + } + return sizeof(MGPResidualValueState) + sizeof(ResidualValueBlock); + } + + // Set when the capability set may have moved, cleared when the block goes out. It is + // not part of the tracker because it is emission state, not a shutter: the shutter + // (the pipeline version) has already been consumed by the time this is read. + Bool g_residualDue = true; + constexpr Uint32 kAllDynamicChunks = static_cast((Uint64{1} << kMGPipeDynamicChunkCount) - 1); @@ -903,13 +945,6 @@ namespace MobileGL::MG_Pipe { (dirty & MGPipeDirtyBit(MGPipeDirty::NewVertexAttribDefaults)) != 0) { payloadBytes += EmitVertexAttribDefaults(*ctx); } - if (payloadBytes != 0 && MG_Util::PipeStats::Enabled()) { - // PipeStats::RecordDrawPayloadBytes has been implemented and unit-tested since - // P0 and called by nothing; this is its first emitter, and the 24-bucket - // histogram is what answers ROADMAP.md open question 4's chunk-granularity - // retune with data instead of a guess. - MG_Util::PipeStats::RecordDrawPayloadBytes(payloadBytes); - } // ---- step 4: the residual fill, for what an emitted call did NOT supply ---- const Bool applierDerives = ApplierDerivesRenderStateFields(); @@ -943,6 +978,33 @@ namespace MobileGL::MG_Pipe { if (!IsOmitted(verb, field)) filled.FilledGen[i] = filled.CurrentVerbSerial; #endif } + // ---- step 4b: the residual value block, and it goes out HERE ---- + // Its trip wire compares the carried bits against the ASSEMBLED capability mirror, + // and that mirror is written either by the applier's derivation or by the fill loop + // above - so the block is only meaningful once step 4 has run. Emitting it with the + // other calls would compare against the previous verb's answer. + if ((pushMask & kMGPipeSubsystemResidualValues) != 0) { + if (tracker.FreshlyPrimed() || (dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState)) != 0) { + g_residualDue = true; + } + // The trip wire compares against the ASSEMBLED capability mirror, so it can only + // run at a verb whose class actually carries that mirror - IsCapabilityEnabled is + // in seven of the nine class masks and kQuery and kXfbSpan do not read it, so at + // those verbs the mirror is whatever the last verb that did read it left behind. + // The change is HELD rather than dropped: dropping it would silently disarm the + // wire for a capability that moved between two queries. + if (g_residualDue && MGPipeFieldMaskHas(mask, MGPipeInputField::IsCapabilityEnabled)) { + payloadBytes += EmitResidualValueState(*ctx); + g_residualDue = false; + } + } + if (payloadBytes != 0 && MG_Util::PipeStats::Enabled()) { + // PipeStats::RecordDrawPayloadBytes has been implemented and unit-tested since + // P0 and called by nothing; this is its first emitter, and the 24-bucket + // histogram is what answers ROADMAP.md open question 4's chunk-granularity + // retune with data instead of a guess. + MG_Util::PipeStats::RecordDrawPayloadBytes(payloadBytes); + } #if MOBILEGL_PIPE_VERIFY EntryCompare(inputs, mask); #endif From 3302ee82b506dd3f656043f175f764db2724bc49 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:49:21 -0400 Subject: [PATCH 105/529] [Feat] (Pipe): map every frontend mutator onto the aggregate generation that publishes it, and make the dirty-surface scanner a gate - MG_Pipe/DirtySurface.def: 73 rows, one per distinct mutator the scanner finds, each answering "what publishes this". The answer vocabulary is a MGPipeDirty bit name or one of five non-bit answers, and each of the five is documented in the file's header rather than left to be inferred: kImmediate, kReverseChannel, kNoBackendRead, kExplicitDestroy and kPulledEveryVerb. Where a mutator has more than one true answer the row carries the COARSER one - the one that cannot under-fire. - gen_pipe_dirty_surface.py --check is the gate and it fails in BOTH directions: an unmapped mutator renders stale, and a row naming a mutator the scan no longer finds keeps a real hole looking covered. It also rejects an answer that is neither a documented non-bit answer nor a bit name read out of Tracker.h's own kMGPipeDirtyNames, so a renamed bit cannot leave a row silently pointing at nothing. - --self-test runs three canned negative controls - a withheld mutator, a stale row, a bad answer - and each must trip; trips == 0 is itself an error, the shape check_include_closure.py and gen_pipe.py --self-test already use. ROADMAP.md's rule is that every gate must be able to go red for the reason it exists. - --summary keeps working unchanged, because the CI file that still calls it belongs to another package until it lands. - The human report prints the mapped answer where it printed UNMAPPED. - FillPoints.def: the verdict on the eight statically over-approximated rows, recorded per group in the def's own comment. All eight are KEPT and the reason is the same in all three groups - each row names a concrete backend path (the depth/stencil read emulation's paused capture, VkClearManager::PreCompensateSrgbClearColor's GL_FRAMEBUFFER_SRGB read, the shader blit's viewport / provoking vertex / binding-point reads), and the only evidence that could retire one is dynamic. A corpus that never reaches a path proves nothing about it, and a row dropped on that basis turns a rare path into Fatal{UnmigratedPipeInput} in a shipped build. The contract's new FramebufferSrgb storage in fact makes one of the eight MORE load-bearing than it was, not less: it used to read a compile-time constant. --- MobileGL/MG_Pipe/DirtySurface.def | 151 +++++++++++++++++++++++ MobileGL/MG_Pipe/FillPoints.def | 38 ++++++ scripts/gen_pipe_dirty_surface.py | 192 +++++++++++++++++++++++++++--- 3 files changed, 363 insertions(+), 18 deletions(-) create mode 100644 MobileGL/MG_Pipe/DirtySurface.def diff --git a/MobileGL/MG_Pipe/DirtySurface.def b/MobileGL/MG_Pipe/DirtySurface.def new file mode 100644 index 000000000..3fe79067f --- /dev/null +++ b/MobileGL/MG_Pipe/DirtySurface.def @@ -0,0 +1,151 @@ +// MobileGL - MobileGL/MG_Pipe/DirtySurface.def +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The dirty-surface mapping (ARCHITECTURE.md 5.2 corollary 4, P2 brief D16). +// +// MGPipe replaces "the backend rediscovers what changed" with "the frontend says what +// changed", which only works if EVERY frontend mutation a backend can observe has an answer +// to "what publishes this". The failure mode is silent and one-directional: a mutation that +// forgets to publish renders stale, and no purity gate can see it. +// +// So the surface is enumerated MECHANICALLY. scripts/gen_pipe_dirty_surface.py scans +// MG_Impl/GLImpl for every pGLContext-> mutator call and, with --check, fails if a scanned +// mutator has no row here or a row here names a mutator the scan no longer finds. Both +// directions, so a deleted mutator cannot leave a stale row behind either. +// +// ANSWERS. One per row, and where a mutator has more than one true answer the row carries +// the COARSER one - the one that cannot under-fire: +// +// NEW_* a MGPipeDirty bit (MG_Impl/Pipe/Tracker.h). The tracker's shutter for +// that bit moves when this mutator runs, so the next verb publishes it. +// kImmediate the mutating function also reaches the backend in the same body, so the +// mutation is published inline and needs no shutter at all. +// kReverseChannel not state: a write INTO the frontend from the backend's side. +// kNoBackendRead no backend read point observes this state at all. +// kExplicitDestroy published by the delete_* call the Track H slice emits when the object's +// last reference drops - an object's DEATH, which no generation shutters +// because there is no longer an object to carry one. +// kPulledEveryVerb no shutter exists, and none is needed yet: the PipeInputs field this +// writes is in its verb class's may-read mask, so the residual fill copies +// it at EVERY verb of that class. A shutter here is a P3/P4 optimisation, +// not a correctness gap. +// +// KNOWN BLIND SPOTS OF THE SCANNER, recorded here rather than left implicit +// (gen_pipe_dirty_surface.py's own notes plus its scan root): +// 1. it matches braced function bodies textually, so a mutator inside a LAMBDA is +// attributed to the enclosing function; +// 2. a mutation published through a HELPER the entry point calls reads as deferred here; +// 3. the scan root is MG_Impl/GLImpl only, so the four MGP_NOTE_MUTATION sites in +// MG_State/GLState/TextureState/TextureState.h are outside it entirely. +// The gate is therefore a COMPLETENESS gate over what the scanner does see. The semantic +// proof stays the MOBILEGL_PIPE_VERIFY lane, which is blind to none of the three. +// +// clang-format off + +// X(Mutator, Answer) +#define MGP_DIRTY_SURFACE_LIST(X) \ + /* ---- the reverse channel: 836 of the 926 calls, 90% of the surface ---- */ \ + X(RecordError, kReverseChannel) \ + /* ---- immediate publish points: the same body reaches the backend ---- */ \ + X(SetActiveTextureUnit, kImmediate) \ + X(BeginTransformFeedback, kImmediate) \ + X(EndTransformFeedback, kImmediate) \ + X(SetTransformFeedbackPaused, kImmediate) \ + X(MarkTransformFeedbackObjectForDeletion, kImmediate) \ + /* ---- the render state: NEW_PIPELINE_STATE when a setter calls */ \ + /* BumpVersions (P2 brief D6's only rule), NEW_RENDER_STATE otherwise */ \ + X(SetBlendEquation, NEW_PIPELINE_STATE) \ + X(SetBlendEquationIndexed, NEW_PIPELINE_STATE) \ + X(SetBlendFunc, NEW_PIPELINE_STATE) \ + X(SetBlendFuncIndexed, NEW_PIPELINE_STATE) \ + /* SetCapability's ClipDistance0..7 arms move only m_version; every other */ \ + /* arm calls BumpVersions, and the coarser answer is the one that holds. */ \ + X(SetCapability, NEW_PIPELINE_STATE) \ + X(SetCapabilityIndexed, NEW_PIPELINE_STATE) \ + X(SetColorMask, NEW_PIPELINE_STATE) \ + X(SetColorMaskIndexed, NEW_PIPELINE_STATE) \ + X(SetCullFaceMode, NEW_PIPELINE_STATE) \ + X(SetDepthFunc, NEW_PIPELINE_STATE) \ + X(SetDepthMask, NEW_PIPELINE_STATE) \ + X(SetFrontFaceMode, NEW_PIPELINE_STATE) \ + X(SetLogicOp, NEW_PIPELINE_STATE) \ + X(SetMinSampleShadingValue, NEW_PIPELINE_STATE) \ + X(SetPolygonMode, NEW_PIPELINE_STATE) \ + X(SetProvokingVertexMode, NEW_PIPELINE_STATE) \ + X(SetSampleCoverage, NEW_PIPELINE_STATE) \ + X(SetSampleMaskValue, NEW_PIPELINE_STATE) \ + /* SetStencilFunc writes Func (pipeline chunk P2/P3) AND Ref/ValueMask */ \ + /* (dynamic D3/D4); SetStencilOp is wholly pipeline, SetStencilMask wholly */ \ + /* dynamic. That split is what keeps a glStencilFunc that moves only the */ \ + /* reference from evicting a cached pipeline. */ \ + X(SetStencilFunc, NEW_PIPELINE_STATE) \ + X(SetStencilOp, NEW_PIPELINE_STATE) \ + X(SetStencilMask, NEW_RENDER_STATE) \ + X(SetBlendColor, NEW_RENDER_STATE) \ + X(SetClampReadColor, NEW_RENDER_STATE) \ + X(SetClearColor, NEW_RENDER_STATE) \ + X(SetClearDepth, NEW_RENDER_STATE) \ + X(SetClearStencil, NEW_RENDER_STATE) \ + X(SetClipControl, NEW_RENDER_STATE) \ + X(SetDepthRange, NEW_RENDER_STATE) \ + X(SetDepthRangeIndexed, NEW_RENDER_STATE) \ + X(SetHint, NEW_RENDER_STATE) \ + X(SetLineWidth, NEW_RENDER_STATE) \ + X(SetPointFadeThresholdSize, NEW_RENDER_STATE) \ + X(SetPointSize, NEW_RENDER_STATE) \ + X(SetPointSpriteCoordOrigin, NEW_RENDER_STATE) \ + X(SetPolygonOffset, NEW_RENDER_STATE) \ + X(SetPolygonOffsetClamped, NEW_RENDER_STATE) \ + X(SetPrimitiveRestartIndex, NEW_RENDER_STATE) \ + X(SetScissorBox, NEW_RENDER_STATE) \ + X(SetScissorBoxIndexed, NEW_RENDER_STATE) \ + X(SetViewport, NEW_RENDER_STATE) \ + X(SetViewportIndexed, NEW_RENDER_STATE) \ + /* ---- the other value-class bits ---- */ \ + X(SetPixelStoreParam, NEW_PIXEL_PACK) \ + X(SetPatchDefaultInnerLevel, NEW_PATCH_STATE) \ + X(SetPatchDefaultOuterLevel, NEW_PATCH_STATE) \ + /* Also an immediate publish point, but it has a real bit and the bit is */ \ + /* the more useful answer: set_patch_state carries it whatever the caller */ \ + /* does next. */ \ + X(SetPatchVertices, NEW_PATCH_STATE) \ + X(SetCurrentVertexAttributeFloat, NEW_VERTEX_ATTRIB_DEFAULTS) \ + X(SetCurrentVertexAttributeInt, NEW_VERTEX_ATTRIB_DEFAULTS) \ + X(SetCurrentVertexAttributeUint, NEW_VERTEX_ATTRIB_DEFAULTS) \ + /* ---- object class ---- */ \ + X(BumpTextureBindGeneration, NEW_SAMPLER_VIEWS) \ + X(SetNamedTransformFeedbackBinding, NEW_SO_TARGETS) \ + /* ---- an object's death: no generation, because there is no longer an */ \ + /* object to carry one. Espryt 0b's delete_* is what publishes these. */ \ + X(MarkBufferObjectForDeletion, kExplicitDestroy) \ + X(MarkFramebufferObjectForDeletion, kExplicitDestroy) \ + X(MarkProgramForDeletion, kExplicitDestroy) \ + X(MarkProgramPipelineForDeletion, kExplicitDestroy) \ + X(MarkRenderbufferObjectForDeletion, kExplicitDestroy) \ + X(MarkSamplerObjectForDeletion, kExplicitDestroy) \ + X(MarkShaderForDeletion, kExplicitDestroy) \ + X(MarkTextureObjectForDeletion, kExplicitDestroy) \ + X(MarkVertexArrayForDeletion, kExplicitDestroy) \ + /* ---- no backend read point observes these at all ---- */ \ + /* GL_ANY_SAMPLES_PASSED conditional rendering is resolved wholly in the */ \ + /* frontend: IsConditionalRenderActive / GetConditionalRenderQuery have no */ \ + /* reader under MG_Backend and no Coverage.def row. */ \ + X(BeginConditionalRender, kNoBackendRead) \ + X(EndConditionalRender, kNoBackendRead) \ + /* ---- pulled at every verb of the class, so the next verb publishes them */ \ + /* unconditionally. The transform-feedback accounting counters reach the */ \ + /* backend through GetTransformFeedbackCapturedVertices and friends, which */ \ + /* are in the kDraw and kXfbSpan may-read masks. */ \ + X(AddTransformFeedbackAccountedCaptureDraw, kPulledEveryVerb) \ + X(AddTransformFeedbackCapturedVertices, kPulledEveryVerb) \ + X(AddTransformFeedbackGeometryCaptureDraw, kPulledEveryVerb) \ + X(AddTransformFeedbackInputPrimitives, kPulledEveryVerb) \ + X(AddTransformFeedbackPausedPrimitives, kPulledEveryVerb) \ + X(AddTransformFeedbackPrimitives, kPulledEveryVerb) + +// clang-format on diff --git a/MobileGL/MG_Pipe/FillPoints.def b/MobileGL/MG_Pipe/FillPoints.def index b71a9b4cc..01edcd784 100644 --- a/MobileGL/MG_Pipe/FillPoints.def +++ b/MobileGL/MG_Pipe/FillPoints.def @@ -26,6 +26,44 @@ // Fatal{UnmigratedPipeInput, "Field@Verb"} found there is fixed by adding the (class, field) // row, never by marking the field sticky. // +// --------------------------------------------------------------------------------------- +// THE VERDICT ON THE EIGHT STATICALLY OVER-APPROXIMATED ROWS (P2 brief C.1, MEASUREMENTS.md +// section 4). Every one of them is KEPT, and the reason is the same in all three groups: the +// row is not a guess, it names a concrete backend path, and the only evidence that could +// retire it is DYNAMIC - a corpus that never reaches the path proves nothing, because a row +// removed on that basis turns a rare path into Fatal{UnmigratedPipeInput} in a shipped build. +// +// kReadback + IsTransformFeedbackActive / IsTransformFeedbackPaused +// KEPT. The depth/stencil read emulation draws (ScopedEmulationDrawState, DirectGLES.cpp) +// and pauses an active capture around its own draw, so a glReadPixels of a depth or +// stencil attachment reads the transform-feedback state exactly as a draw does. Reached +// only when the emulation is armed, which is a driver-shaped decision, so no desktop +// corpus can decide it. +// +// kTextureOp + IsCapabilityEnabled, kDispatch + IsCapabilityEnabled +// KEPT. Magma's GenerateMipmap materialises a texture's queued clear before it blits and +// PrepareStorageImageTextures does the same for every storage image a dispatch writes; +// both go through VkClearManager::PreCompensateSrgbClearColor, which reads +// GL_FRAMEBUFFER_SRGB. The P2 contract gave that capability real storage for the first +// time, so this row went from reading a compile-time constant to reading real state - +// which is the opposite of a row that could be dropped. +// +// kBlitOrCopy / kTextureOp + the shader blit's viewport and vertex/buffer bindings +// (GetViewportIndexed, GetDepthRangeIndexed, GetProvokingVertexMode, GetBufferBindingPoint) +// KEPT. TryBlitToDefaultFramebufferWithShader is a real draw of a backend-owned helper +// program: ApplyGLViewportState -> ComputeGLViewport reads viewport 0 and its depth range, +// GetOrCreateBlitPipeline -> SelectProvokingVertexMode reads the provoking vertex, and +// BindProgramUniformBuffers' block resolvers read the frontend binding points. It is taken +// when a blit's destination is the default framebuffer and the driver cannot do it +// natively - again a driver-shaped decision. +// +// What WOULD retire a row: the poison build already answers "was this field read at this +// verb" exactly (MOBILEGL_PIPE_POISON_OMIT withholds one field's stamp for one verb and a +// read of it aborts naming the pair). Turning that into a retirement gate means running the +// omission across the full CTS caselist on both devices, not the desktop corpus, and that is +// recorded as P3a work rather than done here on evidence that cannot support it. +// --------------------------------------------------------------------------------------- +// // gen_pipe.py's block regexes end at a blank line: keep the empty line after each macro. // // clang-format off diff --git a/scripts/gen_pipe_dirty_surface.py b/scripts/gen_pipe_dirty_surface.py index 36deee1cd..f11ed2c4e 100644 --- a/scripts/gen_pipe_dirty_surface.py +++ b/scripts/gen_pipe_dirty_surface.py @@ -21,8 +21,10 @@ P0 is the skeleton: it reports. P1 adds the mapping file and CI regenerates it with `git diff --exit-code` and zero unmapped mutators, the same shape as gen_pipe.py's G6. - python3 scripts/gen_pipe_dirty_surface.py # human-readable report - python3 scripts/gen_pipe_dirty_surface.py --summary # counts only + python3 scripts/gen_pipe_dirty_surface.py # human-readable report + python3 scripts/gen_pipe_dirty_surface.py --summary # counts only + python3 scripts/gen_pipe_dirty_surface.py --check # THE GATE: rc 1 on any hole + python3 scripts/gen_pipe_dirty_surface.py --self-test # the gate's own negative controls """ import argparse @@ -135,14 +137,76 @@ def scan_file(path): return findings, all_mutators -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--summary", action="store_true", help="print the counts only") - args = parser.parse_args() +DEF_PATH = os.path.join(REPO_ROOT, "MobileGL", "MG_Pipe", "DirtySurface.def") +TRACKER_PATH = os.path.join(REPO_ROOT, "MobileGL", "MG_Impl", "Pipe", "Tracker.h") - if not os.path.isdir(SCAN_ROOT): - sys.exit("missing %s" % SCAN_ROOT) +ROW_RE = re.compile(r"^[ \t]*X\((\w+),\s*(\w+)\)\s*\\?\s*$", re.M) +DIRTY_NAME_RE = re.compile(r'^\s*"(NEW_[A-Z0-9_]+)",\s*$', re.M) + +# The answers that are not a dirty-bit name. Each one is documented in DirtySurface.def's +# header; a row that uses anything else is a typo, and a typo that read as "mapped" would be +# exactly the silent hole this gate exists to close. +NON_BIT_ANSWERS = ("kImmediate", "kReverseChannel", "kNoBackendRead", "kExplicitDestroy", + "kPulledEveryVerb") + +def dirty_bit_names(): + """The MGPipeDirty bit names, read out of Tracker.h's kMGPipeDirtyNames so a row cannot + name a bit that does not exist and a bit cannot be renamed out from under a row. Read + from the RAW text on purpose: the names are string literals, which is exactly what + mask_comments_and_strings blanks.""" + with open(TRACKER_PATH, "r", encoding="utf-8", errors="replace") as handle: + return set(DIRTY_NAME_RE.findall(handle.read())) + + +def load_mapping(text=None): + """{mutator: answer} from DirtySurface.def, or from `text` for the self-test.""" + if text is None: + with open(DEF_PATH, "r", encoding="utf-8", errors="replace") as handle: + text = handle.read() + rows = {} + duplicates = [] + for match in ROW_RE.finditer(mask_comments_and_strings(text)): + mutator, answer = match.group(1), match.group(2) + if mutator in rows: + duplicates.append(mutator) + rows[mutator] = answer + return rows, duplicates + + +def check_mapping(mapping, duplicates, scanned, bits): + """Every problem the gate fails on, as a list of human-readable lines. BOTH directions: + an unmapped mutator renders stale, and a row naming a mutator the scan no longer finds is + a stale row that would keep a real hole looking covered.""" + problems = [] + for mutator in sorted(set(scanned) - set(mapping)): + problems.append("UNMAPPED mutator %s - add a row to MG_Pipe/DirtySurface.def" % mutator) + for mutator in sorted(set(mapping) - set(scanned)): + problems.append("STALE row %s - the scan no longer finds this mutator; delete the row" + % mutator) + for mutator in sorted(duplicates): + problems.append("DUPLICATE row %s" % mutator) + for mutator in sorted(mapping): + answer = mapping[mutator] + if answer in NON_BIT_ANSWERS: + continue + if answer in bits: + continue + problems.append("BAD answer %s for %s - not a MGPipeDirty bit name and not one of %s" + % (answer, mutator, ", ".join(NON_BIT_ANSWERS))) + return problems + + +SELF_TEST_WITHHELD = """ +#define MGP_DIRTY_SURFACE_LIST(X) \\ + X(RecordError, kReverseChannel) +""" + +SELF_TEST_STALE = None # built from the real def at run time + + +def scan_all(): + """(findings-per-file, {mutator: call count}) over the whole scan root.""" sources = [] for root, _, files in os.walk(SCAN_ROOT): for name in sorted(files): @@ -150,15 +214,102 @@ def main(): sources.append(os.path.join(root, name)) sources.sort() - total_functions = 0 - total_mutators = 0 - deferred_mutators = 0 - distinct_mutators = {} + per_file = [] distinct_all = {} for path in sources: findings, all_mutators = scan_file(path) for mutator, _ in all_mutators: distinct_all[mutator] = distinct_all.get(mutator, 0) + 1 + per_file.append((path, findings, all_mutators)) + return sources, per_file, distinct_all + + +def self_test(scanned, bits): + """Canned negative controls. Each MUST trip; trips == 0 is an error, which is the shape + check_include_closure.py and gen_pipe.py --self-test already use.""" + trips = 0 + failures = [] + + # 1. a mutator withheld from the def. + mapping, duplicates = load_mapping(SELF_TEST_WITHHELD) + problems = check_mapping(mapping, duplicates, scanned, bits) + if any(p.startswith("UNMAPPED") for p in problems): + trips += 1 + else: + failures.append("negative control 1 (a withheld mutator) did NOT trip") + + # 2. a row naming a mutator the scan does not find. + real, real_duplicates = load_mapping() + with_ghost = dict(real) + with_ghost["SetSomethingThatDoesNotExist"] = "kImmediate" + problems = check_mapping(with_ghost, real_duplicates, scanned, bits) + if any(p.startswith("STALE") for p in problems): + trips += 1 + else: + failures.append("negative control 2 (a stale row) did NOT trip") + + # 3. a row whose answer is neither a dirty bit nor one of the documented non-bit answers. + with_typo = dict(real) + with_typo["RecordError"] = "NEW_TYPO_THAT_IS_NOT_A_BIT" + problems = check_mapping(with_typo, real_duplicates, scanned, bits) + if any(p.startswith("BAD answer") for p in problems): + trips += 1 + else: + failures.append("negative control 3 (a bad answer) did NOT trip") + + for failure in failures: + print("dirty-surface self-test: %s" % failure) + if trips == 0: + print("dirty-surface self-test: NOTHING tripped - the gate cannot fail, which is worse " + "than a red gate") + return 1 + if failures: + return 1 + print("dirty-surface self-test: %d negative controls, all tripped" % trips) + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--summary", action="store_true", help="print the counts only") + parser.add_argument("--check", action="store_true", + help="fail when a scanned mutator has no row in DirtySurface.def, or a " + "row names a mutator the scan no longer finds") + parser.add_argument("--self-test", action="store_true", + help="run the canned negative controls; each must trip") + args = parser.parse_args() + + if not os.path.isdir(SCAN_ROOT): + sys.exit("missing %s" % SCAN_ROOT) + if not os.path.isfile(DEF_PATH): + sys.exit("missing %s" % DEF_PATH) + + sources, per_file, distinct_all = scan_all() + bits = dirty_bit_names() + if not bits: + sys.exit("could not read the MGPipeDirty bit names out of %s" % TRACKER_PATH) + + if args.self_test: + return self_test(distinct_all, bits) + + mapping, duplicates = load_mapping() + + if args.check: + problems = check_mapping(mapping, duplicates, distinct_all, bits) + for problem in problems: + print("dirty-surface: %s" % problem) + if problems: + print("dirty-surface: %d problem(s); the mapping must cover every mutator the scan " + "finds, in both directions" % len(problems)) + return 1 + print("dirty-surface: %d mutators, all mapped, no stale rows" % len(mapping)) + return 0 + + total_functions = 0 + total_mutators = 0 + deferred_mutators = 0 + distinct_mutators = {} + for path, findings, all_mutators in per_file: deferred_mutators += len(all_mutators) if not findings: continue @@ -175,7 +326,7 @@ def main(): print(" %s (line %d) -> backend: %s" % (finding["function"], finding["line"], ", ".join(finding["backend"][:4]))) for mutator, line in finding["mutators"]: - print(" %-44s :%d UNMAPPED" % (mutator, line)) + print(" %-44s :%d %s" % (mutator, line, mapping.get(mutator, "UNMAPPED"))) print("\ndirty-surface: %d files scanned under MG_Impl/GLImpl" % len(sources)) print("dirty-surface: %d mutator calls in total, %d distinct mutators" % (deferred_mutators, @@ -186,12 +337,17 @@ def main(): print("dirty-surface: the remaining %d are DEFERRED: nothing reaches the backend in the same " "function, so the next verb publishes them, and each one needs an aggregate generation" % (deferred_mutators - total_mutators)) - print("dirty-surface: distinct mutators, by call count") + print("dirty-surface: distinct mutators, by call count, with what publishes each") for mutator in sorted(distinct_all, key=lambda k: (-distinct_all[k], k)): - print(" %5d %s%s" % (distinct_all[mutator], mutator, - " (immediate)" if mutator in distinct_mutators else "")) - print("dirty-surface: every mutator above is UNMAPPED - the aggregate-generation mapping file " - "lands in P1, and this report is what it has to cover.") + print(" %5d %-42s %s%s" % (distinct_all[mutator], mutator, + mapping.get(mutator, "UNMAPPED"), + " (immediate)" if mutator in distinct_mutators else "")) + unmapped = sorted(set(distinct_all) - set(mapping)) + if unmapped: + print("dirty-surface: %d UNMAPPED - run --check, which is a gate since P2" % len(unmapped)) + else: + print("dirty-surface: every mutator above is mapped (MG_Pipe/DirtySurface.def); --check " + "is a gate and --self-test proves it can fail") print("dirty-surface: known limits of this scanner - it matches braced function bodies " "textually, so a mutator inside a lambda is attributed to the enclosing function, and a " "mutation published through a helper the entry point calls reads as deferred here.") From 43bf97cc8784a484c4ac764df63afcfe198f0e57 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:53:24 -0400 Subject: [PATCH 106/529] [Test] (Pipe): pin the tracker's shutters and the CSO cache's content addressing, including the collision the memcmp exists to stop - TrackerWalk drives the tracker and the cache DIRECTLY rather than through MGPipeValidateForVerb: the validate point reaches the library's one process-wide tracker, and a unit test that asserts on a shared singleton fails the moment ctest runs the suite in parallel. The three lines of emission logic it reproduces are the same three lines. - BlendToggleReusesTwoCsos is the Blaze3D shape the whole "push at validate, not in the setter" decision was made for: 32 enable/draw/disable/draw pairs mint exactly TWO CSOs, bind 64 times and hit 62. A per-setter design would show up here as 64 mints. - ViewportDoesNotMintACso is the regression RenderState.h records: 16 glViewports mint nothing, never move the pipeline version, and each sends exactly chunk D0 - not the other seven. - WrapAroundRePushesButNeverMisses drives m_version past 65535 and asserts every one of 70000 changes fired. The alternating value deliberately never touches the default: a setter that early-outs would otherwise make the first iteration a false miss and hide a real one. - AggregateGenerationCatchesABoundTextureMoving is the first test of the direction the P1 verify comparator cannot see - it compares object-class fields by identity only, so a bound texture whose content moved looks unchanged to it. The bit fires and then settles, so it is a shutter and not a stuck flag. - ANaNPatchLevelEqualsItselfAndDoesNotFireForever: a NaN outer level is a legal glPatchParameterfv value, float equality says it differs from itself and a byte compare says it does not. That is why the shutter is a memcmp. - ThePixelPackShutterIsAByteCompareOfThePackHalfOnly asserts an UNPACK write does not move the pack shutter, which is the half that deliberately has no carrier. - HashCollisionDoesNotAliasTwoStates needed a seam and got one: MGPipeCsoCache::s_hashForTest, null in every real build, one never-taken branch on a path that runs only when the pipeline version moved. Without it the memcmp confirm is unreachable code that nothing can prove is doing anything, and what it stops - two different render states on one CSO - is silent wrong pixels with no gate that can see it. - ContentAddressingOffMintsEveryTime pins that bit 63 really changes mint/reuse behaviour, so the negative control cannot rot into a dead switch. - The set-hash suppressor is exercised on all seven slots even though P2 wires one, including the reserved-zero contract: a computed hash of 0 is remapped to 1 so it is never confused with "never emitted". --- MobileGL/MG_Impl/Pipe/CsoCache.h | 12 +- MobileGL/MG_Test/Pipe/CsoCacheTest.cpp | 174 +++++++++++++++++-- MobileGL/MG_Test/Pipe/TrackerTest.cpp | 226 +++++++++++++++++++++++++ 3 files changed, 399 insertions(+), 13 deletions(-) diff --git a/MobileGL/MG_Impl/Pipe/CsoCache.h b/MobileGL/MG_Impl/Pipe/CsoCache.h index 6a26b08d9..b13745949 100644 --- a/MobileGL/MG_Impl/Pipe/CsoCache.h +++ b/MobileGL/MG_Impl/Pipe/CsoCache.h @@ -72,7 +72,8 @@ namespace MobileGL::MG_Pipe { const Bool contentAddressed = (MG_Config::Features.PipePush & kMGPipeBehaviourNoCsoContentAddressing) == 0; if (contentAddressed) { - const Uint64 hash = MGPipeHashPipelineBytes(bytes.data()); + const Uint64 hash = s_hashForTest != nullptr ? s_hashForTest(bytes.data()) + : MGPipeHashPipelineBytes(bytes.data()); for (SizeT i = 0; i < m_entries.size(); ++i) { if (m_entries[i].Hash != hash) continue; if (std::memcmp(m_entries[i].Bytes.data(), bytes.data(), bytes.size()) != 0) { @@ -110,6 +111,15 @@ namespace MobileGL::MG_Pipe { SizeT Size() const { return m_entries.size(); } const Counters& GetCounters() const { return m_counters; } + // TEST SEAM, and it is here because the thing it tests cannot be reached any other + // way. A 64-bit collision between two DIFFERENT render states is silent wrong pixels + // and it is exactly what the memcmp confirm above exists to stop, so + // CsoCacheTest.HashCollisionDoesNotAliasTwoStates has to be able to make one happen. + // Null in every real build - one never-taken, perfectly-predicted branch on a path + // that runs only when the pipeline version moved, i.e. never in the steady state. + using HashForTestFn = Uint64 (*)(const void* pipelineBytes); + inline static HashForTestFn s_hashForTest = nullptr; + private: struct Entry { Uint64 Hash = 0; diff --git a/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp b/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp index 6dae4b003..334288495 100644 --- a/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp +++ b/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp @@ -6,27 +6,177 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header -// The 64-entry render-state CSO cache: hash, probe, memcmp, LRU evict, and the content-addressing-off control (P2 brief D7). +// The render-state CSO cache (P2 brief D7). Owned by P2 package B (p2/tracker); the file and +// its CMake registration are the contract commit's. // -// STUB, and deliberately one. It is created by the P2 CONTRACT commit together with its -// CMakeLists.txt registration, so that the package which owns its CONTENTS -// (P2 package B, p2/tracker) never has to touch MG_Test/Pipe/CMakeLists.txt - no two P2 -// packages edit the same file, which is what keeps the integrator's rebases clean. -// -// The placeholder case is not decoration: without it the binary has no test, and -// gtest_discover_tests on a binary with no test is a silently green lane. +// Needs the push sources, so every case is a visible SKIP in a pull build rather than a +// vanishing test. #include #include "Includes.h" #include +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include +#endif + using namespace MobileGL; using namespace MobileGL::MG_Pipe; namespace { - // The cache does not exist yet; the behaviour bit it is measured against does, and it - // is deliberately the TOP bit so no subsystem allocation can ever collide with it. - TEST(CsoCache, PlaceholderUntilTheOwningPackageFillsThisIn) { - EXPECT_EQ(kMGPipeBehaviourNoCsoContentAddressing, 1ull << 63); +#if !MOBILEGL_PIPE_PUSH + TEST(CsoCache, SkippedInAPullBuild) { + GTEST_SKIP() << "the CSO cache is compiled only under MOBILEGL_PIPE_PUSH"; + } +#else + class CsoCacheTest : public ::testing::Test { + protected: + void SetUp() override { + m_savedPush = MG_Config::Features.PipePush; + MGPipeCsoCache::s_hashForTest = nullptr; + MGPipeApplierReset(); + } + void TearDown() override { + MG_Config::Features.PipePush = m_savedPush; + MGPipeCsoCache::s_hashForTest = nullptr; + MGPipeApplierReset(); + } + + // A render state that differs from every other `seed` in a PIPELINE byte, so each one + // is a genuinely different CSO. SampleMaskValue is in pipeline chunk P2. + static RenderStateParameters PipelineState(Uint32 seed) { + RenderStateParameters params{}; + params.SampleMaskValue = seed; + return params; + } + + Uint64 m_savedPush = 0; + }; + + TEST_F(CsoCacheTest, TheSameStateIsMintedOnceAndReusedForever) { + MGPipeCsoCache cache; + Uint64 bytes = 0; + const RenderStateParameters params = PipelineState(7); + const MGPipeHandle first = cache.Acquire(params, bytes); + for (int i = 0; i < 16; ++i) EXPECT_TRUE(cache.Acquire(params, bytes) == first); + EXPECT_EQ(cache.GetCounters().Mints, 1u); + EXPECT_EQ(cache.GetCounters().Hits, 16u); + EXPECT_EQ(cache.Size(), 1u); + cache.Reset(); + } + + TEST_F(CsoCacheTest, LruEvictsTheOldestAndEmitsDelete) { + MGPipeCsoCache cache; + Uint64 bytes = 0; + Vector handles; + for (Uint32 i = 0; i < kMGPipeCsoCacheCapacity; ++i) { + handles.push_back(cache.Acquire(PipelineState(i), bytes)); + } + EXPECT_EQ(cache.Size(), kMGPipeCsoCacheCapacity); + EXPECT_EQ(cache.GetCounters().Evictions, 0u); + // Touch entry 0 so it is no longer the oldest; entry 1 becomes the victim. + EXPECT_TRUE(cache.Acquire(PipelineState(0), bytes) == handles[0]); + + const MGPipeHandle overflow = cache.Acquire(PipelineState(kMGPipeCsoCacheCapacity), bytes); + EXPECT_EQ(cache.Size(), kMGPipeCsoCacheCapacity); + EXPECT_EQ(cache.GetCounters().Evictions, 1u); + EXPECT_EQ(cache.GetCounters().Mints, kMGPipeCsoCacheCapacity + 1); + EXPECT_FALSE(overflow == handles[1]); + // The one that was touched survived; the evicted one has to be minted again. + EXPECT_TRUE(cache.Acquire(PipelineState(0), bytes) == handles[0]); + const MGPipeHandle reborn = cache.Acquire(PipelineState(1), bytes); + EXPECT_FALSE(reborn == handles[1]); + EXPECT_EQ(cache.GetCounters().Evictions, 2u); + cache.Reset(); + } + + // A 64-bit hash collision between two different render states would alias them onto one + // CSO, which is silent wrong pixels with no gate that can see it. The memcmp confirm is + // what stops it, and this is what proves the memcmp is doing something. + TEST_F(CsoCacheTest, HashCollisionDoesNotAliasTwoStates) { + MGPipeCsoCache::s_hashForTest = [](const void*) -> Uint64 { return 0x1234'5678'9abc'def0ull; }; + MGPipeCsoCache cache; + Uint64 bytes = 0; + const MGPipeHandle a = cache.Acquire(PipelineState(1), bytes); + const MGPipeHandle b = cache.Acquire(PipelineState(2), bytes); + EXPECT_FALSE(a == b) << "two different render states were aliased onto one CSO"; + EXPECT_EQ(cache.GetCounters().Collisions, 1u); + EXPECT_EQ(cache.GetCounters().Mints, 2u); + EXPECT_EQ(cache.GetCounters().Hits, 0u); + cache.Reset(); + } + + // The negative control the whole CSO design is measured against (ROADMAP.md P2). It turns + // off the PROBE and the handle reuse, not the records - otherwise it would measure a + // different design rather than this one without content addressing. + TEST_F(CsoCacheTest, ContentAddressingOffMintsEveryTime) { + MG_Config::Features.PipePush |= kMGPipeBehaviourNoCsoContentAddressing; + MGPipeCsoCache cache; + Uint64 bytes = 0; + const RenderStateParameters params = PipelineState(3); + const MGPipeHandle first = cache.Acquire(params, bytes); + const MGPipeHandle second = cache.Acquire(params, bytes); + const MGPipeHandle third = cache.Acquire(params, bytes); + EXPECT_FALSE(first == second); + EXPECT_FALSE(second == third); + EXPECT_EQ(cache.GetCounters().Mints, 3u); + EXPECT_EQ(cache.GetCounters().Hits, 0u); + cache.Reset(); + } + + TEST_F(CsoCacheTest, EveryAcquireCountsItsPayloadBytes) { + MGPipeCsoCache cache; + Uint64 bytes = 0; + cache.Acquire(PipelineState(11), bytes); + // A mint puts the descriptor and the whole pipeline half on the wire. + EXPECT_EQ(bytes, sizeof(MGPRenderStateDesc) + kMGPipePipelineChunkBytes); + const Uint64 afterMint = bytes; + cache.Acquire(PipelineState(11), bytes); + // A hit puts NOTHING on the wire: the 12-byte bind is the caller's, not the cache's. + EXPECT_EQ(bytes, afterMint); + cache.Reset(); + } + + // ---- the set-hash suppressor (D11) ---- + + TEST(SetHashSuppressorTest, TheFirstEmissionAlwaysGoesOutOnEverySlot) { + MGPipeSetHashSuppressor suppressor; + for (SizeT i = 0; i < kMGPipeSuppressorSlotCount; ++i) { + const auto slot = static_cast(i); + EXPECT_EQ(suppressor.LastEmitted(slot), 0u) << "slot " << i << " did not start at 0"; + EXPECT_TRUE(suppressor.ShouldEmit(slot, 0)) << "slot " << i << " suppressed its first set"; + EXPECT_FALSE(suppressor.ShouldEmit(slot, 0)) << "slot " << i << " re-emitted an unmoved set"; + } + } + + TEST(SetHashSuppressorTest, AComputedZeroIsRemappedSoItIsNeverConfusedWithNeverEmitted) { + MGPipeSetHashSuppressor suppressor; + const auto slot = MGPipeSuppressorSlot::SetVertexAttribDefaults; + EXPECT_TRUE(suppressor.ShouldEmit(slot, 0)); + EXPECT_EQ(suppressor.LastEmitted(slot), 1u) << "a computed 0 must not read as never emitted"; + EXPECT_FALSE(suppressor.ShouldEmit(slot, 0)); + } + + TEST(SetHashSuppressorTest, SlotsAreIndependent) { + MGPipeSetHashSuppressor suppressor; + EXPECT_TRUE(suppressor.ShouldEmit(MGPipeSuppressorSlot::SetVertexBuffers, 42)); + EXPECT_TRUE(suppressor.ShouldEmit(MGPipeSuppressorSlot::SetSamplerViews, 42)); + EXPECT_FALSE(suppressor.ShouldEmit(MGPipeSuppressorSlot::SetVertexBuffers, 42)); + } + + TEST(SetHashSuppressorTest, InvalidateMakesTheNextSetGoOutWhateverItHashesTo) { + MGPipeSetHashSuppressor suppressor; + const auto slot = MGPipeSuppressorSlot::SetShaderImages; + EXPECT_TRUE(suppressor.ShouldEmit(slot, 99)); + EXPECT_FALSE(suppressor.ShouldEmit(slot, 99)); + suppressor.Invalidate(slot); + EXPECT_TRUE(suppressor.ShouldEmit(slot, 99)); + suppressor.InvalidateAll(); + EXPECT_TRUE(suppressor.ShouldEmit(slot, 99)); } +#endif // MOBILEGL_PIPE_PUSH } // namespace diff --git a/MobileGL/MG_Test/Pipe/TrackerTest.cpp b/MobileGL/MG_Test/Pipe/TrackerTest.cpp index 2bdda5783..87f976940 100644 --- a/MobileGL/MG_Test/Pipe/TrackerTest.cpp +++ b/MobileGL/MG_Test/Pipe/TrackerTest.cpp @@ -18,8 +18,17 @@ #include #if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include #include #include +#include + +#include +#include #endif using namespace MobileGL; @@ -169,5 +178,222 @@ namespace { MG_State::pGLContext = Move(held); SUCCEED(); } + + // =================================================================================== + // The dirty walk itself, and the render-state emission it drives (P2 brief D4, D6, D7) + // =================================================================================== + // + // These drive the tracker and the cache DIRECTLY rather than through + // MGPipeValidateForVerb. That is deliberate: MGPipeValidateForVerb reaches the library's + // one process-wide tracker, and a unit test that asserts on a shared singleton is a test + // that fails when ctest runs the suite in parallel. The emission logic these reproduce is + // three lines long and is the same three lines the validate point runs. + class TrackerWalk : public ::testing::Test { + protected: + void SetUp() override { + m_previous = Move(MG_State::pGLContext); + MG_State::pGLContext = MakeUnique(); + m_savedPush = MG_Config::Features.PipePush; + MGPipeApplierReset(); + } + void TearDown() override { + MG_Config::Features.PipePush = m_savedPush; + MGPipeApplierReset(); + MG_State::pGLContext = Move(m_previous); + } + + static GLContext& Ctx() { return *MG_State::pGLContext; } + + // What MGPipeValidateForVerb's step 3 does, minus the PipeStats plumbing: acquire a + // CSO when the pipeline version moved, and compute the dynamic chunk mask when + // m_version moved. + Uint32 Walk(MGPipeVerbClass verbClass = MGPipeVerbClass::kDraw) { + const Uint32 dirty = m_tracker.Update(Ctx(), verbClass); + const RenderStateParameters& live = Ctx().GetRenderStateParameters(); + m_lastDynamicMask = 0; + if (dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState)) { + m_lastCso = m_cache.Acquire(live, m_payloadBytes); + ++m_binds; + } + if (dirty & MGPipeDirtyBit(MGPipeDirty::NewRenderState)) { + m_lastDynamicMask = m_tracker.FreshlyPrimed() + ? ~0u + : MGPipeDynamicChunksThatMoved(live, m_tracker.Staged()); + } + if (dirty & (MGPipeDirtyBit(MGPipeDirty::NewPipelineState) | + MGPipeDirtyBit(MGPipeDirty::NewRenderState))) { + m_tracker.Staged() = live; + } + return dirty; + } + + MGPipeTracker m_tracker; + MGPipeCsoCache m_cache; + MGPipeHandle m_lastCso = kMGPipeNullHandle; + Uint32 m_lastDynamicMask = 0; + Uint64 m_payloadBytes = 0; + Uint64 m_binds = 0; + Uint64 m_savedPush = 0; + UniquePtr m_previous; + }; + + TEST_F(TrackerWalk, EveryBitHasAName) { + for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) { + ASSERT_NE(kMGPipeDirtyNames[i], nullptr); + EXPECT_EQ(std::string(kMGPipeDirtyNames[i]).rfind("NEW_", 0), 0u); + } + } + + // The five P2 emits for each name their own subsystem; the rest name none, which is what + // makes MOBILEGL_PIPE_PUSH a per-subsystem A/B instead of one switch. + TEST_F(TrackerWalk, OnlyTheFiveEmittedBitsNameASubsystem) { + for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) { + const auto bit = static_cast(i); + const Bool emitted = (kMGPipeDirtyEmittedAtP2 & MGPipeDirtyBit(bit)) != 0; + EXPECT_EQ(MGPipeSubsystemForDirty(bit) != 0, emitted) << kMGPipeDirtyNames[i]; + } + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewRenderState), kMGPipeSubsystemRenderState); + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewPixelPack), kMGPipeSubsystemPixelPack); + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewPatchState), kMGPipeSubsystemPatchState); + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewVertexAttribDefaults), + kMGPipeSubsystemVertexAttribDefaults); + } + + TEST_F(TrackerWalk, TheFirstWalkOnAFreshContextPublishesEverything) { + const Uint32 dirty = Walk(); + EXPECT_TRUE(m_tracker.FreshlyPrimed()); + for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) { + EXPECT_NE(dirty & (Uint32{1} << static_cast(i)), 0u) + << kMGPipeDirtyNames[i] << " did not fire on a fresh context"; + } + } + + // The whole point of a validate-point tracker: two identical draws in a row cost two + // Uint16 compares and emit nothing at all. + TEST_F(TrackerWalk, SteadyStateEmitsNothing) { + Walk(); + const Uint64 mintsAfterFirst = m_cache.GetCounters().Mints; + const Uint64 bindsAfterFirst = m_binds; + for (int i = 0; i < 8; ++i) EXPECT_EQ(Walk(), 0u) << "walk " << i << " fired with nothing moved"; + EXPECT_EQ(m_cache.GetCounters().Mints, mintsAfterFirst); + EXPECT_EQ(m_binds, bindsAfterFirst); + } + + // The Blaze3D shape ARCHITECTURE.md 5.1 names as the reason push happens at validate and + // not in the setter: enable / draw / disable / draw forever mints exactly TWO CSOs and + // reuses them for every toggle after that. + TEST_F(TrackerWalk, BlendToggleReusesTwoCsos) { + constexpr int kToggles = 32; + Walk(); // prime + // The priming walk already minted and cached the blend-DISABLED state, so the cache + // starts empty here or the count below would be one short of the shape it describes. + m_cache.Reset(); + m_cache.ResetCounters(); + m_binds = 0; + for (int i = 0; i < kToggles; ++i) { + Ctx().SetCapability(CapabilityInput::Blend, true); + Walk(); + Ctx().SetCapability(CapabilityInput::Blend, false); + Walk(); + } + EXPECT_EQ(m_cache.GetCounters().Mints, 2u) + << "a two-state ping-pong must mint two CSOs and then never mint again"; + EXPECT_EQ(m_binds, static_cast(2 * kToggles)); + EXPECT_EQ(m_cache.GetCounters().Hits, static_cast(2 * kToggles - 2)); + EXPECT_EQ(m_cache.Size(), 2u); + m_cache.Reset(); + } + + // The regression RenderState.h records: a glViewport must not evict a cached pipeline. + // It mints nothing and its payload is one dynamic chunk - D0, the viewports - and not the + // other seven. + TEST_F(TrackerWalk, ViewportDoesNotMintACso) { + Walk(); // prime + m_cache.ResetCounters(); + for (Int i = 1; i <= 16; ++i) { + Ctx().SetViewport(IntVec4(0, 0, 64 + i, 48 + i)); + const Uint32 dirty = Walk(); + EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewRenderState), 0u); + EXPECT_EQ(dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState), 0u) + << "glViewport moved the PIPELINE version"; + EXPECT_EQ(m_lastDynamicMask, 1u) << "glViewport sent something other than chunk D0"; + } + EXPECT_EQ(m_cache.GetCounters().Mints, 0u); + EXPECT_EQ(m_binds, 1u) << "only the priming walk may bind"; + m_cache.Reset(); + } + + // RenderState's two shutters are Uint16 and the tracker widens them in its OWN state, + // never in MG_State. A wrap must cost an extra re-push at worst and never a missed one. + TEST_F(TrackerWalk, WrapAroundRePushesButNeverMisses) { + Walk(); // prime + constexpr int kMoves = 70000; // past 65535 with room to spare + Uint64 fired = 0; + for (int i = 0; i < kMoves; ++i) { + // Never the default 1.0f: a setter that early-outs on an unchanged value would + // not move m_version, and the first iteration would then be a false miss. + Ctx().SetLineWidth((i & 1) ? 2.0f : 3.0f); + if (Walk() & MGPipeDirtyBit(MGPipeDirty::NewRenderState)) ++fired; + } + EXPECT_EQ(fired, static_cast(kMoves)) + << "a Uint16 wrap swallowed a render-state change"; + m_cache.Reset(); + } + + // The one direction the P1 verify comparator cannot see: it compares object-class fields + // by IDENTITY only, so a bound texture whose CONTENT moved looks unchanged to it. + // ARCHITECTURE.md 13.2 names under-firing as the dangerous direction, and this is the + // first test of it. + TEST_F(TrackerWalk, AggregateGenerationCatchesABoundTextureMoving) { + const auto& tex = Ctx().CreateTextureObject(1, TextureTarget::Texture2D); + ASSERT_TRUE(tex != nullptr); + Walk(); // prime + EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewSamplerViews), 0u); + + static_cast(tex.get())->BumpContentVersion(); + EXPECT_NE(Walk() & MGPipeDirtyBit(MGPipeDirty::NewSamplerViews), 0u) + << "a bound texture's content moved and NEW_SAMPLER_VIEWS did not fire"; + // and it settles again, so the bit is a shutter and not a stuck flag + EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewSamplerViews), 0u); + m_cache.Reset(); + } + + // A NaN outer level is a legal glPatchParameterfv value and has to compare equal to + // itself, which float equality does not do and a byte compare does. + TEST_F(TrackerWalk, ANaNPatchLevelEqualsItselfAndDoesNotFireForever) { + Walk(); // prime + Ctx().SetPatchDefaultOuterLevel( + FloatVec4(std::numeric_limits::quiet_NaN(), 1.0f, 1.0f, 1.0f)); + EXPECT_NE(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPatchState), 0u); + EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPatchState), 0u) + << "a NaN patch level re-fired against itself"; + m_cache.Reset(); + } + + TEST_F(TrackerWalk, ThePixelPackShutterIsAByteCompareOfThePackHalfOnly) { + Walk(); // prime + EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPixelPack), 0u); + Ctx().SetPixelStoreParam(PixelStoreParam::PackAlignment, 8); + EXPECT_NE(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPixelPack), 0u); + EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPixelPack), 0u); + // The UNPACK half has no carrier at all, so it must not move the pack shutter. + Ctx().SetPixelStoreParam(PixelStoreParam::UnpackAlignment, 8); + EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPixelPack), 0u) + << "an unpack write moved the PACK shutter"; + m_cache.Reset(); + } + + TEST_F(TrackerWalk, TheFireTalliesOnlyRunWhilePipeStatsIsOn) { + // PipeStats is off in a unit-test process, which is the state the ROADMAP rule about + // hot-path instrumentation cares about: the walk must cost nothing extra there. + ASSERT_FALSE(MG_Util::PipeStats::Enabled()); + Walk(); + Ctx().SetLineWidth(3.0f); + Walk(); + EXPECT_EQ(m_tracker.WalkCount(), 0u); + EXPECT_EQ(m_tracker.FireCount(MGPipeDirty::NewRenderState), 0u); + m_cache.Reset(); + } + #endif // MOBILEGL_PIPE_PUSH } // namespace From 3ab394e2b80e8da35f4c9ec13d699d7d8492f50c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:56:41 -0400 Subject: [PATCH 107/529] [Test] (Pipe): give the pull build the same ctest names as the push build so a push-only case skips instead of vanishing - G2 requires the pull and push ctest name sets to be identical, name for name. The two new suites had a single hand-written "SkippedInAPullBuild" placeholder each, which made the pull build 30 names short - a diff G2 exists to catch. - Each file now carries an X-macro list of its push-only suite.name pairs, expanded in the pull branch into cases that GTEST_SKIP. A case added on one side and forgotten on the other is a visible ctest-name diff rather than a test that silently is not there. - Measured with a CORRECTED gate command. The brief's G2/G14 grep is '^\s+Test #', which only matches a four-digit test number: ctest right-aligns the number, so tests 1..999 print as "Test #7:" with more than one space, and on this tree that silently dropped 999 of 2368 names - i.e. the gate as written passes while looking at 58% of the list. The pattern that works is '^ +Test +#[0-9]+: '. Both gates are green under it: 2396 names in the pull build and 2396 in the push build with a zero-line diff, and zero of the 2363 baseline names gone. --- MobileGL/MG_Test/Pipe/CsoCacheTest.cpp | 24 ++++++++++++++--- MobileGL/MG_Test/Pipe/TrackerTest.cpp | 36 +++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp b/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp index 334288495..d88520c78 100644 --- a/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp +++ b/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp @@ -29,9 +29,27 @@ using namespace MobileGL::MG_Pipe; namespace { #if !MOBILEGL_PIPE_PUSH - TEST(CsoCache, SkippedInAPullBuild) { - GTEST_SKIP() << "the CSO cache is compiled only under MOBILEGL_PIPE_PUSH"; - } + // G2 REQUIRES THE PULL AND PUSH CTEST NAME SETS TO BE IDENTICAL, name for name. A + // push-only case therefore cannot be ABSENT from a pull build; it has to be there and + // SKIP, which is the shape PipeInputsTest.cpp established for the same reason. This list + // declares exactly the suite.name pairs the push build gets from the real cases below, so + // a case added on one side and forgotten on the other shows up as a ctest-name diff + // rather than as a test that silently is not there. +#define MGL_CSO_CACHE_TEST_LIST(X) \ + X(CsoCacheTest, TheSameStateIsMintedOnceAndReusedForever) \ + X(CsoCacheTest, LruEvictsTheOldestAndEmitsDelete) \ + X(CsoCacheTest, HashCollisionDoesNotAliasTwoStates) \ + X(CsoCacheTest, ContentAddressingOffMintsEveryTime) \ + X(CsoCacheTest, EveryAcquireCountsItsPayloadBytes) \ + X(SetHashSuppressorTest, TheFirstEmissionAlwaysGoesOutOnEverySlot) \ + X(SetHashSuppressorTest, AComputedZeroIsRemappedSoItIsNeverConfusedWithNeverEmitted) \ + X(SetHashSuppressorTest, SlotsAreIndependent) \ + X(SetHashSuppressorTest, InvalidateMakesTheNextSetGoOutWhateverItHashesTo) + +#define MGL_DECLARE_PULL_SKIP(Suite, Name) \ + TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; } + MGL_CSO_CACHE_TEST_LIST(MGL_DECLARE_PULL_SKIP) +#undef MGL_DECLARE_PULL_SKIP #else class CsoCacheTest : public ::testing::Test { protected: diff --git a/MobileGL/MG_Test/Pipe/TrackerTest.cpp b/MobileGL/MG_Test/Pipe/TrackerTest.cpp index 87f976940..2b44e79c1 100644 --- a/MobileGL/MG_Test/Pipe/TrackerTest.cpp +++ b/MobileGL/MG_Test/Pipe/TrackerTest.cpp @@ -41,9 +41,39 @@ namespace { } #if !MOBILEGL_PIPE_PUSH - TEST(Tracker, SkippedInAPullBuild) { - GTEST_SKIP() << "the tracker is compiled only under MOBILEGL_PIPE_PUSH"; - } + // G2 REQUIRES THE PULL AND PUSH CTEST NAME SETS TO BE IDENTICAL, name for name. A + // push-only case therefore cannot be ABSENT from a pull build; it has to be there and + // SKIP, which is the shape PipeInputsTest.cpp established for the same reason. This list + // declares exactly the suite.name pairs the push build gets from the real cases below, so + // a case added on one side and forgotten on the other shows up as a ctest-name diff + // rather than as a test that silently is not there. +#define MGL_TRACKER_TEST_LIST(X) \ + X(TrackerAggregates, EveryAggregateStartsAtZero) \ + X(TrackerAggregates, AVertexArrayAttributeMovesOnlyTheVaoAggregate) \ + X(TrackerAggregates, AFramebufferObjectWriteMovesOnlyTheFramebufferAggregate) \ + X(TrackerAggregates, AFramebufferDefaultSetterMovesOnlyTheFramebufferAggregate) \ + X(TrackerAggregates, ATextureContentWriteMovesOnlyTheContentAggregate) \ + X(TrackerAggregates, ATextureParameterMovesOnlyTheParamsAggregate) \ + X(TrackerAggregates, ASamplerParameterMovesOnlyTheParamsAggregate) \ + X(TrackerAggregates, ABufferRespecifyMovesOnlyTheBufferAggregate) \ + X(TrackerAggregates, AVertexAttribDefaultMovesOnlyItsOwnAggregate) \ + X(TrackerAggregates, ANoteWithoutALiveContextIsANoOp) \ + X(TrackerWalk, EveryBitHasAName) \ + X(TrackerWalk, OnlyTheFiveEmittedBitsNameASubsystem) \ + X(TrackerWalk, TheFirstWalkOnAFreshContextPublishesEverything) \ + X(TrackerWalk, SteadyStateEmitsNothing) \ + X(TrackerWalk, BlendToggleReusesTwoCsos) \ + X(TrackerWalk, ViewportDoesNotMintACso) \ + X(TrackerWalk, WrapAroundRePushesButNeverMisses) \ + X(TrackerWalk, AggregateGenerationCatchesABoundTextureMoving) \ + X(TrackerWalk, ANaNPatchLevelEqualsItselfAndDoesNotFireForever) \ + X(TrackerWalk, ThePixelPackShutterIsAByteCompareOfThePackHalfOnly) \ + X(TrackerWalk, TheFireTalliesOnlyRunWhilePipeStatsIsOn) + +#define MGL_DECLARE_PULL_SKIP(Suite, Name) \ + TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; } + MGL_TRACKER_TEST_LIST(MGL_DECLARE_PULL_SKIP) +#undef MGL_DECLARE_PULL_SKIP #else using GLContext = MG_State::GLState::GLContext; From c574043c1330c6b2ab137245f3062f8fc35c8f4c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 10:08:11 -0400 Subject: [PATCH 108/529] [Fix] (Pipe): derive the render-state answers of the dirty-surface map from RenderState.cpp instead of believing them, and correct the two rows that named a publisher which does not always fire - SetCapability named NEW_PIPELINE_STATE, but its ClipDistance0..7 arms write ClipDistanceEnabledMask (dynamic chunk D7) and deliberately do not BumpVersions, so that publisher does not fire at all for glEnable(GL_CLIP_DISTANCE0); SetStencilFunc named it too, while ++m_pipelineStateVersion there is conditional on Func moving, so a ref-only glStencilFunc does not move it either. Both are now NEW_RENDER_STATE, the answer that holds on every path - a row may now carry several publishers joined with '|', which is what lets the 18 setters that call BumpVersions on every path state both counters, and the patch trio state its own bit and the two render counters it also moves - --check no longer validates only row existence and answer vocabulary: it reads RenderState.cpp, derives per setter which of the two counters moves on EVERY path (BumpVersions moves both, a bare ++m_version only the first, a setter with both kinds of path only the first, a delegating setter inherits its callee's) and fails when a row claims a publisher that under-fires or omits one that always fires - two new self-test negative controls, one per direction, both built from the defects that were actually in the file - MarkProgram/MarkProgramPipeline/MarkShaderForDeletion answered kExplicitDestroy, a mechanism D13 does not build for them: Espryt 0b's explicit destroy is scoped to six object kinds that exclude programs, pipelines and shaders. They answer kUnpublishedDestroy now - a recorded hole rather than a mechanism that does not exist --- MobileGL/MG_Pipe/DirtySurface.def | 237 +++++++++++++++++------------- scripts/gen_pipe_dirty_surface.py | 178 ++++++++++++++++++++-- 2 files changed, 296 insertions(+), 119 deletions(-) diff --git a/MobileGL/MG_Pipe/DirtySurface.def b/MobileGL/MG_Pipe/DirtySurface.def index 3fe79067f..fbee04a9e 100644 --- a/MobileGL/MG_Pipe/DirtySurface.def +++ b/MobileGL/MG_Pipe/DirtySurface.def @@ -18,8 +18,21 @@ // mutator has no row here or a row here names a mutator the scan no longer finds. Both // directions, so a deleted mutator cannot leave a stale row behind either. // -// ANSWERS. One per row, and where a mutator has more than one true answer the row carries -// the COARSER one - the one that cannot under-fire: +// ANSWERS. A row lists EVERY publisher that fires on EVERY path through that mutator, +// and only those; several are joined with '|'. A publisher that fires on some paths but +// not all must not appear, because a shutter built from this file would then UNDER-fire, +// and ARCHITECTURE.md 13.2 names under-firing as the dangerous direction. +// +// For the RenderState family that answer is not a matter of taste and it is CHECKED +// rather than asserted: scripts/gen_pipe_dirty_surface.py reads RenderState.cpp and +// derives, per setter, which of NEW_RENDER_STATE / NEW_PIPELINE_STATE moves on every +// path - BumpVersions() moves both, a bare ++m_version moves only NEW_RENDER_STATE, and +// a setter with both kinds of path therefore always-fires only NEW_RENDER_STATE - and +// --check fails when a row disagrees, in either direction. That check exists because +// this file got exactly two rows wrong: SetCapability, whose ClipDistance0..7 arms move +// only m_version, and SetStencilFunc, whose pipeline bump is conditional on Func moving. +// Both named NEW_PIPELINE_STATE, which does not fire for glEnable(GL_CLIP_DISTANCE0) or +// for a reference-only glStencilFunc. // // NEW_* a MGPipeDirty bit (MG_Impl/Pipe/Tracker.h). The tracker's shutter for // that bit moves when this mutator runs, so the next verb publishes it. @@ -27,9 +40,19 @@ // mutation is published inline and needs no shutter at all. // kReverseChannel not state: a write INTO the frontend from the backend's side. // kNoBackendRead no backend read point observes this state at all. -// kExplicitDestroy published by the delete_* call the Track H slice emits when the object's -// last reference drops - an object's DEATH, which no generation shutters -// because there is no longer an object to carry one. +// kExplicitDestroy published by the delete_* call the Track H slice emits when the +// object's last reference drops - an object's DEATH, which no +// generation shutters because there is no longer an object to carry +// one. Only the six object kinds P2 brief D13 scopes Espryt 0b's +// explicit destroy to (buffers, framebuffers, renderbuffers, +// samplers, textures, vertex arrays) may use this answer. +// kUnpublishedDestroy +// the same event for a kind NO call publishes yet: programs, program +// pipelines and shaders are outside D13's six, so their death reaches +// a backend only through the object's own teardown path. Recorded as +// a hole rather than dressed up as a mechanism that exists - naming +// kExplicitDestroy here would be the same defect the RenderState +// check above exists to stop, one class down in stakes. // kPulledEveryVerb no shutter exists, and none is needed yet: the PipeInputs field this // writes is in its verb class's may-read mask, so the residual fill copies // it at EVERY verb of that class. A shutter here is a P3/P4 optimisation, @@ -48,104 +71,110 @@ // clang-format off // X(Mutator, Answer) -#define MGP_DIRTY_SURFACE_LIST(X) \ - /* ---- the reverse channel: 836 of the 926 calls, 90% of the surface ---- */ \ - X(RecordError, kReverseChannel) \ - /* ---- immediate publish points: the same body reaches the backend ---- */ \ - X(SetActiveTextureUnit, kImmediate) \ - X(BeginTransformFeedback, kImmediate) \ - X(EndTransformFeedback, kImmediate) \ - X(SetTransformFeedbackPaused, kImmediate) \ - X(MarkTransformFeedbackObjectForDeletion, kImmediate) \ - /* ---- the render state: NEW_PIPELINE_STATE when a setter calls */ \ - /* BumpVersions (P2 brief D6's only rule), NEW_RENDER_STATE otherwise */ \ - X(SetBlendEquation, NEW_PIPELINE_STATE) \ - X(SetBlendEquationIndexed, NEW_PIPELINE_STATE) \ - X(SetBlendFunc, NEW_PIPELINE_STATE) \ - X(SetBlendFuncIndexed, NEW_PIPELINE_STATE) \ - /* SetCapability's ClipDistance0..7 arms move only m_version; every other */ \ - /* arm calls BumpVersions, and the coarser answer is the one that holds. */ \ - X(SetCapability, NEW_PIPELINE_STATE) \ - X(SetCapabilityIndexed, NEW_PIPELINE_STATE) \ - X(SetColorMask, NEW_PIPELINE_STATE) \ - X(SetColorMaskIndexed, NEW_PIPELINE_STATE) \ - X(SetCullFaceMode, NEW_PIPELINE_STATE) \ - X(SetDepthFunc, NEW_PIPELINE_STATE) \ - X(SetDepthMask, NEW_PIPELINE_STATE) \ - X(SetFrontFaceMode, NEW_PIPELINE_STATE) \ - X(SetLogicOp, NEW_PIPELINE_STATE) \ - X(SetMinSampleShadingValue, NEW_PIPELINE_STATE) \ - X(SetPolygonMode, NEW_PIPELINE_STATE) \ - X(SetProvokingVertexMode, NEW_PIPELINE_STATE) \ - X(SetSampleCoverage, NEW_PIPELINE_STATE) \ - X(SetSampleMaskValue, NEW_PIPELINE_STATE) \ - /* SetStencilFunc writes Func (pipeline chunk P2/P3) AND Ref/ValueMask */ \ - /* (dynamic D3/D4); SetStencilOp is wholly pipeline, SetStencilMask wholly */ \ - /* dynamic. That split is what keeps a glStencilFunc that moves only the */ \ - /* reference from evicting a cached pipeline. */ \ - X(SetStencilFunc, NEW_PIPELINE_STATE) \ - X(SetStencilOp, NEW_PIPELINE_STATE) \ - X(SetStencilMask, NEW_RENDER_STATE) \ - X(SetBlendColor, NEW_RENDER_STATE) \ - X(SetClampReadColor, NEW_RENDER_STATE) \ - X(SetClearColor, NEW_RENDER_STATE) \ - X(SetClearDepth, NEW_RENDER_STATE) \ - X(SetClearStencil, NEW_RENDER_STATE) \ - X(SetClipControl, NEW_RENDER_STATE) \ - X(SetDepthRange, NEW_RENDER_STATE) \ - X(SetDepthRangeIndexed, NEW_RENDER_STATE) \ - X(SetHint, NEW_RENDER_STATE) \ - X(SetLineWidth, NEW_RENDER_STATE) \ - X(SetPointFadeThresholdSize, NEW_RENDER_STATE) \ - X(SetPointSize, NEW_RENDER_STATE) \ - X(SetPointSpriteCoordOrigin, NEW_RENDER_STATE) \ - X(SetPolygonOffset, NEW_RENDER_STATE) \ - X(SetPolygonOffsetClamped, NEW_RENDER_STATE) \ - X(SetPrimitiveRestartIndex, NEW_RENDER_STATE) \ - X(SetScissorBox, NEW_RENDER_STATE) \ - X(SetScissorBoxIndexed, NEW_RENDER_STATE) \ - X(SetViewport, NEW_RENDER_STATE) \ - X(SetViewportIndexed, NEW_RENDER_STATE) \ - /* ---- the other value-class bits ---- */ \ - X(SetPixelStoreParam, NEW_PIXEL_PACK) \ - X(SetPatchDefaultInnerLevel, NEW_PATCH_STATE) \ - X(SetPatchDefaultOuterLevel, NEW_PATCH_STATE) \ - /* Also an immediate publish point, but it has a real bit and the bit is */ \ - /* the more useful answer: set_patch_state carries it whatever the caller */ \ - /* does next. */ \ - X(SetPatchVertices, NEW_PATCH_STATE) \ - X(SetCurrentVertexAttributeFloat, NEW_VERTEX_ATTRIB_DEFAULTS) \ - X(SetCurrentVertexAttributeInt, NEW_VERTEX_ATTRIB_DEFAULTS) \ - X(SetCurrentVertexAttributeUint, NEW_VERTEX_ATTRIB_DEFAULTS) \ - /* ---- object class ---- */ \ - X(BumpTextureBindGeneration, NEW_SAMPLER_VIEWS) \ - X(SetNamedTransformFeedbackBinding, NEW_SO_TARGETS) \ - /* ---- an object's death: no generation, because there is no longer an */ \ - /* object to carry one. Espryt 0b's delete_* is what publishes these. */ \ - X(MarkBufferObjectForDeletion, kExplicitDestroy) \ - X(MarkFramebufferObjectForDeletion, kExplicitDestroy) \ - X(MarkProgramForDeletion, kExplicitDestroy) \ - X(MarkProgramPipelineForDeletion, kExplicitDestroy) \ - X(MarkRenderbufferObjectForDeletion, kExplicitDestroy) \ - X(MarkSamplerObjectForDeletion, kExplicitDestroy) \ - X(MarkShaderForDeletion, kExplicitDestroy) \ - X(MarkTextureObjectForDeletion, kExplicitDestroy) \ - X(MarkVertexArrayForDeletion, kExplicitDestroy) \ - /* ---- no backend read point observes these at all ---- */ \ - /* GL_ANY_SAMPLES_PASSED conditional rendering is resolved wholly in the */ \ - /* frontend: IsConditionalRenderActive / GetConditionalRenderQuery have no */ \ - /* reader under MG_Backend and no Coverage.def row. */ \ - X(BeginConditionalRender, kNoBackendRead) \ - X(EndConditionalRender, kNoBackendRead) \ - /* ---- pulled at every verb of the class, so the next verb publishes them */ \ - /* unconditionally. The transform-feedback accounting counters reach the */ \ - /* backend through GetTransformFeedbackCapturedVertices and friends, which */ \ - /* are in the kDraw and kXfbSpan may-read masks. */ \ - X(AddTransformFeedbackAccountedCaptureDraw, kPulledEveryVerb) \ - X(AddTransformFeedbackCapturedVertices, kPulledEveryVerb) \ - X(AddTransformFeedbackGeometryCaptureDraw, kPulledEveryVerb) \ - X(AddTransformFeedbackInputPrimitives, kPulledEveryVerb) \ - X(AddTransformFeedbackPausedPrimitives, kPulledEveryVerb) \ - X(AddTransformFeedbackPrimitives, kPulledEveryVerb) +#define MGP_DIRTY_SURFACE_LIST(X) \ + /* ---- the reverse channel: 836 of the 926 calls, 90% of the surface ---- */ \ + X(RecordError, kReverseChannel) \ + /* ---- immediate publish points: the same body reaches the backend ---- */ \ + X(SetActiveTextureUnit, kImmediate) \ + X(BeginTransformFeedback, kImmediate) \ + X(EndTransformFeedback, kImmediate) \ + X(SetTransformFeedbackPaused, kImmediate) \ + X(MarkTransformFeedbackObjectForDeletion, kImmediate) \ + /* ---- the render state. Derived from RenderState.cpp and gated by --check: */ \ + /* a setter that calls BumpVersions() on every path publishes BOTH counters; */ \ + /* one that also has a bare ++m_version path publishes only NEW_RENDER_STATE. */ \ + X(SetBlendEquation, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetBlendEquationIndexed, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetBlendFunc, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetBlendFuncIndexed, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + /* SetCapability's ClipDistance0..7 arms write ClipDistanceEnabledMask (dynamic */ \ + /* chunk D7) and deliberately do NOT BumpVersions, so NEW_PIPELINE_STATE does */ \ + /* not fire at all for glEnable(GL_CLIP_DISTANCE0): set_dynamic_state publishes */ \ + /* it, and NEW_RENDER_STATE is the only answer that holds on every arm. */ \ + X(SetCapability, NEW_RENDER_STATE) \ + X(SetCapabilityIndexed, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetColorMask, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetColorMaskIndexed, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetCullFaceMode, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetDepthFunc, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetDepthMask, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetFrontFaceMode, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetLogicOp, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetMinSampleShadingValue, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetPolygonMode, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetProvokingVertexMode, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetSampleCoverage, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetSampleMaskValue, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + /* SetStencilFunc writes Func (pipeline chunk P2/P3) AND Ref/ValueMask (dynamic */ \ + /* D3/D4), and ++m_pipelineStateVersion is CONDITIONAL on Func moving - which is */ \ + /* what keeps a glStencilFunc that moves only the reference from evicting a */ \ + /* cached pipeline, and is why only NEW_RENDER_STATE fires on every call. */ \ + /* SetStencilOp is wholly pipeline, SetStencilMask wholly dynamic. */ \ + X(SetStencilFunc, NEW_RENDER_STATE) \ + X(SetStencilOp, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetStencilMask, NEW_RENDER_STATE) \ + X(SetBlendColor, NEW_RENDER_STATE) \ + X(SetClampReadColor, NEW_RENDER_STATE) \ + X(SetClearColor, NEW_RENDER_STATE) \ + X(SetClearDepth, NEW_RENDER_STATE) \ + X(SetClearStencil, NEW_RENDER_STATE) \ + X(SetClipControl, NEW_RENDER_STATE) \ + X(SetDepthRange, NEW_RENDER_STATE) \ + X(SetDepthRangeIndexed, NEW_RENDER_STATE) \ + X(SetHint, NEW_RENDER_STATE) \ + X(SetLineWidth, NEW_RENDER_STATE) \ + X(SetPointFadeThresholdSize, NEW_RENDER_STATE) \ + X(SetPointSize, NEW_RENDER_STATE) \ + X(SetPointSpriteCoordOrigin, NEW_RENDER_STATE) \ + X(SetPolygonOffset, NEW_RENDER_STATE) \ + X(SetPolygonOffsetClamped, NEW_RENDER_STATE) \ + X(SetPrimitiveRestartIndex, NEW_RENDER_STATE) \ + X(SetScissorBox, NEW_RENDER_STATE) \ + X(SetScissorBoxIndexed, NEW_RENDER_STATE) \ + X(SetViewport, NEW_RENDER_STATE) \ + X(SetViewportIndexed, NEW_RENDER_STATE) \ + /* ---- the other value-class bits ---- */ \ + X(SetPixelStoreParam, NEW_PIXEL_PACK) \ + X(SetPatchDefaultInnerLevel, NEW_PATCH_STATE|NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetPatchDefaultOuterLevel, NEW_PATCH_STATE|NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + /* Also an immediate publish point, but it has a real bit and the bit is */ \ + /* the more useful answer: set_patch_state carries it whatever the caller */ \ + /* does next. */ \ + X(SetPatchVertices, NEW_PATCH_STATE|NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ + X(SetCurrentVertexAttributeFloat, NEW_VERTEX_ATTRIB_DEFAULTS) \ + X(SetCurrentVertexAttributeInt, NEW_VERTEX_ATTRIB_DEFAULTS) \ + X(SetCurrentVertexAttributeUint, NEW_VERTEX_ATTRIB_DEFAULTS) \ + /* ---- object class ---- */ \ + X(BumpTextureBindGeneration, NEW_SAMPLER_VIEWS) \ + X(SetNamedTransformFeedbackBinding, NEW_SO_TARGETS) \ + /* ---- an object's death: no generation, because there is no longer an object */ \ + /* to carry one. Espryt 0b's delete_* publishes the six kinds D13 scopes it to; */ \ + /* programs, program pipelines and shaders are NOT among them and nothing else */ \ + /* publishes their death, so they answer kUnpublishedDestroy - a recorded hole. */ \ + X(MarkBufferObjectForDeletion, kExplicitDestroy) \ + X(MarkFramebufferObjectForDeletion, kExplicitDestroy) \ + X(MarkProgramForDeletion, kUnpublishedDestroy) \ + X(MarkProgramPipelineForDeletion, kUnpublishedDestroy) \ + X(MarkRenderbufferObjectForDeletion, kExplicitDestroy) \ + X(MarkSamplerObjectForDeletion, kExplicitDestroy) \ + X(MarkShaderForDeletion, kUnpublishedDestroy) \ + X(MarkTextureObjectForDeletion, kExplicitDestroy) \ + X(MarkVertexArrayForDeletion, kExplicitDestroy) \ + /* ---- no backend read point observes these at all ---- */ \ + /* GL_ANY_SAMPLES_PASSED conditional rendering is resolved wholly in the */ \ + /* frontend: IsConditionalRenderActive / GetConditionalRenderQuery have no */ \ + /* reader under MG_Backend and no Coverage.def row. */ \ + X(BeginConditionalRender, kNoBackendRead) \ + X(EndConditionalRender, kNoBackendRead) \ + /* ---- pulled at every verb of the class, so the next verb publishes them */ \ + /* unconditionally. The transform-feedback accounting counters reach the */ \ + /* backend through GetTransformFeedbackCapturedVertices and friends, which */ \ + /* are in the kDraw and kXfbSpan may-read masks. */ \ + X(AddTransformFeedbackAccountedCaptureDraw, kPulledEveryVerb) \ + X(AddTransformFeedbackCapturedVertices, kPulledEveryVerb) \ + X(AddTransformFeedbackGeometryCaptureDraw, kPulledEveryVerb) \ + X(AddTransformFeedbackInputPrimitives, kPulledEveryVerb) \ + X(AddTransformFeedbackPausedPrimitives, kPulledEveryVerb) \ + X(AddTransformFeedbackPrimitives, kPulledEveryVerb) // clang-format on diff --git a/scripts/gen_pipe_dirty_surface.py b/scripts/gen_pipe_dirty_surface.py index f11ed2c4e..18e6a90a1 100644 --- a/scripts/gen_pipe_dirty_surface.py +++ b/scripts/gen_pipe_dirty_surface.py @@ -21,6 +21,12 @@ P0 is the skeleton: it reports. P1 adds the mapping file and CI regenerates it with `git diff --exit-code` and zero unmapped mutators, the same shape as gen_pipe.py's G6. +P2 adds the half a completeness gate cannot have: for the RenderState family the ANSWER is +derived from RenderState.cpp rather than believed, so a row that names a publisher which +fires on only some paths through the setter (or omits one that always fires) is red. Without +it a row could be wrong in exactly the direction ARCHITECTURE.md 13.2 calls dangerous while +--check stayed green, which is how two rows in this mapping were wrong for a whole review. + python3 scripts/gen_pipe_dirty_surface.py # human-readable report python3 scripts/gen_pipe_dirty_surface.py --summary # counts only python3 scripts/gen_pipe_dirty_surface.py --check # THE GATE: rc 1 on any hole @@ -139,15 +145,83 @@ def scan_file(path): DEF_PATH = os.path.join(REPO_ROOT, "MobileGL", "MG_Pipe", "DirtySurface.def") TRACKER_PATH = os.path.join(REPO_ROOT, "MobileGL", "MG_Impl", "Pipe", "Tracker.h") +RENDER_STATE_PATH = os.path.join(REPO_ROOT, "MobileGL", "MG_State", "GLState", "RenderState", + "RenderState.cpp") -ROW_RE = re.compile(r"^[ \t]*X\((\w+),\s*(\w+)\)\s*\\?\s*$", re.M) +# An answer is one or more publishers joined with "|" - every publisher that fires on EVERY +# path through the mutator (DirtySurface.def's header states the rule). +ROW_RE = re.compile(r"^[ \t]*X\((\w+),\s*([\w|]+)\)\s*\\?\s*$", re.M) DIRTY_NAME_RE = re.compile(r'^\s*"(NEW_[A-Z0-9_]+)",\s*$', re.M) # The answers that are not a dirty-bit name. Each one is documented in DirtySurface.def's # header; a row that uses anything else is a typo, and a typo that read as "mapped" would be # exactly the silent hole this gate exists to close. NON_BIT_ANSWERS = ("kImmediate", "kReverseChannel", "kNoBackendRead", "kExplicitDestroy", - "kPulledEveryVerb") + "kUnpublishedDestroy", "kPulledEveryVerb") + +# ---- the render-state answers, DERIVED rather than believed ----------------------------- +# The two RenderState counters are the one place in the mapping where "what publishes this" +# has a mechanical answer, and where getting it wrong is not a documentation slip: P3a builds +# its narrow shutters from this file, so a row that claims NEW_PIPELINE_STATE for a setter +# whose pipeline bump is conditional (SetStencilFunc) or absent (SetCapability's +# ClipDistance0..7 arms) encodes exactly the under-firing ARCHITECTURE.md 13.2 calls the +# dangerous direction. So the gate derives the answer from RenderState.cpp: +# +# BumpVersions() moves m_version AND m_pipelineStateVersion (RenderState.h); +# a bare ++m_version moves only the first; +# a setter that has BOTH kinds of path always-fires only NEW_RENDER_STATE. +# +# A setter whose body has no bump at all is resolved through the RenderState setter it +# delegates to (SetPolygonOffset -> SetPolygonOffsetClamped). +RENDER_STATE_BIT = "NEW_RENDER_STATE" +PIPELINE_STATE_BIT = "NEW_PIPELINE_STATE" +BUMP_VERSIONS_RE = re.compile(r"\bBumpVersions\s*\(\s*\)") +BARE_VERSION_RE = re.compile(r"\+\+\s*m_version\b") +BARE_PIPELINE_RE = re.compile(r"\+\+\s*m_pipelineStateVersion\b") +SETTER_CALL_RE = re.compile(r"\b(Set\w+)\s*\(") + + +def render_state_publishers(): + """{setter: set of always-firing render-state publishers} read out of RenderState.cpp. + + A setter absent from the result is not a RenderState setter at all; a setter mapped to an + EMPTY set moves neither counter (SetPixelStoreParam).""" + with open(RENDER_STATE_PATH, "r", encoding="utf-8", errors="replace") as handle: + masked = mask_comments_and_strings(handle.read()) + + bodies = {} + for name, start, end in function_bodies(masked): + if name.startswith("Set"): + bodies.setdefault(name, []).append(masked[start:end]) + + def direct(name): + publishers = set() + for body in bodies[name]: + bump = BUMP_VERSIONS_RE.search(body) is not None + bare_version = BARE_VERSION_RE.search(body) is not None + bare_pipeline = BARE_PIPELINE_RE.search(body) is not None + if bump or bare_version: + publishers.add(RENDER_STATE_BIT) + if bump and not bare_version and not bare_pipeline: + publishers.add(PIPELINE_STATE_BIT) + return publishers + + def resolve(name, seen): + if name in seen: + return set() + seen.add(name) + publishers = direct(name) + if publishers: + return publishers + # No bump of its own: whatever the setter it delegates to publishes. + for body in bodies[name]: + for match in SETTER_CALL_RE.finditer(body): + callee = match.group(1) + if callee != name and callee in bodies: + publishers |= resolve(callee, seen) + return publishers + + return {name: resolve(name, set()) for name in bodies} def dirty_bit_names(): @@ -160,7 +234,8 @@ def dirty_bit_names(): def load_mapping(text=None): - """{mutator: answer} from DirtySurface.def, or from `text` for the self-test.""" + """{mutator: answer} from DirtySurface.def, or from `text` for the self-test. An answer + keeps its "|"-joined spelling; answer_set() below is what compares them.""" if text is None: with open(DEF_PATH, "r", encoding="utf-8", errors="replace") as handle: text = handle.read() @@ -174,10 +249,16 @@ def load_mapping(text=None): return rows, duplicates -def check_mapping(mapping, duplicates, scanned, bits): +def answer_set(answer): + return {part.strip() for part in answer.split("|") if part.strip()} + + +def check_mapping(mapping, duplicates, scanned, bits, publishers=None): """Every problem the gate fails on, as a list of human-readable lines. BOTH directions: an unmapped mutator renders stale, and a row naming a mutator the scan no longer finds is - a stale row that would keep a real hole looking covered.""" + a stale row that would keep a real hole looking covered. `publishers` is + render_state_publishers()'s table; passing None checks only existence and vocabulary, + which is what the mutator-level negative controls want.""" problems = [] for mutator in sorted(set(scanned) - set(mapping)): problems.append("UNMAPPED mutator %s - add a row to MG_Pipe/DirtySurface.def" % mutator) @@ -187,13 +268,50 @@ def check_mapping(mapping, duplicates, scanned, bits): for mutator in sorted(duplicates): problems.append("DUPLICATE row %s" % mutator) for mutator in sorted(mapping): - answer = mapping[mutator] - if answer in NON_BIT_ANSWERS: + answers = answer_set(mapping[mutator]) + if not answers: + problems.append("BAD answer for %s - empty" % mutator) + continue + for answer in sorted(answers): + if answer in NON_BIT_ANSWERS or answer in bits: + continue + problems.append("BAD answer %s for %s - not a MGPipeDirty bit name and not one of %s" + % (answer, mutator, ", ".join(NON_BIT_ANSWERS))) + if len(answers) > 1 and answers & set(NON_BIT_ANSWERS): + problems.append("BAD answer %s for %s - a non-bit answer stands alone" + % (mapping[mutator], mutator)) + + if publishers is None: + return problems + + # THE TRUTH HALF, and it is the half a row can be green and wrong without. For every + # mutator that is a RenderState setter, the render-state publishers the row claims must + # be exactly the ones RenderState.cpp always moves - a claimed publisher that does not + # always fire is an under-firing shutter waiting to be built from this file, and a + # publisher that always fires but is not claimed hides one. + render_bits = {RENDER_STATE_BIT, PIPELINE_STATE_BIT} + for mutator in sorted(mapping): + claimed = answer_set(mapping[mutator]) & render_bits + if mutator not in publishers: + if claimed: + problems.append( + "UNVERIFIABLE answer %s for %s - it claims a render-state publisher but " + "RenderState.cpp has no such setter to derive it from" + % (mapping[mutator], mutator)) continue - if answer in bits: + derived = publishers[mutator] & render_bits + if claimed == derived: continue - problems.append("BAD answer %s for %s - not a MGPipeDirty bit name and not one of %s" - % (answer, mutator, ", ".join(NON_BIT_ANSWERS))) + for missing in sorted(derived - claimed): + problems.append( + "MISSING publisher %s for %s - RenderState.cpp moves it on every path, so the " + "row must name it (derived: %s)" + % (missing, mutator, "|".join(sorted(derived)) or "none")) + for extra in sorted(claimed - derived): + problems.append( + "UNDER-FIRING answer %s for %s - RenderState.cpp does NOT move it on every " + "path, so a shutter built on it would miss a mutation (derived: %s)" + % (extra, mutator, "|".join(sorted(derived)) or "none")) return problems @@ -224,7 +342,7 @@ def scan_all(): return sources, per_file, distinct_all -def self_test(scanned, bits): +def self_test(scanned, bits, publishers): """Canned negative controls. Each MUST trip; trips == 0 is an error, which is the shape check_include_closure.py and gen_pipe.py --self-test already use.""" trips = 0 @@ -257,6 +375,28 @@ def self_test(scanned, bits): else: failures.append("negative control 3 (a bad answer) did NOT trip") + # 4. THE CONTROL FOR THE TRUTH HALF, and it is the shape of the defect that was actually + # in this file: a row claiming a publisher that fires on only some paths through the + # setter. SetCapability's ClipDistance0..7 arms move m_version alone, so + # NEW_PIPELINE_STATE here must read as under-firing rather than as a valid answer. + with_under_firing = dict(real) + with_under_firing["SetCapability"] = PIPELINE_STATE_BIT + problems = check_mapping(with_under_firing, real_duplicates, scanned, bits, publishers) + if any(p.startswith("UNDER-FIRING") for p in problems): + trips += 1 + else: + failures.append("negative control 4 (an under-firing render-state answer) did NOT trip") + + # 5. the other direction: a row that drops a publisher which DOES always fire. Silent + # today, load-bearing the moment P3a builds a shutter from the file. + with_missing = dict(real) + with_missing["SetBlendEquation"] = RENDER_STATE_BIT + problems = check_mapping(with_missing, real_duplicates, scanned, bits, publishers) + if any(p.startswith("MISSING publisher") for p in problems): + trips += 1 + else: + failures.append("negative control 5 (a dropped render-state publisher) did NOT trip") + for failure in failures: print("dirty-surface self-test: %s" % failure) if trips == 0: @@ -283,26 +423,34 @@ def main(): sys.exit("missing %s" % SCAN_ROOT) if not os.path.isfile(DEF_PATH): sys.exit("missing %s" % DEF_PATH) + if not os.path.isfile(RENDER_STATE_PATH): + sys.exit("missing %s" % RENDER_STATE_PATH) sources, per_file, distinct_all = scan_all() bits = dirty_bit_names() if not bits: sys.exit("could not read the MGPipeDirty bit names out of %s" % TRACKER_PATH) + publishers = render_state_publishers() + if not publishers: + sys.exit("could not derive any RenderState setter out of %s" % RENDER_STATE_PATH) if args.self_test: - return self_test(distinct_all, bits) + return self_test(distinct_all, bits, publishers) mapping, duplicates = load_mapping() if args.check: - problems = check_mapping(mapping, duplicates, distinct_all, bits) + problems = check_mapping(mapping, duplicates, distinct_all, bits, publishers) for problem in problems: print("dirty-surface: %s" % problem) if problems: print("dirty-surface: %d problem(s); the mapping must cover every mutator the scan " - "finds, in both directions" % len(problems)) + "finds, in both directions, and every render-state answer must be the one " + "RenderState.cpp actually publishes" % len(problems)) return 1 - print("dirty-surface: %d mutators, all mapped, no stale rows" % len(mapping)) + derived = sum(1 for m in mapping if m in publishers) + print("dirty-surface: %d mutators, all mapped, no stale rows; %d render-state answers " + "derived from RenderState.cpp and matching" % (len(mapping), derived)) return 0 total_functions = 0 From bb781df527776075d616815dab9e355b9ace6ae3 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 10:25:25 -0400 Subject: [PATCH 109/529] [Fix] (Pipe): count the render-state CSO binds the cache has always declared and never incremented - Counters::Binds was declared, documented as one of the three numbers P13's capacity retune reads, and incremented nowhere: the retune would have read a permanent zero, and the unit tests counted binds in a local of their own - counted in Acquire, which has exactly one caller and is followed by a bind_render_state every time, so the count cannot drift from the emitter forgetting to tick it --- MobileGL/MG_Impl/Pipe/CsoCache.h | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/MobileGL/MG_Impl/Pipe/CsoCache.h b/MobileGL/MG_Impl/Pipe/CsoCache.h index b13745949..2f7427453 100644 --- a/MobileGL/MG_Impl/Pipe/CsoCache.h +++ b/MobileGL/MG_Impl/Pipe/CsoCache.h @@ -56,7 +56,13 @@ namespace MobileGL::MG_Pipe { public: struct Counters { Uint64 Mints = 0; // create_render_state emissions - Uint64 Binds = 0; // bind_render_state emissions, mint or reuse + // bind_render_state emissions, mint or reuse. Counted in Acquire because Acquire + // has exactly ONE caller (PipeFill.cpp's EmitRenderState) and that caller binds + // immediately after every call - so "acquisitions" and "binds" are the same + // number, and counting it here keeps the count from depending on an emitter + // remembering to tick it. mints/binds is the cache's hit rate and it is the + // number the CSO content-addressing negative control moves. + Uint64 Binds = 0; Uint64 Hits = 0; // a probe that found a live entry and passed the memcmp Uint64 Collisions = 0; // a hash hit the memcmp REJECTED - the reason it exists Uint64 Evictions = 0; // LRU evictions, each one a delete_render_state @@ -68,6 +74,7 @@ namespace MobileGL::MG_Pipe { MGPipeHandle Acquire(const RenderStateParameters& params, Uint64& payloadBytes) { Array bytes; MGPipeGatherPipelineBytes(params, bytes.data()); + ++m_counters.Binds; const Bool contentAddressed = (MG_Config::Features.PipePush & kMGPipeBehaviourNoCsoContentAddressing) == 0; From 5d4d91fe7ed6e2b4bc7fc128725fa5b9adb0ced8 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 10:25:25 -0400 Subject: [PATCH 110/529] [Fix] (Pipe): carry the class a vertex-attribute default was written through, and stop the applier's lossy write from being observable - set_vertex_attrib_defaults hard-coded MGPAttribValue::ValueClass to 0 for every attribute and always sent the FLOAT view's four words. A CurrentVertexAttributeValue is one value in three views and GLContext converts numerically between them, so those bytes cannot reproduce the frontend value: glVertexAttrib4f(loc, 1.5f, ..) leaves 1 in intValue and 0x3FC00000 in floatValue, and every glVertexAttribI4i/ui default was wrong too - GLContext now records which view each glVertexAttrib* write filled directly (GetCurrentVertexAttributeClass, push-only) and the payload carries that class and THAT class's own words. It is kept beside the value rather than inside it because CurrentVertexAttributeValue is mirrored into PipeInputs and compared there by a memcmp whose size assertion lives in a file this package does not own - MGPipeFillAttribValue is the flattening, in one named place, so TrackerAttribPayload can pin it: the old defect turns three of its four cases red - the applier (package A's) still memcpys the four words into all three views regardless of ValueClass, so the emitter now CHECKS: it compares the mirror the applier wrote against the frontend's value and, when they differ, copies the field itself and says so once. That closes the window the old code left wrong - a glVertexAttrib* write followed by a verb whose class does not read the field, where the residual fill does not run for it - and it stops repairing by itself the day A's applier honours the class - MGPipeVertexAttribDefaultRepairCount() makes that repair observable to a test without reading storage the fill table forbids that verb to read - the emission gate now goes through MGPipeSubsystemForDirty, the one bit-to-subsystem map, instead of a second copy of it written out by hand at the validate point; five static_asserts tie that map to the field-emitter map it has to agree with - the staging mirror is advanced only by the branch that sent dynamic bytes, with the invariant it used to rely on (BumpVersions moves both counters, RenderState.h) asserted here rather than assumed of another package's file - TrackerShippedEmitter drives MGPipeValidateForVerb itself and reads the real singletons back, so the blend-toggle and viewport shapes are pinned on the shipped emitter and not only on the unit tests' local re-implementation; the fixtures reset the applier, the cache and the tracker together, which is the only consistent state of the three - comments: the derivation probe is a one-field sample, the residual block's trip wire is half a tautology until package A's c1 lands, and the widened counter cannot see a change of exactly 65536 - all three recorded where the code is, not only in a review --- MobileGL/MG_Impl/Pipe/PipeFill.cpp | 159 +++++++++++++++++--- MobileGL/MG_Impl/Pipe/PipeFill.h | 13 ++ MobileGL/MG_Impl/Pipe/Tracker.h | 34 +++++ MobileGL/MG_State/GLState/Core.cpp | 11 ++ MobileGL/MG_State/GLState/Core.h | 39 +++++ MobileGL/MG_Test/Pipe/TrackerTest.cpp | 209 +++++++++++++++++++++++++- 6 files changed, 437 insertions(+), 28 deletions(-) diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index efb1fb712..ad032e91c 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -43,6 +43,14 @@ namespace MobileGL::MG_Pipe { // path writes through them. static RenderStateParameters& RenderStateOf(PipeInputs& inputs) { return inputs.m_renderState; } static Uint32& ClearStencilOf(PipeInputs& inputs) { return inputs.m_clearStencil; } + // Read-only, and it exists for one thing: after set_vertex_attrib_defaults goes out, + // the emitter compares what the applier left here against what the frontend holds + // (EmitVertexAttribDefaults). Reading it through this door rather than through the + // accessor is deliberate - the accessor is poison-checked and this is a fill-time + // read, not a backend read. + static const PipeInputs::CurrentVertexAttributeValue* VertexAttribDefaultsOf(const PipeInputs& inputs) { + return inputs.m_currentVertexAttribute; + } static void CopyField(PipeInputs& dst, GLContext& ctx, MGPipeInputField field) { using F = MGPipeInputField; @@ -631,6 +639,27 @@ namespace MobileGL::MG_Pipe { return 0; } + // The two maps answer different questions - this one takes a field's EMITTER, the + // tracker's MGPipeSubsystemForDirty takes a dirty BIT - and they must agree, because + // the emission is gated on one and the residual-fill skip on the other. A divergence + // would push a call whose field is still pulled, or (worse) skip a field whose call + // was never emitted. Cheap to state, impossible to drift: + static_assert(SubsystemForEmitter(MGPipeFieldEmitter::BindRenderState) == + MGPipeSubsystemForDirty(MGPipeDirty::NewPipelineState), + "bind_render_state and NEW_PIPELINE_STATE must name one subsystem"); + static_assert(SubsystemForEmitter(MGPipeFieldEmitter::SetDynamicState) == + MGPipeSubsystemForDirty(MGPipeDirty::NewRenderState), + "set_dynamic_state and NEW_RENDER_STATE must name one subsystem"); + static_assert(SubsystemForEmitter(MGPipeFieldEmitter::SetPixelPackState) == + MGPipeSubsystemForDirty(MGPipeDirty::NewPixelPack), + "set_pixel_pack_state and NEW_PIXEL_PACK must name one subsystem"); + static_assert(SubsystemForEmitter(MGPipeFieldEmitter::SetPatchState) == + MGPipeSubsystemForDirty(MGPipeDirty::NewPatchState), + "set_patch_state and NEW_PATCH_STATE must name one subsystem"); + static_assert(SubsystemForEmitter(MGPipeFieldEmitter::SetVertexAttribDefaults) == + MGPipeSubsystemForDirty(MGPipeDirty::NewVertexAttribDefaults), + "set_vertex_attrib_defaults and NEW_VERTEX_ATTRIB_DEFAULTS must name one subsystem"); + // Which of those subsystems THIS BUILD actually emits for. It grows one commit at a // time, and a field whose emitter is not wired here keeps being pulled - so adding a // row to Coverage.def can never silently drop a field on the floor before the call @@ -651,13 +680,14 @@ namespace MobileGL::MG_Pipe { // // GetCurrentVertexAttribute's three views are NOT bit-identical: GLContext // CONVERTS between them (SetCurrentVertexAttributeFloat writes (Int32)value into - // intValue), while MGPipeApplySetVertexAttribDefaults memcpys one Data[4] into - // all three and ignores MGPAttribValue::ValueClass, which the wire type carries - // precisely so it does not have to. Until that applier reads ValueClass the - // carrier cannot reproduce the frontend value, so the field keeps being pulled. - // The call is still emitted: the wire shape, the payload bytes and the set-hash - // suppressor are all real, and the residual fill runs AFTER emission, so the - // mirror ends up with the frontend's value either way. + // intValue), while MGPipeApplySetVertexAttribDefaults (package A's) memcpys one + // Data[4] into all three views and ignores MGPAttribValue::ValueClass. The CLIENT + // half of that is fixed - the call now carries the class the frontend actually + // wrote and that class's own bytes - but the APPLIER still cannot reproduce the + // conversion, so this row stays SHAPE-ONLY: the field keeps being pulled, and + // retiring that pull is blocked on A teaching the applier to switch on + // ValueClass. EmitVertexAttribDefaults checks rather than trusts, and repairs the + // mirror when the applier's write does not reproduce the value. constexpr Bool EmittedCallSuppliesTheWholeField(MGPipeInputField field) { switch (field) { case MGPipeInputField::GetPixelStoreParameters: @@ -704,6 +734,13 @@ namespace MobileGL::MG_Pipe { // filler degrades to PULLING those fields instead of rendering a default, which is // the safe direction. The verify lane and RenderStateSpansTest are what say the // derivation is CORRECT; this only says it is THERE. + // + // AND IT IS A ONE-FIELD SAMPLE, deliberately: it probes m_clearStencil and nothing + // else, so a PARTIAL derivation - one that recomputes m_clearStencil and forgets, say, + // GetViewport's rounding - flips this latch to true and lets the other mirrors go + // unwritten. That is a real risk of a half-landed package A and the backstop for it is + // the verify lane (which re-reads every field at every backend read), not this probe. + // Widening the probe to all 29 would re-implement the derivation to check it. Bool ApplierDerivesRenderStateFields() { static const Bool answer = [] { static PipeInputs probe; @@ -749,6 +786,32 @@ namespace MobileGL::MG_Pipe { // 32 values, all three views - is hashed on the client and the call does not go out // when the hash has not moved. That is coalescing rule 4, and this is its one wired // consumer in P2. + // + // THE PAYLOAD AND ITS ONE MISSING HALF. A CurrentVertexAttributeValue is one value in + // three views, and GLContext CONVERTS between them numerically, so "the bytes of one + // view" is not the value: glVertexAttrib4f(loc, 1.5f, ...) leaves 1 in intValue and + // 0x3FC00000 in floatValue, and every glVertexAttribI4i/ui is a different pair again. + // MGPAttribValue carries ValueClass for exactly this reason, so the client sends the + // class the frontend actually wrote (GLContext::GetCurrentVertexAttributeClass) and + // THAT class's own four words. What is still missing is the other half: + // MGPipeApplySetVertexAttribDefaults (package A's file) memcpys the four words into + // all three views regardless of ValueClass, which cannot reproduce the conversion. + // + // The suppressing memcmp below is over the three VIEWS only, and that is not an + // oversight: the class decides how the views are REBUILT, so two writes that leave + // the three views identical rebuild identically whichever class they carried, and a + // class that moved without moving any view has nothing to publish. + // + // So the emitter CHECKS rather than assumes, the same self-healing shape as + // ApplierDerivesRenderStateFields: after the call it compares the mirror the applier + // wrote against the frontend's value, and when they differ it copies the field itself + // and says so once. That is what keeps the block correct in the window this call used + // to corrupt - a glVertexAttrib4f followed by a non-kDraw verb, where the residual + // fill does not run for this field and nothing else would have put the value back. + // The day the applier honours ValueClass the compare stops failing and the repair + // stops happening, with no edit here. + Uint64 g_attribDefaultRepairs = 0; + Uint64 EmitVertexAttribDefaults(GLContext& ctx) { MGPipeTracker& tracker = MGPipeTrackerInstance(); auto& staged = tracker.StagedAttribDefaults(); @@ -768,19 +831,37 @@ namespace MobileGL::MG_Pipe { MGPVertexAttribDefaults header{}; for (SizeT i = 0; i < kAttribs; ++i) { if (std::memcmp(&resolved[i], &staged[i], sizeof(resolved[i])) == 0) continue; - MGPAttribValue& value = tail[header.Count]; - value.Location = static_cast(i); - // ClassifyVertexAttribType resolves the float/int/uint view on the CLIENT - // (MGPipeTypes.h); the frontend keeps all three populated, so the class the - // shader input consumes is what decides which one is authoritative. - value.ValueClass = 0; - std::memcpy(value.Data, resolved[i].floatValue.data(), sizeof(value.Data)); + // The class the frontend WROTE, and that class's own bytes. Not a literal 0 + // and not ClassifyVertexAttribType's answer: that one is the SHADER's question + // ("which view does this input consume"), asked at the backend read sites, and + // it says nothing about which view holds the value the other two were + // converted from. + MGPipeFillAttribValue(static_cast(i), resolved[i], + ctx.GetCurrentVertexAttributeClass(static_cast(i)), + tail[header.Count]); header.Mask |= Uint32{1} << static_cast(i); ++header.Count; staged[i] = resolved[i]; } if (header.Count == 0) return 0; MGPipeApplySetVertexAttribDefaults(header, tail.data()); + + // Did the applier reproduce it? Byte for byte, over the attributes this call + // named - anything less would be a mirror that disagrees with the frontend in a + // window no gate looks at. + const auto* mirror = MGPipeFillAccess::VertexAttribDefaultsOf(gPipeInputs); + Bool reproduced = true; + for (SizeT i = 0; i < kAttribs && reproduced; ++i) { + if ((header.Mask & (Uint32{1} << static_cast(i))) == 0) continue; + reproduced = std::memcmp(&mirror[i], &resolved[i], sizeof(resolved[i])) == 0; + } + if (!reproduced) { + ++g_attribDefaultRepairs; + MGLOG_W_ONCE("MGPipe: MGPipeApplySetVertexAttribDefaults does not reproduce the " + "carried value on this build (it ignores MGPAttribValue::ValueClass) " + "- the client is keeping m_currentVertexAttribute authoritative"); + MGPipeFillAccess::CopyField(gPipeInputs, ctx, MGPipeInputField::GetCurrentVertexAttribute); + } return sizeof(MGPVertexAttribDefaults) + header.Count * sizeof(MGPAttribValue); } @@ -799,6 +880,15 @@ namespace MobileGL::MG_Pipe { // wire a tautology, which is exactly the failure P1's entry compare had and P2 is // paying to remove. // + // ON THIS BRANCH IT IS STILL HALF A TAUTOLOGY, and saying so is part of the honesty + // the trip wire is for: the applier compares these bits against gPipeInputs' + // capability mirror, and while MGPipeDeriveRenderStateFields is a stub that mirror is + // filled by the residual fill from the SAME IsCapabilityEnabled accessor a few lines + // below. It becomes an independent oracle the moment package A's c1 lands and the + // fill stops copying those fields. What it proves already is that the block is + // emitted, sized and suppressed - the resid= byte class and the one divergence it + // caught during development (GL_DITHER) are that evidence. + // // Emitted once per context and again whenever the capability set may have moved, // which is whenever the pipeline version moved: every SET_CAPABILITY arm calls // BumpVersions, so that shutter cannot miss one. @@ -878,14 +968,29 @@ namespace MobileGL::MG_Pipe { payloadBytes += sizeof(MGPDynamicState) + blobBytes; } - if (dirty & (MGPipeDirtyBit(MGPipeDirty::NewPipelineState) | - MGPipeDirtyBit(MGPipeDirty::NewRenderState))) { + // The staging mirror is what set_dynamic_state diffs against, so it may only be + // advanced by the branch that actually SENT dynamic bytes. Latching it whenever + // either bit fired would, if NEW_PIPELINE_STATE could ever fire alone, claim the + // server holds chunks it never received - and the chunk-level suppressor would + // then never resend them, which is a permanently stale answer with no gate on it. + // + // It cannot fire alone today because BumpVersions() moves both counters + // (RenderState.h), but that is an invariant of ANOTHER package's file. So it is + // asserted here rather than assumed, and the assignment is narrowed to the one + // bit that owns the mirror. + MOBILEGL_ASSERT((dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState)) == 0 || + (dirty & MGPipeDirtyBit(MGPipeDirty::NewRenderState)) != 0, + "NEW_PIPELINE_STATE fired without NEW_RENDER_STATE: RenderState's " + "BumpVersions no longer moves both counters"); + if (dirty & MGPipeDirtyBit(MGPipeDirty::NewRenderState)) { tracker.Staged() = live; } return payloadBytes; } } // namespace + Uint64 MGPipeVertexAttribDefaultRepairCount() { return g_attribDefaultRepairs; } + // ---- the validate point (P2 brief D1) ---- void MGPipeValidateForVerb(MGPipeVerb verb) { PipeInputs& inputs = gPipeInputs; @@ -922,27 +1027,31 @@ namespace MobileGL::MG_Pipe { const Uint32 dirty = tracker.Update(*ctx, verbClass); // ---- step 3: emission ---- + // Every gate below goes through MGPipeSubsystemForDirty, the ONE map from a dirty bit + // to the runtime subsystem that owns it. Naming the subsystem constants here instead + // would be a second copy of that map in the only path that runs, and mis-gating a bit + // in it would pass every test the map has. const Uint64 pushMask = MG_Config::Features.PipePush; + const auto wants = [&](MGPipeDirty bit) { + const Uint64 subsystem = MGPipeSubsystemForDirty(bit); + return subsystem != 0 && (pushMask & subsystem) != 0 && + (dirty & MGPipeDirtyBit(bit)) != 0; + }; Uint64 payloadBytes = 0; - if ((pushMask & kMGPipeSubsystemRenderState) != 0 && - (dirty & (MGPipeDirtyBit(MGPipeDirty::NewPipelineState) | - MGPipeDirtyBit(MGPipeDirty::NewRenderState))) != 0) { + if (wants(MGPipeDirty::NewPipelineState) || wants(MGPipeDirty::NewRenderState)) { payloadBytes += EmitRenderState(*ctx, dirty, tracker.FreshlyPrimed()); } if (tracker.FreshlyPrimed()) { // A fresh context: what the server has is no longer what any slot last emitted. MGPipeSetHashSuppressorInstance().InvalidateAll(); } - if ((pushMask & kMGPipeSubsystemPixelPack) != 0 && - (dirty & MGPipeDirtyBit(MGPipeDirty::NewPixelPack)) != 0) { + if (wants(MGPipeDirty::NewPixelPack)) { payloadBytes += EmitPixelPackState(*ctx); } - if ((pushMask & kMGPipeSubsystemPatchState) != 0 && - (dirty & MGPipeDirtyBit(MGPipeDirty::NewPatchState)) != 0) { + if (wants(MGPipeDirty::NewPatchState)) { payloadBytes += EmitPatchState(*ctx); } - if ((pushMask & kMGPipeSubsystemVertexAttribDefaults) != 0 && - (dirty & MGPipeDirtyBit(MGPipeDirty::NewVertexAttribDefaults)) != 0) { + if (wants(MGPipeDirty::NewVertexAttribDefaults)) { payloadBytes += EmitVertexAttribDefaults(*ctx); } diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.h b/MobileGL/MG_Impl/Pipe/PipeFill.h index f9be638d7..9a8df9b1b 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.h +++ b/MobileGL/MG_Impl/Pipe/PipeFill.h @@ -51,6 +51,19 @@ namespace MobileGL::MG_Pipe { // Fatal{PipeVerifyBadKnob}. void MGPipeSetPoisonOmission(const char* verb, const char* field); + // PipeFill.cpp. How many times set_vertex_attrib_defaults' applier failed to reproduce + // the value the call carried, so the client wrote the mirror itself + // (EmitVertexAttribDefaults). It is the ONE observable of that repair: the window it + // covers is a verb whose class does not read m_currentVertexAttribute, where reading the + // storage to check it would be the poison violation the fill table exists to forbid. So + // TrackerShippedEmitter asserts on this counter instead, and the day package A's applier + // switches on MGPAttribValue::ValueClass the counter stops moving. + // + // Not hot-path instrumentation: it is incremented only inside the repair branch, which + // runs only when the call actually went out, which is only when an attribute default + // moved. + Uint64 MGPipeVertexAttribDefaultRepairCount(); + #if MOBILEGL_PIPE_VERIFY // PipeFill.cpp. The second arm of the comparator (P1 brief D8, ARCHITECTURE.md 13.2-2): // fills `snapshot` from the live GLContext the old way, for every field in `mask`. This diff --git a/MobileGL/MG_Impl/Pipe/Tracker.h b/MobileGL/MG_Impl/Pipe/Tracker.h index 493fea124..4ec0d6e87 100644 --- a/MobileGL/MG_Impl/Pipe/Tracker.h +++ b/MobileGL/MG_Impl/Pipe/Tracker.h @@ -149,6 +149,13 @@ namespace MobileGL::MG_Pipe { // (ARCHITECTURE.md 5.2: MG_State is not changed for this). A decrease is a wrap and adds // 65536. A wrap is harmless locally - one extra re-push, never a missed one - which is // exactly what TrackerTest.WrapAroundRePushesButNeverMisses pins. + // + // THE ONE CASE IT CANNOT SEE, stated because "never a missed push" is otherwise stronger + // than what is true: the wrap test is `now < m_last`, so a counter that advances by + // EXACTLY 65536 (or a multiple) between two walks reads as unchanged. That needs 65536 + // render-state mutations inside one verb boundary, and it is pre-existing in class - + // both backends already compare raw Uint16 versions the same way - so P2 records it + // rather than widening MG_State's counters, which ARCHITECTURE.md 5.2 rules out. class MGPipeWidenedCounter { public: Uint64 Observe(Uint16 now) { @@ -392,6 +399,33 @@ namespace MobileGL::MG_Pipe { Uint64 m_walks[kMGPipeVerbClassCount]{}; }; + // ONE attribute default, flattened onto the wire (P2 brief D10). A named function rather + // than four lines inside the emitter because this flattening is the whole correctness + // question of set_vertex_attrib_defaults: a CurrentVertexAttributeValue is one value in + // three views and GLContext converts NUMERICALLY between them, so four words alone are + // not the value - glVertexAttrib4f(loc, 1.5f, ...) leaves 1 in intValue and 0x3FC00000 in + // floatValue. MGPAttribValue::ValueClass is what makes the four words readable again, and + // TrackerAttribPayload pins that here instead of leaving it to the emitter's shape. + inline void MGPipeFillAttribValue(Uint32 location, + const MG_State::GLState::CurrentVertexAttributeValue& value, + Uint32 writtenClass, MGPAttribValue& out) { + out = MGPAttribValue{}; + out.Location = location; + out.ValueClass = static_cast(writtenClass); + static_assert(sizeof(out.Data) == sizeof(value.floatValue), "MGPAttribValue::Data is four words"); + switch (writtenClass) { + case MG_State::GLState::kVertexAttribValueClassInt: + std::memcpy(out.Data, value.intValue.data(), sizeof(out.Data)); + break; + case MG_State::GLState::kVertexAttribValueClassUint: + std::memcpy(out.Data, value.uintValue.data(), sizeof(out.Data)); + break; + default: + std::memcpy(out.Data, value.floatValue.data(), sizeof(out.Data)); + break; + } + } + // The monolith's one tracker. Under split there is one per client context; the context // identity check inside Update is what makes the single instance safe today. inline MGPipeTracker& MGPipeTrackerInstance() { diff --git a/MobileGL/MG_State/GLState/Core.cpp b/MobileGL/MG_State/GLState/Core.cpp index c814f921e..3f4f28f8d 100644 --- a/MobileGL/MG_State/GLState/Core.cpp +++ b/MobileGL/MG_State/GLState/Core.cpp @@ -214,6 +214,11 @@ namespace MobileGL::MG_State { current.intValue[component] = static_cast(value[component]); current.uintValue[component] = static_cast(value[component]); } +#if MOBILEGL_PIPE_PUSH + // The two views above are CONVERSIONS, not bit copies, so which one was written + // is part of the value; set_vertex_attrib_defaults carries it. + m_currentVertexAttributeClasses[index] = kVertexAttribValueClassFloat; +#endif MGP_NOTE_AGGREGATE(VertexAttribDefault); } @@ -229,6 +234,9 @@ namespace MobileGL::MG_State { current.floatValue[component] = static_cast(value[component]); current.uintValue[component] = static_cast(value[component]); } +#if MOBILEGL_PIPE_PUSH + m_currentVertexAttributeClasses[index] = kVertexAttribValueClassInt; +#endif MGP_NOTE_AGGREGATE(VertexAttribDefault); } @@ -244,6 +252,9 @@ namespace MobileGL::MG_State { current.floatValue[component] = static_cast(value[component]); current.intValue[component] = static_cast(value[component]); } +#if MOBILEGL_PIPE_PUSH + m_currentVertexAttributeClasses[index] = kVertexAttribValueClassUint; +#endif MGP_NOTE_AGGREGATE(VertexAttribDefault); } diff --git a/MobileGL/MG_State/GLState/Core.h b/MobileGL/MG_State/GLState/Core.h index a72398acd..b01310c81 100644 --- a/MobileGL/MG_State/GLState/Core.h +++ b/MobileGL/MG_State/GLState/Core.h @@ -30,10 +30,26 @@ namespace MobileGL { void Init(); namespace GLState { +#if MOBILEGL_PIPE_PUSH + // MGPAttribValue::ValueClass' encoding (MG_Pipe/MGPipeTypes.h documents the order + // "Float | Int | Uint | Double"). It lives here rather than in MG_Pipe because the + // FRONTEND is the only thing that knows which of the three views below a value was + // written through - the other two are numeric conversions of it - and MG_Pipe has + // no enum for the field yet. If package A introduces one, this becomes its alias. + inline constexpr Uint32 kVertexAttribValueClassFloat = 0; + inline constexpr Uint32 kVertexAttribValueClassInt = 1; + inline constexpr Uint32 kVertexAttribValueClassUint = 2; +#endif + struct CurrentVertexAttributeValue { Array floatValue{0.f, 0.f, 0.f, 1.f}; Array intValue{0, 0, 0, 1}; Array uintValue{0u, 0u, 0u, 1u}; + // Three scalar arrays and NOTHING ELSE. MG_Backend/MGPipe/PipeInputs.cpp + // compares this storage with one memcmp and asserts that size, so a fourth + // member here is a build break in a file P2 package B does not own. The + // written-class discriminator set_vertex_attrib_defaults needs therefore + // lives beside the array on GLContext, not inside the value. }; // Which of the three views above a shader input of a given GLSL type consumes. @@ -233,6 +249,27 @@ namespace MobileGL { Uint64 GetAnyVertexAttribDefaultGeneration() const { return m_anyVertexAttribDefaultGeneration; } + + // Which of the three views of m_currentVertexAttributes[index] the last + // glVertexAttrib* write filled DIRECTLY. The other two are NUMERIC + // conversions of it (SetCurrentVertexAttribute* below), not bit copies, so + // four words on a wire are not the value unless the class travels with them: + // glVertexAttrib4f(loc, 1.5f, ...) leaves 1 in intValue and 0x3FC00000 in + // floatValue. set_vertex_attrib_defaults carries this as MGPAttribValue's + // ValueClass so the applier can redo the conversion instead of memcpying one + // view into all three. + // + // It is kept BESIDE the array rather than inside CurrentVertexAttributeValue + // because that struct is mirrored into PipeInputs and compared there by a + // memcmp whose size assertion (MG_Backend/MGPipe/PipeInputs.cpp) is a file + // this package does not own - and because it need not be mirrored: the class + // only decides how to REBUILD the three views, so two writes that leave the + // three views identical rebuild identically whichever class they carried. + Uint32 GetCurrentVertexAttributeClass(Uint index) const { + return index < m_currentVertexAttributeClasses.size() + ? m_currentVertexAttributeClasses[index] + : kVertexAttribValueClassFloat; + } #endif // RenderState @@ -554,6 +591,8 @@ namespace MobileGL { SharedPtr m_transformFeedbackProgram; #if MOBILEGL_PIPE_PUSH Uint64 m_anyVertexAttribDefaultGeneration = 0; + // Parallel to m_currentVertexAttributes; see GetCurrentVertexAttributeClass. + Array m_currentVertexAttributeClasses{}; #endif Uint64 m_transformFeedbackGeneration = 0; // Source of the per-span ids above; never rolls back with an object switch. diff --git a/MobileGL/MG_Test/Pipe/TrackerTest.cpp b/MobileGL/MG_Test/Pipe/TrackerTest.cpp index 2b44e79c1..5a3fa1b35 100644 --- a/MobileGL/MG_Test/Pipe/TrackerTest.cpp +++ b/MobileGL/MG_Test/Pipe/TrackerTest.cpp @@ -20,6 +20,8 @@ #if MOBILEGL_PIPE_PUSH #include #include +#include +#include #include #include #include @@ -27,6 +29,7 @@ #include #include +#include #include #include #endif @@ -68,7 +71,15 @@ namespace { X(TrackerWalk, AggregateGenerationCatchesABoundTextureMoving) \ X(TrackerWalk, ANaNPatchLevelEqualsItselfAndDoesNotFireForever) \ X(TrackerWalk, ThePixelPackShutterIsAByteCompareOfThePackHalfOnly) \ - X(TrackerWalk, TheFireTalliesOnlyRunWhilePipeStatsIsOn) + X(TrackerWalk, TheFireTalliesOnlyRunWhilePipeStatsIsOn) \ + X(TrackerAttribPayload, AFloatWriteCarriesTheFloatBitsAndNamesItsClass) \ + X(TrackerAttribPayload, AnIntWriteCarriesTheIntWordsAndNamesItsClass) \ + X(TrackerAttribPayload, AUintWriteCarriesTheUintWordsAndNamesItsClass) \ + X(TrackerAttribPayload, TheSameNumbersWrittenThroughADifferentClassAreADifferentValue) \ + X(TrackerShippedEmitter, ABlendToggleThroughTheValidatePointMintsTwoCsos) \ + X(TrackerShippedEmitter, TheSteadyStateThroughTheValidatePointEmitsNothing) \ + X(TrackerShippedEmitter, APushedAttributeDefaultTheApplierCannotReproduceIsRepaired) \ + X(TrackerShippedEmitter, AViewportThroughTheValidatePointMintsNoCso) #define MGL_DECLARE_PULL_SKIP(Suite, Name) \ TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; } @@ -218,17 +229,29 @@ namespace { // one process-wide tracker, and a unit test that asserts on a shared singleton is a test // that fails when ctest runs the suite in parallel. The emission logic these reproduce is // three lines long and is the same three lines the validate point runs. + // Resetting the applier without resetting the process-wide cache and tracker would leave + // the next bind_render_state naming a CSO the applier no longer has - it asserts and + // returns, leaving m_renderState unwritten. The three are one state, so they are reset + // together, here and in TrackerShippedEmitter. + void ResetTheServerSideSingletons() { + MGPipeApplierReset(); + MGPipeCsoCacheInstance().Reset(); + MGPipeCsoCacheInstance().ResetCounters(); + MGPipeTrackerInstance().Reset(); + MGPipeSetHashSuppressorInstance().InvalidateAll(); + } + class TrackerWalk : public ::testing::Test { protected: void SetUp() override { m_previous = Move(MG_State::pGLContext); MG_State::pGLContext = MakeUnique(); m_savedPush = MG_Config::Features.PipePush; - MGPipeApplierReset(); + ResetTheServerSideSingletons(); } void TearDown() override { MG_Config::Features.PipePush = m_savedPush; - MGPipeApplierReset(); + ResetTheServerSideSingletons(); MG_State::pGLContext = Move(m_previous); } @@ -425,5 +448,185 @@ namespace { m_cache.Reset(); } + // =================================================================================== + // set_vertex_attrib_defaults' payload (P2 brief D10) + // =================================================================================== + // + // A CurrentVertexAttributeValue is ONE value in three views and GLContext converts + // numerically between them, so four words on the wire are not the value unless the class + // travels with them. These pin exactly that, because nothing else can: the emission + // happens at step 3 and the residual fill re-pulls the field at step 4, so at a kDraw + // verb a wrong payload is overwritten before any comparator or backend read sees it - + // which is how a hard-coded ValueClass of 0 survived a green verify lane. + class TrackerAttribPayload : public ::testing::Test { + protected: + void SetUp() override { + m_previous = Move(MG_State::pGLContext); + MG_State::pGLContext = MakeUnique(); + } + void TearDown() override { MG_State::pGLContext = Move(m_previous); } + + static GLContext& Ctx() { return *MG_State::pGLContext; } + + static MGPAttribValue PayloadFor(Uint location) { + MGPAttribValue value{}; + MGPipeFillAttribValue(static_cast(location), Ctx().GetCurrentVertexAttribute(location), + Ctx().GetCurrentVertexAttributeClass(location), value); + return value; + } + + static Uint32 Word(Float value) { + Uint32 bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + return bits; + } + + UniquePtr m_previous; + }; + + TEST_F(TrackerAttribPayload, AFloatWriteCarriesTheFloatBitsAndNamesItsClass) { + Ctx().SetCurrentVertexAttributeFloat(3, Array{1.5f, -2.5f, 3.0f, 4.0f}); + const MGPAttribValue value = PayloadFor(3); + EXPECT_EQ(value.Location, 3u); + EXPECT_EQ(value.ValueClass, MG_State::GLState::kVertexAttribValueClassFloat); + EXPECT_EQ(value.Data[0], Word(1.5f)); + EXPECT_EQ(value.Data[1], Word(-2.5f)); + // The defect this exists to stop: 1.5f's int VIEW is 1, and a carrier that sent the + // float bits while calling them class 0 for every attribute would be sending + // 0x3FC00000 where the frontend holds 1. + EXPECT_NE(value.Data[0], static_cast(Ctx().GetCurrentVertexAttribute(3).intValue[0])); + } + + TEST_F(TrackerAttribPayload, AnIntWriteCarriesTheIntWordsAndNamesItsClass) { + Ctx().SetCurrentVertexAttributeInt(5, Array{7, -9, 11, 13}); + const MGPAttribValue value = PayloadFor(5); + EXPECT_EQ(value.ValueClass, MG_State::GLState::kVertexAttribValueClassInt); + EXPECT_EQ(static_cast(value.Data[0]), 7); + EXPECT_EQ(static_cast(value.Data[1]), -9); + // and NOT the float view the frontend converted it into + EXPECT_NE(value.Data[0], Word(7.0f)); + } + + TEST_F(TrackerAttribPayload, AUintWriteCarriesTheUintWordsAndNamesItsClass) { + Ctx().SetCurrentVertexAttributeUint(6, Array{4000000000u, 2u, 3u, 4u}); + const MGPAttribValue value = PayloadFor(6); + EXPECT_EQ(value.ValueClass, MG_State::GLState::kVertexAttribValueClassUint); + EXPECT_EQ(value.Data[0], 4000000000u); + EXPECT_NE(value.Data[0], Word(4000000000.0f)); + } + + // The class is PER ATTRIBUTE and it is the last writer's, not the context's - a payload + // that took one attribute's class for all 32 would be the same defect as a hard-coded 0. + TEST_F(TrackerAttribPayload, TheSameNumbersWrittenThroughADifferentClassAreADifferentValue) { + Ctx().SetCurrentVertexAttributeFloat(1, Array{1.0f, 2.0f, 3.0f, 4.0f}); + Ctx().SetCurrentVertexAttributeInt(2, Array{1, 2, 3, 4}); + EXPECT_EQ(PayloadFor(1).ValueClass, MG_State::GLState::kVertexAttribValueClassFloat); + EXPECT_EQ(PayloadFor(2).ValueClass, MG_State::GLState::kVertexAttribValueClassInt); + // Same numbers, different classes, so the same four words mean different things: + // 1.0f is 0x3F800000 and the integer 1 is 0x00000001. + EXPECT_NE(PayloadFor(1).Data[0], PayloadFor(2).Data[0]); + // An attribute nobody wrote answers Float, which is what the GL default (0,0,0,1) is. + EXPECT_EQ(PayloadFor(7).ValueClass, MG_State::GLState::kVertexAttribValueClassFloat); + // and a later write of the other class moves the class of THAT attribute only + Ctx().SetCurrentVertexAttributeUint(1, Array{1u, 2u, 3u, 4u}); + EXPECT_EQ(PayloadFor(1).ValueClass, MG_State::GLState::kVertexAttribValueClassUint); + EXPECT_EQ(PayloadFor(2).ValueClass, MG_State::GLState::kVertexAttribValueClassInt); + } + + // =================================================================================== + // The SHIPPED emitter, driven through MGPipeValidateForVerb itself + // =================================================================================== + // + // TrackerWalk above reproduces step 3 against a local tracker and cache, which cannot + // fail on a defect in the validate point itself (a bit gated on the wrong subsystem, an + // emission dropped). These drive the real entry point and read the real singletons back. + // Safe because ctest runs one gtest case per process and the fixture resets all three + // pieces of server-side state on both sides of every case. + class TrackerShippedEmitter : public ::testing::Test { + protected: + void SetUp() override { + m_previous = Move(MG_State::pGLContext); + MG_State::pGLContext = MakeUnique(); + m_savedPush = MG_Config::Features.PipePush; + MG_Config::Features.PipePush = kMGPipeSubsystemsMigratedAtP2; + ResetTheServerSideSingletons(); + } + void TearDown() override { + MGPipeLeaveVerb(); + MG_Config::Features.PipePush = m_savedPush; + ResetTheServerSideSingletons(); + MG_State::pGLContext = Move(m_previous); + } + + static GLContext& Ctx() { return *MG_State::pGLContext; } + static void Draw() { MGPipeValidateForVerb(MGPipeVerb::DrawArrays); } + static const MGPipeCsoCache::Counters& Cso() { return MGPipeCsoCacheInstance().GetCounters(); } + + Uint64 m_savedPush = 0; + UniquePtr m_previous; + }; + + TEST_F(TrackerShippedEmitter, ABlendToggleThroughTheValidatePointMintsTwoCsos) { + constexpr int kToggles = 16; + Draw(); // prime: a fresh context resets the cache inside the emitter and mints once + MGPipeCsoCacheInstance().ResetCounters(); + for (int i = 0; i < kToggles; ++i) { + Ctx().SetCapability(CapabilityInput::Blend, true); + Draw(); + Ctx().SetCapability(CapabilityInput::Blend, false); + Draw(); + } + EXPECT_EQ(Cso().Mints, 1u) << "the blend-disabled state was already cached by the priming draw"; + EXPECT_EQ(Cso().Binds, static_cast(2 * kToggles)); + EXPECT_EQ(Cso().Hits, static_cast(2 * kToggles - 1)); + EXPECT_EQ(MGPipeCsoCacheInstance().Size(), 2u); + } + + TEST_F(TrackerShippedEmitter, TheSteadyStateThroughTheValidatePointEmitsNothing) { + Draw(); + // The positive half, so "nothing was emitted" cannot pass because nothing is wired: + // the first draw on a fresh context mints and binds exactly one CSO. + ASSERT_EQ(Cso().Mints, 1u) << "the priming draw emitted no create_render_state at all"; + ASSERT_EQ(Cso().Binds, 1u); + MGPipeCsoCacheInstance().ResetCounters(); + for (int i = 0; i < 8; ++i) { + Draw(); + EXPECT_EQ(MGPipeTrackerInstance().LastDirty(), 0u) << "walk " << i << " fired with nothing moved"; + } + EXPECT_EQ(Cso().Mints, 0u); + EXPECT_EQ(Cso().Binds, 0u) << "a steady-state draw bound a render-state CSO"; + } + + // The window MAJOR-2's repair covers: a glVertexAttrib* write followed by a verb whose + // class does NOT read m_currentVertexAttribute. The call still goes out (the dirty bit + // and the subsystem bit are all step 3 looks at), the applier writes four words into all + // three views because it ignores ValueClass, and nothing in step 4 puts the value back - + // so the client checks and repairs. Reading the storage here to prove it would be the + // poison violation the fill table forbids, so the repair counter is the observable. + TEST_F(TrackerShippedEmitter, APushedAttributeDefaultTheApplierCannotReproduceIsRepaired) { + Draw(); + const Uint64 before = MGPipeVertexAttribDefaultRepairCount(); + // 1.5f is the point: its int view is 1 and its bit pattern is 0x3FC00000, so the two + // cannot be the same four words whichever view the carrier picks. + Ctx().SetCurrentVertexAttributeFloat(0, Array{1.5f, 2.5f, 3.5f, 4.5f}); + MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap); + EXPECT_EQ(MGPipeVertexAttribDefaultRepairCount(), before + 1) + << "the emitter accepted an applier write that cannot reproduce a converted value"; + } + + TEST_F(TrackerShippedEmitter, AViewportThroughTheValidatePointMintsNoCso) { + Draw(); + ASSERT_EQ(Cso().Mints, 1u) << "the priming draw emitted no create_render_state at all"; + MGPipeCsoCacheInstance().ResetCounters(); + for (Int i = 1; i <= 8; ++i) { + Ctx().SetViewport(IntVec4(0, 0, 64 + i, 48 + i)); + Draw(); + EXPECT_NE(MGPipeTrackerInstance().LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewRenderState), 0u); + EXPECT_EQ(MGPipeTrackerInstance().LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewPipelineState), 0u); + } + EXPECT_EQ(Cso().Mints, 0u); + EXPECT_EQ(Cso().Binds, 0u) << "glViewport reached the CSO cache"; + } + #endif // MOBILEGL_PIPE_PUSH } // namespace From 067b1866778945c848c4078f632160e6e1284d7c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 10:28:06 -0400 Subject: [PATCH 111/529] [Docs] (Pipe): say what actually fails to publish a program, a pipeline and a shader dying, instead of naming a scope - the kUnpublishedDestroy note said the three kinds were outside the six D13 scopes explicit destroy to; the tree's reason is sharper and does not depend on reading D13's prose one way or the other: those three have no per-object handle on the wire at all, so their DirectGLES twins are still reclaimed by the backend's own registry teardown - records the brief/tree disagreement in place: D13 says "six kinds" while the Core.cpp line ranges it cites also cover MarkProgram/MarkShaderForDeletion --- MobileGL/MG_Pipe/DirtySurface.def | 35 ++++++++++++++++++------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/MobileGL/MG_Pipe/DirtySurface.def b/MobileGL/MG_Pipe/DirtySurface.def index fbee04a9e..84be476d0 100644 --- a/MobileGL/MG_Pipe/DirtySurface.def +++ b/MobileGL/MG_Pipe/DirtySurface.def @@ -40,19 +40,24 @@ // mutation is published inline and needs no shutter at all. // kReverseChannel not state: a write INTO the frontend from the backend's side. // kNoBackendRead no backend read point observes this state at all. -// kExplicitDestroy published by the delete_* call the Track H slice emits when the -// object's last reference drops - an object's DEATH, which no -// generation shutters because there is no longer an object to carry -// one. Only the six object kinds P2 brief D13 scopes Espryt 0b's -// explicit destroy to (buffers, framebuffers, renderbuffers, -// samplers, textures, vertex arrays) may use this answer. +// kExplicitDestroy published by the delete_* / resource_destroy call the Track H slice +// emits when the object's last reference drops - an object's DEATH, +// which no generation shutters because there is no longer an object +// to carry one. Only for a kind that HAS an identity on the wire to +// destroy: the resources and CSOs of PipeCalls.def, which is what P2 +// brief D13 scopes Espryt 0b's explicit destroy to. // kUnpublishedDestroy -// the same event for a kind NO call publishes yet: programs, program -// pipelines and shaders are outside D13's six, so their death reaches -// a backend only through the object's own teardown path. Recorded as -// a hole rather than dressed up as a mechanism that exists - naming -// kExplicitDestroy here would be the same defect the RenderState -// check above exists to stop, one class down in stakes. +// the same event for a kind NOTHING publishes: a program, a program +// pipeline and a shader have no per-object handle on the wire at all +// in P2 - resource_destroy and the delete_* family name resources and +// CSOs - so their DirectGLES twins are still reclaimed by the +// backend's own registry teardown and no frontend call says they +// died. Recorded as a hole rather than dressed up as a mechanism that +// exists; naming kExplicitDestroy here would be the same defect the +// RenderState derivation above exists to stop, one class down in +// stakes. (D13's prose says 'six kinds' while the Core.cpp ranges it +// cites also cover MarkProgram/MarkShaderForDeletion; the tree +// decides, and the tree has no wire object for those three.) // kPulledEveryVerb no shutter exists, and none is needed yet: the PipeInputs field this // writes is in its verb class's may-read mask, so the residual fill copies // it at EVERY verb of that class. A shutter here is a P3/P4 optimisation, @@ -148,9 +153,9 @@ X(BumpTextureBindGeneration, NEW_SAMPLER_VIEWS) \ X(SetNamedTransformFeedbackBinding, NEW_SO_TARGETS) \ /* ---- an object's death: no generation, because there is no longer an object */ \ - /* to carry one. Espryt 0b's delete_* publishes the six kinds D13 scopes it to; */ \ - /* programs, program pipelines and shaders are NOT among them and nothing else */ \ - /* publishes their death, so they answer kUnpublishedDestroy - a recorded hole. */ \ + /* to carry one. Espryt 0b's delete_* / resource_destroy publishes the kinds */ \ + /* that have a handle on the wire; programs, program pipelines and shaders have */ \ + /* none in P2, so nothing publishes theirs - kUnpublishedDestroy, a known hole. */ \ X(MarkBufferObjectForDeletion, kExplicitDestroy) \ X(MarkFramebufferObjectForDeletion, kExplicitDestroy) \ X(MarkProgramForDeletion, kUnpublishedDestroy) \ From 8f66c374aa10eaeac60ce8306465ee2ca61a0325 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 11:42:11 -0400 Subject: [PATCH 112/529] [Fix] (Pipe): re-arm the residual block on any render-state move and republish every vertex-attribute default on a fresh context - the residual value block armed on NEW_PIPELINE_STATE, so glEnable(GL_CLIP_DISTANCE0) never re-armed it: SetCapability's ClipDistance0..7 arms are deliberately not BumpVersions() and those eight are 8 of the 35 CapabilityInputs the block carries, so the D9/G10 trip wire was disarmed for them for an unbounded window - and invisibly, a block that is never emitted cannot diverge. It now arms on either render-state counter, the same answer DirtySurface.def derives for SetCapability, and the comment that asserted the opposite ("every SET_CAPABILITY arm calls BumpVersions") is corrected. - the arming moved outside the residual subsystem gate: whether the capability set may have moved is a fact about the frontend, not about which subsystems this build pushes. - set_vertex_attrib_defaults published NOTHING across a context change. Tracker::Reset() sets the staging mirror to the GL defaults and a fresh GLContext holds the same, so the per-attribute diff was empty on the one walk that must publish a COMPLETE state, while MGPipeApplierReset() leaves gPipeInputs.m_currentVertexAttribute holding the previous context's values - which cancelled, two lines later, the InvalidateAll() written for exactly that case. It now sends all 32 when the tracker is freshly primed, the arm EmitRenderState already had. - the fresh-context reset of the CSO cache and the applier moved out of EmitRenderState, which runs only when bit 0 of MOBILEGL_PIPE_PUSH is set: the per-subsystem A/B D14 invites gave a fresh context a never-reset applier while every suppressor slot was invalidated. - MGPipeVertexAttribDefaultsLastHeader() is the observable for both properties of that call that cannot be read back without a poisoned read of m_currentVertexAttribute. - the repair case now asserts the invariant (the call named exactly what moved, at most one repair) instead of repairs == before + 1, which pinned today's applier and would have gone red the day package A honours MGPAttribValue::ValueClass. - a static_assert that no MGPipeDirty bit owns kMGPipeSubsystemResidualValues, which is what makes the residual block's direct subsystem test the one safe exception to MGPipeSubsystemForDirty, and a note that the NEW_PIPELINE_STATE/NEW_RENDER_STATE MOBILEGL_ASSERT is a debug/verify alarm over behaviour that is safe in every build. --- MobileGL/MG_Impl/Pipe/PipeFill.cpp | 119 +++++++++++++++++++++----- MobileGL/MG_Impl/Pipe/PipeFill.h | 9 ++ MobileGL/MG_Test/Pipe/TrackerTest.cpp | 107 +++++++++++++++++++++-- 3 files changed, 204 insertions(+), 31 deletions(-) diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index ad032e91c..884cc69e9 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -812,7 +812,14 @@ namespace MobileGL::MG_Pipe { // stops happening, with no edit here. Uint64 g_attribDefaultRepairs = 0; - Uint64 EmitVertexAttribDefaults(GLContext& ctx) { + // The header of the last set_vertex_attrib_defaults that actually went out. Count == 0 + // means none ever did, because a call that names no attribute is not emitted at all. + // It is the observable for the two things about this call that cannot be read back + // without a poisoned read of m_currentVertexAttribute: that a fresh context republishes + // the COMPLETE set, and that a single moved attribute publishes exactly that one. + MGPVertexAttribDefaults g_attribDefaultLastHeader{}; + + Uint64 EmitVertexAttribDefaults(GLContext& ctx, Bool freshlyPrimed) { MGPipeTracker& tracker = MGPipeTrackerInstance(); auto& staged = tracker.StagedAttribDefaults(); constexpr SizeT kAttribs = MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS; @@ -827,10 +834,25 @@ namespace MobileGL::MG_Pipe { return 0; } + // A FRESH CONTEXT PUBLISHES ALL 32, not the difference against a mirror that + // describes a context that is gone. Tracker::Reset() sets the staging mirror to + // AttribDefaults{}, whose NSDMIs are the GL defaults {0,0,0,1} - and a fresh + // GLContext's m_currentVertexAttributes hold exactly those, so the diff below is + // EMPTY on the one walk that must publish everything. The server's mirror is not + // default: MGPipeApplierReset() clears the CSO store and the residual block and + // leaves gPipeInputs.m_currentVertexAttribute holding the PREVIOUS context's + // defaults. So the InvalidateAll() a fresh context does to the set-hash + // suppressor would have been cancelled two lines later by this diff, and the one + // call P2 fully owns would publish nothing across a context change - exactly the + // "memo that serves a stale answer" the tracker's own COMPLETE-state rule + // (Tracker.h) exists to forbid. EmitRenderState has the same arm + // (freshlyPrimed ? kAllDynamicChunks) and the other two calls send whole values. Array tail{}; MGPVertexAttribDefaults header{}; for (SizeT i = 0; i < kAttribs; ++i) { - if (std::memcmp(&resolved[i], &staged[i], sizeof(resolved[i])) == 0) continue; + if (!freshlyPrimed && std::memcmp(&resolved[i], &staged[i], sizeof(resolved[i])) == 0) { + continue; + } // The class the frontend WROTE, and that class's own bytes. Not a literal 0 // and not ClassifyVertexAttribType's answer: that one is the SHADER's question // ("which view does this input consume"), asked at the backend read sites, and @@ -844,6 +866,7 @@ namespace MobileGL::MG_Pipe { staged[i] = resolved[i]; } if (header.Count == 0) return 0; + g_attribDefaultLastHeader = header; MGPipeApplySetVertexAttribDefaults(header, tail.data()); // Did the applier reproduce it? Byte for byte, over the attributes this call @@ -889,9 +912,21 @@ namespace MobileGL::MG_Pipe { // emitted, sized and suppressed - the resid= byte class and the one divergence it // caught during development (GL_DITHER) are that evidence. // - // Emitted once per context and again whenever the capability set may have moved, - // which is whenever the pipeline version moved: every SET_CAPABILITY arm calls - // BumpVersions, so that shutter cannot miss one. + // Emitted once per context and again whenever the capability set may have moved + // (D9). THE SHUTTER FOR THAT IS NEW_RENDER_STATE, NOT NEW_PIPELINE_STATE, and the + // difference is a hole rather than a nicety: SetCapability's ClipDistance0..7 arms + // are deliberately NOT BumpVersions() (RenderState.cpp says so in as many words), so + // glEnable(GL_CLIP_DISTANCE0) moves m_version alone - and ClipDistance0..7 are 8 of + // the 35 CapabilityInputs this block carries. Arming on the pipeline version would + // leave the trip wire disarmed for those eight for an unbounded window, which is the + // under-firing direction ARCHITECTURE.md 13.2 names as the dangerous one, and no gate + // could see it: a block that is never emitted cannot diverge. + // + // So the arming is the coarsest always-true shutter - either render-state counter + // moved - which is the same answer DirtySurface.def's derivation gives SetCapability. + // It over-fires (a glViewport re-sends 8 bytes and re-runs the compare) and that is + // the intended trade: over-firing costs one 35-bit loop on a verb that already moved + // render state, under-firing renders stale. Uint64 EmitResidualValueState(GLContext& ctx) { ResidualValueBlock block{}; constexpr SizeT kCapabilityCount = static_cast(CapabilityInput::CapabilityInputCount); @@ -913,9 +948,30 @@ namespace MobileGL::MG_Pipe { // Set when the capability set may have moved, cleared when the block goes out. It is // not part of the tracker because it is emission state, not a shutter: the shutter - // (the pipeline version) has already been consumed by the time this is read. + // (the render-state counter) has already been consumed by the time this is read. Bool g_residualDue = true; + // The residual block is the ONE emission whose gate names a subsystem constant + // directly instead of going through MGPipeSubsystemForDirty, and the reason is that + // it has no dirty bit: it carries what has no shutter of its own, which is what makes + // it the residue. That exception is safe only while no dirty bit claims the same + // subsystem - if one ever did, the block would be gated twice and that bit's own + // emission would silently inherit the residual A/B switch. Asserted rather than + // assumed, the same discipline SubsystemForEmitter's five static_asserts use. + constexpr Bool NoDirtyBitOwnsTheResidualSubsystem() { + for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) { + if (MGPipeSubsystemForDirty(static_cast(i)) == + kMGPipeSubsystemResidualValues) { + return false; + } + } + return true; + } + static_assert(NoDirtyBitOwnsTheResidualSubsystem(), + "a MGPipeDirty bit now owns kMGPipeSubsystemResidualValues: route the " + "residual block's gate through MGPipeSubsystemForDirty like every other " + "emission, or the two gates will disagree"); + constexpr Uint32 kAllDynamicChunks = static_cast((Uint64{1} << kMGPipeDynamicChunkCount) - 1); @@ -928,14 +984,6 @@ namespace MobileGL::MG_Pipe { const auto pipelineVersion = static_cast(ctx.GetPipelineStateVersion()); Uint64 payloadBytes = 0; - if (freshlyPrimed) { - // A fresh context is a fresh server: the cache's handles name slots this - // client's allocator is about to hand out again, so both sides start over - // together rather than one of them remembering the other's objects. - MGPipeCsoCacheInstance().Reset(); - MGPipeApplierReset(); - } - if (dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState)) { const MGPipeHandle cso = MGPipeCsoCacheInstance().Acquire(live, payloadBytes); MGPBindRenderState bind{}; @@ -978,6 +1026,13 @@ namespace MobileGL::MG_Pipe { // (RenderState.h), but that is an invariant of ANOTHER package's file. So it is // asserted here rather than assumed, and the assignment is narrowed to the one // bit that owns the mirror. + // + // MOBILEGL_ASSERT compiles out in Release/INFO, which is the G1/G3 + // configuration, so the assert itself is a debug/verify-only alarm. THE + // BEHAVIOUR IS SAFE IN EVERY BUILD REGARDLESS, and it is the narrowing below + // rather than the assert that makes it so: if the invariant ever broke in a + // shipping build the mirror would simply not advance, which costs a re-send of + // chunks the server already has and never claims it holds chunks it does not. MOBILEGL_ASSERT((dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState)) == 0 || (dirty & MGPipeDirtyBit(MGPipeDirty::NewRenderState)) != 0, "NEW_PIPELINE_STATE fired without NEW_RENDER_STATE: RenderState's " @@ -990,6 +1045,7 @@ namespace MobileGL::MG_Pipe { } // namespace Uint64 MGPipeVertexAttribDefaultRepairCount() { return g_attribDefaultRepairs; } + MGPVertexAttribDefaults MGPipeVertexAttribDefaultsLastHeader() { return g_attribDefaultLastHeader; } // ---- the validate point (P2 brief D1) ---- void MGPipeValidateForVerb(MGPipeVerb verb) { @@ -1038,12 +1094,26 @@ namespace MobileGL::MG_Pipe { (dirty & MGPipeDirtyBit(bit)) != 0; }; Uint64 payloadBytes = 0; - if (wants(MGPipeDirty::NewPipelineState) || wants(MGPipeDirty::NewRenderState)) { - payloadBytes += EmitRenderState(*ctx, dirty, tracker.FreshlyPrimed()); - } + + // A fresh context is a fresh server, and that is true of EVERY subsystem, so it is + // handled BEFORE the per-subsystem gates rather than inside one of them. It used to + // live inside EmitRenderState, which runs only when bit 0 of MOBILEGL_PIPE_PUSH is + // set - so the per-subsystem A/B D14 invites (clear bit 0, keep bits 1..3) gave a + // fresh context a never-reset applier while every other slot WAS invalidated. + // - the CSO cache's handles name slots this client's allocator is about to hand + // out again, so both sides start over together rather than one of them + // remembering the other's objects; + // - what the server has is no longer what any suppressor slot last emitted; + // - and the residual block owes a fresh publication whatever else moved. if (tracker.FreshlyPrimed()) { - // A fresh context: what the server has is no longer what any slot last emitted. + MGPipeCsoCacheInstance().Reset(); + MGPipeApplierReset(); MGPipeSetHashSuppressorInstance().InvalidateAll(); + g_residualDue = true; + } + + if (wants(MGPipeDirty::NewPipelineState) || wants(MGPipeDirty::NewRenderState)) { + payloadBytes += EmitRenderState(*ctx, dirty, tracker.FreshlyPrimed()); } if (wants(MGPipeDirty::NewPixelPack)) { payloadBytes += EmitPixelPackState(*ctx); @@ -1052,7 +1122,7 @@ namespace MobileGL::MG_Pipe { payloadBytes += EmitPatchState(*ctx); } if (wants(MGPipeDirty::NewVertexAttribDefaults)) { - payloadBytes += EmitVertexAttribDefaults(*ctx); + payloadBytes += EmitVertexAttribDefaults(*ctx, tracker.FreshlyPrimed()); } // ---- step 4: the residual fill, for what an emitted call did NOT supply ---- @@ -1088,14 +1158,19 @@ namespace MobileGL::MG_Pipe { #endif } // ---- step 4b: the residual value block, and it goes out HERE ---- + // ARMED OUTSIDE THE SUBSYSTEM GATE: whether the capability set may have moved is a + // fact about the frontend, not about which subsystems this build pushes, and a + // per-subsystem A/B that turns the block off must not also lose the record that one + // is owed. + if ((dirty & (MGPipeDirtyBit(MGPipeDirty::NewRenderState) | + MGPipeDirtyBit(MGPipeDirty::NewPipelineState))) != 0) { + g_residualDue = true; + } // Its trip wire compares the carried bits against the ASSEMBLED capability mirror, // and that mirror is written either by the applier's derivation or by the fill loop // above - so the block is only meaningful once step 4 has run. Emitting it with the // other calls would compare against the previous verb's answer. if ((pushMask & kMGPipeSubsystemResidualValues) != 0) { - if (tracker.FreshlyPrimed() || (dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState)) != 0) { - g_residualDue = true; - } // The trip wire compares against the ASSEMBLED capability mirror, so it can only // run at a verb whose class actually carries that mirror - IsCapabilityEnabled is // in seven of the nine class masks and kQuery and kXfbSpan do not read it, so at diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.h b/MobileGL/MG_Impl/Pipe/PipeFill.h index 9a8df9b1b..f85a07b2a 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.h +++ b/MobileGL/MG_Impl/Pipe/PipeFill.h @@ -64,6 +64,15 @@ namespace MobileGL::MG_Pipe { // moved. Uint64 MGPipeVertexAttribDefaultRepairCount(); + // PipeFill.cpp. The header of the last set_vertex_attrib_defaults that actually went out + // - Mask, and Count == 0 for "none ever did", since a call naming no attribute is not + // emitted. Two properties of this call have no other observable, because reading + // m_currentVertexAttribute back at a verb whose class does not carry it is the poison + // violation the fill table exists to forbid: that a FRESH CONTEXT republishes all 32 + // (the server's mirror still holds the previous context's defaults), and that one moved + // attribute publishes exactly one. Eight bytes, written only when a call goes out. + MGPVertexAttribDefaults MGPipeVertexAttribDefaultsLastHeader(); + #if MOBILEGL_PIPE_VERIFY // PipeFill.cpp. The second arm of the comparator (P1 brief D8, ARCHITECTURE.md 13.2-2): // fills `snapshot` from the live GLContext the old way, for every field in `mask`. This diff --git a/MobileGL/MG_Test/Pipe/TrackerTest.cpp b/MobileGL/MG_Test/Pipe/TrackerTest.cpp index 5a3fa1b35..6cfd1453d 100644 --- a/MobileGL/MG_Test/Pipe/TrackerTest.cpp +++ b/MobileGL/MG_Test/Pipe/TrackerTest.cpp @@ -79,7 +79,10 @@ namespace { X(TrackerShippedEmitter, ABlendToggleThroughTheValidatePointMintsTwoCsos) \ X(TrackerShippedEmitter, TheSteadyStateThroughTheValidatePointEmitsNothing) \ X(TrackerShippedEmitter, APushedAttributeDefaultTheApplierCannotReproduceIsRepaired) \ - X(TrackerShippedEmitter, AViewportThroughTheValidatePointMintsNoCso) + X(TrackerShippedEmitter, AViewportThroughTheValidatePointMintsNoCso) \ + X(TrackerShippedEmitter, AClipDistanceEnableReArmsTheResidualBlock) \ + X(TrackerShippedEmitter, AFreshContextRepublishesEveryVertexAttributeDefault) \ + X(TrackerShippedEmitter, AFreshContextResetsTheApplierWithTheRenderStateSubsystemOff) #define MGL_DECLARE_PULL_SKIP(Suite, Name) \ TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; } @@ -597,12 +600,19 @@ namespace { EXPECT_EQ(Cso().Binds, 0u) << "a steady-state draw bound a render-state CSO"; } - // The window MAJOR-2's repair covers: a glVertexAttrib* write followed by a verb whose - // class does NOT read m_currentVertexAttribute. The call still goes out (the dirty bit - // and the subsystem bit are all step 3 looks at), the applier writes four words into all - // three views because it ignores ValueClass, and nothing in step 4 puts the value back - - // so the client checks and repairs. Reading the storage here to prove it would be the - // poison violation the fill table forbids, so the repair counter is the observable. + // The window the repair covers: a glVertexAttrib* write followed by a verb whose class + // does NOT read m_currentVertexAttribute. The call still goes out (the dirty bit and the + // subsystem bit are all step 3 looks at), and today's applier writes four words into all + // three views because it ignores ValueClass, so nothing in step 4 puts the converted + // value back and the client repairs the mirror itself. + // + // THE ASSERTION IS THE INVARIANT, NOT THE DEFECT. "repairs == before + 1" would pin + // today's applier and go red the day package A teaches + // MGPipeApplySetVertexAttribDefaults to switch on MGPAttribValue::ValueClass - which is + // the hand-off this package declares as blocking, and which is supposed to need no edit + // here. What must hold either way is that the call went out naming exactly the attribute + // that moved, and that the mirror ends up right by at most one repair: zero repairs once + // the applier reproduces the value, one until then. TEST_F(TrackerShippedEmitter, APushedAttributeDefaultTheApplierCannotReproduceIsRepaired) { Draw(); const Uint64 before = MGPipeVertexAttribDefaultRepairCount(); @@ -610,8 +620,87 @@ namespace { // cannot be the same four words whichever view the carrier picks. Ctx().SetCurrentVertexAttributeFloat(0, Array{1.5f, 2.5f, 3.5f, 4.5f}); MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap); - EXPECT_EQ(MGPipeVertexAttribDefaultRepairCount(), before + 1) - << "the emitter accepted an applier write that cannot reproduce a converted value"; + const MGPVertexAttribDefaults header = MGPipeVertexAttribDefaultsLastHeader(); + ASSERT_EQ(header.Count, 1u) << "the moved attribute default did not go out at all"; + EXPECT_EQ(header.Mask, 1u) << "the call named an attribute that did not move"; + const Uint64 repairs = MGPipeVertexAttribDefaultRepairCount() - before; + EXPECT_LE(repairs, 1u) << "one call cannot need two repairs"; + // and the repair is not free-running: a second identical walk moves nothing, so it + // neither re-emits nor re-repairs. + MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap); + EXPECT_EQ(MGPipeVertexAttribDefaultRepairCount() - before, repairs); + } + + // MAJOR 1 of round 2's review, pinned. glEnable(GL_CLIP_DISTANCE0) is one of the 35 + // capabilities the residual block carries AND one of the eight whose SetCapability arm + // deliberately does not BumpVersions(), so it moves m_version alone. An arming condition + // that reads the PIPELINE version - which is what this emitter used - never re-arms for + // those eight, and nothing can see it downstream: a block that is not emitted cannot + // diverge, so the trip wire is simply disarmed. + TEST_F(TrackerShippedEmitter, AClipDistanceEnableReArmsTheResidualBlock) { + Draw(); + ASSERT_TRUE(MGPipeApplier().HasResidual) << "the priming draw sent no residual block"; + // Poison the server's copy so a re-emission is the only thing that can restore it. + MGPipeApplier().Residual = ResidualValueBlock{}; + MGPipeApplier().HasResidual = false; + + Ctx().SetCapability(CapabilityInput::ClipDistance0, true); + // The premise: this moved the render-state counter and NOT the pipeline one. + const Uint16 pipelineBefore = static_cast(Ctx().GetPipelineStateVersion()); + Draw(); + ASSERT_EQ(MGPipeTrackerInstance().LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewPipelineState), 0u) + << "the premise is gone: a clip-distance enable now moves the pipeline version"; + EXPECT_EQ(static_cast(Ctx().GetPipelineStateVersion()), pipelineBefore); + + ASSERT_TRUE(MGPipeApplier().HasResidual) + << "a capability change that moves only m_version never re-armed the residual block"; + const Uint64 bit = Uint64{1} << static_cast(CapabilityInput::ClipDistance0); + EXPECT_NE(MGPipeApplier().Residual.CapabilityBits & bit, 0ull) + << "the re-emitted block does not carry the capability that moved"; + } + + // MAJOR 3 of round 2's review, pinned. A fresh context resets the tracker's staging + // mirror to the GL defaults, which are exactly what a fresh GLContext holds - so the + // per-attribute diff is empty on the one walk that must publish everything, while the + // applier's mirror still holds the PREVIOUS context's defaults. + TEST_F(TrackerShippedEmitter, AFreshContextRepublishesEveryVertexAttributeDefault) { + // The fixture's context is itself fresh, so the priming draw is the first half of the + // same statement: a fresh context publishes the COMPLETE set, not a difference. + Draw(); + ASSERT_EQ(MGPipeVertexAttribDefaultsLastHeader().Count, 32u) + << "the first walk on a fresh context published an increment, not a complete state"; + Ctx().SetCurrentVertexAttributeFloat(3, Array{9.f, 8.f, 7.f, 6.f}); + Draw(); + ASSERT_EQ(MGPipeVertexAttribDefaultsLastHeader().Count, 1u) + << "a steady context published more than the one attribute that moved"; + + // A different context, whose 32 defaults are the value-initialised {0,0,0,1} the + // tracker's own reset produces - so a diff against the staging mirror finds nothing. + MG_State::pGLContext = MakeUnique(); + Draw(); + const MGPVertexAttribDefaults header = MGPipeVertexAttribDefaultsLastHeader(); + EXPECT_EQ(header.Count, 32u) + << "a fresh context published " << header.Count + << " attribute defaults; the server's mirror still holds the previous context's"; + EXPECT_EQ(header.Mask, 0xFFFFFFFFu); + } + + // Minor 4 of round 2's review. The fresh-context reset of the applier and the CSO cache + // used to sit inside EmitRenderState, i.e. behind bit 0 of MOBILEGL_PIPE_PUSH, so the + // per-subsystem A/B D14 invites gave a fresh context a never-reset applier holding the + // previous context's CSO records while every suppressor slot WAS invalidated. + TEST_F(TrackerShippedEmitter, AFreshContextResetsTheApplierWithTheRenderStateSubsystemOff) { + Draw(); + ASSERT_FALSE(MGPipeApplier().RenderStateCsos.empty()) + << "the priming draw created no CSO record to leak into the next context"; + + MG_Config::Features.PipePush = kMGPipeSubsystemsMigratedAtP2 & ~kMGPipeSubsystemRenderState; + MG_State::pGLContext = MakeUnique(); + Draw(); + EXPECT_TRUE(MGPipeApplier().RenderStateCsos.empty()) + << "a fresh context kept the previous context's CSO records because the reset was " + "behind the render-state subsystem bit"; + EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundRenderStateCso)); } TEST_F(TrackerShippedEmitter, AViewportThroughTheValidatePointMintsNoCso) { From f15b0fdf4bab3e0cd4ee64f4385aa7d5b109741b Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 11:49:31 -0400 Subject: [PATCH 113/529] [Fix] (Pipe): derive the dirty-surface map's object-class and value-class answers too, and correct the two rows that named a shutter their mutator never moves - X(SetNamedTransformFeedbackBinding, NEW_SO_TARGETS) was false on EVERY path: that mutator binds a BufferState binding point or writes a saved-bindings entry, while the bit's shutter mixes the buffer-CONTENT aggregate with the transform-feedback generation, and a binding moves neither. It answers kPulledEveryVerb, which is what reaches the backend today (GetBufferBindingPoint, in the class's may-read mask). - X(SetPixelStoreParam, NEW_PIXEL_PACK) was false on the eight Unpack arms: the setter writes both halves and the tracker's bit 2 is a byte compare of the PACK half alone, because set_pixel_pack_state deliberately has no unpack counterpart. It answers kPulledEveryVerb, the one publisher every arm has. - --check no longer rubber-stamps the 28 rows the RenderState derivation cannot reach. It reads Tracker.h's Update() for what each bit's shutter READS, resolves those accessors through MG_State's getters to the members behind them, computes what every mutator transitively WRITES as a fixed point over MG_State/GLState and MG_Impl/Pipe (expanding MGP_NOTE_AGGREGATE through MGPipeNoteAggregate's own switch rather than assuming the hop), and fails a row naming a bit whose shutter its mutator moves on no path. One-directional by construction: the write analysis over-approximates, so it can prove absence and not presence, and absence is the under-firing direction. - the enumerator spelling and the NEW_* spelling are paired BY POSITION out of Tracker.h, so the enum and kMGPipeDirtyNames drifting apart is itself a gate failure. - two more self-test negative controls, one per family, both built from the defect that was really in the file; 7 controls now, all tripping. - --check prints what it did NOT check: how many rows carry a prose answer, and every row the derivation declined, so "all mapped" cannot be read as "all verified". - render_state_publishers() folds the bodies of one name with INTERSECTION, so two overloads - one BumpVersions, one bare ++m_version - can no longer derive as "both always fire" and bless an under-firing row. - the header states what "every path" means: every path that MUTATES, so a redundant-write guard does not make its publisher conditional, while a publisher reached on only some mutating paths must not be named. --- MobileGL/MG_Pipe/DirtySurface.def | 43 +++- scripts/gen_pipe_dirty_surface.py | 372 ++++++++++++++++++++++++++++-- 2 files changed, 390 insertions(+), 25 deletions(-) diff --git a/MobileGL/MG_Pipe/DirtySurface.def b/MobileGL/MG_Pipe/DirtySurface.def index 84be476d0..7964944ad 100644 --- a/MobileGL/MG_Pipe/DirtySurface.def +++ b/MobileGL/MG_Pipe/DirtySurface.def @@ -23,6 +23,27 @@ // not all must not appear, because a shutter built from this file would then UNDER-fire, // and ARCHITECTURE.md 13.2 names under-firing as the dangerous direction. // +// "EVERY PATH" MEANS EVERY PATH THAT MUTATES. A setter that returns early because the value +// did not change publishes nothing and needs to publish nothing - there is no mutation to +// carry - so a redundant-write guard (SetColorMask's `if (changed) BumpVersions();`, the +// BitwiseEqual guards on the patch levels) does not make its publisher conditional in the +// sense this rule cares about. A publisher reached on only SOME of the paths that DO mutate +// - SetCapability's ClipDistance arms, SetStencilFunc's reference-only call - is the thing +// that must not be named. +// +// EVERY BIT ANSWER IN THIS FILE IS DERIVED AND CHECKED, in two families and one +// direction. The RenderState family (45 rows) is checked both ways against RenderState.cpp, +// below. Every other NEW_* answer is checked against the shutter Tracker.h builds for that +// bit: gen_pipe_dirty_surface.py resolves what the shutter READS to the members behind it, +// computes what each mutator transitively WRITES (through MGP_NOTE_AGGREGATE too, whose hop +// it reads out of MGPipeNoteAggregate's own switch), and fails a row that names a bit whose +// shutter its mutator moves on no path at all. That half is one-directional on purpose - +// "it does write something the shutter reads" cannot prove it does so on EVERY path - so it +// catches under-firing and not over-claiming. The prose answers (kImmediate, kExplicitDestroy, +// kUnpublishedDestroy, kNoBackendRead, kPulledEveryVerb, kReverseChannel) are statements no +// derivation checks; --check prints how many rows carry one, and prints every row it had to +// decline, so "all mapped" can never be read as "all verified". +// // For the RenderState family that answer is not a matter of taste and it is CHECKED // rather than asserted: scripts/gen_pipe_dirty_surface.py reads RenderState.cpp and // derives, per setter, which of NEW_RENDER_STATE / NEW_PIPELINE_STATE moves on every @@ -139,7 +160,16 @@ X(SetViewport, NEW_RENDER_STATE) \ X(SetViewportIndexed, NEW_RENDER_STATE) \ /* ---- the other value-class bits ---- */ \ - X(SetPixelStoreParam, NEW_PIXEL_PACK) \ + /* NOT NEW_PIXEL_PACK, though half of it does move that bit: RenderState::SetPixelStore */ \ + /* Param writes BOTH halves - eight Pack arms and eight Unpack arms - while the */ \ + /* tracker's bit 2 is a byte compare of the PACK half alone (Tracker.h), because */ \ + /* set_pixel_pack_state deliberately has no unpack counterpart (ARCHITECTURE.md 4.6). */ \ + /* So glPixelStorei(GL_UNPACK_ALIGNMENT, 8) and its seven siblings move NOTHING that */ \ + /* bit reads, and naming it here would be an under-firing shutter for eight of the */ \ + /* sixteen arms. What is true on every path is the pull: GetPixelStoreParameters is one */ \ + /* of the two Coverage.def rows an emitted call does not supply completely (PipeFill. */ \ + /* cpp), so the residual fill copies both halves at every verb of the class. */ \ + X(SetPixelStoreParam, kPulledEveryVerb) \ X(SetPatchDefaultInnerLevel, NEW_PATCH_STATE|NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ X(SetPatchDefaultOuterLevel, NEW_PATCH_STATE|NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ /* Also an immediate publish point, but it has a real bit and the bit is */ \ @@ -151,7 +181,16 @@ X(SetCurrentVertexAttributeUint, NEW_VERTEX_ATTRIB_DEFAULTS) \ /* ---- object class ---- */ \ X(BumpTextureBindGeneration, NEW_SAMPLER_VIEWS) \ - X(SetNamedTransformFeedbackBinding, NEW_SO_TARGETS) \ + /* NOT NEW_SO_TARGETS, and this one was false on EVERY path: GLContext::SetNamed */ \ + /* TransformFeedbackBinding either binds a BufferState binding point (index == the */ \ + /* bound XFB object) or writes a saved-bindings entry, and NEW_SO_TARGETS mixes the */ \ + /* buffer-CONTENT aggregate with the transform-feedback generation - the first moves */ \ + /* only at BufferObject.cpp's content sites, the second only in BeginTransformFeedback. */ \ + /* A binding moves neither. It reaches the backend the same way every other buffer */ \ + /* binding point does, through GetBufferBindingPoint in the verb class's may-read mask, */ \ + /* so the honest answer is the pull. Narrowing it is P3b's, when it takes the subsystem */ \ + /* over and the binding points get a generation of their own. */ \ + X(SetNamedTransformFeedbackBinding, kPulledEveryVerb) \ /* ---- an object's death: no generation, because there is no longer an object */ \ /* to carry one. Espryt 0b's delete_* / resource_destroy publishes the kinds */ \ /* that have a handle on the wire; programs, program pipelines and shaders have */ \ diff --git a/scripts/gen_pipe_dirty_surface.py b/scripts/gen_pipe_dirty_surface.py index 18e6a90a1..cbda9d236 100644 --- a/scripts/gen_pipe_dirty_surface.py +++ b/scripts/gen_pipe_dirty_surface.py @@ -194,36 +194,271 @@ def render_state_publishers(): if name.startswith("Set"): bodies.setdefault(name, []).append(masked[start:end]) - def direct(name): + def direct(body): publishers = set() - for body in bodies[name]: - bump = BUMP_VERSIONS_RE.search(body) is not None - bare_version = BARE_VERSION_RE.search(body) is not None - bare_pipeline = BARE_PIPELINE_RE.search(body) is not None - if bump or bare_version: - publishers.add(RENDER_STATE_BIT) - if bump and not bare_version and not bare_pipeline: - publishers.add(PIPELINE_STATE_BIT) + bump = BUMP_VERSIONS_RE.search(body) is not None + bare_version = BARE_VERSION_RE.search(body) is not None + bare_pipeline = BARE_PIPELINE_RE.search(body) is not None + if bump or bare_version: + publishers.add(RENDER_STATE_BIT) + if bump and not bare_version and not bare_pipeline: + publishers.add(PIPELINE_STATE_BIT) return publishers - def resolve(name, seen): - if name in seen: - return set() - seen.add(name) - publishers = direct(name) + def resolve_body(name, body, seen): + publishers = direct(body) if publishers: return publishers # No bump of its own: whatever the setter it delegates to publishes. - for body in bodies[name]: - for match in SETTER_CALL_RE.finditer(body): - callee = match.group(1) - if callee != name and callee in bodies: - publishers |= resolve(callee, seen) + for match in SETTER_CALL_RE.finditer(body): + callee = match.group(1) + if callee != name and callee in bodies: + publishers |= resolve(callee, seen) return publishers + def resolve(name, seen): + if name in seen: + return set() + seen.add(name) + # INTERSECTION, not union, across the bodies of one name. A union would let two + # overloads - one calling BumpVersions(), one bumping m_version alone - derive as + # "both counters always fire" and bless the exact under-firing row this derivation + # exists to catch. Every Set* name in RenderState.cpp has exactly one body today, so + # this changes no answer; it is the fold that stays right when one does not. + answers = [resolve_body(name, body, seen) for body in bodies[name]] + return set.intersection(*answers) if answers else set() + return {name: resolve(name, set()) for name in bodies} +# ---- the OTHER answers, derived from the shutter each bit is built out of --------------- +# The render-state derivation above covers 45 of the 73 rows. For the rest, "does this +# mutator move the shutter it names" is still a mechanical question, just one asked of a +# different pair of files: MG_Impl/Pipe/Tracker.h says which counters and which bytes each +# MGPipeDirty bit compares, and MG_State says who moves those. So: +# +# 1. read Tracker.h's Update() and, per bit, collect what its shutter READS - +# ctx.GetXxx() accessors and `render.Field` reads, with the walk's own locals expanded; +# 2. resolve each accessor, through MG_State's one-line getters, to the MEMBER it returns; +# 3. walk every function body under MG_State/GLState and MG_Impl/Pipe and compute, as a +# fixed point over call names, which members and struct fields each one transitively +# WRITES - including through MGP_NOTE_AGGREGATE, whose per-aggregate hop is read out of +# MGPipeNoteAggregate's own switch rather than assumed; +# 4. a row claiming bit B for mutator M is UNDER-FIRING when M writes nothing B reads. +# +# It is deliberately ONE-DIRECTIONAL. Step 3 is an over-approximation (a call name resolves +# to every body of that name, and a write inside an `if` counts), so "M does write something +# B reads" is not proof that it does so on every path and cannot be turned into a MISSING +# check without false reds. "M writes NOTHING B reads" needs no such assumption, and it is +# the under-firing direction ARCHITECTURE.md 13.2 calls the dangerous one - which is what +# was wrong in this file: X(SetNamedTransformFeedbackBinding, NEW_SO_TARGETS) named a +# shutter that moves on NO path through that mutator. +STATE_ROOTS = (os.path.join(REPO_ROOT, "MobileGL", "MG_State", "GLState"), + os.path.join(REPO_ROOT, "MobileGL", "MG_Impl", "Pipe")) +UPDATE_RE = re.compile(r"Uint32\s+Update\s*\(") +NOW_RE = re.compile(r"now\[Index\(MGPipeDirty::(\w+)\)\]\s*=\s*([^;]*);") +DIRTY_OR_RE = re.compile(r"dirty\s*\|=\s*MGPipeDirtyBit\(MGPipeDirty::(\w+)\)") +DIRTY_ANY_RE = re.compile(r"dirty\s*\|=") +LOCAL_RE = re.compile(r"(\w+)\s*=\s*([^;]*);") +CTX_READ_RE = re.compile(r"\bctx\.(\w+)\s*\(") +ARROW_READ_RE = re.compile(r"\b\w+\s*->\s*(\w+)\s*\(") +FIELD_READ_RE = re.compile(r"\brender\.(\w+)") +RETURN_RE = re.compile(r"\breturn\s+([^;]*);") +MEMBER_RE = re.compile(r"\b(m_\w+)\b") +MEMBER_CALL_RE = re.compile(r"\b(m_\w+)\s*\.\s*(\w+)\s*\(") +CALL_RE = re.compile(r"\b(\w+)\s*\(") +WORD_RE = re.compile(r"\b(\w+)\b") +AGGREGATE_RE = re.compile(r"MGP_NOTE_AGGREGATE\(\s*(\w+)\s*\)") +MEMBER_WRITE_RE = re.compile(r"\+\+\s*(m_\w+)|\b(m_\w+)\s*(?:\+\+|\+=|=(?!=))") +FIELD_WRITE_RE = re.compile(r"\.\s*(\w+)\s*(?:\[[^\]]*\])?\s*(?:\+\+|\+=|=(?!=))") +AGGREGATE_CASE_RE = re.compile(r"case\s+MGPipeAggregate::(\w+)\s*:\s*([^;]*);") + + +def state_bodies(): + """{function name: [body text]} over MG_State/GLState and MG_Impl/Pipe.""" + bodies = {} + for root in STATE_ROOTS: + for directory, _, files in os.walk(root): + for name in sorted(files): + if not name.endswith((".h", ".cpp")): + continue + path = os.path.join(directory, name) + with open(path, "r", encoding="utf-8", errors="replace") as handle: + masked = mask_comments_and_strings(handle.read()) + for fn, start, end in function_bodies(masked): + bodies.setdefault(fn, []).append(masked[start:end]) + return bodies + + +def written_tokens(bodies): + """{function name: set of tokens it transitively WRITES}, a fixed point over call names. + + A token is MEM:, FIELD: or AGG:.""" + reach = {} + for name, bodylist in bodies.items(): + tokens = set() + for body in bodylist: + tokens |= set("AGG:" + m.group(1) for m in AGGREGATE_RE.finditer(body)) + for match in MEMBER_WRITE_RE.finditer(body): + tokens.add("MEM:" + (match.group(1) or match.group(2))) + tokens |= set("FIELD:" + m.group(1) for m in FIELD_WRITE_RE.finditer(body)) + reach[name] = tokens + changed = True + rounds = 0 + while changed and rounds < 16: + changed = False + rounds += 1 + for name, bodylist in bodies.items(): + before = len(reach[name]) + for body in bodylist: + for match in CALL_RE.finditer(body): + callee = match.group(1) + if callee != name and callee in reach: + reach[name] |= reach[callee] + if len(reach[name]) != before: + changed = True + return reach + + +def aggregate_tokens(bodies, reach): + """{MGPipeAggregate enumerator: the tokens its notice writes}, read out of + MGPipeNoteAggregate's own switch rather than assumed.""" + out = {} + for body in bodies.get("MGPipeNoteAggregate", []): + for match in AGGREGATE_CASE_RE.finditer(body): + aggregate, statement = match.group(1), match.group(2) + tokens = set() + for call in CALL_RE.finditer(statement): + tokens |= reach.get(call.group(1), set()) + out.setdefault(aggregate, set()) + out[aggregate] |= tokens + return out + + +def expand_aggregates(tokens, aggregates): + """AGG:X stands for whatever X's notice writes.""" + out = set() + for token in tokens: + if token.startswith("AGG:"): + out |= aggregates.get(token[4:], set()) + else: + out.add(token) + return out + + +def resolve_reader(name, bodies, seen=None): + """The members an accessor returns, through however many one-line getters it delegates + to. An empty answer means the derivation could not follow it, which is reported as + UNVERIFIED rather than treated as "moves nothing".""" + seen = seen if seen is not None else set() + if name in seen or name not in bodies: + return set() + seen.add(name) + members = set() + for body in bodies[name]: + for match in RETURN_RE.finditer(body): + expression = match.group(1) + delegated = set() + for call in MEMBER_CALL_RE.finditer(expression): + delegated.add(call.group(1)) + members |= resolve_reader(call.group(2), bodies, seen) + for member in MEMBER_RE.finditer(expression): + if member.group(1) not in delegated: + members.add("MEM:" + member.group(1)) + return members + + +def shutter_readers(): + """{MGPipeDirty bit name: set of reader tokens} out of Tracker.h's Update().""" + with open(TRACKER_PATH, "r", encoding="utf-8", errors="replace") as handle: + masked = mask_comments_and_strings(handle.read()) + body = None + for name, start, end in function_bodies(masked): + if name == "Update" and UPDATE_RE.search(masked[max(0, start - 200):start]): + body = masked[start:end] + break + if body is None: + return {} + + assignments = {} + for match in LOCAL_RE.finditer(body): + assignments.setdefault(match.group(1), set()).add(match.group(2)) + + def readers_of(expression, depth=0): + found = set() + if depth > 4: + return found + found |= set("CTX:" + m.group(1) for m in CTX_READ_RE.finditer(expression)) + found |= set("CTX:" + m.group(1) for m in ARROW_READ_RE.finditer(expression)) + found |= set("FIELD:" + m.group(1) for m in FIELD_READ_RE.finditer(expression)) + for word in WORD_RE.findall(expression): + if word in assignments and word not in ("now", "dirty"): + for assigned in assignments[word]: + if assigned != expression: + found |= readers_of(assigned, depth + 1) + return found + + out = {} + for match in NOW_RE.finditer(body): + out.setdefault(match.group(1), set()) + out[match.group(1)] |= readers_of(match.group(2)) + # The two BitwiseEqual bits have no `now[]` entry: their shutter is the byte compare + # itself. The window is the text since the previous `dirty |=`, which is the block that + # builds the value being compared. + for match in DIRTY_OR_RE.finditer(body): + # Since the previous `dirty |=` of ANY form - the counter loop's included, or the + # window would start at the top of the walk and inherit every other bit's readers. + previous = 0 + for boundary in DIRTY_ANY_RE.finditer(body, 0, match.start()): + previous = boundary.end() + window = body[previous:match.start()] + out.setdefault(match.group(1), set()) + out[match.group(1)] |= readers_of(window) + return out + + +ENUM_BODY_RE = re.compile(r"enum\s+class\s+MGPipeDirty\s*:\s*Uint32\s*\{([^}]*)\}") +ENUMERATOR_RE = re.compile(r"^\s*(\w+)\s*(?:=\s*\d+\s*)?,", re.M) + + +def dirty_bit_aliases(): + """{MGPipeDirty enumerator: the NEW_* name a row spells}, paired BY POSITION with + kMGPipeDirtyNames. Tracker.h's Update() names the enumerators and DirtySurface.def names + the strings, so the two spellings have to be tied together somewhere; doing it by + position also checks that the enum and its name table have not drifted apart.""" + with open(TRACKER_PATH, "r", encoding="utf-8", errors="replace") as handle: + text = handle.read() + match = ENUM_BODY_RE.search(mask_comments_and_strings(text)) + if not match: + return {} + enumerators = [name for name in ENUMERATOR_RE.findall(match.group(1)) if name != "Count"] + names = DIRTY_NAME_RE.findall(text) + if len(enumerators) != len(names): + return {} + return dict(zip(enumerators, names)) + + +def shutter_movers(readers, bodies, aliases): + """{NEW_* bit name: (tokens whose write moves that bit's shutter, every reader resolved?)}""" + out = {} + for enumerator, tokens in readers.items(): + bit = aliases.get(enumerator) + if bit is None: + continue + movers = set() + resolved = True + for token in tokens: + if token.startswith("FIELD:"): + movers.add(token) + continue + members = resolve_reader(token[4:], bodies) + if not members: + resolved = False + movers |= members + out[bit] = (movers, resolved and bool(movers)) + return out + + def dirty_bit_names(): """The MGPipeDirty bit names, read out of Tracker.h's kMGPipeDirtyNames so a row cannot name a bit that does not exist and a bit cannot be renamed out from under a row. Read @@ -253,7 +488,41 @@ def answer_set(answer): return {part.strip() for part in answer.split("|") if part.strip()} -def check_mapping(mapping, duplicates, scanned, bits, publishers=None): +def object_class_problems(mapping, bits, movers, moved): + """The under-firing check for every answer the RenderState derivation cannot reach. + + Returns (problems, verified, unverified) - `unverified` names the rows the derivation + had to decline, with the reason, so --check reports its own coverage instead of letting + a row it never looked at read as checked.""" + problems = [] + verified = 0 + unverified = [] + render_bits = {RENDER_STATE_BIT, PIPELINE_STATE_BIT} + for mutator in sorted(mapping): + claimed = (answer_set(mapping[mutator]) & bits) - render_bits + if not claimed: + continue + if mutator not in moved: + unverified.append("%s (no body found under MG_State/GLState or MG_Impl/Pipe to " + "derive from)" % mutator) + continue + for bit in sorted(claimed): + shutter, resolved = movers.get(bit, (set(), False)) + if not resolved: + unverified.append("%s <- %s (Tracker.h's shutter for that bit reads something " + "this script cannot resolve to a member)" % (mutator, bit)) + continue + if moved[mutator] & shutter: + verified += 1 + continue + problems.append( + "UNDER-FIRING answer %s for %s - it writes nothing %s's shutter reads " + "(shutter: %s), so a mutation through it publishes nothing" + % (bit, mutator, bit, ", ".join(sorted(t.split(":", 1)[1] for t in shutter)))) + return problems, verified, unverified + + +def check_mapping(mapping, duplicates, scanned, bits, publishers=None, movers=None, moved=None): """Every problem the gate fails on, as a list of human-readable lines. BOTH directions: an unmapped mutator renders stale, and a row naming a mutator the scan no longer finds is a stale row that would keep a real hole looking covered. `publishers` is @@ -281,6 +550,10 @@ def check_mapping(mapping, duplicates, scanned, bits, publishers=None): problems.append("BAD answer %s for %s - a non-bit answer stands alone" % (mapping[mutator], mutator)) + if movers is not None and moved is not None: + object_problems, _, _ = object_class_problems(mapping, bits, movers, moved) + problems += object_problems + if publishers is None: return problems @@ -342,7 +615,7 @@ def scan_all(): return sources, per_file, distinct_all -def self_test(scanned, bits, publishers): +def self_test(scanned, bits, publishers, movers, moved): """Canned negative controls. Each MUST trip; trips == 0 is an error, which is the shape check_include_closure.py and gen_pipe.py --self-test already use.""" trips = 0 @@ -397,6 +670,34 @@ def self_test(scanned, bits, publishers): else: failures.append("negative control 5 (a dropped render-state publisher) did NOT trip") + # 6. THE CONTROL FOR THE OBJECT-CLASS HALF, and it is again the shape of a defect that + # was actually in this file: X(SetNamedTransformFeedbackBinding, NEW_SO_TARGETS) + # named a shutter (the buffer-content aggregate mixed with the transform-feedback + # generation) that GLContext::SetNamedTransformFeedbackBinding moves on no path - it + # binds a BufferState binding point or writes a saved-bindings entry, and neither is + # a buffer CONTENT write or a BeginTransformFeedback. + with_dead_shutter = dict(real) + with_dead_shutter["SetNamedTransformFeedbackBinding"] = "NEW_SO_TARGETS" + problems = check_mapping(with_dead_shutter, real_duplicates, scanned, bits, publishers, + movers, moved) + if any("UNDER-FIRING answer NEW_SO_TARGETS" in p for p in problems): + trips += 1 + else: + failures.append("negative control 6 (an object-class answer whose shutter the mutator " + "never moves) did NOT trip") + + # 7. the same check pointed at a value-class bit, so one passing control cannot stand in + # for the whole family: a vertex-attribute default does not move the pixel-store bytes. + with_wrong_bit = dict(real) + with_wrong_bit["SetCurrentVertexAttributeInt"] = "NEW_PIXEL_PACK" + problems = check_mapping(with_wrong_bit, real_duplicates, scanned, bits, publishers, + movers, moved) + if any("UNDER-FIRING answer NEW_PIXEL_PACK" in p for p in problems): + trips += 1 + else: + failures.append("negative control 7 (a value-class answer whose shutter the mutator " + "never moves) did NOT trip") + for failure in failures: print("dirty-surface self-test: %s" % failure) if trips == 0: @@ -433,14 +734,28 @@ def main(): publishers = render_state_publishers() if not publishers: sys.exit("could not derive any RenderState setter out of %s" % RENDER_STATE_PATH) + bodies = state_bodies() + reach = written_tokens(bodies) + aggregates = aggregate_tokens(bodies, reach) + moved = {name: expand_aggregates(tokens, aggregates) for name, tokens in reach.items()} + readers = shutter_readers() + if not readers: + sys.exit("could not read the dirty shutters out of %s - has MGPipeTracker::Update been " + "renamed?" % TRACKER_PATH) + aliases = dirty_bit_aliases() + if not aliases: + sys.exit("could not pair MGPipeDirty's enumerators with kMGPipeDirtyNames in %s - the " + "enum and its name table have drifted apart" % TRACKER_PATH) + movers = shutter_movers(readers, bodies, aliases) if args.self_test: - return self_test(distinct_all, bits, publishers) + return self_test(distinct_all, bits, publishers, movers, moved) mapping, duplicates = load_mapping() if args.check: - problems = check_mapping(mapping, duplicates, distinct_all, bits, publishers) + problems = check_mapping(mapping, duplicates, distinct_all, bits, publishers, movers, + moved) for problem in problems: print("dirty-surface: %s" % problem) if problems: @@ -449,8 +764,19 @@ def main(): "RenderState.cpp actually publishes" % len(problems)) return 1 derived = sum(1 for m in mapping if m in publishers) + _, verified, unverified = object_class_problems(mapping, bits, movers, moved) + prose = sorted(m for m in mapping if not (answer_set(mapping[m]) & bits)) print("dirty-surface: %d mutators, all mapped, no stale rows; %d render-state answers " "derived from RenderState.cpp and matching" % (len(mapping), derived)) + # What the gate did NOT check is part of its output, or "all mapped" reads as "all + # verified" - which it is not, and was not for two rows through a whole review. + print("dirty-surface: %d other bit answers derived from their shutter in Tracker.h " + "(under-firing only); %d declined; %d rows carry a prose answer (%s) that no " + "derivation checks" + % (verified, len(unverified), len(prose), + ", ".join(sorted(set(a for m in prose for a in answer_set(mapping[m])))))) + for row in unverified: + print("dirty-surface: not derived: %s" % row) return 0 total_functions = 0 From 8d0ed5b82c67581465bceb2f867418a85f63a0ba Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 13:06:01 -0400 Subject: [PATCH 114/529] [Fix] (Pipe, DirtySurface): read the writes that go through a member's field and the ones the preprocessor pastes together, and decline the rows the write analysis cannot answer - its "UNDER-FIRING" verdicts were an absence proof it did not have, and one of them put a false answer in the map for a bit P2 already ships - the write analysis under-approximated in the exact direction its own claim forbids: written_tokens recorded a write through a member's field (m_foo.bar = v) as FIELD:bar and never as MEM:m_foo, while resolve_reader resolves a shutter's accessor to MEM:m_foo, so for any struct-valued member the two halves could not meet; a new MEMBER_ROOTED_WRITE_RE records both, for m_x.f, m_x[i].f, m_x->f and nested - it could not see RenderState::SetPixelStoreParam's sixteen writes twice over, because they are spelled with the token-pasting operator and the file was read raw - the "field" it recorded was the macro parameter name, paramNameTail. The derivation now expands the function-like macros defined under its two roots (directives blanked, parameters substituted, ## pasted), which is also what makes SET_CAPABILITY's m_parameters.capability##Enabled writes visible - and it now DECLINES rather than answers wherever it cannot say it read every writer: a body carrying a construct it does not model (an unexpandable token paste), anything that reaches such a body through the call-graph fixed point the writes already travel, and any shutter member with a write-shaped occurrence outside the analysed roots. --check prints every decline with its site, plus how many bodies and files the absence claim rests on and the one place it stays coarse - the BitwiseEqual bits' shutter window now also starts at the last `}` before the `dirty |=`, so the pack block's trailing `m_pack = pack;` no longer leaks the pixel store into NEW_PATCH_STATE's reader set - consequence in the map: X(SetPixelStoreParam, NEW_PIXEL_PACK) went from a verdict the gate could not support - no function name in the tree could carry that bit - to an accepted, checked answer, and the row it forced (kPulledEveryVerb, documented as "no shutter exists, and none is needed yet") said that of the only mutator behind the shipped set_pixel_pack_state. The row is now kPulledPartialShutter|NEW_PIXEL_PACK: the pull is what holds on every mutating path, the bit moves on the eight Pack arms, and both facts are machine-readable for the P3a reader D16 writes this file for - --self-test grows from 7 negative controls to 10 - kPulledPartialShutter naming no bit, a mutator whose write analysis is incomplete, and a shutter member written outside the roots, the last two asserting a DECLINE and no verdict - and gains a positive control that fails if SetPixelStoreParam's pasted writes ever go unread again --- MobileGL/MG_Pipe/DirtySurface.def | 85 +++-- scripts/gen_pipe_dirty_surface.py | 500 ++++++++++++++++++++++++++---- 2 files changed, 509 insertions(+), 76 deletions(-) diff --git a/MobileGL/MG_Pipe/DirtySurface.def b/MobileGL/MG_Pipe/DirtySurface.def index 7964944ad..6dd22c7b1 100644 --- a/MobileGL/MG_Pipe/DirtySurface.def +++ b/MobileGL/MG_Pipe/DirtySurface.def @@ -21,7 +21,10 @@ // ANSWERS. A row lists EVERY publisher that fires on EVERY path through that mutator, // and only those; several are joined with '|'. A publisher that fires on some paths but // not all must not appear, because a shutter built from this file would then UNDER-fire, -// and ARCHITECTURE.md 13.2 names under-firing as the dangerous direction. +// and ARCHITECTURE.md 13.2 names under-firing as the dangerous direction. The one row that +// carries a bit which fires on only some paths says so in its answer - kPulledPartialShutter +// joined with that bit - because the alternative, dropping the bit, tells a reader of this +// file that a bit P2 already emits a call for has no shutter at all. // // "EVERY PATH" MEANS EVERY PATH THAT MUTATES. A setter that returns early because the value // did not change publishes nothing and needs to publish nothing - there is no mutation to @@ -36,13 +39,31 @@ // below. Every other NEW_* answer is checked against the shutter Tracker.h builds for that // bit: gen_pipe_dirty_surface.py resolves what the shutter READS to the members behind it, // computes what each mutator transitively WRITES (through MGP_NOTE_AGGREGATE too, whose hop -// it reads out of MGPipeNoteAggregate's own switch), and fails a row that names a bit whose -// shutter its mutator moves on no path at all. That half is one-directional on purpose - -// "it does write something the shutter reads" cannot prove it does so on EVERY path - so it -// catches under-firing and not over-claiming. The prose answers (kImmediate, kExplicitDestroy, -// kUnpublishedDestroy, kNoBackendRead, kPulledEveryVerb, kReverseChannel) are statements no -// derivation checks; --check prints how many rows carry one, and prints every row it had to -// decline, so "all mapped" can never be read as "all verified". +// it reads out of MGPipeNoteAggregate's own switch, and through the function-like macros of +// MG_State, which it EXPANDS - sixteen of RenderState.cpp's writes exist only after the +// preprocessor has pasted them together), and fails a row that names a bit whose shutter its +// mutator moves on no path at all. That half is one-directional on purpose - "it does write +// something the shutter reads" cannot prove it does so on EVERY path - so it catches +// under-firing and not over-claiming. +// +// AN ABSENCE CLAIM IS ONLY WORTH THE READING BEHIND IT, and this gate learned that the +// expensive way: its write analysis used to record a write through a member's field +// (m_foo.bar = v) as the FIELD alone and never as the member, while the shutter side +// resolves an accessor to the MEMBER - so the two halves could not meet for any +// struct-valued member, and --check printed, as a fact about RenderState.cpp, that +// SetPixelStoreParam "writes nothing NEW_PIXEL_PACK's shutter reads" about a setter whose +// whole body is sixteen writes to exactly that member. The answer below is what that put in +// this file. So the derivation now DECLINES rather than answers whenever it cannot say it +// read every writer: a body carrying a construct it does not model (an unexpandable token +// paste), anything that reaches such a body, and any shutter member written outside +// MG_State/GLState + MG_Impl/Pipe at all. --check prints every decline with the site that +// caused it, and prints how many bodies and files the claim rests on. +// +// The prose answers (kImmediate, kExplicitDestroy, kUnpublishedDestroy, kNoBackendRead, +// kPulledEveryVerb, kPulledPartialShutter, kReverseChannel) are statements no derivation +// checks - except the bits a kPulledPartialShutter row names, which are checked like any +// other bit answer. --check prints how many rows carry a prose answer, so "all mapped" can +// never be read as "all verified". // // For the RenderState family that answer is not a matter of taste and it is CHECKED // rather than asserted: scripts/gen_pipe_dirty_surface.py reads RenderState.cpp and @@ -79,10 +100,25 @@ // stakes. (D13's prose says 'six kinds' while the Core.cpp ranges it // cites also cover MarkProgram/MarkShaderForDeletion; the tree // decides, and the tree has no wire object for those three.) -// kPulledEveryVerb no shutter exists, and none is needed yet: the PipeInputs field this -// writes is in its verb class's may-read mask, so the residual fill copies -// it at EVERY verb of that class. A shutter here is a P3/P4 optimisation, -// not a correctness gap. +// kPulledEveryVerb no shutter exists at all - no MGPipeDirty bit moves on any path through +// this mutator - and none is needed yet: the PipeInputs field it writes is +// in its verb class's may-read mask, so the residual fill copies it at +// EVERY verb of that class. A shutter here is a P3/P4 optimisation, not a +// correctness gap. +// kPulledPartialShutter +// the same pull, but a bit DOES move - on some of the paths that mutate, +// not all of them - so this row must never be read as "no shutter exists". +// The bits that move are named after the '|', which is the one place this +// file joins a prose answer with a bit, and the reason is exactly that a +// P3a shutter builder has to be able to tell "no bit covers this" from "a +// bit covers half of it". The named bits are checked the same way every +// other bit answer is - a dead one is a red gate - but they are NOT a +// licence to narrow: what holds on every mutating path is the pull. +// Which rows need this answer is a human judgement and stays one: the +// derivation's "it does move that shutter" direction over-approximates +// (a call name resolves to every body of that name, a write inside an +// `if` counts), so it can refute a named bit but cannot find the rows +// that should have named one. // // KNOWN BLIND SPOTS OF THE SCANNER, recorded here rather than left implicit // (gen_pipe_dirty_surface.py's own notes plus its scan root): @@ -160,16 +196,21 @@ X(SetViewport, NEW_RENDER_STATE) \ X(SetViewportIndexed, NEW_RENDER_STATE) \ /* ---- the other value-class bits ---- */ \ - /* NOT NEW_PIXEL_PACK, though half of it does move that bit: RenderState::SetPixelStore */ \ - /* Param writes BOTH halves - eight Pack arms and eight Unpack arms - while the */ \ - /* tracker's bit 2 is a byte compare of the PACK half alone (Tracker.h), because */ \ - /* set_pixel_pack_state deliberately has no unpack counterpart (ARCHITECTURE.md 4.6). */ \ - /* So glPixelStorei(GL_UNPACK_ALIGNMENT, 8) and its seven siblings move NOTHING that */ \ - /* bit reads, and naming it here would be an under-firing shutter for eight of the */ \ - /* sixteen arms. What is true on every path is the pull: GetPixelStoreParameters is one */ \ - /* of the two Coverage.def rows an emitted call does not supply completely (PipeFill. */ \ - /* cpp), so the residual fill copies both halves at every verb of the class. */ \ - X(SetPixelStoreParam, kPulledEveryVerb) \ + /* kPulledPartialShutter, NOT kPulledEveryVerb, and NOT a bare NEW_PIXEL_PACK: */ \ + /* RenderState::SetPixelStoreParam writes BOTH halves - eight Pack arms and eight */ \ + /* Unpack arms - while the tracker's bit 2 is a byte compare of the PACK half alone */ \ + /* (Tracker.h), because set_pixel_pack_state deliberately has no unpack counterpart */ \ + /* (ARCHITECTURE.md 4.6). So glPixelStorei(GL_PACK_ALIGNMENT, 8) DOES move bit 2 and */ \ + /* glPixelStorei(GL_UNPACK_ALIGNMENT, 8) moves nothing at all, and a shutter narrowed */ \ + /* to bit 2 would under-fire for eight of the sixteen arms. What is true on every path */ \ + /* is the pull: GetPixelStoreParameters is one of the two Coverage.def rows an emitted */ \ + /* call does not supply completely (PipeFill.cpp), so the residual fill copies both */ \ + /* halves at every verb of the class. The bit is named anyway because P2 already EMITS */ \ + /* set_pixel_pack_state off it: a row that said "no shutter exists" about the only */ \ + /* mutator behind a shipped call would be a false answer to the one question D16 hands */ \ + /* P3a. Splitting this setter into a pack half and an unpack half is what would let the */ \ + /* pack half answer NEW_PIXEL_PACK outright; that is P3's move, not P2's. */ \ + X(SetPixelStoreParam, kPulledPartialShutter|NEW_PIXEL_PACK) \ X(SetPatchDefaultInnerLevel, NEW_PATCH_STATE|NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ X(SetPatchDefaultOuterLevel, NEW_PATCH_STATE|NEW_RENDER_STATE|NEW_PIPELINE_STATE) \ /* Also an immediate publish point, but it has a real bit and the bit is */ \ diff --git a/scripts/gen_pipe_dirty_surface.py b/scripts/gen_pipe_dirty_surface.py index cbda9d236..d24923cab 100644 --- a/scripts/gen_pipe_dirty_surface.py +++ b/scripts/gen_pipe_dirty_surface.py @@ -27,6 +27,14 @@ it a row could be wrong in exactly the direction ARCHITECTURE.md 13.2 calls dangerous while --check stayed green, which is how two rows in this mapping were wrong for a whole review. +Every other bit answer is derived too, one-directionally, against the shutter Tracker.h +builds for that bit. That half makes an ABSENCE claim ("this mutator writes nothing that +shutter reads"), so it is only as good as the code it reads, and it once was not: it missed +every write through a member's field and every write the preprocessor pastes together, and +reported their absence as a fact about RenderState.cpp. It now expands MG_State's +function-like macros, records the member as well as the field, and DECLINES - prints the row +and the site, and does not fail it - wherever it cannot say it read every writer. + python3 scripts/gen_pipe_dirty_surface.py # human-readable report python3 scripts/gen_pipe_dirty_surface.py --summary # counts only python3 scripts/gen_pipe_dirty_surface.py --check # THE GATE: rc 1 on any hole @@ -157,7 +165,16 @@ def scan_file(path): # header; a row that uses anything else is a typo, and a typo that read as "mapped" would be # exactly the silent hole this gate exists to close. NON_BIT_ANSWERS = ("kImmediate", "kReverseChannel", "kNoBackendRead", "kExplicitDestroy", - "kUnpublishedDestroy", "kPulledEveryVerb") + "kUnpublishedDestroy", "kPulledEveryVerb", "kPulledPartialShutter") + +# The one non-bit answer that must NOT stand alone. kPulledEveryVerb says "no shutter exists"; +# kPulledPartialShutter says "the pull is what holds on every mutating path, and these bits DO +# move on some of them" - so it carries those bits, and the derivation below checks that each +# one really is movable by that mutator. Without the distinction, a reader of this file (P3a +# builds its shutters from it) is told that the mutator behind a bit P2 already emits a call +# for has no shutter at all, which is exactly what X(SetPixelStoreParam, kPulledEveryVerb) +# said about NEW_PIXEL_PACK. +PARTIAL_ANSWERS = ("kPulledPartialShutter",) # ---- the render-state answers, DERIVED rather than believed ----------------------------- # The two RenderState counters are the one place in the mapping where "what publishes this" @@ -246,15 +263,47 @@ def resolve(name, seen): # MGPipeNoteAggregate's own switch rather than assumed; # 4. a row claiming bit B for mutator M is UNDER-FIRING when M writes nothing B reads. # -# It is deliberately ONE-DIRECTIONAL. Step 3 is an over-approximation (a call name resolves -# to every body of that name, and a write inside an `if` counts), so "M does write something -# B reads" is not proof that it does so on every path and cannot be turned into a MISSING -# check without false reds. "M writes NOTHING B reads" needs no such assumption, and it is -# the under-firing direction ARCHITECTURE.md 13.2 calls the dangerous one - which is what -# was wrong in this file: X(SetNamedTransformFeedbackBinding, NEW_SO_TARGETS) named a -# shutter that moves on NO path through that mutator. +# STEP 4 IS AN ABSENCE CLAIM, so step 3 must not MISS a write, and that is an obligation this +# script violated rather than a slogan: until this commit a write through a member's FIELD +# (`m_foo.bar = v`) recorded FIELD:bar and never MEM:m_foo, while the reader side resolves an +# accessor to MEM:m_foo - so the two halves could not meet for any struct-valued member, and +# the gate printed "SetPixelStoreParam writes nothing NEW_PIXEL_PACK's shutter reads" about a +# setter whose entire body is sixteen writes to exactly that member. It could not see them +# twice over, because those writes are spelled with the token-pasting operator +# (RenderState.cpp's SET_PIXEL_STORE_PARAM) and the file was read raw, so the "field" it +# recorded was the MACRO PARAMETER NAME. What step 3 models is therefore written down here, +# and what it does not model is DECLINED rather than answered: +# +# modelled ++m_x / m_x = / m_x op=; a write through a member-rooted lvalue (m_x.f, +# m_x[i].f, m_x->f, nested), which records BOTH MEM:m_x and FIELD:f; a bare +# `.f =` write (FIELD:f alone - the FIELD tokens are coarse, and coarse only +# ever WIDENS what a mutator is credited with writing, which is the safe +# direction for an absence claim); MGP_NOTE_AGGREGATE; a call to any function +# whose body is under the two roots; and any function-like macro defined under +# the two roots, which is EXPANDED first, token pasting performed. +# declined a body that still contains `##` after expansion, or that invokes a macro this +# script can see but could not expand and whose body pastes tokens. The taint is +# a token like every other, so it travels the same call-graph fixed point the +# writes do: a mutator that REACHES an unreadable body gets no verdict either. +# A shutter member with a write-shaped occurrence OUTSIDE the two roots is +# declined the same way - the analysis has not read every writer, so it cannot +# say there is none. --check prints every decline with the site that caused it. +# +# What remains one-directional is the OTHER direction: a call name resolves to every body of +# that name, a write inside an `if` counts, and a reader that resolves through a ternary +# yields both members - so "M does write something B reads" is not proof that it does so on +# every path and cannot become a MISSING check without false reds. That is why a mutator +# whose bit moves on half its arms answers kPulledPartialShutter rather than the bit. "M +# writes NOTHING B reads" is the direction the gate fails on, and it is the under-firing one +# ARCHITECTURE.md 13.2 calls dangerous - which is what was wrong in this file: +# X(SetNamedTransformFeedbackBinding, NEW_SO_TARGETS) named a shutter that moves on NO path +# through that mutator. STATE_ROOTS = (os.path.join(REPO_ROOT, "MobileGL", "MG_State", "GLState"), os.path.join(REPO_ROOT, "MobileGL", "MG_Impl", "Pipe")) +# Everything the absence claim has to be checked against, which is wider than what it reads: +# a write to a shutter member from outside STATE_ROOTS is a writer this analysis never looks +# at, and the only honest answer to a row whose shutter has one is "declined". +MOBILEGL_ROOT = os.path.join(REPO_ROOT, "MobileGL") UPDATE_RE = re.compile(r"Uint32\s+Update\s*\(") NOW_RE = re.compile(r"now\[Index\(MGPipeDirty::(\w+)\)\]\s*=\s*([^;]*);") DIRTY_OR_RE = re.compile(r"dirty\s*\|=\s*MGPipeDirtyBit\(MGPipeDirty::(\w+)\)") @@ -269,38 +318,283 @@ def resolve(name, seen): CALL_RE = re.compile(r"\b(\w+)\s*\(") WORD_RE = re.compile(r"\b(\w+)\b") AGGREGATE_RE = re.compile(r"MGP_NOTE_AGGREGATE\(\s*(\w+)\s*\)") -MEMBER_WRITE_RE = re.compile(r"\+\+\s*(m_\w+)|\b(m_\w+)\s*(?:\+\+|\+=|=(?!=))") -FIELD_WRITE_RE = re.compile(r"\.\s*(\w+)\s*(?:\[[^\]]*\])?\s*(?:\+\+|\+=|=(?!=))") AGGREGATE_CASE_RE = re.compile(r"case\s+MGPipeAggregate::(\w+)\s*:\s*([^;]*);") +# The assignment operators, as a suffix every write pattern below shares. `=(?!=)` keeps == +# out; !=, <= and >= cannot match at all, because the character where the operator must start +# is then `!`, `<` or `>` and no alternative here begins with one except <<= / >>=. +ASSIGN = r"(?:\+\+|--|\+=|-=|\*=|/=|%=|&=|\|=|\^=|<<=|>>=|=(?!=))" +# A write to the member itself: ++m_x, m_x = v, m_x += v. +MEMBER_WRITE_RE = re.compile(r"\+\+\s*(m_\w+)|\b(m_\w+)\s*%s" % ASSIGN) +# A write THROUGH a member: m_x.f = v, m_x[i].f = v, m_x->f = v, and nested. This is the one +# that was missing, and its absence is why the reader side (which resolves an accessor to +# MEM:m_x) and the writer side could never meet for a struct-valued member. +MEMBER_ROOTED_WRITE_RE = re.compile( + r"\b(m_\w+)\s*(?:\[[^;\n]*?\]|\.\s*\w+|->\s*\w+)+\s*%s" % ASSIGN) +# A write to a field whose base this script did not resolve to a member. Coarse on purpose: +# a FIELD token only ever widens what a mutator is credited with writing. +FIELD_WRITE_RE = re.compile(r"\.\s*(\w+)\s*(?:\[[^\]]*\])?\s*%s" % ASSIGN) + +# ---- the preprocessor's half of the write analysis -------------------------------------- +# Sixteen of RenderState.cpp's writes exist only after the preprocessor has run: SET_PIXEL_ +# STORE_PARAM (:829) pastes `m_pixelStore##paramNameHead##Parameters`, and SET_CAPABILITY +# (:309) pastes `m_parameters.capability##Enabled`. Read raw, neither is a write to any token +# this script can name, so it saw none of them and reported their absence as a fact. So the +# function-like macros defined under the two roots are expanded first, and the ones that +# cannot be expanded are recorded so that a body reaching one is declined rather than +# answered. +MACRO_DIRECTIVE_RE = re.compile( + r"^[ \t]*#[ \t]*(define|undef)[ \t]+(\w+)(\([^()\n]*\))?((?:\\\n|[^\n])*)", re.M) +# MGP_NOTE_AGGREGATE is modelled directly (its hop is read out of MGPipeNoteAggregate's own +# switch), so expanding it would delete the very token AGGREGATE_RE looks for. +NEVER_EXPAND = frozenset(("MGP_NOTE_AGGREGATE", "MGP_NOTE_MUTATION")) +MACRO_BODY_LIMIT = 4000 +PASTE_RE = re.compile(r"##") +# A member DECLARATION - a type, then the name, then the end of the statement. Used to keep +# the containment check below from confusing two classes that spell a member the same way: +# MG_Backend/MGPipe/PipeInputs.h has its own m_transformFeedbackGeneration, and a write to +# THAT one says nothing about who writes GLContext's. +MEMBER_DECL_RE = re.compile( + r"^[ \t]*[A-Za-z_][\w:<>,&*\s]*?[\s&*](m_\w+)\s*(?:\[[^\]\n]*\])?" + r"\s*(?:=[^;\n]*|\{[^}\n]*\})?;", re.M) + + +def source_files(root): + """Every .h/.cpp under `root`, in a stable order.""" + out = [] + for directory, _, files in os.walk(root): + for name in sorted(files): + if name.endswith((".h", ".cpp")): + out.append(os.path.join(directory, name)) + return sorted(out) + + +def macro_definitions(masked): + """(kind, name, params-or-None, body, start, end) for every #define / #undef, with line + continuations joined.""" + for match in MACRO_DIRECTIVE_RE.finditer(masked): + yield (match.group(1), match.group(2), match.group(3), + match.group(4).replace("\\\n", " "), match.start(), match.end()) + + +def blank_directives(masked): + """`masked` with every #define / #undef blanked (newlines kept), so a macro BODY is never + read as code belonging to whatever function encloses the directive - RenderState.cpp + defines SET_PIXEL_STORE_PARAM *inside* SetPixelStoreParam, and read raw its body + contributed a field literally named `paramNameTail`.""" + out = list(masked) + for _, _, _, _, start, end in macro_definitions(masked): + for index in range(start, end): + if out[index] != "\n": + out[index] = " " + return "".join(out) + + +def balanced(text): + counts = {"(": 0, "[": 0, "{": 0} + closing = {")": "(", "]": "[", "}": "{"} + for char in text: + if char in counts: + counts[char] += 1 + elif char in closing: + counts[closing[char]] -= 1 + if counts[closing[char]] < 0: + return False + return not any(counts.values()) + + +def macro_table(paths): + """({name: (params, body)} this script will expand, {name: body} it saw and refused). + + Function-like macros only - an object-like macro is a constant and expanding it buys the + write analysis nothing. A macro is REFUSED when expanding it could corrupt the brace + matching every function body here is found by (an unbalanced body), when it is variadic, + over-long or defined more than once with different bodies, or when the analysis models it + directly. A refused macro is not silently ignored: if its body pastes tokens, every body + that invokes it is declined.""" + seen = {} + for path in paths: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + masked = mask_comments_and_strings(handle.read()) + for kind, name, params, body, _, _ in macro_definitions(masked): + if kind == "undef" or params is None: + continue + seen.setdefault(name, []).append((params, body)) + expandable = {} + refused = {} + for name, definitions in seen.items(): + bodies = set(body for _, body in definitions) + params = [part.strip() for part in definitions[0][0][1:-1].split(",") if part.strip()] + body = definitions[0][1] + if (name in NEVER_EXPAND or len(bodies) > 1 or len(body) > MACRO_BODY_LIMIT + or not balanced(body) or any(not part.isidentifier() for part in params)): + refused[name] = " ".join(sorted(bodies)) + continue + expandable[name] = (params, body) + return expandable, refused + + +def matching_paren(text, open_index): + depth = 0 + for index in range(open_index, len(text)): + if text[index] == "(": + depth += 1 + elif text[index] == ")": + depth -= 1 + if depth == 0: + return index + return None + + +def split_arguments(text): + args = [] + current = [] + depth = 0 + for char in text: + if char in "([{": + depth += 1 + elif char in ")]}": + depth -= 1 + if char == "," and depth == 0: + args.append("".join(current)) + current = [] + continue + current.append(char) + if current or args: + args.append("".join(current)) + return args + + +def substitute(body, params, args): + """One macro expansion: parameters replaced, then `##` pasted away - which is the step + that turns `m_pixelStore##paramNameHead##Parameters` into a member this script can name.""" + text = body + for param, arg in sorted(zip(params, args), key=lambda pair: -len(pair[0])): + text = re.sub(r"\b%s\b" % re.escape(param), lambda _match, value=arg: value, text) + return re.sub(r"\s*##\s*", "", text) + + +def expand_macros(masked, expandable, rounds=4): + """`masked` with its directives blanked and every invocation of an expandable + function-like macro replaced by its expansion.""" + text = blank_directives(masked) + if not expandable: + return text + pattern = re.compile(r"\b(%s)\s*\(" % "|".join(sorted((re.escape(name) for name in expandable), + key=len, reverse=True))) + for _ in range(rounds): + out = [] + index = 0 + changed = False + while True: + match = pattern.search(text, index) + if match is None: + out.append(text[index:]) + break + close = matching_paren(text, match.end() - 1) + params, body = expandable[match.group(1)] + args = split_arguments(text[match.end():close]) if close is not None else None + if args is None or len(args) != len(params): + out.append(text[index:match.end()]) + index = match.end() + continue + out.append(text[index:match.start()]) + out.append(" %s " % substitute(body, params, args)) + index = close + 1 + changed = True + text = "".join(out) + if not changed: + break + return text + + +def unmodelled_sites(body, relative, pasting): + """The constructs in `body` this script does NOT model, as decline reasons. Today there + is exactly one class of them, and it is the one that produced a false verdict: a token + paste it could not expand.""" + sites = set() + if PASTE_RE.search(body): + sites.add("%s: a `##` token paste no visible macro definition expands" % relative) + for match in CALL_RE.finditer(body): + if match.group(1) in pasting: + sites.add("%s: %s(), a token-pasting macro this script refused to expand" + % (relative, match.group(1))) + return sites + def state_bodies(): - """{function name: [body text]} over MG_State/GLState and MG_Impl/Pipe.""" - bodies = {} + """({function name: [body text]}, {function name: {decline reason}}, [analysed paths]) + over MG_State/GLState and MG_Impl/Pipe, macros expanded first.""" + paths = [] for root in STATE_ROOTS: - for directory, _, files in os.walk(root): - for name in sorted(files): - if not name.endswith((".h", ".cpp")): - continue - path = os.path.join(directory, name) - with open(path, "r", encoding="utf-8", errors="replace") as handle: - masked = mask_comments_and_strings(handle.read()) - for fn, start, end in function_bodies(masked): - bodies.setdefault(fn, []).append(masked[start:end]) - return bodies - - -def written_tokens(bodies): + paths += source_files(root) + expandable, refused = macro_table(paths) + # A macro this script MODELS is not an unread construct even though it is not expanded. + pasting = frozenset(name for name, body in refused.items() + if name not in NEVER_EXPAND and PASTE_RE.search(body)) + bodies = {} + taints = {} + for path in paths: + relative = os.path.relpath(path, REPO_ROOT).replace(os.sep, "/") + with open(path, "r", encoding="utf-8", errors="replace") as handle: + expanded = expand_macros(mask_comments_and_strings(handle.read()), expandable) + for fn, start, end in function_bodies(expanded): + body = expanded[start:end] + bodies.setdefault(fn, []).append(body) + sites = unmodelled_sites(body, relative, pasting) + if sites: + taints.setdefault(fn, set()) + taints[fn] |= sites + return bodies, taints, paths + + +def writers_outside(analysed): + """{MEM token: a file OUTSIDE the analysed roots that writes it}. + + The absence claim is only as good as the set of writers the analysis reads. Every .h/.cpp + under MobileGL/ that is not one of the analysed files is scanned with the same write + patterns, and a shutter member that turns up here is declined rather than answered. + + A file that DECLARES a member of that name is writing its own, not the frontend's - the + backend's PipeInputs mirrors half of GLState's member names - so its writes do not count. + That is the one judgement here, and it is the conservative way round only for names the + two sides share; a genuine outside writer of a frontend member does not declare it.""" + seen = set(analysed) + out = {} + for path in source_files(MOBILEGL_ROOT): + if path in seen: + continue + with open(path, "r", encoding="utf-8", errors="replace") as handle: + masked = mask_comments_and_strings(handle.read()) + relative = os.path.relpath(path, REPO_ROOT).replace(os.sep, "/") + own = set(MEMBER_DECL_RE.findall(masked)) + written = set(match.group(1) or match.group(2) for match in MEMBER_WRITE_RE.finditer(masked)) + written |= set(match.group(1) for match in MEMBER_ROOTED_WRITE_RE.finditer(masked)) + for member in written - own: + out.setdefault("MEM:" + member, relative) + return out + + +def written_tokens(bodies, taints=None): """{function name: set of tokens it transitively WRITES}, a fixed point over call names. - A token is MEM:, FIELD: or AGG:.""" + A token is MEM:, FIELD:, AGG: or + TAINT:. The last is not a write: it is "this body contains something the analysis + does not model", and it rides the same fixed point so that a mutator which REACHES an + unreadable body inherits it and is declined rather than answered.""" + taints = taints or {} reach = {} for name, bodylist in bodies.items(): - tokens = set() + tokens = set("TAINT:" + site for site in taints.get(name, ())) for body in bodylist: tokens |= set("AGG:" + m.group(1) for m in AGGREGATE_RE.finditer(body)) for match in MEMBER_WRITE_RE.finditer(body): tokens.add("MEM:" + (match.group(1) or match.group(2))) + # A write THROUGH a member records the member AND the field: the reader side + # resolves an accessor to the member, so recording only the field is what kept + # the two halves from ever meeting. + for match in MEMBER_ROOTED_WRITE_RE.finditer(body): + tokens.add("MEM:" + match.group(1)) tokens |= set("FIELD:" + m.group(1) for m in FIELD_WRITE_RE.finditer(body)) reach[name] = tokens changed = True @@ -407,11 +701,16 @@ def readers_of(expression, depth=0): # builds the value being compared. for match in DIRTY_OR_RE.finditer(body): # Since the previous `dirty |=` of ANY form - the counter loop's included, or the - # window would start at the top of the walk and inherit every other bit's readers. + # window would start at the top of the walk and inherit every other bit's readers - + # AND since the last `}` before this one, whichever is later. The second boundary is + # what keeps the pack block's trailing `m_pack = pack;` out of the PATCH bit's + # window: `pack` is a local of the walk, so it expands to ctx.GetPixelStore + # Parameters and the patch shutter would read as though it compared the pixel store. previous = 0 for boundary in DIRTY_ANY_RE.finditer(body, 0, match.start()): previous = boundary.end() - window = body[previous:match.start()] + closing = body.rfind("}", 0, match.start()) + window = body[max(previous, closing + 1):match.start()] out.setdefault(match.group(1), set()) out[match.group(1)] |= readers_of(window) return out @@ -488,41 +787,59 @@ def answer_set(answer): return {part.strip() for part in answer.split("|") if part.strip()} -def object_class_problems(mapping, bits, movers, moved): +def object_class_problems(mapping, bits, movers, moved, outside=None): """The under-firing check for every answer the RenderState derivation cannot reach. - Returns (problems, verified, unverified) - `unverified` names the rows the derivation - had to decline, with the reason, so --check reports its own coverage instead of letting - a row it never looked at read as checked.""" + Returns (problems, verified, declined) - `declined` names the rows the derivation REFUSED + to answer, with the reason. Every branch below that does not end in a verdict ends here + instead, because the alternative is what this gate did to NEW_PIXEL_PACK: turn "this + script cannot read that construct" into "that mutator writes nothing".""" + outside = outside or {} problems = [] verified = 0 - unverified = [] + declined = [] render_bits = {RENDER_STATE_BIT, PIPELINE_STATE_BIT} for mutator in sorted(mapping): claimed = (answer_set(mapping[mutator]) & bits) - render_bits if not claimed: continue if mutator not in moved: - unverified.append("%s (no body found under MG_State/GLState or MG_Impl/Pipe to " - "derive from)" % mutator) + declined.append("%s (no body found under MG_State/GLState or MG_Impl/Pipe to " + "derive from)" % mutator) continue + blind = sorted(token[6:] for token in moved[mutator] if token.startswith("TAINT:")) for bit in sorted(claimed): shutter, resolved = movers.get(bit, (set(), False)) if not resolved: - unverified.append("%s <- %s (Tracker.h's shutter for that bit reads something " - "this script cannot resolve to a member)" % (mutator, bit)) + declined.append("%s <- %s (Tracker.h's shutter for that bit reads something " + "this script cannot resolve to a member)" % (mutator, bit)) continue if moved[mutator] & shutter: verified += 1 continue + # Everything past here would be an ABSENCE claim, so the gate first has to be + # able to say it read every writer of that shutter. Two things stop it, and each + # is a decline rather than a verdict. + unread = sorted(set(outside[token] for token in shutter if token in outside)) + if unread: + declined.append("%s <- %s (that shutter's members are written outside the " + "analysed roots too, e.g. %s, so 'it writes nothing that " + "shutter reads' is not a fact this script has)" + % (mutator, bit, unread[0])) + continue + if blind: + declined.append("%s <- %s (it reaches %s, so the write analysis is not " + "complete for this mutator)" % (mutator, bit, blind[0])) + continue problems.append( "UNDER-FIRING answer %s for %s - it writes nothing %s's shutter reads " "(shutter: %s), so a mutation through it publishes nothing" % (bit, mutator, bit, ", ".join(sorted(t.split(":", 1)[1] for t in shutter)))) - return problems, verified, unverified + return problems, verified, declined -def check_mapping(mapping, duplicates, scanned, bits, publishers=None, movers=None, moved=None): +def check_mapping(mapping, duplicates, scanned, bits, publishers=None, movers=None, moved=None, + outside=None): """Every problem the gate fails on, as a list of human-readable lines. BOTH directions: an unmapped mutator renders stale, and a row naming a mutator the scan no longer finds is a stale row that would keep a real hole looking covered. `publishers` is @@ -546,12 +863,17 @@ def check_mapping(mapping, duplicates, scanned, bits, publishers=None, movers=No continue problems.append("BAD answer %s for %s - not a MGPipeDirty bit name and not one of %s" % (answer, mutator, ", ".join(NON_BIT_ANSWERS))) - if len(answers) > 1 and answers & set(NON_BIT_ANSWERS): + partial = answers & set(PARTIAL_ANSWERS) + if len(answers) > 1 and (answers & set(NON_BIT_ANSWERS)) - partial: problems.append("BAD answer %s for %s - a non-bit answer stands alone" % (mapping[mutator], mutator)) + if partial and not answers & bits: + problems.append("BAD answer %s for %s - %s has to NAME the bits that move on some " + "of the paths that mutate; kPulledEveryVerb is the answer when " + "none does" % (mapping[mutator], mutator, "|".join(sorted(partial)))) if movers is not None and moved is not None: - object_problems, _, _ = object_class_problems(mapping, bits, movers, moved) + object_problems, _, _ = object_class_problems(mapping, bits, movers, moved, outside) problems += object_problems if publishers is None: @@ -615,7 +937,7 @@ def scan_all(): return sources, per_file, distinct_all -def self_test(scanned, bits, publishers, movers, moved): +def self_test(scanned, bits, publishers, movers, moved, outside=None): """Canned negative controls. Each MUST trip; trips == 0 is an error, which is the shape check_include_closure.py and gen_pipe.py --self-test already use.""" trips = 0 @@ -679,7 +1001,7 @@ def self_test(scanned, bits, publishers, movers, moved): with_dead_shutter = dict(real) with_dead_shutter["SetNamedTransformFeedbackBinding"] = "NEW_SO_TARGETS" problems = check_mapping(with_dead_shutter, real_duplicates, scanned, bits, publishers, - movers, moved) + movers, moved, outside) if any("UNDER-FIRING answer NEW_SO_TARGETS" in p for p in problems): trips += 1 else: @@ -691,13 +1013,69 @@ def self_test(scanned, bits, publishers, movers, moved): with_wrong_bit = dict(real) with_wrong_bit["SetCurrentVertexAttributeInt"] = "NEW_PIXEL_PACK" problems = check_mapping(with_wrong_bit, real_duplicates, scanned, bits, publishers, - movers, moved) + movers, moved, outside) if any("UNDER-FIRING answer NEW_PIXEL_PACK" in p for p in problems): trips += 1 else: failures.append("negative control 7 (a value-class answer whose shutter the mutator " "never moves) did NOT trip") + # 8. A row that says kPulledPartialShutter and names no bit. The whole point of that + # answer is to carry the bits that DO move, so an empty one is kPulledEveryVerb with + # a different spelling - and kPulledEveryVerb is the claim that got NEW_PIXEL_PACK + # wrong in the first place. + with_empty_partial = dict(real) + with_empty_partial["SetPixelStoreParam"] = "kPulledPartialShutter" + problems = check_mapping(with_empty_partial, real_duplicates, scanned, bits) + if any("has to NAME the bits" in p for p in problems): + trips += 1 + else: + failures.append("negative control 8 (kPulledPartialShutter naming no bit) did NOT trip") + + # 9. THE CONTROL FOR THE DECLINE PATH, and it is the shape of the defect this round fixed. + # The gate did not merely miss a check: it turned "this script cannot read that + # construct" into "that mutator writes nothing", and printed a verdict about + # RenderState.cpp that was false. So a mutator whose reachable text contains something + # the analysis does not model has to come out DECLINED and must NOT appear as a + # problem, whatever else is true of it. + blinded = dict(moved) + blinded["SetCurrentVertexAttributeInt"] = {"TAINT:a canned unexpandable token paste"} + problems, _, declined = object_class_problems(with_wrong_bit, bits, movers, blinded, outside) + if (not any("SetCurrentVertexAttributeInt" in p for p in problems) + and any(d.startswith("SetCurrentVertexAttributeInt <- NEW_PIXEL_PACK") for d in declined)): + trips += 1 + else: + failures.append("negative control 9 (a mutator whose write analysis is incomplete) did " + "NOT decline - the gate answered a question it cannot answer") + + # 10. The other decline reason, and the other half of the same principle: a shutter whose + # members have a writer OUTSIDE the roots this analysis reads. "Nobody writes it" is + # not a claim about code the script never opened. + hidden = dict(outside or {}) + for token in movers.get("NEW_PIXEL_PACK", (set(), False))[0]: + hidden[token] = "MobileGL/MG_Backend/a-file-this-analysis-never-reads.cpp" + problems, _, declined = object_class_problems(with_wrong_bit, bits, movers, moved, hidden) + if (not any("SetCurrentVertexAttributeInt" in p for p in problems) + and any("outside the analysed roots" in d for d in declined)): + trips += 1 + else: + failures.append("negative control 10 (a shutter member written outside the analysed " + "roots) did NOT decline - the gate claimed an absence over code it " + "never read") + + # THE POSITIVE CONTROL, and it is the row that was wrong: RenderState::SetPixelStoreParam + # writes NEW_PIXEL_PACK's shutter member sixteen times, through a token-pasting macro. If + # this ever reads as UNDER-FIRING again, the write analysis has lost the preprocessor and + # every absence claim in this gate is worthless. + with_the_bit = dict(real) + with_the_bit["SetPixelStoreParam"] = "NEW_PIXEL_PACK" + problems, _, declined = object_class_problems(with_the_bit, bits, movers, moved, outside) + if any("SetPixelStoreParam" in line for line in problems + declined): + failures.append("the positive control (SetPixelStoreParam DOES write " + "NEW_PIXEL_PACK's shutter) did not pass: %s" + % "; ".join(line for line in problems + declined + if "SetPixelStoreParam" in line)) + for failure in failures: print("dirty-surface self-test: %s" % failure) if trips == 0: @@ -706,7 +1084,8 @@ def self_test(scanned, bits, publishers, movers, moved): return 1 if failures: return 1 - print("dirty-surface self-test: %d negative controls, all tripped" % trips) + print("dirty-surface self-test: %d negative controls, all tripped; positive control OK " + "(SetPixelStoreParam's sixteen token-pasted writes are read)" % trips) return 0 @@ -734,8 +1113,9 @@ def main(): publishers = render_state_publishers() if not publishers: sys.exit("could not derive any RenderState setter out of %s" % RENDER_STATE_PATH) - bodies = state_bodies() - reach = written_tokens(bodies) + bodies, taints, analysed = state_bodies() + reach = written_tokens(bodies, taints) + outside = writers_outside(analysed) aggregates = aggregate_tokens(bodies, reach) moved = {name: expand_aggregates(tokens, aggregates) for name, tokens in reach.items()} readers = shutter_readers() @@ -749,13 +1129,13 @@ def main(): movers = shutter_movers(readers, bodies, aliases) if args.self_test: - return self_test(distinct_all, bits, publishers, movers, moved) + return self_test(distinct_all, bits, publishers, movers, moved, outside) mapping, duplicates = load_mapping() if args.check: problems = check_mapping(mapping, duplicates, distinct_all, bits, publishers, movers, - moved) + moved, outside) for problem in problems: print("dirty-surface: %s" % problem) if problems: @@ -764,7 +1144,7 @@ def main(): "RenderState.cpp actually publishes" % len(problems)) return 1 derived = sum(1 for m in mapping if m in publishers) - _, verified, unverified = object_class_problems(mapping, bits, movers, moved) + _, verified, declined = object_class_problems(mapping, bits, movers, moved, outside) prose = sorted(m for m in mapping if not (answer_set(mapping[m]) & bits)) print("dirty-surface: %d mutators, all mapped, no stale rows; %d render-state answers " "derived from RenderState.cpp and matching" % (len(mapping), derived)) @@ -773,10 +1153,22 @@ def main(): print("dirty-surface: %d other bit answers derived from their shutter in Tracker.h " "(under-firing only); %d declined; %d rows carry a prose answer (%s) that no " "derivation checks" - % (verified, len(unverified), len(prose), + % (verified, len(declined), len(prose), ", ".join(sorted(set(a for m in prose for a in answer_set(mapping[m])))))) - for row in unverified: - print("dirty-surface: not derived: %s" % row) + for row in declined: + print("dirty-surface: DECLINED, no verdict: %s" % row) + # The absence claim's own footprint, printed rather than assumed: what the write + # analysis read, and the one place it is still coarse. + print("dirty-surface: the under-firing half read %d function bodies across %d files " + "under %s, macros expanded first; %d of those bodies carry a construct it does " + "not model and taint whatever reaches them; %d members are written outside " + "those roots and any shutter that names one is declined; a FIELD token is " + "matched by name and not scoped to a type, so it only ever WIDENS what a " + "mutator is credited with writing" + % (sum(len(v) for v in bodies.values()), len(analysed), + " + ".join(os.path.relpath(root, REPO_ROOT).replace(os.sep, "/") + for root in STATE_ROOTS), + len(taints), len(outside))) return 0 total_functions = 0 From 59191cd296406b564fe2e9d8277e93ed29a4860b Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Mon, 7 Sep 2026 22:43:44 -0400 Subject: [PATCH 115/529] [Fix] (Pipe, DirtySurface): resolve both sides of the dirty-surface derivation to member+field, follow reference and pointer aliases, and turn every write the analysis cannot place into an UNDECIDED answer instead of a verdict - The writer side recorded a member-rooted write bound to a reference (`for (auto& blendState : m_parameters.BlendStates)`) as the field alone, so seven RenderState setters read as writing nothing; the reader side resolved `render.PatchVertices` to the whole of m_parameters, so every setter that touched any byte of it "supported" NEW_PATCH_STATE and a row saying glClearColor publishes the patch state was green. Both were the same defect: the two sides did not resolve to the same token. - Both sides now carry MEM: and FIELD:.; a whole-member write or read is every field. A reference, pointer or range-for alias bound to a member-rooted lvalue is followed (rebinds and aliases of aliases included), a write through a call-result lvalue and a mutating call on a member-rooted lvalue count as whole writes, a const alias cannot be written through with `.`. - A write, or a non-read-only method call, whose root the analysis cannot place - a reference parameter, a call result, a member without the m_ prefix, an unattributable assignment operator - taints the function; the taint rides the call-graph fixed point and every (row, bit) that depends on a tainted function is UNDECIDED, never a verdict. Nothing is trusted by name. - Match rule: a writer supports a bit iff the two sides share a member and, both field-resolved, their field sets intersect; a member in common with no field information on one side is COARSE, reported and never counted; UNDER-FIRING only when both sides are resolved and disjoint for every member the shutter reads. - --check counts only supported answers as derived, prints the COARSE and UNDECIDED tallies, and fails on an UNDECIDED row unless MGP_DIRTY_SURFACE_UNDECIDED_LIST in DirtySurface.def marks it; a mark on a row the derivation decides is a red gate too. The list is empty: all 8 non-render bit answers are supported at field level, and NEW_PATCH_STATE has exactly three legal carriers again. - --self-test grows from 10 to 21 negative controls, including the synthetic bodies of every shape above through the real extractor, the NEW_PATCH_STATE analogue of the value-class control, the taint, COARSE and stale-mark paths, and positive controls for SetPixelStoreParam's pasted writes and the seven alias setters. --- MobileGL/MG_Pipe/DirtySurface.def | 60 +- scripts/gen_pipe_dirty_surface.py | 1201 +++++++++++++++++++++++------ 2 files changed, 990 insertions(+), 271 deletions(-) diff --git a/MobileGL/MG_Pipe/DirtySurface.def b/MobileGL/MG_Pipe/DirtySurface.def index 6dd22c7b1..a72a73055 100644 --- a/MobileGL/MG_Pipe/DirtySurface.def +++ b/MobileGL/MG_Pipe/DirtySurface.def @@ -37,27 +37,40 @@ // EVERY BIT ANSWER IN THIS FILE IS DERIVED AND CHECKED, in two families and one // direction. The RenderState family (45 rows) is checked both ways against RenderState.cpp, // below. Every other NEW_* answer is checked against the shutter Tracker.h builds for that -// bit: gen_pipe_dirty_surface.py resolves what the shutter READS to the members behind it, -// computes what each mutator transitively WRITES (through MGP_NOTE_AGGREGATE too, whose hop -// it reads out of MGPipeNoteAggregate's own switch, and through the function-like macros of -// MG_State, which it EXPANDS - sixteen of RenderState.cpp's writes exist only after the -// preprocessor has pasted them together), and fails a row that names a bit whose shutter its -// mutator moves on no path at all. That half is one-directional on purpose - "it does write -// something the shutter reads" cannot prove it does so on EVERY path - so it catches -// under-firing and not over-claiming. +// bit: gen_pipe_dirty_surface.py resolves what the shutter READS and what each mutator +// transitively WRITES (through MGP_NOTE_AGGREGATE too, whose hop it reads out of +// MGPipeNoteAggregate's own switch, and through the function-like macros of MG_State, which +// it EXPANDS - sixteen of RenderState.cpp's writes exist only after the preprocessor has +// pasted them together) to the SAME two-level token, MEM: plus FIELD:., +// and fails a row that names a bit whose shutter its mutator moves on no path at all. That +// half is one-directional on purpose - "it does write something the shutter reads" cannot +// prove it does so on EVERY path - so it catches under-firing and not over-claiming. // -// AN ABSENCE CLAIM IS ONLY WORTH THE READING BEHIND IT, and this gate learned that the -// expensive way: its write analysis used to record a write through a member's field -// (m_foo.bar = v) as the FIELD alone and never as the member, while the shutter side -// resolves an accessor to the MEMBER - so the two halves could not meet for any -// struct-valued member, and --check printed, as a fact about RenderState.cpp, that +// AN ABSENCE CLAIM IS ONLY WORTH THE READING BEHIND IT, and this gate learned that twice: +// its write analysis first recorded a write through a member's field as the field alone +// and never the member, so --check printed, as a fact about RenderState.cpp, that // SetPixelStoreParam "writes nothing NEW_PIXEL_PACK's shutter reads" about a setter whose -// whole body is sixteen writes to exactly that member. The answer below is what that put in -// this file. So the derivation now DECLINES rather than answers whenever it cannot say it -// read every writer: a body carrying a construct it does not model (an unexpandable token -// paste), anything that reaches such a body, and any shutter member written outside -// MG_State/GLState + MG_Impl/Pipe at all. --check prints every decline with the site that -// caused it, and prints how many bodies and files the claim rests on. +// whole body is sixteen writes to exactly that member; then, once it read the member, it +// still could not see a write through a REFERENCE (SetBlendEquation's `for (auto& +// blendState : m_parameters.BlendStates)`) and said the same false thing about seven more +// setters - while its reader side resolved `render.PatchVertices` to the WHOLE of +// m_parameters, so every setter that touched any byte of it "supported" NEW_PATCH_STATE and +// a row saying glClearColor publishes the patch state was green. So now: a reference or +// pointer bound to a member-rooted lvalue is followed, and its writes are credited to the +// member and the field it was bound to; a write whose root the analysis cannot place (a +// reference parameter, a call result, a member without the m_ prefix, a token it could not +// expand) TAINTS the function, and every answer that depends on a tainted function is +// UNDECIDED - printed with its reason, never a verdict; a writer supports a bit only when +// the two sides share a member AND, both resolved to fields, their field sets intersect (a +// whole-member write or read is every field); a member in common with no field information +// on one side is COARSE, reported and never counted. --check counts only the supported +// answers as derived, prints the COARSE and UNDECIDED tallies, and FAILS on an UNDECIDED +// row unless MGP_DIRTY_SURFACE_UNDECIDED_LIST at the bottom of this file marks it - a +// mark that outlives its reason is a red gate too. What it still cannot claim: a shutter +// member written outside MG_State/GLState + MG_Impl/Pipe is undecided in the absence +// direction, a call is resolved by NAME to every body of that name, and a FIELD token is +// not scoped to a type - all three only widen what a mutator is credited with, and the +// second is also how a taint spreads. // // The prose answers (kImmediate, kExplicitDestroy, kUnpublishedDestroy, kNoBackendRead, // kPulledEveryVerb, kPulledPartialShutter, kReverseChannel) are statements no derivation @@ -262,4 +275,13 @@ X(AddTransformFeedbackPausedPrimitives, kPulledEveryVerb) \ X(AddTransformFeedbackPrimitives, kPulledEveryVerb) +// X(Mutator, Bit) - the (row, bit) pairs above whose derivation is KNOWN to come out +// UNDECIDED, each with the reason --check prints for it. Every bit answer NOT listed here +// is marked derived: --check fails when the derivation cannot decide it, and fails again +// when a mark here names a pair the derivation now decides, so this list can neither hide a +// row nor outlive its reason. Empty today: every bit answer above is supported at field +// level. The ten mutators that reach a tainted body (--check prints the count) all carry a +// prose answer, which no derivation checks. +#define MGP_DIRTY_SURFACE_UNDECIDED_LIST(X) + // clang-format on diff --git a/scripts/gen_pipe_dirty_surface.py b/scripts/gen_pipe_dirty_surface.py index d24923cab..bbd81dc58 100644 --- a/scripts/gen_pipe_dirty_surface.py +++ b/scripts/gen_pipe_dirty_surface.py @@ -27,13 +27,17 @@ it a row could be wrong in exactly the direction ARCHITECTURE.md 13.2 calls dangerous while --check stayed green, which is how two rows in this mapping were wrong for a whole review. -Every other bit answer is derived too, one-directionally, against the shutter Tracker.h -builds for that bit. That half makes an ABSENCE claim ("this mutator writes nothing that -shutter reads"), so it is only as good as the code it reads, and it once was not: it missed -every write through a member's field and every write the preprocessor pastes together, and -reported their absence as a fact about RenderState.cpp. It now expands MG_State's -function-like macros, records the member as well as the field, and DECLINES - prints the row -and the site, and does not fail it - wherever it cannot say it read every writer. +Every other bit answer is derived too, against the shutter Tracker.h builds for that bit. +That derivation makes an ABSENCE claim ("this mutator writes nothing that shutter reads"), +so it is only as good as the code it reads, and twice it was not: it missed the writes that +go through a member's field or through the preprocessor, and then - once it read those - it +resolved the reader side to a whole struct while the writer side resolved to the struct's +fields, so every setter of RenderState.cpp "supported" the patch-state bit. Both halves now +resolve to the SAME two-level token, MEM: plus FIELD:.; a reference or +pointer bound to a member-rooted lvalue is followed; and a write whose root the analysis +cannot resolve to a member TAINTS the function, so every answer that depends on it comes +out UNDECIDED - printed, tallied, never a verdict. A match at the member level with no +field information on one side is COARSE, reported separately and never counted as derived. python3 scripts/gen_pipe_dirty_surface.py # human-readable report python3 scripts/gen_pipe_dirty_surface.py --summary # counts only @@ -55,7 +59,7 @@ MUTATOR_RE = re.compile(r"pGLContext->\s*((?:%s)\w*)\s*\(" % "|".join(MUTATOR_PREFIXES)) BACKEND_RE = re.compile(r"gBackendFunctionsTable\.GL\.(\w+)|pActiveBackendObject->\s*(\w+)") -FUNCTION_RE = re.compile(r"(?:^|\n)[ \t]*(?:[A-Za-z_][\w:<>,&*\s]*?)\b(\w+)\s*\([^;{}]*\)\s*" +FUNCTION_RE = re.compile(r"(?:^|\n)[ \t]*(?:[A-Za-z_][\w:<>,&*\s]*?)\b(\w+)\s*\(([^;{}]*)\)\s*" r"(?:const\s*)?(?:noexcept\s*)?\{") @@ -101,10 +105,14 @@ def mask_comments_and_strings(text): return "".join(out) -def function_bodies(masked): - """Yield (name, start_offset, end_offset) for every braced function body.""" +def function_signatures(masked): + """Yield (name, parameter text, start_offset, end_offset) for every braced function + body. The parameter text is what the write analysis needs to tell a reference + parameter (a write through it may land anywhere) from a by-value one (it cannot).""" for match in FUNCTION_RE.finditer(masked): name = match.group(1) + if name in CONTROL_KEYWORDS: + continue start = masked.index("{", match.end() - 1) if masked[match.end() - 1] != "{" else match.end() - 1 depth = 0 i = start @@ -114,11 +122,17 @@ def function_bodies(masked): elif masked[i] == "}": depth -= 1 if depth == 0: - yield name, start, i + yield name, match.group(2), start, i break i += 1 +def function_bodies(masked): + """Yield (name, start_offset, end_offset) for every braced function body.""" + for name, _, start, end in function_signatures(masked): + yield name, start, end + + def line_of(text, offset): return text.count("\n", 0, offset) + 1 @@ -160,6 +174,10 @@ def scan_file(path): # path through the mutator (DirtySurface.def's header states the rule). ROW_RE = re.compile(r"^[ \t]*X\((\w+),\s*([\w|]+)\)\s*\\?\s*$", re.M) DIRTY_NAME_RE = re.compile(r'^\s*"(NEW_[A-Z0-9_]+)",\s*$', re.M) +# The second list of DirtySurface.def: the (mutator, bit) pairs the derivation is KNOWN to +# leave UNDECIDED, each with the reason --check prints. Every bit answer not listed here is +# marked derived, and --check fails when the derivation cannot decide it. +UNDECIDED_LIST_RE = re.compile(r"^[ \t]*#[ \t]*define[ \t]+MGP_DIRTY_SURFACE_UNDECIDED_LIST\s*\(", re.M) # The answers that are not a dirty-bit name. Each one is documented in DirtySurface.def's # header; a row that uses anything else is a typo, and a typo that read as "mapped" would be @@ -254,55 +272,75 @@ def resolve(name, seen): # different pair of files: MG_Impl/Pipe/Tracker.h says which counters and which bytes each # MGPipeDirty bit compares, and MG_State says who moves those. So: # -# 1. read Tracker.h's Update() and, per bit, collect what its shutter READS - -# ctx.GetXxx() accessors and `render.Field` reads, with the walk's own locals expanded; -# 2. resolve each accessor, through MG_State's one-line getters, to the MEMBER it returns; +# 1. read Tracker.h's Update() and, per bit, collect what its shutter READS - ctx.GetXxx() +# accessors, and `local.Field` reads of a walk local that holds an accessor's result; +# 2. resolve each read, through MG_State's one-line getters, to the same TWO-LEVEL TOKEN +# the writer side uses: MEM:, and FIELD:. - `render.PatchVertices` +# is FIELD:m_parameters.PatchVertices, and an accessor that returns a whole member reads +# every field of it, FIELD:.*; # 3. walk every function body under MG_State/GLState and MG_Impl/Pipe and compute, as a -# fixed point over call names, which members and struct fields each one transitively -# WRITES - including through MGP_NOTE_AGGREGATE, whose per-aggregate hop is read out of +# fixed point over call names, which two-level tokens each one transitively WRITES - +# including through MGP_NOTE_AGGREGATE, whose per-aggregate hop is read out of # MGPipeNoteAggregate's own switch rather than assumed; -# 4. a row claiming bit B for mutator M is UNDER-FIRING when M writes nothing B reads. +# 4. match: a writer SUPPORTS a bit iff it writes a member the shutter reads AND, both sides +# being field-resolved for that member, their FIELD sets intersect (a whole-member write +# or read is every field). A member in common with no field information on one side is +# COARSE. UNDER-FIRING - the red verdict - only when every member the shutter reads is +# either unwritten by the mutator or written in disjoint fields, both sides resolved. # -# STEP 4 IS AN ABSENCE CLAIM, so step 3 must not MISS a write, and that is an obligation this -# script violated rather than a slogan: until this commit a write through a member's FIELD -# (`m_foo.bar = v`) recorded FIELD:bar and never MEM:m_foo, while the reader side resolves an -# accessor to MEM:m_foo - so the two halves could not meet for any struct-valued member, and -# the gate printed "SetPixelStoreParam writes nothing NEW_PIXEL_PACK's shutter reads" about a -# setter whose entire body is sixteen writes to exactly that member. It could not see them -# twice over, because those writes are spelled with the token-pasting operator -# (RenderState.cpp's SET_PIXEL_STORE_PARAM) and the file was read raw, so the "field" it -# recorded was the MACRO PARAMETER NAME. What step 3 models is therefore written down here, -# and what it does not model is DECLINED rather than answered: +# STEP 4 IS AN ABSENCE CLAIM, so step 3 must not MISS a write and must not CREDIT one it did +# not read. What it models is written down here, and what it does not model taints the +# function it is in, which turns every answer depending on that function into UNDECIDED: # -# modelled ++m_x / m_x = / m_x op=; a write through a member-rooted lvalue (m_x.f, -# m_x[i].f, m_x->f, nested), which records BOTH MEM:m_x and FIELD:f; a bare -# `.f =` write (FIELD:f alone - the FIELD tokens are coarse, and coarse only -# ever WIDENS what a mutator is credited with writing, which is the safe -# direction for an absence claim); MGP_NOTE_AGGREGATE; a call to any function -# whose body is under the two roots; and any function-like macro defined under -# the two roots, which is EXPANDED first, token pasting performed. -# declined a body that still contains `##` after expansion, or that invokes a macro this -# script can see but could not expand and whose body pastes tokens. The taint is -# a token like every other, so it travels the same call-graph fixed point the -# writes do: a mutator that REACHES an unreadable body gets no verdict either. -# A shutter member with a write-shaped occurrence OUTSIDE the two roots is -# declined the same way - the analysis has not read every writer, so it cannot -# say there is none. --check prints every decline with the site that caused it. +# modelled ++m_x / m_x = / m_x op= (a whole-member write: MEM:m_x, FIELD:m_x.*); a write +# through a member-rooted lvalue (m_x.f, m_x[i].f, m_x->f, nested), which records +# MEM:m_x and FIELD:m_x.f - the first field below the member, deeper paths +# collapse to it, because that is the granularity the shutter reads at; a +# reference or pointer bound to a member-rooted lvalue (auto& r = m_x.f; +# for (auto& e : m_x.arr); T* p = &m_x.f; a pointer REBOUND by p = &m_x.g, every +# binding counting; an alias of an alias), whose writes record the root member +# and the path they were bound to; a write through a call that returns a +# reference into an lvalue (m_x.f() = v), a member-function call on a +# member-rooted lvalue (m_x.push_back(), m_x.reset(), m_x.f.clear()) and a +# memcpy/memset/memmove/swap whose destination is one, all credited as a whole +# write of that lvalue - a read-only call is over-credited, which only WIDENS the +# writer side; a write to a value local, a by-value parameter or an aggregate +# initialiser's `.f = v`, which touch no member; a write through a const alias +# with `.` or through a const raw pointer, which the language forbids - but `->` +# through a const REFERENCE may be a smart pointer's and is NOT taken as +# read-only; MGP_NOTE_AGGREGATE; a call to any function whose body is under the +# two roots, resolved BY NAME (every body of that name); and any function-like +# macro defined under the two roots, EXPANDED first, token pasting performed. +# tainted a write, or a non-read-only method call, through a reference or pointer +# PARAMETER; through a local reference, pointer or iterator bound to something +# that is not a member-rooted lvalue (a call result, a ternary, an arithmetic +# expression); to or on a name the body never declares (a member without the m_ +# prefix, a global, an unexpanded token); an assignment operator the analysis +# could not attribute to any lvalue at all; a `##` left after macro expansion; +# and a call to a token-pasting macro it refused to expand. Nothing is trusted +# by name except the standard library's size()/begin()/find() family. The taint +# is a token like every other, so it travels the same call-graph fixed point the +# writes do: a mutator that REACHES a tainted body gets no verdict either. +# undecided also when the shutter itself reads something this script cannot resolve to a +# member, when the mutator has no body under the two roots, and - for the absence +# direction only - when a member the shutter reads has a write-shaped occurrence +# OUTSIDE the two roots. --check prints every UNDECIDED pair with its reason, and +# fails on one that DirtySurface.def does not list as known-undecided. # # What remains one-directional is the OTHER direction: a call name resolves to every body of -# that name, a write inside an `if` counts, and a reader that resolves through a ternary -# yields both members - so "M does write something B reads" is not proof that it does so on -# every path and cannot become a MISSING check without false reds. That is why a mutator -# whose bit moves on half its arms answers kPulledPartialShutter rather than the bit. "M -# writes NOTHING B reads" is the direction the gate fails on, and it is the under-firing one -# ARCHITECTURE.md 13.2 calls dangerous - which is what was wrong in this file: -# X(SetNamedTransformFeedbackBinding, NEW_SO_TARGETS) named a shutter that moves on NO path -# through that mutator. +# that name, a write inside an `if` counts, a reader that resolves through a ternary yields +# both members, a FIELD token is not scoped to a type - so "M does write something B reads" +# is not proof that it does so on every path and cannot become a MISSING check without false +# reds. That is why a mutator whose bit moves on half its arms answers kPulledPartialShutter +# rather than the bit. "M writes NOTHING B reads" is the direction the gate fails on, and it +# is the under-firing one ARCHITECTURE.md 13.2 calls dangerous - which is what was wrong in +# this file: X(SetNamedTransformFeedbackBinding, NEW_SO_TARGETS) named a shutter that moves +# on NO path through that mutator. STATE_ROOTS = (os.path.join(REPO_ROOT, "MobileGL", "MG_State", "GLState"), os.path.join(REPO_ROOT, "MobileGL", "MG_Impl", "Pipe")) # Everything the absence claim has to be checked against, which is wider than what it reads: # a write to a shutter member from outside STATE_ROOTS is a writer this analysis never looks -# at, and the only honest answer to a row whose shutter has one is "declined". +# at, and the only honest answer to a row whose shutter has one is "undecided". MOBILEGL_ROOT = os.path.join(REPO_ROOT, "MobileGL") UPDATE_RE = re.compile(r"Uint32\s+Update\s*\(") NOW_RE = re.compile(r"now\[Index\(MGPipeDirty::(\w+)\)\]\s*=\s*([^;]*);") @@ -311,7 +349,10 @@ def resolve(name, seen): LOCAL_RE = re.compile(r"(\w+)\s*=\s*([^;]*);") CTX_READ_RE = re.compile(r"\bctx\.(\w+)\s*\(") ARROW_READ_RE = re.compile(r"\b\w+\s*->\s*(\w+)\s*\(") -FIELD_READ_RE = re.compile(r"\brender\.(\w+)") +# `local.Field` / `local->Field` that is NOT a call: a read of one field of whatever the +# local holds. This is what keeps `render.PatchVertices` from reading as the whole of +# m_parameters, which is what disarmed the patch-state check for 30 rows. +LOCAL_FIELD_RE = re.compile(r"\b(\w+)\s*(?:\.|->)\s*(\w+)\b(?!\s*\()") RETURN_RE = re.compile(r"\breturn\s+([^;]*);") MEMBER_RE = re.compile(r"\b(m_\w+)\b") MEMBER_CALL_RE = re.compile(r"\b(m_\w+)\s*\.\s*(\w+)\s*\(") @@ -324,16 +365,73 @@ def resolve(name, seen): # out; !=, <= and >= cannot match at all, because the character where the operator must start # is then `!`, `<` or `>` and no alternative here begins with one except <<= / >>=. ASSIGN = r"(?:\+\+|--|\+=|-=|\*=|/=|%=|&=|\|=|\^=|<<=|>>=|=(?!=))" -# A write to the member itself: ++m_x, m_x = v, m_x += v. +# A write to the member itself: ++m_x, m_x = v, m_x += v. Used by the OUTSIDE scan only; the +# analysed bodies go through extract_writes below. MEMBER_WRITE_RE = re.compile(r"\+\+\s*(m_\w+)|\b(m_\w+)\s*%s" % ASSIGN) -# A write THROUGH a member: m_x.f = v, m_x[i].f = v, m_x->f = v, and nested. This is the one -# that was missing, and its absence is why the reader side (which resolves an accessor to -# MEM:m_x) and the writer side could never meet for a struct-valued member. +# A write THROUGH a member: m_x.f = v, m_x[i].f = v, m_x->f = v, and nested. Outside scan only. MEMBER_ROOTED_WRITE_RE = re.compile( r"\b(m_\w+)\s*(?:\[[^;\n]*?\]|\.\s*\w+|->\s*\w+)+\s*%s" % ASSIGN) -# A write to a field whose base this script did not resolve to a member. Coarse on purpose: -# a FIELD token only ever widens what a mutator is credited with writing. -FIELD_WRITE_RE = re.compile(r"\.\s*(\w+)\s*(?:\[[^\]]*\])?\s*%s" % ASSIGN) + +# ---- the write analysis: two-level tokens, aliases, taint -------------------------------- +# An lvalue is a root name followed by a path of `.f`, `->f` and `[i]` steps. The root is a +# member (m_x), a local, a parameter, `this`, or nothing this script can name. +INDEX = r"\[(?:[^\[\]]|\[[^\[\]]*\])*\]" +PATH = r"((?:\s*(?:\.|->)\s*\w+|\s*%s)*)" % INDEX +LVALUE_RE = re.compile(r"(\w+)%s" % PATH) +PATH_NAME_RE = re.compile(r"(?:\.|->)\s*(\w+)") +# `lvalue op= rhs`, `lvalue++`, and `*p = v`. +ASSIGN_RE = re.compile(r"(\*?)\s*\b(\w+)%s\s*(%s)" % (PATH, ASSIGN)) +# `++lvalue`, `++(*p)`. +PRE_INC_RE = re.compile(r"(\+\+|--)\s*\(?\s*(\*?)\s*(\w+)%s" % PATH) +# `lvalue.method(...) = v`: a write through a call that returns a reference into the lvalue +# (m_levelRange.x() = level). Credited as a whole write of the lvalue. +CALL_LVALUE_RE = re.compile(r"\b(\w+)%s\s*(\.|->)\s*(\w+)\s*\(" % PATH) +ASSIGN_AFTER_RE = re.compile(r"\s*(%s)" % ASSIGN) +# `lvalue.method(`: credited as a whole write of the lvalue when its root is a member or an +# alias of one, unless the method is one of the few that can only read. +METHOD_RE = re.compile(r"\b(\w+)%s\s*(?:\.|->)\s*(\w+)\s*\(" % PATH) +# The standard-library calls that cannot write their object. Nothing else is trusted by +# name: a call to any other method on something this script cannot place is a taint, which +# is what a helper that reads through a `GLContext&` costs whatever reaches it - an +# UNDECIDED answer, not a wrong one. +READ_ONLY_METHODS = frozenset(("size", "empty", "begin", "end", "cbegin", "cend", "rbegin", + "rend", "capacity", "max_size", "length", "c_str", "count", + "contains", "find")) +# memcpy(&dst, ...), memset(&dst, ...), memmove(&dst, ...), swap(a, b): whole writes. +BULK_RE = re.compile(r"\b(?:std\s*::\s*)?(memcpy|memmove|memset|swap)\s*\(") +# A local declaration: [qualifiers] Type[<...>][::More] [const] [& | && | *] name, then the +# initialiser's opener. A `&`/`*` mark (or an initialiser that takes an address) makes the +# local an ALIAS whose initialiser must resolve to a member-rooted lvalue; anything else is +# a value local, which cannot alias a member - unless the body later writes through it with +# `->` or `*`, in which case it is a pointer or an iterator and is treated as an alias. A +# pointer alias is REBOUND by `p = expr;` (every binding counts); a reference alias is +# written through by it. A const-qualified alias cannot be written through with `.`, and a +# const raw pointer cannot be written through at all - but `->` on a const REFERENCE may be +# a smart pointer's, whose pointee is not const, so that one is not read-only. +TYPE_WORD = r"[A-Za-z_]\w*" +DECL_RE = re.compile( + r"(?:^|[;{}(,])[ \t\n]*" + r"((?:(?:const|constexpr|static|volatile|mutable)\s+)*" + r"(?:%s\s*::\s*)*%s(?:\s*<[^<>;{}()]*>)?(?:\s*::\s*%s)*)" + r"(\s+const)?(?:\s*(&&|&|\*(?:\s*const)?)\s*|\s+)" + r"(\w+)\s*(=(?!=)|\{|;|\(|\[|:(?!:))" % (TYPE_WORD, TYPE_WORD, TYPE_WORD)) +KEYWORDS = frozenset(( + "return", "if", "else", "for", "while", "do", "switch", "case", "default", "break", + "continue", "goto", "throw", "delete", "new", "sizeof", "alignof", "typedef", "using", + "namespace", "static_cast", "reinterpret_cast", "const_cast", "dynamic_cast", "struct", + "class", "enum", "union", "template", "typename", "operator", "co_return", "co_await", + "co_yield", "extern", "friend", "explicit", "inline", "virtual", "override", "final", + "public", "private", "protected", "try", "catch", "asm", "this")) +# `if (...) {` and friends look exactly like a function definition to FUNCTION_RE. Their +# blocks are inside the enclosing body already, so as "functions" they would only be read a +# second time without their enclosing declarations - and every body with a loop would +# "call" a function named `for`. +CONTROL_KEYWORDS = frozenset(("if", "for", "while", "switch", "catch", "else")) +USING_RE = re.compile(r"\busing\s+\w+\s*(=)") +PARAM_NAME_RE = re.compile(r"(\w+)\s*(\[[^\]]*\])?\s*(?:=[^=].*)?$") +# Every assignment operator in a body. Each one must be attributed to an lvalue by the +# patterns above, or it taints the body: an unread write is the whole failure mode. +ASSIGN_OP_RE = re.compile(r"(\+\+|--|<<=|>>=|[-+*/%&|^]=|(?\[\-+*/%&|^])=(?![=\]]))") # ---- the preprocessor's half of the write analysis -------------------------------------- # Sixteen of RenderState.cpp's writes exist only after the preprocessor has run: SET_PIXEL_ @@ -341,7 +439,7 @@ def resolve(name, seen): # (:309) pastes `m_parameters.capability##Enabled`. Read raw, neither is a write to any token # this script can name, so it saw none of them and reported their absence as a fact. So the # function-like macros defined under the two roots are expanded first, and the ones that -# cannot be expanded are recorded so that a body reaching one is declined rather than +# cannot be expanded are recorded so that a body reaching one is undecided rather than # answered. MACRO_DIRECTIVE_RE = re.compile( r"^[ \t]*#[ \t]*(define|undef)[ \t]+(\w+)(\([^()\n]*\))?((?:\\\n|[^\n])*)", re.M) @@ -411,7 +509,7 @@ def macro_table(paths): matching every function body here is found by (an unbalanced body), when it is variadic, over-long or defined more than once with different bodies, or when the analysis models it directly. A refused macro is not silently ignored: if its body pastes tokens, every body - that invokes it is declined.""" + that invokes it is tainted.""" seen = {} for path in paths: with open(path, "r", encoding="utf-8", errors="replace") as handle: @@ -446,6 +544,18 @@ def matching_paren(text, open_index): return None +def matching_close(text, open_index, opener, closer): + depth = 0 + for index in range(open_index, len(text)): + if text[index] == opener: + depth += 1 + elif text[index] == closer: + depth -= 1 + if depth == 0: + return index + return None + + def split_arguments(text): args = [] current = [] @@ -509,9 +619,8 @@ def expand_macros(masked, expandable, rounds=4): def unmodelled_sites(body, relative, pasting): - """The constructs in `body` this script does NOT model, as decline reasons. Today there - is exactly one class of them, and it is the one that produced a false verdict: a token - paste it could not expand.""" + """The preprocessor constructs in `body` this script does NOT model, as taint reasons: + a token paste it could not expand, and a call to a pasting macro it refused.""" sites = set() if PASTE_RE.search(body): sites.add("%s: a `##` token paste no visible macro definition expands" % relative) @@ -522,9 +631,260 @@ def unmodelled_sites(body, relative, pasting): return sites +def parse_parameters(params_text): + """{parameter name: "ref" | "value"}. A reference, pointer or array parameter can alias + any member of any object; a by-value one cannot.""" + kinds = {} + for part in split_arguments(params_text or ""): + part = part.strip() + if not part or part in ("void", "..."): + continue + match = PARAM_NAME_RE.search(part) + if not match: + continue + kinds[match.group(1)] = ("ref" if ("&" in part or "*" in part or match.group(2)) + else "value") + return kinds + + +def strip_parens(text): + text = text.strip() + while text.startswith("(") and text.endswith(")") and balanced(text[1:-1]): + text = text[1:-1].strip() + return text + + +def lvalue_parts(expression): + """(root, [path names]) when `expression` is a plain lvalue - a name followed by + `.f` / `->f` / `[i]` steps - and None for anything else (a call, a ternary, arithmetic). + `this->m_x.f` is folded to root m_x.""" + text = strip_parens(expression) + match = LVALUE_RE.fullmatch(text) + if not match: + return None + root, path = match.group(1), match.group(2) + names = PATH_NAME_RE.findall(path) + if root == "this": + if not names: + return None + root, names = names[0], names[1:] + return root, names + + +def field_token(member, names): + """The two-level FIELD token for a write or read of member `member` at path `names`: + the first name below the member, or `*` (every field) when the path is empty.""" + return "FIELD:%s.%s" % (member, names[0] if names else "*") + + +LOCAL = ("", []) # an alias that resolves to a value local: writes through it touch no member + + +def extract_writes(body, params_text, site): + """(tokens, taints) for ONE function body: every write it makes, as MEM:/FIELD: two-level + tokens, and every reason the analysis could not attribute one of its writes. + + `site` names the body in the taint text so --check can print where the gate lost its + footing.""" + tokens = set() + taints = set() + params = parse_parameters(params_text) + declarations = {} + covered = set() + + for match in DECL_RE.finditer(body): + type_text, post_const, mark, name, opener = match.groups() + type_words = re.findall(r"\w+", type_text) + if any(word in KEYWORDS for word in type_words) or name in KEYWORDS: + continue + opener_at = match.end() - 1 + init = None + if opener == "=": + end = body.find(";", opener_at) + init = body[opener_at + 1:end if end >= 0 else len(body)] + covered.add(opener_at) + elif opener == ":": + close = matching_paren(body, body.rfind("(", 0, opener_at)) + init = body[opener_at + 1:close if close is not None else len(body)] + elif opener == "(": + close = matching_paren(body, opener_at) + init = body[opener_at + 1:close if close is not None else len(body)] + elif opener == "{": + close = matching_close(body, opener_at, "{", "}") + init = body[opener_at + 1:close if close is not None else len(body)] + mark = mark or "" + pointer = "*" in mark or (not mark and (init or "").strip().startswith("&")) + readonly = "const" in type_words[:-1] or bool(post_const) + declarations.setdefault(name, []).append({ + "alias": bool(mark) or pointer, "pointer": pointer, "readonly": readonly, + "init": init}) + for match in USING_RE.finditer(body): + covered.add(match.start(1)) + + def kind_of(name): + if name in declarations: + return "alias" if any(b["alias"] for b in declarations[name]) else "value" + return params.get(name, "unknown") + + # A pointer alias is rebound by `p = expr;` - every binding is a place its later writes + # may land, so the rebinds are collected before any write is classified. + rebinds = set() + for match in ASSIGN_RE.finditer(body): + root, path, op = match.group(2), match.group(3), match.group(4) + if (op == "=" and not match.group(1) and not path.strip() and root in declarations + and any(b["pointer"] for b in declarations[root])): + end = body.find(";", match.end()) + declarations[root].append({ + "alias": True, "pointer": True, "init": body[match.end():end if end >= 0 else len(body)], + "readonly": any(b["readonly"] for b in declarations[root])}) + rebinds.add(match.start()) + covered.add(match.start(4)) + + def resolve_alias(name, through, seen): + """[(member, path)] for every binding of alias `name` a write can land through; LOCAL + for a binding to a value local; None in the list for a binding this script cannot + resolve. A binding no write can go through (const) contributes nothing.""" + if name in seen: + return [None] + seen = seen | {name} + out = [] + for binding in declarations.get(name, ()): + if binding["readonly"] and (not through or binding["pointer"]): + continue + text = (binding["init"] or "").strip() + while text[:1] in ("&", "*"): + text = text[1:].strip() + if strip_parens(text) in ("", "nullptr", "NULL", "0", "{}"): + continue # a null binding has no target; the rebind that gives it one counts + parts = lvalue_parts(text) + if parts is None: + out.append(None) + continue + root, names = parts + if root.startswith("m_"): + out.append((root, names)) + elif kind_of(root) == "value": + out.append(LOCAL) + elif kind_of(root) == "alias": + for resolved in resolve_alias(root, through, seen): + out.append(None if resolved is None else (resolved[0], resolved[1] + names)) + else: + out.append(None) + return out + + def record(root, names, through, what="writes"): + """A write (or a mutating call, `what`) to lvalue root+names. `through` says the + access went through `->` or `*`, which makes even a value local a pointer.""" + if root == "this": + if not names: + return + root, names = names[0], names[1:] + if root.startswith("m_"): + tokens.add("MEM:" + root) + tokens.add(field_token(root, names)) + return + kind = kind_of(root) + if kind == "value" and not through: + return + if kind in ("value", "alias"): + if kind == "value" and not declarations[root]: + return + for resolved in resolve_alias(root, through, set()): + if resolved is None: + taints.add("%s %s through '%s', which is bound to something this script " + "cannot resolve to a member" % (site, what, root)) + elif resolved is not LOCAL: + tokens.add("MEM:" + resolved[0]) + tokens.add(field_token(resolved[0], resolved[1] + names)) + return + if kind == "ref": + taints.add("%s %s through its reference parameter '%s'" % (site, what, root)) + return + taints.add("%s %s '%s', which it never declares - a member without the m_ prefix, a " + "global, or a call result; none of which this script can place" + % (site, what, root)) + + def designated(text, root_at): + """`{.f = v, .g = w}`: an aggregate initialiser, not a write to a field named f.""" + before = text[:root_at].rstrip() + if not before.endswith("."): + return False + return before[:-1].rstrip()[-1:] in ("{", ",") + + def collect(text, base): + """Every write in `text` (an absolute offset `base` into the body), recursing into + index expressions so that `m_a[m_count++] = v` credits m_count as well as m_a.""" + for match in ASSIGN_RE.finditer(text): + covered.add(base + match.start(4)) + if designated(text, match.start(2)) or base + match.start() in rebinds: + continue + path = match.group(3) + record(match.group(2), PATH_NAME_RE.findall(path), + bool(match.group(1)) or path.lstrip().startswith("->")) + if "[" in path: + collect(path, base + match.start(3)) + for match in PRE_INC_RE.finditer(text): + covered.add(base + match.start(1)) + path = match.group(4) + record(match.group(3), PATH_NAME_RE.findall(path), + bool(match.group(2)) or path.lstrip().startswith("->")) + if "[" in path: + collect(path, base + match.start(4)) + for match in METHOD_RE.finditer(text): + root, path, method = match.group(1), match.group(2), match.group(3) + if method in READ_ONLY_METHODS: + continue + if root in KEYWORDS and root != "this": + continue + # A call through `.` on a value local mutates the local; through `->` it + # mutates whatever the pointer points at. A call on a member-rooted lvalue is + # a whole write of it; on anything this script cannot place, a taint - the + # callee's own writes are still inherited by name, but the OBJECT they land + # in is unattributed, and for a callee with no body under the roots + # (push_back, reset) that is the whole write. + record(root, PATH_NAME_RE.findall(path), path.lstrip().startswith("->"), + what="calls %s()" % method) + for match in CALL_LVALUE_RE.finditer(text): + close = matching_paren(text, match.end() - 1) + if close is None: + continue + after = ASSIGN_AFTER_RE.match(text, close + 1) + if after is None: + continue + covered.add(base + after.start(1)) + root, path, separator, method = match.groups() + record(root, PATH_NAME_RE.findall(path), separator == "->", + what="writes through %s()" % method) + for match in BULK_RE.finditer(text): + close = matching_paren(text, match.end() - 1) + args = split_arguments(text[match.end():close]) if close is not None else [] + targets = args[:2] if match.group(1) == "swap" else args[:1] + for target in targets: + arg = strip_parens(target) + while arg[:1] in ("&", "*"): + arg = arg[1:].strip() + parts = lvalue_parts(arg) + if parts is None: + taints.add("%s passes '%s' to %s(), which this script cannot resolve to " + "an lvalue" % (site, arg.strip()[:40], match.group(1))) + continue + record(parts[0], parts[1], True, what="passes to %s()" % match.group(1)) + + collect(body, 0) + for match in ASSIGN_OP_RE.finditer(body): + if match.start() not in covered: + taints.add("%s has an assignment at offset %d this script could not attribute to " + "any lvalue (%s)" % (site, match.start(), + body[max(0, match.start() - 24):match.start() + 8] + .replace("\n", " ").strip())) + break + return tokens, taints + + def state_bodies(): - """({function name: [body text]}, {function name: {decline reason}}, [analysed paths]) - over MG_State/GLState and MG_Impl/Pipe, macros expanded first.""" + """({function name: [body text]}, {function name: [parameter text]}, + {function name: {taint reason}}, [analysed paths]) over MG_State/GLState and + MG_Impl/Pipe, macros expanded first.""" paths = [] for root in STATE_ROOTS: paths += source_files(root) @@ -533,32 +893,38 @@ def state_bodies(): pasting = frozenset(name for name, body in refused.items() if name not in NEVER_EXPAND and PASTE_RE.search(body)) bodies = {} + signatures = {} taints = {} for path in paths: relative = os.path.relpath(path, REPO_ROOT).replace(os.sep, "/") with open(path, "r", encoding="utf-8", errors="replace") as handle: expanded = expand_macros(mask_comments_and_strings(handle.read()), expandable) - for fn, start, end in function_bodies(expanded): + for fn, params, start, end in function_signatures(expanded): body = expanded[start:end] bodies.setdefault(fn, []).append(body) + signatures.setdefault(fn, []).append(params) sites = unmodelled_sites(body, relative, pasting) if sites: taints.setdefault(fn, set()) taints[fn] |= sites - return bodies, taints, paths + return bodies, signatures, taints, paths def writers_outside(analysed): """{MEM token: a file OUTSIDE the analysed roots that writes it}. The absence claim is only as good as the set of writers the analysis reads. Every .h/.cpp - under MobileGL/ that is not one of the analysed files is scanned with the same write - patterns, and a shutter member that turns up here is declined rather than answered. + under MobileGL/ that is not one of the analysed files is scanned with the direct write + patterns, and a shutter member that turns up here is undecided rather than answered. A file that DECLARES a member of that name is writing its own, not the frontend's - the backend's PipeInputs mirrors half of GLState's member names - so its writes do not count. That is the one judgement here, and it is the conservative way round only for names the - two sides share; a genuine outside writer of a frontend member does not declare it.""" + two sides share; a genuine outside writer of a frontend member does not declare it. + + This scan is textual and file-wide: it sees a direct member write and a member-rooted + one, not a write through a reference or a mutating call. It is the containment check for + code the analysis does not read, and that is the limit of what it can say.""" seen = set(analysed) out = {} for path in source_files(MOBILEGL_ROOT): @@ -575,27 +941,27 @@ def writers_outside(analysed): return out -def written_tokens(bodies, taints=None): +def written_tokens(bodies, signatures, taints=None, relative_names=None): """{function name: set of tokens it transitively WRITES}, a fixed point over call names. - A token is MEM:, FIELD:, AGG: or - TAINT:. The last is not a write: it is "this body contains something the analysis - does not model", and it rides the same fixed point so that a mutator which REACHES an - unreadable body inherits it and is declined rather than answered.""" + A token is MEM:, FIELD:., AGG: or + TAINT:. The last is not a write: it is "this body contains a write the analysis + could not attribute", and it rides the same fixed point so that a mutator which REACHES + such a body inherits it and is undecided rather than answered.""" taints = taints or {} reach = {} + own_taints = {} for name, bodylist in bodies.items(): tokens = set("TAINT:" + site for site in taints.get(name, ())) - for body in bodylist: + for index, body in enumerate(bodylist): tokens |= set("AGG:" + m.group(1) for m in AGGREGATE_RE.finditer(body)) - for match in MEMBER_WRITE_RE.finditer(body): - tokens.add("MEM:" + (match.group(1) or match.group(2))) - # A write THROUGH a member records the member AND the field: the reader side - # resolves an accessor to the member, so recording only the field is what kept - # the two halves from ever meeting. - for match in MEMBER_ROOTED_WRITE_RE.finditer(body): - tokens.add("MEM:" + match.group(1)) - tokens |= set("FIELD:" + m.group(1) for m in FIELD_WRITE_RE.finditer(body)) + params = signatures.get(name, [""] * len(bodylist))[index] + site = "%s()" % name if relative_names is None else relative_names.get(name, name) + written, tainted = extract_writes(body, params, site) + tokens |= written + tokens |= set("TAINT:" + reason for reason in tainted) + if any(token.startswith("TAINT:") for token in tokens): + own_taints[name] = set(token[6:] for token in tokens if token.startswith("TAINT:")) reach[name] = tokens changed = True rounds = 0 @@ -611,7 +977,7 @@ def written_tokens(bodies, taints=None): reach[name] |= reach[callee] if len(reach[name]) != before: changed = True - return reach + return reach, own_taints def aggregate_tokens(bodies, reach): @@ -640,30 +1006,77 @@ def expand_aggregates(tokens, aggregates): return out +def ternary_parts(expression): + """The arms of `c ? a : b` at depth 0 (the condition is dropped), or [expression].""" + depth = 0 + question = None + colon = None + i = 0 + while i < len(expression): + char = expression[i] + if char in "([{": + depth += 1 + elif char in ")]}": + depth -= 1 + elif depth == 0 and char == "?" and question is None: + question = i + elif (depth == 0 and char == ":" and question is not None and colon is None + and expression[i - 1:i] != ":" and expression[i + 1:i + 2] != ":"): + colon = i + i += 1 + if question is not None and colon is not None: + return [expression[question + 1:colon], expression[colon + 1:]] + return [expression] + + def resolve_reader(name, bodies, seen=None): - """The members an accessor returns, through however many one-line getters it delegates - to. An empty answer means the derivation could not follow it, which is reported as - UNVERIFIED rather than treated as "moves nothing".""" + """The two-level tokens an accessor returns, through however many one-line getters it + delegates to: MEM:m and FIELD:m. for `return m.leaf;`, FIELD:m.* for `return m;` + (every field), and a bare MEM:m - COARSE, no field information - for a member that is + read in some other shape (`return Mix(m_a, m_b);`). An empty answer means the derivation + could not follow it, which is reported as UNDECIDED rather than treated as "moves + nothing".""" seen = seen if seen is not None else set() if name in seen or name not in bodies: return set() seen.add(name) - members = set() + tokens = set() for body in bodies[name]: for match in RETURN_RE.finditer(body): - expression = match.group(1) - delegated = set() - for call in MEMBER_CALL_RE.finditer(expression): - delegated.add(call.group(1)) - members |= resolve_reader(call.group(2), bodies, seen) - for member in MEMBER_RE.finditer(expression): - if member.group(1) not in delegated: - members.add("MEM:" + member.group(1)) - return members + for arm in ternary_parts(match.group(1)): + text = strip_parens(arm) + parts = lvalue_parts(text) + if parts is not None and parts[0].startswith("m_"): + tokens.add("MEM:" + parts[0]) + tokens.add(field_token(parts[0], parts[1])) + continue + delegated = set() + for call in MEMBER_CALL_RE.finditer(text): + delegated.add(call.group(1)) + tokens |= resolve_reader(call.group(2), bodies, seen) + for member in MEMBER_RE.finditer(text): + if member.group(1) not in delegated: + tokens.add("MEM:" + member.group(1)) + return tokens + + +def narrow_tokens(tokens, field): + """`local.Field` where `local` holds an accessor's result: a whole-member read becomes a + read of that one field; a read already narrower than the member stays as it is (a deeper + path collapses to the member's first field, the granularity every token here has).""" + out = set() + for token in tokens: + if token.startswith("FIELD:") and token.endswith(".*"): + out.add("%s%s" % (token[:-1], field)) + else: + out.add(token) + return out def shutter_readers(): - """{MGPipeDirty bit name: set of reader tokens} out of Tracker.h's Update().""" + """{MGPipeDirty bit name: set of reader tokens} out of Tracker.h's Update(): CTX: + for a whole read of what the accessor returns, CTXFIELD:. for a read of + one field of it through a walk local.""" with open(TRACKER_PATH, "r", encoding="utf-8", errors="replace") as handle: masked = mask_comments_and_strings(handle.read()) body = None @@ -684,12 +1097,26 @@ def readers_of(expression, depth=0): return found found |= set("CTX:" + m.group(1) for m in CTX_READ_RE.finditer(expression)) found |= set("CTX:" + m.group(1) for m in ARROW_READ_RE.finditer(expression)) - found |= set("FIELD:" + m.group(1) for m in FIELD_READ_RE.finditer(expression)) - for word in WORD_RE.findall(expression): - if word in assignments and word not in ("now", "dirty"): - for assigned in assignments[word]: - if assigned != expression: - found |= readers_of(assigned, depth + 1) + narrowed = set() + for match in LOCAL_FIELD_RE.finditer(expression): + local, field = match.group(1), match.group(2) + if local not in assignments or local in ("now", "dirty"): + continue + narrowed.add(match.start(1)) + for assigned in assignments[local]: + if assigned != expression: + for token in readers_of(assigned, depth + 1): + if token.startswith("CTX:"): + found.add("CTXFIELD:%s.%s" % (token[4:], field)) + else: + found.add(token) + for match in WORD_RE.finditer(expression): + word = match.group(1) + if match.start(1) in narrowed or word in ("now", "dirty") or word not in assignments: + continue + for assigned in assignments[word]: + if assigned != expression: + found |= readers_of(assigned, depth + 1) return found out = {} @@ -738,7 +1165,7 @@ def dirty_bit_aliases(): def shutter_movers(readers, bodies, aliases): - """{NEW_* bit name: (tokens whose write moves that bit's shutter, every reader resolved?)}""" + """{NEW_* bit name: (two-level tokens the shutter reads, every reader resolved?)}""" out = {} for enumerator, tokens in readers.items(): bit = aliases.get(enumerator) @@ -747,10 +1174,11 @@ def shutter_movers(readers, bodies, aliases): movers = set() resolved = True for token in tokens: - if token.startswith("FIELD:"): - movers.add(token) - continue - members = resolve_reader(token[4:], bodies) + if token.startswith("CTXFIELD:"): + accessor, field = token[9:].rsplit(".", 1) + members = narrow_tokens(resolve_reader(accessor, bodies), field) + else: + members = resolve_reader(token[4:], bodies) if not members: resolved = False movers |= members @@ -768,78 +1196,179 @@ def dirty_bit_names(): def load_mapping(text=None): - """{mutator: answer} from DirtySurface.def, or from `text` for the self-test. An answer - keeps its "|"-joined spelling; answer_set() below is what compares them.""" + """({mutator: answer}, [duplicate mutators], {mutator: {bit}} marked known-undecided) from + DirtySurface.def, or from `text` for the self-test. An answer keeps its "|"-joined + spelling; answer_set() below is what compares them. The rows after the + MGP_DIRTY_SURFACE_UNDECIDED_LIST define are the marks; everything before it is the map.""" if text is None: with open(DEF_PATH, "r", encoding="utf-8", errors="replace") as handle: text = handle.read() + masked = mask_comments_and_strings(text) + split = UNDECIDED_LIST_RE.search(masked) + main_text = masked if split is None else masked[:split.start()] + mark_text = "" if split is None else masked[split.start():] rows = {} duplicates = [] - for match in ROW_RE.finditer(mask_comments_and_strings(text)): + for match in ROW_RE.finditer(main_text): mutator, answer = match.group(1), match.group(2) if mutator in rows: duplicates.append(mutator) rows[mutator] = answer - return rows, duplicates + undecided = {} + for match in ROW_RE.finditer(mark_text): + undecided.setdefault(match.group(1), set()).update(answer_set(match.group(2))) + return rows, duplicates, undecided def answer_set(answer): return {part.strip() for part in answer.split("|") if part.strip()} -def object_class_problems(mapping, bits, movers, moved, outside=None): - """The under-firing check for every answer the RenderState derivation cannot reach. +# ---- the match rule ----------------------------------------------------------------------- +SUPPORTED = "SUPPORTED" +UNDER_FIRING = "UNDER-FIRING" +COARSE = "COARSE" +UNDECIDED = "UNDECIDED" + + +def split_tokens(tokens): + """({member}, {member: {leaf}}) out of a set of two-level tokens.""" + members = set() + fields = {} + for token in tokens: + if token.startswith("MEM:"): + members.add(token[4:]) + elif token.startswith("FIELD:"): + member, leaf = token[6:].split(".", 1) + fields.setdefault(member, set()).add(leaf) + return members, fields + + +def match_writes(writer, shutter): + """SUPPORTED, COARSE or UNDER_FIRING for one (writer tokens, shutter tokens) pair. + + A member in common whose FIELD sets intersect (either side's `*` is every field) is + support. A member in common with no field information on one side is COARSE - the + analysis cannot say whether the bytes the shutter compares are the ones the writer + moved. No member in common, or every common member disjoint at field level with both + sides resolved, is UNDER_FIRING.""" + writer_members, writer_fields = split_tokens(writer) + shutter_members, shutter_fields = split_tokens(shutter) + coarse = False + for member in writer_members & shutter_members: + written = writer_fields.get(member) + read = shutter_fields.get(member) + if not written or not read: + coarse = True + continue + if "*" in written or "*" in read or written & read: + return SUPPORTED + return COARSE if coarse else UNDER_FIRING - Returns (problems, verified, declined) - `declined` names the rows the derivation REFUSED - to answer, with the reason. Every branch below that does not end in a verdict ends here - instead, because the alternative is what this gate did to NEW_PIXEL_PACK: turn "this - script cannot read that construct" into "that mutator writes nothing".""" + +def describe(tokens): + _, fields = split_tokens(tokens) + members, _ = split_tokens(tokens) + parts = [] + for member in sorted(members): + leaves = sorted(fields.get(member, ())) + parts.append("%s{%s}" % (member, ",".join(leaves)) if leaves else "%s{?}" % member) + return ", ".join(parts) + + +def derive_bit_answers(mapping, bits, movers, moved, outside=None): + """{(mutator, bit): (verdict, detail)} for every non-render bit answer in `mapping`. + + Every branch that does not end in SUPPORTED or UNDER_FIRING ends in COARSE or UNDECIDED, + because the alternative is what this gate did to NEW_PIXEL_PACK and to NEW_PATCH_STATE: + turn "this script cannot read that construct" into a verdict.""" outside = outside or {} - problems = [] - verified = 0 - declined = [] + out = {} render_bits = {RENDER_STATE_BIT, PIPELINE_STATE_BIT} for mutator in sorted(mapping): claimed = (answer_set(mapping[mutator]) & bits) - render_bits if not claimed: continue - if mutator not in moved: - declined.append("%s (no body found under MG_State/GLState or MG_Impl/Pipe to " - "derive from)" % mutator) - continue - blind = sorted(token[6:] for token in moved[mutator] if token.startswith("TAINT:")) for bit in sorted(claimed): + if mutator not in moved: + out[(mutator, bit)] = (UNDECIDED, "no body found under MG_State/GLState or " + "MG_Impl/Pipe to derive from") + continue shutter, resolved = movers.get(bit, (set(), False)) if not resolved: - declined.append("%s <- %s (Tracker.h's shutter for that bit reads something " - "this script cannot resolve to a member)" % (mutator, bit)) + out[(mutator, bit)] = (UNDECIDED, "Tracker.h's shutter for that bit reads " + "something this script cannot resolve to a member") + continue + blind = sorted(token[6:] for token in moved[mutator] if token.startswith("TAINT:")) + if blind: + out[(mutator, bit)] = (UNDECIDED, "the write analysis is not complete for this " + "mutator: %s" % blind[0]) + continue + verdict = match_writes(moved[mutator], shutter) + if verdict == SUPPORTED: + out[(mutator, bit)] = (SUPPORTED, "") continue - if moved[mutator] & shutter: - verified += 1 + if verdict == COARSE: + out[(mutator, bit)] = (COARSE, "a member in common, but no field information on " + "one side (shutter: %s; written: %s)" + % (describe(shutter), describe(moved[mutator]))) continue - # Everything past here would be an ABSENCE claim, so the gate first has to be - # able to say it read every writer of that shutter. Two things stop it, and each - # is a decline rather than a verdict. + # An ABSENCE claim: the gate first has to be able to say it read every writer of + # that shutter. unread = sorted(set(outside[token] for token in shutter if token in outside)) if unread: - declined.append("%s <- %s (that shutter's members are written outside the " - "analysed roots too, e.g. %s, so 'it writes nothing that " - "shutter reads' is not a fact this script has)" - % (mutator, bit, unread[0])) + out[(mutator, bit)] = (UNDECIDED, "that shutter's members are written outside " + "the analysed roots too, e.g. %s, so 'it writes " + "nothing that shutter reads' is not a fact this " + "script has" % unread[0]) continue - if blind: - declined.append("%s <- %s (it reaches %s, so the write analysis is not " - "complete for this mutator)" % (mutator, bit, blind[0])) - continue - problems.append( - "UNDER-FIRING answer %s for %s - it writes nothing %s's shutter reads " - "(shutter: %s), so a mutation through it publishes nothing" - % (bit, mutator, bit, ", ".join(sorted(t.split(":", 1)[1] for t in shutter)))) - return problems, verified, declined + out[(mutator, bit)] = (UNDER_FIRING, "it writes nothing %s's shutter reads (shutter: " + "%s; it writes: %s), so a mutation through it " + "publishes nothing" + % (bit, describe(shutter), describe(moved[mutator]) or "no member")) + return out + + +def object_class_problems(mapping, bits, movers, moved, outside=None, undecided_marks=None): + """The under-firing check for every answer the RenderState derivation cannot reach. + + Returns (problems, supported count, coarse count, [undecided lines]). A row that derives + UNDECIDED is a problem unless DirtySurface.def marks it so; a mark on a row that DOES + derive is stale and a problem too, so the marks cannot silently outlive their reason.""" + undecided_marks = undecided_marks or {} + verdicts = derive_bit_answers(mapping, bits, movers, moved, outside) + problems = [] + supported = 0 + coarse = 0 + undecided = [] + for (mutator, bit), (verdict, detail) in sorted(verdicts.items()): + marked = bit in undecided_marks.get(mutator, set()) + if verdict == SUPPORTED: + supported += 1 + elif verdict == COARSE: + coarse += 1 + elif verdict == UNDECIDED: + undecided.append("%s <- %s (%s)" % (mutator, bit, detail)) + if not marked: + problems.append("UNDECIDED answer %s for %s - %s; a bit answer the derivation " + "cannot decide is not a derived answer, so either widen the " + "analysis or list the row in MGP_DIRTY_SURFACE_UNDECIDED_LIST " + "with this reason" % (bit, mutator, detail)) + else: + problems.append("UNDER-FIRING answer %s for %s - %s" % (bit, mutator, detail)) + if marked and verdict != UNDECIDED: + problems.append("STALE undecided mark %s for %s - the derivation now decides it " + "(%s); delete the mark" % (bit, mutator, verdict)) + for mutator, marks in sorted(undecided_marks.items()): + for bit in sorted(marks): + if (mutator, bit) not in verdicts: + problems.append("STALE undecided mark %s for %s - no row claims that bit for " + "that mutator" % (bit, mutator)) + return problems, supported, coarse, undecided def check_mapping(mapping, duplicates, scanned, bits, publishers=None, movers=None, moved=None, - outside=None): + outside=None, undecided_marks=None): """Every problem the gate fails on, as a list of human-readable lines. BOTH directions: an unmapped mutator renders stale, and a row naming a mutator the scan no longer finds is a stale row that would keep a real hole looking covered. `publishers` is @@ -873,7 +1402,8 @@ def check_mapping(mapping, duplicates, scanned, bits, publishers=None, movers=No "none does" % (mapping[mutator], mutator, "|".join(sorted(partial)))) if movers is not None and moved is not None: - object_problems, _, _ = object_class_problems(mapping, bits, movers, moved, outside) + object_problems, _, _, _ = object_class_problems(mapping, bits, movers, moved, outside, + undecided_marks) problems += object_problems if publishers is None: @@ -915,7 +1445,53 @@ def check_mapping(mapping, duplicates, scanned, bits, publishers=None, movers=No X(RecordError, kReverseChannel) """ -SELF_TEST_STALE = None # built from the real def at run time +# The synthetic bodies the write-analysis controls run through the REAL extractor. Each is +# one shape the review found the analysis blind to, spelled the way RenderState.cpp spells it. +SELF_TEST_BODIES = """ +void Fixture::WriteAField() { + if (m_parameters.ClearColor == color) return; + m_parameters.ClearColor = color; + ++m_version; +} +void Fixture::WriteThroughRangeFor(BlendEquation color, BlendEquation alpha) { + Bool stateChanged = false; + for (auto& blendState : m_parameters.BlendStates) { + if (blendState.ColorEquation == color && blendState.AlphaEquation == alpha) continue; + blendState.ColorEquation = color; + blendState.AlphaEquation = alpha; + stateChanged = true; + } + if (!stateChanged) return; + BumpVersions(); +} +void Fixture::WriteThroughNamedReference(StencilFace face, Uint32 mask) { + StencilFaceState& state = m_parameters.StencilStates[GetStencilFaceIndex(face)]; + if (state.WriteMask == mask) return; + state.WriteMask = mask; + ++m_version; +} +void Fixture::WriteThroughUnresolvableReference(StencilFace face, Uint32 mask) { + auto& state = LookUpSomewhere(face); + state.WriteMask = mask; + ++m_version; +} +void Fixture::WriteThroughReferenceParameter(RenderStateParameters& target, Uint32 mask) { + target.ScissorBoxWrittenMask = mask; +} +void Fixture::WriteWholeMember(const RenderStateParameters& fresh) { + m_parameters = fresh; +} +""" + + +def analyse_snippet(text): + """{function name: (tokens, taints)} for a synthetic source text, through the same + extractor the real tree goes through (no macros: the text has none).""" + masked = mask_comments_and_strings(text) + out = {} + for name, params, start, end in function_signatures(masked): + out[name] = extract_writes(masked[start:end], params, "%s()" % name) + return out def scan_all(): @@ -937,38 +1513,36 @@ def scan_all(): return sources, per_file, distinct_all -def self_test(scanned, bits, publishers, movers, moved, outside=None): +def self_test(scanned, bits, publishers, movers, moved, outside=None, undecided_marks=None): """Canned negative controls. Each MUST trip; trips == 0 is an error, which is the shape check_include_closure.py and gen_pipe.py --self-test already use.""" trips = 0 failures = [] + def tripped(condition, label): + nonlocal trips + if condition: + trips += 1 + else: + failures.append("negative control %s did NOT trip" % label) + # 1. a mutator withheld from the def. - mapping, duplicates = load_mapping(SELF_TEST_WITHHELD) + mapping, duplicates, _ = load_mapping(SELF_TEST_WITHHELD) problems = check_mapping(mapping, duplicates, scanned, bits) - if any(p.startswith("UNMAPPED") for p in problems): - trips += 1 - else: - failures.append("negative control 1 (a withheld mutator) did NOT trip") + tripped(any(p.startswith("UNMAPPED") for p in problems), "1 (a withheld mutator)") # 2. a row naming a mutator the scan does not find. - real, real_duplicates = load_mapping() + real, real_duplicates, real_marks = load_mapping() with_ghost = dict(real) with_ghost["SetSomethingThatDoesNotExist"] = "kImmediate" problems = check_mapping(with_ghost, real_duplicates, scanned, bits) - if any(p.startswith("STALE") for p in problems): - trips += 1 - else: - failures.append("negative control 2 (a stale row) did NOT trip") + tripped(any(p.startswith("STALE row") for p in problems), "2 (a stale row)") # 3. a row whose answer is neither a dirty bit nor one of the documented non-bit answers. with_typo = dict(real) with_typo["RecordError"] = "NEW_TYPO_THAT_IS_NOT_A_BIT" problems = check_mapping(with_typo, real_duplicates, scanned, bits) - if any(p.startswith("BAD answer") for p in problems): - trips += 1 - else: - failures.append("negative control 3 (a bad answer) did NOT trip") + tripped(any(p.startswith("BAD answer") for p in problems), "3 (a bad answer)") # 4. THE CONTROL FOR THE TRUTH HALF, and it is the shape of the defect that was actually # in this file: a row claiming a publisher that fires on only some paths through the @@ -977,48 +1551,53 @@ def self_test(scanned, bits, publishers, movers, moved, outside=None): with_under_firing = dict(real) with_under_firing["SetCapability"] = PIPELINE_STATE_BIT problems = check_mapping(with_under_firing, real_duplicates, scanned, bits, publishers) - if any(p.startswith("UNDER-FIRING") for p in problems): - trips += 1 - else: - failures.append("negative control 4 (an under-firing render-state answer) did NOT trip") + tripped(any(p.startswith("UNDER-FIRING") for p in problems), + "4 (an under-firing render-state answer)") # 5. the other direction: a row that drops a publisher which DOES always fire. Silent # today, load-bearing the moment P3a builds a shutter from the file. with_missing = dict(real) with_missing["SetBlendEquation"] = RENDER_STATE_BIT problems = check_mapping(with_missing, real_duplicates, scanned, bits, publishers) - if any(p.startswith("MISSING publisher") for p in problems): - trips += 1 - else: - failures.append("negative control 5 (a dropped render-state publisher) did NOT trip") + tripped(any(p.startswith("MISSING publisher") for p in problems), + "5 (a dropped render-state publisher)") # 6. THE CONTROL FOR THE OBJECT-CLASS HALF, and it is again the shape of a defect that # was actually in this file: X(SetNamedTransformFeedbackBinding, NEW_SO_TARGETS) # named a shutter (the buffer-content aggregate mixed with the transform-feedback # generation) that GLContext::SetNamedTransformFeedbackBinding moves on no path - it # binds a BufferState binding point or writes a saved-bindings entry, and neither is - # a buffer CONTENT write or a BeginTransformFeedback. + # a buffer CONTENT write or a BeginTransformFeedback. The row has to be RED - and + # the analysis reaches (by name, through `Bind(`) a body it cannot fully read, so the + # red it can honestly print is "UNDECIDED and unmarked", never "supported". with_dead_shutter = dict(real) with_dead_shutter["SetNamedTransformFeedbackBinding"] = "NEW_SO_TARGETS" problems = check_mapping(with_dead_shutter, real_duplicates, scanned, bits, publishers, - movers, moved, outside) - if any("UNDER-FIRING answer NEW_SO_TARGETS" in p for p in problems): - trips += 1 - else: - failures.append("negative control 6 (an object-class answer whose shutter the mutator " - "never moves) did NOT trip") + movers, moved, outside, real_marks) + verdicts = derive_bit_answers(with_dead_shutter, bits, movers, moved, outside) + tripped(any(("NEW_SO_TARGETS for SetNamedTransformFeedbackBinding" in p + and p.startswith(("UNDER-FIRING", "UNDECIDED"))) for p in problems) + and verdicts[("SetNamedTransformFeedbackBinding", "NEW_SO_TARGETS")][0] != SUPPORTED, + "6 (an object-class answer whose shutter the mutator never moves)") + # 6b. the same family, on a mutator the analysis reads completely, so the red is the + # verdict itself: a texture-bind bump moves nothing NEW_GLOBAL_CONSTANTS compares. + with_dead_object = dict(real) + with_dead_object["BumpTextureBindGeneration"] = "NEW_GLOBAL_CONSTANTS" + problems = check_mapping(with_dead_object, real_duplicates, scanned, bits, publishers, + movers, moved, outside, real_marks) + tripped(any("UNDER-FIRING answer NEW_GLOBAL_CONSTANTS for BumpTextureBindGeneration" in p + for p in problems), + "6b (an object-class answer that is UNDER-FIRING outright)") # 7. the same check pointed at a value-class bit, so one passing control cannot stand in # for the whole family: a vertex-attribute default does not move the pixel-store bytes. with_wrong_bit = dict(real) with_wrong_bit["SetCurrentVertexAttributeInt"] = "NEW_PIXEL_PACK" problems = check_mapping(with_wrong_bit, real_duplicates, scanned, bits, publishers, - movers, moved, outside) - if any("UNDER-FIRING answer NEW_PIXEL_PACK" in p for p in problems): - trips += 1 - else: - failures.append("negative control 7 (a value-class answer whose shutter the mutator " - "never moves) did NOT trip") + movers, moved, outside, real_marks) + tripped(any("UNDER-FIRING answer NEW_PIXEL_PACK for SetCurrentVertexAttributeInt" in p + for p in problems), + "7 (a value-class answer whose shutter the mutator never moves)") # 8. A row that says kPulledPartialShutter and names no bit. The whole point of that # answer is to carry the bits that DO move, so an empty one is kPulledEveryVerb with @@ -1027,54 +1606,163 @@ def self_test(scanned, bits, publishers, movers, moved, outside=None): with_empty_partial = dict(real) with_empty_partial["SetPixelStoreParam"] = "kPulledPartialShutter" problems = check_mapping(with_empty_partial, real_duplicates, scanned, bits) - if any("has to NAME the bits" in p for p in problems): - trips += 1 - else: - failures.append("negative control 8 (kPulledPartialShutter naming no bit) did NOT trip") - - # 9. THE CONTROL FOR THE DECLINE PATH, and it is the shape of the defect this round fixed. - # The gate did not merely miss a check: it turned "this script cannot read that - # construct" into "that mutator writes nothing", and printed a verdict about - # RenderState.cpp that was false. So a mutator whose reachable text contains something - # the analysis does not model has to come out DECLINED and must NOT appear as a - # problem, whatever else is true of it. + tripped(any("has to NAME the bits" in p for p in problems), + "8 (kPulledPartialShutter naming no bit)") + + # 9. THE CONTROL FOR THE TAINT PATH. A mutator whose reachable text contains a write the + # analysis could not attribute has to come out UNDECIDED - never UNDER-FIRING, never + # SUPPORTED - and (9b) --check must refuse it as a derived answer unless the file + # marks it, because an unmarked UNDECIDED is a row the file claims and the gate + # cannot back. blinded = dict(moved) - blinded["SetCurrentVertexAttributeInt"] = {"TAINT:a canned unexpandable token paste"} - problems, _, declined = object_class_problems(with_wrong_bit, bits, movers, blinded, outside) - if (not any("SetCurrentVertexAttributeInt" in p for p in problems) - and any(d.startswith("SetCurrentVertexAttributeInt <- NEW_PIXEL_PACK") for d in declined)): - trips += 1 - else: - failures.append("negative control 9 (a mutator whose write analysis is incomplete) did " - "NOT decline - the gate answered a question it cannot answer") - - # 10. The other decline reason, and the other half of the same principle: a shutter whose - # members have a writer OUTSIDE the roots this analysis reads. "Nobody writes it" is - # not a claim about code the script never opened. + blinded["SetCurrentVertexAttributeInt"] = {"TAINT:a canned unattributable write"} + verdicts = derive_bit_answers(with_wrong_bit, bits, movers, blinded, outside) + tripped(verdicts.get(("SetCurrentVertexAttributeInt", "NEW_PIXEL_PACK"), ("", ""))[0] + == UNDECIDED, "9a (a tainted mutator is UNDECIDED, not a verdict)") + problems, _, _, undecided = object_class_problems(with_wrong_bit, bits, movers, blinded, + outside, {}) + tripped(any(p.startswith("UNDECIDED answer NEW_PIXEL_PACK for SetCurrentVertexAttributeInt") + for p in problems) + and not any("UNDER-FIRING" in p and "SetCurrentVertexAttributeInt" in p for p in problems) + and any(u.startswith("SetCurrentVertexAttributeInt <- NEW_PIXEL_PACK") for u in undecided), + "9b (an unmarked UNDECIDED row fails --check)") + problems, _, _, _ = object_class_problems(with_wrong_bit, bits, movers, blinded, outside, + {"SetCurrentVertexAttributeInt": {"NEW_PIXEL_PACK"}}) + tripped(not any("SetCurrentVertexAttributeInt" in p for p in problems), + "9c (a marked UNDECIDED row passes --check without a verdict)") + + # 10. The other absence blocker: a shutter whose members have a writer OUTSIDE the roots + # this analysis reads. "Nobody writes it" is not a claim about code the script never + # opened. hidden = dict(outside or {}) for token in movers.get("NEW_PIXEL_PACK", (set(), False))[0]: hidden[token] = "MobileGL/MG_Backend/a-file-this-analysis-never-reads.cpp" - problems, _, declined = object_class_problems(with_wrong_bit, bits, movers, moved, hidden) - if (not any("SetCurrentVertexAttributeInt" in p for p in problems) - and any("outside the analysed roots" in d for d in declined)): - trips += 1 - else: - failures.append("negative control 10 (a shutter member written outside the analysed " - "roots) did NOT decline - the gate claimed an absence over code it " - "never read") - - # THE POSITIVE CONTROL, and it is the row that was wrong: RenderState::SetPixelStoreParam - # writes NEW_PIXEL_PACK's shutter member sixteen times, through a token-pasting macro. If - # this ever reads as UNDER-FIRING again, the write analysis has lost the preprocessor and - # every absence claim in this gate is worthless. + verdicts = derive_bit_answers(with_wrong_bit, bits, movers, moved, hidden) + verdict, detail = verdicts.get(("SetCurrentVertexAttributeInt", "NEW_PIXEL_PACK"), ("", "")) + tripped(verdict == UNDECIDED and "outside the analysed roots" in detail, + "10 (a shutter member written outside the analysed roots)") + + # 11-14. THE WRITE ANALYSIS ITSELF, on synthetic bodies through the real extractor. The + # review's false verdict was "SetBlendEquation writes nothing NEW_PATCH_STATE's + # shutter reads", said about a body that writes m_parameters through a range-for + # reference; the collapse that followed was every setter "supporting" the patch + # bit because the shutter resolved to the whole struct. + snippet = analyse_snippet(SELF_TEST_BODIES) + patch_shutter = {"MEM:m_parameters", "FIELD:m_parameters.PatchVertices", + "FIELD:m_parameters.PatchDefaultOuterLevel", + "FIELD:m_parameters.PatchDefaultInnerLevel"} + blend_shutter = {"MEM:m_parameters", "FIELD:m_parameters.BlendStates"} + stencil_shutter = {"MEM:m_parameters", "FIELD:m_parameters.StencilStates"} + + # 11. a bit whose shutter fields are disjoint from a setter's writes -> UNDER-FIRING. + tokens, taints = snippet["WriteAField"] + tripped(not taints and "FIELD:m_parameters.ClearColor" in tokens + and match_writes(tokens, patch_shutter) == UNDER_FIRING, + "11 (a field write disjoint from the shutter's fields is UNDER-FIRING)") + + # 12. a write through a reference alias - both the range-for form and the named form - + # is credited to the member and the field it was bound to, and supports the bit. + tokens, taints = snippet["WriteThroughRangeFor"] + tokens2, taints2 = snippet["WriteThroughNamedReference"] + tripped(not taints and not taints2 + and "FIELD:m_parameters.BlendStates" in tokens and "MEM:m_parameters" in tokens + and "FIELD:m_parameters.StencilStates" in tokens2 + and match_writes(tokens, blend_shutter) == SUPPORTED + and match_writes(tokens2, stencil_shutter) == SUPPORTED + and match_writes(tokens, patch_shutter) == UNDER_FIRING, + "12 (a write through a reference alias is credited to its member and field)") + + # 13. an alias whose root cannot be resolved -> the body is tainted; and so is a write + # through a reference parameter. Neither may yield a verdict. + tokens, taints = snippet["WriteThroughUnresolvableReference"] + tokens2, taints2 = snippet["WriteThroughReferenceParameter"] + tainted_moved = {"Alias": tokens | set("TAINT:" + t for t in taints), + "Param": tokens2 | set("TAINT:" + t for t in taints2)} + verdicts = derive_bit_answers({"Alias": "NEW_PATCH_STATE", "Param": "NEW_PATCH_STATE"}, + {"NEW_PATCH_STATE"}, {"NEW_PATCH_STATE": (patch_shutter, True)}, + tainted_moved, {}) + tripped(taints and taints2 + and verdicts[("Alias", "NEW_PATCH_STATE")][0] == UNDECIDED + and verdicts[("Param", "NEW_PATCH_STATE")][0] == UNDECIDED, + "13 (an unresolvable alias or a reference parameter is UNDECIDED, never a verdict)") + + # 14. a whole-member write supports every field of that member. + tokens, taints = snippet["WriteWholeMember"] + tripped(not taints and "FIELD:m_parameters.*" in tokens + and match_writes(tokens, patch_shutter) == SUPPORTED + and match_writes(tokens, blend_shutter) == SUPPORTED + and match_writes(tokens, {"MEM:m_parameters", "FIELD:m_parameters.LineWidth"}) == SUPPORTED, + "14 (a whole-member write supports every field of the member)") + + # 15. the pixel-store token-paste setter: SetPixelStoreParam's sixteen writes are either + # expanded to FIELD:m_pixelStore{Pack,Unpack}Parameters. or the mutator is + # tainted; it is never UNDER-FIRING for the bit it moves. + pixel = moved.get("SetPixelStoreParam", set()) + expanded = {"FIELD:m_pixelStorePackParameters.Alignment", + "FIELD:m_pixelStoreUnpackParameters.Alignment", + "FIELD:m_pixelStorePackParameters.LSBFirst", + "FIELD:m_pixelStoreUnpackParameters.SwapBytes"} <= pixel + tainted = any(token.startswith("TAINT:") for token in pixel) with_the_bit = dict(real) with_the_bit["SetPixelStoreParam"] = "NEW_PIXEL_PACK" - problems, _, declined = object_class_problems(with_the_bit, bits, movers, moved, outside) - if any("SetPixelStoreParam" in line for line in problems + declined): - failures.append("the positive control (SetPixelStoreParam DOES write " - "NEW_PIXEL_PACK's shutter) did not pass: %s" - % "; ".join(line for line in problems + declined + verdicts = derive_bit_answers(with_the_bit, bits, movers, moved, outside) + verdict = verdicts.get(("SetPixelStoreParam", "NEW_PIXEL_PACK"), ("", ""))[0] + tripped((expanded or tainted) and verdict != UNDER_FIRING + and "FIELD:paramNameTail" not in pixel and "FIELD:m_parameters.paramNameTail" not in pixel, + "15 (the token-pasted pixel-store writes are expanded to their fields, or undecided)") + + # 16. THE NEW_PATCH_STATE ANALOGUE OF CONTROL 7, the row that was green at 9ff6061c: + # glClearColor does not move the patch trio, so a row that says it does has to be + # red - not "supported because both touch m_parameters". + with_patch = dict(real) + with_patch["SetClearColor"] = "NEW_RENDER_STATE|NEW_PATCH_STATE" + problems = check_mapping(with_patch, real_duplicates, scanned, bits, publishers, movers, + moved, outside, real_marks) + tripped(any("UNDER-FIRING answer NEW_PATCH_STATE for SetClearColor" in p for p in problems), + "16 (a whole-struct reader no longer makes every setter support NEW_PATCH_STATE)") + + # 17. a member in common with no field information on one side is COARSE: reported, not + # counted as derived, and not a problem. + coarse_movers = {"NEW_PATCH_STATE": ({"MEM:m_parameters"}, True)} + verdicts = derive_bit_answers({"WriteAField": "NEW_PATCH_STATE"}, {"NEW_PATCH_STATE"}, + coarse_movers, {"WriteAField": snippet["WriteAField"][0]}, {}) + problems, supported, coarse, _ = object_class_problems( + {"WriteAField": "NEW_PATCH_STATE"}, {"NEW_PATCH_STATE"}, coarse_movers, + {"WriteAField": snippet["WriteAField"][0]}, {}, {}) + tripped(verdicts[("WriteAField", "NEW_PATCH_STATE")][0] == COARSE and supported == 0 + and coarse == 1 and not problems, + "17 (a member-level match without fields is COARSE, never derived)") + + # 18. a stale undecided mark - a row the derivation DOES decide - is a problem, so the + # marks cannot outlive their reason. + problems, _, _, _ = object_class_problems(with_the_bit, bits, movers, moved, outside, + {"SetPixelStoreParam": {"NEW_PIXEL_PACK"}}) + tripped(any(p.startswith("STALE undecided mark NEW_PIXEL_PACK for SetPixelStoreParam") + for p in problems), "18 (a stale undecided mark)") + + # THE POSITIVE CONTROLS. (a) The row that was wrong in round 3: SetPixelStoreParam writes + # NEW_PIXEL_PACK's shutter member sixteen times, through a token-pasting macro; it has + # to be SUPPORTED at field level. (b) The seven setters round 4's review named, which + # write m_parameters only through a reference alias: each has to carry the member and + # the field the alias was bound to, with no taint. + problems, _, _, undecided = object_class_problems(with_the_bit, bits, movers, moved, outside, {}) + if any("SetPixelStoreParam" in line for line in problems + undecided): + failures.append("the positive control (SetPixelStoreParam DOES write NEW_PIXEL_PACK's " + "shutter) did not pass: %s" + % "; ".join(line for line in problems + undecided if "SetPixelStoreParam" in line)) + alias_setters = {"SetBlendFunc": "BlendStates", "SetBlendFuncIndexed": "BlendStates", + "SetBlendEquation": "BlendStates", "SetBlendEquationIndexed": "BlendStates", + "SetStencilFunc": "StencilStates", "SetStencilMask": "StencilStates", + "SetStencilOp": "StencilStates"} + for setter, field in sorted(alias_setters.items()): + tokens = moved.get(setter, set()) + if ("MEM:m_parameters" not in tokens or "FIELD:m_parameters.%s" % field not in tokens + or any(token.startswith("TAINT:") for token in tokens)): + failures.append("the positive control (%s writes m_parameters.%s through a " + "reference alias) did not pass: %s" + % (setter, field, sorted(t for t in tokens + if t.startswith(("TAINT:", "FIELD:m_parameters"))))) for failure in failures: print("dirty-surface self-test: %s" % failure) @@ -1084,8 +1772,10 @@ def self_test(scanned, bits, publishers, movers, moved, outside=None): return 1 if failures: return 1 - print("dirty-surface self-test: %d negative controls, all tripped; positive control OK " - "(SetPixelStoreParam's sixteen token-pasted writes are read)" % trips) + print("dirty-surface self-test: %d negative controls, all tripped; positive controls OK " + "(SetPixelStoreParam's sixteen token-pasted writes are read at field level, and the " + "seven reference-alias setters of RenderState.cpp resolve to m_parameters' fields)" + % trips) return 0 @@ -1113,8 +1803,8 @@ def main(): publishers = render_state_publishers() if not publishers: sys.exit("could not derive any RenderState setter out of %s" % RENDER_STATE_PATH) - bodies, taints, analysed = state_bodies() - reach = written_tokens(bodies, taints) + bodies, signatures, taints, analysed = state_bodies() + reach, own_taints = written_tokens(bodies, signatures, taints) outside = writers_outside(analysed) aggregates = aggregate_tokens(bodies, reach) moved = {name: expand_aggregates(tokens, aggregates) for name, tokens in reach.items()} @@ -1128,47 +1818,54 @@ def main(): "enum and its name table have drifted apart" % TRACKER_PATH) movers = shutter_movers(readers, bodies, aliases) - if args.self_test: - return self_test(distinct_all, bits, publishers, movers, moved, outside) + mapping, duplicates, undecided_marks = load_mapping() - mapping, duplicates = load_mapping() + if args.self_test: + return self_test(distinct_all, bits, publishers, movers, moved, outside, undecided_marks) if args.check: problems = check_mapping(mapping, duplicates, distinct_all, bits, publishers, movers, - moved, outside) + moved, outside, undecided_marks) for problem in problems: print("dirty-surface: %s" % problem) if problems: print("dirty-surface: %d problem(s); the mapping must cover every mutator the scan " - "finds, in both directions, and every render-state answer must be the one " - "RenderState.cpp actually publishes" % len(problems)) + "finds, in both directions, every render-state answer must be the one " + "RenderState.cpp actually publishes, and every other bit answer must be one " + "the derivation can decide" % len(problems)) return 1 derived = sum(1 for m in mapping if m in publishers) - _, verified, declined = object_class_problems(mapping, bits, movers, moved, outside) + _, supported, coarse, undecided = object_class_problems(mapping, bits, movers, moved, + outside, undecided_marks) prose = sorted(m for m in mapping if not (answer_set(mapping[m]) & bits)) print("dirty-surface: %d mutators, all mapped, no stale rows; %d render-state answers " "derived from RenderState.cpp and matching" % (len(mapping), derived)) # What the gate did NOT check is part of its output, or "all mapped" reads as "all # verified" - which it is not, and was not for two rows through a whole review. - print("dirty-surface: %d other bit answers derived from their shutter in Tracker.h " - "(under-firing only); %d declined; %d rows carry a prose answer (%s) that no " - "derivation checks" - % (verified, len(declined), len(prose), + print("dirty-surface: %d other (mutator, bit) answers derived from their shutter in " + "Tracker.h - supported at field level, under-firing only; %d COARSE (a member in " + "common, no field information, not counted); %d UNDECIDED (listed in " + "MGP_DIRTY_SURFACE_UNDECIDED_LIST, not counted); %d rows carry a prose answer " + "(%s) that no derivation checks" + % (supported, coarse, len(undecided), len(prose), ", ".join(sorted(set(a for m in prose for a in answer_set(mapping[m])))))) - for row in declined: - print("dirty-surface: DECLINED, no verdict: %s" % row) + for row in undecided: + print("dirty-surface: UNDECIDED, no verdict: %s" % row) # The absence claim's own footprint, printed rather than assumed: what the write - # analysis read, and the one place it is still coarse. + # analysis read, and where it is still coarse. print("dirty-surface: the under-firing half read %d function bodies across %d files " - "under %s, macros expanded first; %d of those bodies carry a construct it does " - "not model and taint whatever reaches them; %d members are written outside " - "those roots and any shutter that names one is declined; a FIELD token is " - "matched by name and not scoped to a type, so it only ever WIDENS what a " - "mutator is credited with writing" + "under %s, macros expanded first, both sides resolved to MEM: + " + "FIELD:.; %d of those bodies carry a write it could not attribute " + "and taint whatever reaches them, %d of the %d mutators reach one; %d members are " + "written outside those roots and any absence claim over one is undecided; a " + "mutating call on a member-rooted lvalue and a call resolved by name both only " + "WIDEN what a mutator is credited with, and a FIELD token is not scoped to a type" % (sum(len(v) for v in bodies.values()), len(analysed), " + ".join(os.path.relpath(root, REPO_ROOT).replace(os.sep, "/") for root in STATE_ROOTS), - len(taints), len(outside))) + len(own_taints), + sum(1 for m in mapping if any(t.startswith("TAINT:") for t in moved.get(m, ()))), + len(mapping), len(outside))) return 0 total_functions = 0 From 96c544514e9065983701be223f1b21b207e7a914 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:06:22 -0400 Subject: [PATCH 116/529] [Refactor] (Magma): key the pipeline memo on the render-state CSO handle and stop recomputing a hash the client already computed - P2 D12.1. GetOrCreatePipeline's memo compared a VALUE hash of the pipeline-relevant fixed-function state that Magma recomputed itself. After P2 the CLIENT hashes exactly those bytes when it mints a content-addressed render-state CSO (MGPipeComputePipelineSubsetHash over the seven pipeline chunks), so the bound CSO handle IS that key and ComputePipelineStateHash was doing the boundary's work twice. The client's pipeline subset is a strict SUPERSET of the 24 members the hash read, so the handle discriminates at least as finely as the hash it replaces. - renderPassHash STAYS in the key, and that is load-bearing rather than conservative: ComputePipelineStateHash was never a pure function of RenderStateParameters - its signature took colorAttachmentCount and rasterizationSamples, and ResolveEffectiveSampleMask reads the latter - so those two render-pass facts have to stay separated by something. entry.renderPassHash already separates them (the pass hash folds each attachment's sample count and the attachment set), which is why collapsing the state half onto a handle loses no discrimination. ResolveEffectiveSampleMask is NOT deleted with the hash: it is a payload computation, and it keeps reading Multisample / SampleMask / SampleMaskValue out of the working block. - Both memo probes are re-keyed, not just the full path's: TrySetupDrawFastPath carries its own copy of the probe, and a fast path that keyed differently from the full path would hand back a pipeline the full path would not have matched. - The arm is chosen at runtime, per D14: kMGPipeSubsystemRenderState in the MOBILEGL_PIPE_PUSH bitmask AND a non-null bound CSO. The second half is not belt and braces - a tree whose tracker does not emit create/bind_render_state yet has no handle to key on, and keying every draw on the null handle would alias every render state onto one memo entry. Falling into the pre-handle arm with Features.PipeLegacyMemos=0 is Fatal{PipeLegacyMemosDisabled}, so HandleRecycleScenario.Handles cannot go green by quietly running the old code. - ComputePipelineStateHash and its five cached-hash members (m_pipelineStateHash{,Valid, Version,ColorCount,SampleCount}) survive only under MOBILEGL_PIPE_LEGACY_MEMOS, which a pull build forces ON: they exist purely to avoid re-hashing, and the handle arm never hashes. InvalidatePipelineMemo loses them on the same condition. - New MagmaPipeArms.h holds the two-switch arm selector shared by the P2 Magma re-keys. - G1, pull build, symbol_report --threshold 0 against ~/w7/p2-before-libMobileGL.so: 0 added, 0 removed, 0 renamed, 4 resized - and all four are the CONTRACT commit's (RenderState::{RenderState,SetCapability,IsCapabilityEnabled} and _GLOBAL__sub_I_DirectGLES.cpp). This commit adds none: every edit is inside a MOBILEGL_PIPE_PUSH arm and the pre-handle statements are left where they stood, which is why ResolveBoundRenderStateCso is push-only rather than a shared helper - an earlier shared-helper shape moved 104 bytes of GetOrCreatePipeline around for no behaviour change and the gate saw it. --- .../DirectVulkan/Renderer/MagmaPipeArms.h | 63 ++++++++++++ .../DirectVulkan/Renderer/VulkanRenderer.cpp | 99 +++++++++++++++---- .../DirectVulkan/Renderer/VulkanRenderer.h | 99 ++++++++++++++++--- 3 files changed, 229 insertions(+), 32 deletions(-) create mode 100644 MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h new file mode 100644 index 000000000..95ee39c7a --- /dev/null +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h @@ -0,0 +1,63 @@ +// MobileGL - MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +#include +#if MOBILEGL_PIPE_PUSH +// kMGPipeSubsystem* - the runtime bitmask's named bits. Push-only, so the pull build's +// include graph is unchanged. +#include +#endif + +#include + +// Magma's arm selector for the P2 Track H / render-state re-keys (P2 brief D14). +// +// Two switches decide which arm a re-keyed site runs, and they are NOT the same switch: +// +// MOBILEGL_PIPE_PUSH (compile) - is the pushed state there to be keyed on at all +// Features.PipePush (runtime bitmask) - is THIS subsystem migrated in THIS run +// MOBILEGL_PIPE_LEGACY_MEMOS (compile) - is the pre-handle arm compiled beside it +// Features.PipeLegacyMemos (runtime) - may the pre-handle arm be ENTERED in this run +// +// ARCHITECTURE.md 9.6's point: once a handle wave lands, a clear MOBILEGL_PIPE_PUSH bit is +// only a valid A/B while the legacy arm is still compiled, because with the bit clear the +// backend would otherwise still run the re-keyed code. So a clear bit selects the legacy +// arm, and a run that has explicitly disabled the legacy arm may not fall into it. +// +// The whole header is inert in a pull build: MOBILEGL_PIPE_PUSH is 0 there, every helper +// below is behind it, and the pull build's translation units are byte-identical (G1). +namespace MobileGL::MG_Backend::DirectVulkan { + +#if MOBILEGL_PIPE_PUSH + // Is `subsystemBit` (MG_Pipe/MGPipe.h's kMGPipeSubsystem*) migrated in this run? + inline Bool MagmaPipeSubsystemOn(Uint64 subsystemBit) { + return (MG_Config::Features.PipePush & subsystemBit) != 0; + } + + // The legacy arm is about to be entered. Features.PipeLegacyMemos=0 is the operator + // asserting "the pre-handle arm is never entered in this run", which is the lever + // HandleRecycleScenario.Handles pulls (P2 brief D18): entering it anyway would make + // that arm green for the wrong reason, so it is Fatal rather than a fallback. + inline void MagmaPipeRequireLegacyArm(const char* site) { +#if MOBILEGL_PIPE_LEGACY_MEMOS + if (MG_Config::Features.PipeLegacyMemos) return; + MGLOG_F("MGPipe: Fatal{PipeLegacyMemosDisabled} %s wanted the pre-handle arm but " + "MOBILEGL_PIPE_LEGACY_MEMOS=0 forbids entering it", + site); +#else + MGLOG_F("MGPipe: Fatal{PipeLegacyMemosDisabled} %s wanted the pre-handle arm but this " + "build did not compile one (cmake -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF)", + site); +#endif + std::abort(); + } +#endif // MOBILEGL_PIPE_PUSH +} // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 888696299..12a4fadd3 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -4818,6 +4818,7 @@ void main() { return MGB_CTX->GetRenderStateParameters().SampleMaskValue; } +#if MOBILEGL_PIPE_LEGACY_MEMOS Uint64 VulkanRenderer::ComputePipelineStateHash(Uint32 colorAttachmentCount, VkSampleCountFlagBits rasterizationSamples) const { // One bulk fetch instead of ~17 per-field accessor calls into MG_State: every @@ -4908,6 +4909,8 @@ void main() { } return hash; } +#endif // MOBILEGL_PIPE_LEGACY_MEMOS + // A program that runs a geometry shader AND captures transform feedback. Both halves are // link-time properties, so this is safe to fold into a pipeline keyed on the program hash. @@ -4983,23 +4986,49 @@ void main() { // per-draw state flips (GL_BLEND toggles) would otherwise miss entries the memo holds. // The version only guards recomputing the hash - unchanged version, unchanged bytes. const Uint renderStateVersion = MGB_CTX->GetPipelineStateVersion(); - if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion || - m_pipelineStateHashColorCount != renderPassEntry.colorAttachmentCount || - m_pipelineStateHashSampleCount != renderPassEntry.sampleCount) { - m_pipelineStateHash = - ComputePipelineStateHash(renderPassEntry.colorAttachmentCount, renderPassEntry.sampleCount); - m_pipelineStateHashVersion = renderStateVersion; - m_pipelineStateHashColorCount = renderPassEntry.colorAttachmentCount; - m_pipelineStateHashSampleCount = renderPassEntry.sampleCount; - m_pipelineStateHashValid = true; - } - const Uint64 pipelineStateHash = m_pipelineStateHash; +#if MOBILEGL_PIPE_PUSH + // P2 D12.1. Non-null means the client's render-state CSO handle is this draw's state + // key and the hash below is not computed at all; null means the pre-handle arm. The + // two arms' entries can never match each other: the handle arm stores hash 0 and a + // real handle, the legacy arm a real hash and the null handle, and the probe compares + // both components. + const MG_Pipe::MGPipeHandle renderStateCso = ResolveBoundRenderStateCso(); +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS +#if MOBILEGL_PIPE_PUSH + if (MG_Pipe::MGPipeHandleIsNull(renderStateCso)) +#endif + { + if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion || + m_pipelineStateHashColorCount != renderPassEntry.colorAttachmentCount || + m_pipelineStateHashSampleCount != renderPassEntry.sampleCount) { + m_pipelineStateHash = + ComputePipelineStateHash(renderPassEntry.colorAttachmentCount, renderPassEntry.sampleCount); + m_pipelineStateHashVersion = renderStateVersion; + m_pipelineStateHashColorCount = renderPassEntry.colorAttachmentCount; + m_pipelineStateHashSampleCount = renderPassEntry.sampleCount; + m_pipelineStateHashValid = true; + } + } +#endif + const Uint64 pipelineStateHash = +#if MOBILEGL_PIPE_PUSH + !MG_Pipe::MGPipeHandleIsNull(renderStateCso) ? 0 : +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS + m_pipelineStateHash; +#else + 0; +#endif for (Uint32 i = 0; i < m_pipelineMemoCount; ++i) { const PipelineMemoEntry& entry = m_pipelineMemo[i]; if (entry.pipeline != VK_NULL_HANDLE && entry.mode == mode && entry.programHash == programObj.hash && entry.vertexInputHash == vertexLayoutHash && entry.renderPassHash == renderPassHash && entry.pipelineStateHash == pipelineStateHash && +#if MOBILEGL_PIPE_PUSH + entry.renderStateCso == renderStateCso && +#endif entry.primitiveRestartEnable == primitiveRestartEnable && entry.transformFlags == transformFlags) { if (MG_Util::PipeStats::Enabled()) { @@ -5668,6 +5697,9 @@ void main() { entry.vertexInputHash = vertexLayoutHash; entry.renderPassHash = renderPassHash; entry.pipelineStateHash = pipelineStateHash; +#if MOBILEGL_PIPE_PUSH + entry.renderStateCso = renderStateCso; +#endif entry.primitiveRestartEnable = primitiveRestartEnable; entry.transformFlags = transformFlags; entry.pipeline = pipeline; @@ -6332,16 +6364,38 @@ void main() { // what lets a per-draw GL_BLEND toggle alternate between two memo entries // instead of missing forever on a monotonic version. A miss falls through // to the full lookup. - if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion || - m_pipelineStateHashColorCount != snap.renderPassColorCount || - m_pipelineStateHashSampleCount != snap.renderPassSampleCount) { - m_pipelineStateHash = - ComputePipelineStateHash(snap.renderPassColorCount, snap.renderPassSampleCount); - m_pipelineStateHashVersion = renderStateVersion; - m_pipelineStateHashColorCount = snap.renderPassColorCount; - m_pipelineStateHashSampleCount = snap.renderPassSampleCount; - m_pipelineStateHashValid = true; +#if MOBILEGL_PIPE_PUSH + // Same arm selector as GetOrCreatePipeline's probe (P2 D12.1); this site is the + // fast path's copy of it, and the two must key identically or the fast path would + // hand back a pipeline the full path would not have matched. + const MG_Pipe::MGPipeHandle renderStateCso = ResolveBoundRenderStateCso(); +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS +#if MOBILEGL_PIPE_PUSH + if (MG_Pipe::MGPipeHandleIsNull(renderStateCso)) +#endif + { + if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion || + m_pipelineStateHashColorCount != snap.renderPassColorCount || + m_pipelineStateHashSampleCount != snap.renderPassSampleCount) { + m_pipelineStateHash = + ComputePipelineStateHash(snap.renderPassColorCount, snap.renderPassSampleCount); + m_pipelineStateHashVersion = renderStateVersion; + m_pipelineStateHashColorCount = snap.renderPassColorCount; + m_pipelineStateHashSampleCount = snap.renderPassSampleCount; + m_pipelineStateHashValid = true; + } } +#endif + const Uint64 pipelineStateHash = +#if MOBILEGL_PIPE_PUSH + !MG_Pipe::MGPipeHandleIsNull(renderStateCso) ? 0 : +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS + m_pipelineStateHash; +#else + 0; +#endif const auto memoTransformFlags = ProgramFactory::CompileOptionFlags(snap.resolvedTransformFlags); for (Uint32 i = 0; i < m_pipelineMemoCount; ++i) { @@ -6349,7 +6403,10 @@ void main() { if (entry.pipeline != VK_NULL_HANDLE && entry.mode == mode && entry.programHash == programObj.hash && entry.vertexInputHash == vaoLayoutHash && entry.renderPassHash == snap.renderPassHash && - entry.pipelineStateHash == m_pipelineStateHash && + entry.pipelineStateHash == pipelineStateHash && +#if MOBILEGL_PIPE_PUSH + entry.renderStateCso == renderStateCso && +#endif entry.primitiveRestartEnable == drawPrimitiveRestartEnable && entry.transformFlags == memoTransformFlags) { pipeline = entry.pipeline; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index f8498a3af..46a8a1629 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -9,6 +9,7 @@ #pragma once #include "Config.h" #include "FrameContext.h" +#include "MagmaPipeArms.h" #include "PipelineFactory.h" #include "ProgramFactory.h" #include "SwapchainObject.h" @@ -24,7 +25,13 @@ #include "MG_Util/Math/VectorTypes.h" #include #include +#include #include +#if MOBILEGL_PIPE_PUSH +// The applier's CSO store: MGPipeApplier().BoundRenderStateCso is what the pipeline memo +// keys on after P2 (D12.1). Push-only, so the pull build's include graph is unchanged. +#include +#endif #include #include "../VkIncludes.h" @@ -820,12 +827,31 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint64 programHash = 0; Uint64 vertexInputHash = 0; Uint64 renderPassHash = 0; - // VALUE hash of the pipeline-relevant fixed-function state (see - // ComputePipelineStateHash), not the monotonic pipeline-state version: - // the version never repeats, so a per-draw GL_BLEND toggle would miss - // all entries forever even though the state alternates between two - // values the memo already holds. + // The PRE-HANDLE arm's key component (P2 brief D12.1), and 0 in every entry the + // handle arm mints. VALUE hash of the pipeline-relevant fixed-function state (see + // ComputePipelineStateHash), not the monotonic pipeline-state version: the version + // never repeats, so a per-draw GL_BLEND toggle would miss all entries forever even + // though the state alternates between two values the memo already holds. Uint64 pipelineStateHash = 0; +#if MOBILEGL_PIPE_PUSH + // The HANDLE arm's key component, and the whole of D12.1: the CLIENT already + // hashed the pipeline subset of RenderStateParameters and minted a content- + // addressed CSO for it (MG_Pipe/MGPipeRenderStateSpans.h, MG_Impl/Pipe/CsoCache), + // so re-hashing the same 396 bytes here was work the boundary had already done. + // Two draws share a CSO handle exactly when their pipeline bytes are equal, and + // the client's subset is a strict SUPERSET of what ComputePipelineStateHash read, + // so the handle discriminates at least as finely as the hash it replaces. + // + // renderPassHash STAYS beside it and is what keeps this key complete: the CSO + // carries GL state only, while colorAttachmentCount and the rasterization sample + // count - which ComputePipelineStateHash folded in through its signature and + // through ResolveEffectiveSampleMask - are render-pass facts that the render-pass + // hash already separates. + // + // Null in an entry minted by the legacy arm, so entries of the two arms can never + // match each other: the compare below tests BOTH components. + MG_Pipe::MGPipeHandle renderStateCso = MG_Pipe::kMGPipeNullHandle; +#endif ProgramFactory::CompileOptionFlags transformFlags = {}; // Baked into the pipeline (PipelineFactory::ComputeHash mixes it), and NOT derivable // from anything else in this key: it depends on whether the draw is indexed and on the @@ -839,19 +865,66 @@ namespace MobileGL::MG_Backend::DirectVulkan { PipelineMemoEntry m_pipelineMemo[kPipelineMemoSize]; Uint32 m_pipelineMemoCount = 0; Uint32 m_pipelineMemoNext = 0; - // Hash of every fixed-function GL state the pipeline payload reads that the - // memo key's other fields (mode / program / vertex input / render pass / - // transform flags) do not already pin down. Equal hash under an equal rest - // of key => byte-identical PipelineCreatePayload. Cached per pipeline-state + +#if MOBILEGL_PIPE_PUSH + // P2 D12.1's arm selector, and the whole of the pipeline memo's re-key. Returns the + // render-state CSO this draw is keyed on, or the null handle when the pre-handle arm + // is the one that runs. + // + // Under the handle arm the memo's state key IS this handle. The client hashed those + // 396 pipeline bytes when it minted the CSO (MGPipeComputePipelineSubsetHash), so + // recomputing an overlapping hash here was work the boundary had already done; the + // client's pipeline subset is a strict SUPERSET of what ComputePipelineStateHash read, + // so the handle discriminates at least as finely as the hash it replaces. What the + // handle does NOT carry is the render-pass side - colorAttachmentCount and the + // rasterization sample count, which ComputePipelineStateHash folded in through its + // signature and through ResolveEffectiveSampleMask - and that is exactly why + // entry.renderPassHash stays in the key beside it. + // + // The arm is live only when the render-state subsystem is migrated in this run AND the + // client has actually bound a CSO. The second half is not belt and braces: a tree whose + // tracker does not emit create/bind_render_state yet has no handle to key on, and + // keying every draw on the null handle would alias every render state onto one entry. + // + // Push-only by construction: the pull build does not compile this function at all, so + // its two callers are statement-for-statement what they were (G1). + MG_Pipe::MGPipeHandle ResolveBoundRenderStateCso() const { + if (!MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemRenderState)) { + MagmaPipeRequireLegacyArm("GetOrCreatePipeline"); + return MG_Pipe::kMGPipeNullHandle; + } + const MG_Pipe::MGPipeHandle boundCso = MG_Pipe::MGPipeApplier().BoundRenderStateCso; + if (MG_Pipe::MGPipeHandleIsNull(boundCso)) { + MGLOG_D_ONCE("MGPipe: kMGPipeSubsystemRenderState is on but no render-state CSO is " + "bound; the pipeline memo falls back to the pre-handle state hash"); + MagmaPipeRequireLegacyArm("GetOrCreatePipeline"); + } + return boundCso; + } +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS + // THE PRE-HANDLE ARM (P2 brief D12.1 / D14). Hash of every fixed-function GL state the + // pipeline payload reads that the memo key's other fields (mode / program / vertex + // input / render pass / transform flags) do not already pin down. Equal hash under an + // equal rest of key => byte-identical PipelineCreatePayload. Cached per pipeline-state // version: the version is monotonic and bumps on every pipeline-state // change, so an unchanged (version, colorAttachmentCount) proves the state // bytes are unchanged and the hash can be reused without re-reading them. + // + // The handle arm computes none of this: the client hashed the same bytes when it + // minted the CSO, so all five cached-hash members below exist only to avoid a + // re-hash the handle arm never performs. Uint64 ComputePipelineStateHash(Uint32 colorAttachmentCount, VkSampleCountFlagBits rasterizationSamples) const; +#endif // The effective GL_SAMPLE_MASK word for a draw at this rasterization sample count; see // the definition for the GL-vs-Vulkan rule it reconciles. Shared by the pipeline payload - // and the pipeline-state memo word so the two cannot disagree. + // and the pipeline-state memo word so the two cannot disagree. NOT part of the legacy + // arm: it is a PAYLOAD computation that depends on rasterizationSamples, so it survives + // the re-key and keeps reading Multisample / SampleMask / SampleMaskValue out of the + // working block. Uint32 ResolveEffectiveSampleMask(VkSampleCountFlagBits rasterizationSamples) const; +#if MOBILEGL_PIPE_LEGACY_MEMOS Uint m_pipelineStateHashVersion = 0; Uint32 m_pipelineStateHashColorCount = 0; // The sample count the cached hash was computed at. A pipeline-state input now depends on @@ -860,6 +933,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkSampleCountFlagBits m_pipelineStateHashSampleCount = VK_SAMPLE_COUNT_1_BIT; Uint64 m_pipelineStateHash = 0; Bool m_pipelineStateHashValid = false; +#endif // GetShaderTransformFlags memo. NOT pure in the pre-transform alone: the // function also reads whether the bound DRAW framebuffer is the default one // (only the default framebuffer gets the Y-flip and rotation bits - an FBO @@ -877,11 +951,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Drops every memoized pipeline handle. Required at command-buffer // boundaries and whenever any pipeline may have been destroyed. Also drops // the cached pipeline-state hash: the same boundaries can retire the GL - // context whose monotonic version the cache is keyed on. + // context whose monotonic version the cache is keyed on. The handle arm has no + // such cache to drop - a CSO handle is not derived from a monotonic version. void InvalidatePipelineMemo() { m_pipelineMemoCount = 0; m_pipelineMemoNext = 0; +#if MOBILEGL_PIPE_LEGACY_MEMOS m_pipelineStateHashValid = false; +#endif } UnorderedMap m_computePipelines; UniquePtr m_programFactory; From c74c4819fb0f1b97ce15b7bbf041e23c2d7772e3 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:08:33 -0400 Subject: [PATCH 117/529] [Refactor] (Magma): drive the dynamic tail from the pushed dynamic version and make the chunk table check DynamicTailKey's inventory - P2 D12.3. ApplyDynamicDrawStateTail keeps reading GetRenderStateParametersVersion, and under MOBILEGL_PIPE_PUSH that accessor is RE-SOURCED: it returns PipeInputs::m_renderStateParametersVersion, which the applier publishes from MGPDynamicState::Version and MGPBindRenderState::Version. The gate now reads what the client pushed rather than what the backend pulled. - The brief expects the same change to stop a PIPELINE-only change invalidating the tail. It does not, and the tree is right against the brief: the applier publishes bind_render_state's Version into the same counter, and it has to - Espryt's SyncRenderState uses that counter as its all-state change detector and G5 forbids touching one line of it, so a bind that rewrote the pipeline half while leaving the counter still would make Espryt skip re-syncing the state it just changed. Getting the finer gate needs a second, dynamic-only version on the wire, which is a CONTRACT change; recorded for the integrator rather than smuggled in here. The second-level DynamicTailKey compare already absorbs a pipeline-only change at the cost of one key build and no vkCmd*, exactly as it did before P2. - The coverage check D12.3 asks for, as static_asserts rather than a unit test: every RenderStateParameters member DynamicTailKey reads is checked against the P2 chunk table (MGPipeRenderStateSpans.h), including the three stencil members PER FACE, since D6 splits StencilFaceState through the middle. The tail's hand-written input inventory and the offsetof-derived chunk table were written for different reasons, so making them check each other is free evidence, and a chunk edit that demoted one of these is a build break here instead of a tail that stops being re-run when its input moves. A ctest entry would have had to live in MG_Test/Pipe/RenderStateSpansTest.cpp, which the ownership table gives to package A; a static_assert in the file that owns the reader is both in-scope and stricter. - ScissorTestEnabledMask is the one input that is NOT dynamic, and the brief says it should be. The tree wins: the split's only rule is "pipeline iff a setter that calls BumpVersions writes it", and SetCapability(ScissorTest) does, so it sits in pipeline chunk P6 with the other capability bools. It is pinned with the assertion INVERTED, so demoting it - which would be a real G7 violation - is also a build break. Reading it in the tail stays harmless because BumpVersions moves both counters together. - Push-only: the whole block is inside MOBILEGL_PIPE_PUSH and the pull build is unchanged (symbol_report --threshold 0: 0 added / 0 removed / 0 renamed, 4 resized, all four the contract commit's). --- .../DirectVulkan/Renderer/VulkanRenderer.cpp | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 12a4fadd3..f39c63467 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -397,6 +397,89 @@ namespace MobileGL::MG_Backend::DirectVulkan { }; static DynamicStateShadow g_dynamicStateShadow; +#if MOBILEGL_PIPE_PUSH + // ---- D12.3: DynamicTailKey's inputs against the P2 chunk table ---- + // + // DynamicTailKey's inventory (declared above, one line per reader) is an exact, + // hand-maintained enumeration of what the six Apply* in the tail read. The P2 chunk table + // (MG_Pipe/MGPipeRenderStateSpans.h) is an independent, offsetof-derived statement of + // which bytes of RenderStateParameters are dynamic state. The two were written for + // different reasons, so making them check each other is free evidence: if a later chunk + // edit demotes or promotes one of these members, the mismatch is a BUILD BREAK here rather + // than a tail that silently stops being re-run when its input moves. + // + // The brief (P2 D12.3) expects every input to be dynamic; the tree says otherwise for + // exactly one, and the tree is right - see the ScissorTestEnabledMask note below. + namespace { + // Is [begin, begin + size) covered entirely by DYNAMIC chunks? + constexpr Bool MagmaRenderStateRangeIsDynamic(SizeT begin, SizeT size) { + const SizeT end = begin + size; + for (SizeT i = 0; i < MG_Pipe::kMGPipeRenderStateChunkCount; ++i) { + const SizeT chunkBegin = MG_Pipe::kMGPipeRenderStateChunkBoundaries[i]; + const SizeT chunkEnd = MG_Pipe::kMGPipeRenderStateChunkBoundaries[i + 1]; + if (end <= chunkBegin || begin >= chunkEnd) continue; // disjoint + if (MG_Pipe::MGPipeRenderStateChunkIsPipeline(i)) return false; + } + return true; + } + using MagmaTailRsp = RenderStateParameters; + +#define MAGMA_TAIL_INPUT_IS_DYNAMIC(Member) \ + static_assert(MagmaRenderStateRangeIsDynamic(offsetof(MagmaTailRsp, Member), \ + sizeof(MagmaTailRsp::Member)), \ + "ApplyDynamicDrawStateTail reads " #Member \ + ", which the P2 chunk table no longer calls dynamic state: a change to it would " \ + "move the pipeline version, not the parameters version, and the tail would stop " \ + "being re-run for it") + + MAGMA_TAIL_INPUT_IS_DYNAMIC(Viewports); // ApplyGLViewportState: Viewports[0] + MAGMA_TAIL_INPUT_IS_DYNAMIC(DepthRanges); // ApplyGLViewportState: DepthRanges[0] + MAGMA_TAIL_INPUT_IS_DYNAMIC(BlendColor); // ApplyBlendConstants + MAGMA_TAIL_INPUT_IS_DYNAMIC(PolygonOffsetFactor); // ApplyPolygonOffsetState + MAGMA_TAIL_INPUT_IS_DYNAMIC(PolygonOffsetUnits); // ApplyPolygonOffsetState + MAGMA_TAIL_INPUT_IS_DYNAMIC(LineWidth); // ApplyLineWidthState + MAGMA_TAIL_INPUT_IS_DYNAMIC(ScissorBoxes); // the scissor rect: ScissorBoxes[0] +#undef MAGMA_TAIL_INPUT_IS_DYNAMIC + + // ApplyStencilState reads three of the seven members of each face, and D6 splits + // StencilFaceState at sub-member granularity for exactly this reason: Ref, ValueMask + // and WriteMask are VK_DYNAMIC_STATE_STENCIL_{REFERENCE,COMPARE_MASK,WRITE_MASK}, while + // Func and the three ops are baked into the pipeline. Asserted per member, per face, + // because the split runs THROUGH the struct rather than around it. + constexpr SizeT kMagmaStencilFace1 = offsetof(MagmaTailRsp, StencilStates) + sizeof(StencilFaceState); +#define MAGMA_TAIL_STENCIL_IS_DYNAMIC(Member) \ + static_assert(MagmaRenderStateRangeIsDynamic(offsetof(MagmaTailRsp, StencilStates) + \ + offsetof(StencilFaceState, Member), \ + sizeof(StencilFaceState::Member)), \ + "ApplyStencilState reads the FRONT face's " #Member " as dynamic state"); \ + static_assert(MagmaRenderStateRangeIsDynamic(kMagmaStencilFace1 + offsetof(StencilFaceState, Member), \ + sizeof(StencilFaceState::Member)), \ + "ApplyStencilState reads the BACK face's " #Member " as dynamic state") + + MAGMA_TAIL_STENCIL_IS_DYNAMIC(Ref); + MAGMA_TAIL_STENCIL_IS_DYNAMIC(ValueMask); + MAGMA_TAIL_STENCIL_IS_DYNAMIC(WriteMask); +#undef MAGMA_TAIL_STENCIL_IS_DYNAMIC + + // THE ONE INPUT THAT IS NOT DYNAMIC, and the brief's D12.3 says it should be. + // The tree wins, and it is right: the split's only rule is "a byte is pipeline state + // iff a public setter that calls BumpVersions() writes it", and ScissorTestEnabledMask + // is written by SetCapability(ScissorTest), which does. It sits in pipeline chunk P6 + // with the other capability bools. The tail reads it only to decide between the + // scissor box and a full-extent rect, and it is HARMLESS there for a reason worth + // stating: a pipeline-half write moves the pipeline version, and the pipeline version + // moves only together with the parameters version (BumpVersions bumps both), so the + // tail's version gate is invalidated by it just the same. A DYNAMIC member promoted + // into the pipeline half would break that direction, which is what the asserts above + // are for; this one is pinned in the opposite direction so that DEMOTING it - which + // would be a real G7 violation - is also a build break. + static_assert(!MagmaRenderStateRangeIsDynamic(offsetof(MagmaTailRsp, ScissorTestEnabledMask), + sizeof(MagmaTailRsp::ScissorTestEnabledMask)), + "ScissorTestEnabledMask is written by SetCapability(ScissorTest), which calls " + "BumpVersions(), so the chunk table must keep it in the pipeline half"); + } // namespace +#endif // MOBILEGL_PIPE_PUSH + static void ResetDynamicStateShadow() { g_dynamicStateShadow = {}; } @@ -5949,6 +6032,21 @@ void main() { // One compare for the whole tail: see the gate's declaration in // DynamicStateShadow for why (version, extent, default-FBO flag) pins every // input the six Apply* below read. + // + // P2 D12.3: this read is RE-SOURCED, not re-shaped. Under MOBILEGL_PIPE_PUSH the + // accessor no longer walks into GLContext's RenderState - it returns + // PipeInputs::m_renderStateParametersVersion, which the applier publishes from + // MGPDynamicState::Version (set_dynamic_state) and MGPBindRenderState::Version + // (bind_render_state). So the gate now reads what the client PUSHED. + // + // What it does NOT do, and the P2 brief expects it to, is stop moving on a + // pipeline-only change. The tree settles that against the brief: bind_render_state + // carries m_version too and the applier publishes it, and it has to - Espryt's + // SyncRenderState uses the very same counter as its all-state change detector and G5 + // forbids touching it, so a bind that rewrote the pipeline half while leaving the + // counter still would make Espryt skip re-syncing the blend state it just changed. + // The second-level DynamicTailKey compare below is therefore what actually absorbs a + // pipeline-only change, exactly as it did before P2: one key build, no vkCmd*. const Uint paramsVersion = MGB_CTX->GetRenderStateParametersVersion(); if (shadow.dynamicTailValid && shadow.dynamicTailParamsVersion == paramsVersion && shadow.dynamicTailExtentX == extent.x() && shadow.dynamicTailExtentY == extent.y() && From 43f8b47088525480fa11f6954cad1cb1d171184d Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:16:42 -0400 Subject: [PATCH 118/529] [Refactor] (Magma): key the vertex-input cache and the VAO draw memo on {slot, gen} instead of a lifetime id and a heap address - Track H subsystem 4 (P2 brief D12.4, ARCHITECTURE.md 9.5), behind kMGPipeSubsystemMagmaVertexInput. - VertexInputStateFactory::ComputeHash's buffer identity component becomes the buffer's {slot, gen} - "lifetimeId -> gen mixed into every server-side content hash". Both are equally ABA-proof (the allocator maps one onto the other and bumps Gen only on slot REUSE); what changes is that the hash now carries the identity the SERVER will be handed once buffers travel as handles, instead of a number only the client can mint. - LookupVaoDrawMemo becomes a direct slot index: the slot IS the index, and the whole validation is one handle compare. Gone with the re-key are the Fibonacci mix of the VAO's address, the two-way probe, the frame-serial eviction choice and the (pointer, lifetime id) pair - slots are dense by construction, so consecutive VAOs land in consecutive entries and the collision the address hash existed to spread does not arise below the table size. - The table stays FIXED at 2048 entries and the slot index wraps, where the brief calls for a grow-on-demand vector. Reason, and it is a tree fact the brief does not carry: nothing in P2 frees a VertexElementsCso slot. The frontend death notification is Espryt 0b's e2 and it covers Espryt's six kinds; buffers are the only kind with an OnDestroy hook today. A grow-on-demand table would therefore hold one ~1 KB VaoDrawMemo per VAO EVER created, which on a chunk-cycling Minecraft frame is tens of megabytes. Above the table size this degrades to a direct-mapped cache validated by the full {slot, gen}: never wrong, only colder, and strictly better than the address hash it replaces. Revisit when object deletion reaches the client allocator. - SetupDrawSnapshot's VAO identity collapses to the same handle - one compare instead of (address, lifetime id) - so the snapshot and the draw memo cannot disagree about whether the VAO moved. The config version stays: it answers a different question. - Handle acquisition sits behind a one-entry memo in the renderer. Acquiring is a hash probe into the allocator's lifetimeId -> slot map and LookupVaoDrawMemo runs per draw, so without it the arm would have swapped the address hash it deletes for another probe; a run of draws over one VAO now pays a single Uint64 compare. Magma acquires the handles itself because the tracker does not emit object-class state in P2 (it emits for dirty bits 0-4 only); when it does, these become reads of what the client already sent. - Negative control C (MOBILEGL_PIPE_HANDLE_ABA_CONTROL, brief D18) is implemented here because the two guards it defeats live here: it makes ComputeHash hash the raw BufferObject* and makes LookupVaoDrawMemo skip the lifetime-id compare - the exact state the table was in before the ABA fix. It applies to the PRE-HANDLE arm, which is what HandleRecycleScenario.AbaControl runs (MOBILEGL_PIPE_PUSH=0), and it is what proves that scenario's reproducer still reproduces instead of passing for the wrong reason. - Verification on this tree: ctest -L integration-gpu -R DirectVulkan is 432/432 under the default bitmask and 432/432 under MOBILEGL_PIPE_PUSH=0, and -L unit is green. Pull build symbol_report --threshold 0: 0 added / 0 removed / 0 renamed, 4 resized, all four the contract commit's. --- .../DirectVulkan/Renderer/MagmaPipeArms.h | 20 +++++- .../Renderer/VertexInputStateFactory.cpp | 27 ++++++- .../DirectVulkan/Renderer/VulkanRenderer.cpp | 71 ++++++++++++++++++- .../DirectVulkan/Renderer/VulkanRenderer.h | 40 +++++++++++ 4 files changed, 153 insertions(+), 5 deletions(-) diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h index 95ee39c7a..6ccb51dd1 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h @@ -11,8 +11,9 @@ #include #if MOBILEGL_PIPE_PUSH -// kMGPipeSubsystem* - the runtime bitmask's named bits. Push-only, so the pull build's -// include graph is unchanged. +// kMGPipeSubsystem* - the runtime bitmask's named bits - and the client slot allocator that +// mints every MGPipeHandle. Push-only, so the pull build's include graph is unchanged. +#include #include #endif @@ -59,5 +60,20 @@ namespace MobileGL::MG_Backend::DirectVulkan { #endif std::abort(); } + + // The {slot, gen} of a frontend object, minted on first sight and stable for that + // object's whole life (ARCHITECTURE.md 4.2). `lifetimeId` is the client's own identity + // for the object - never a GL name, never a heap address - so a deleted-and-recreated + // object at the same address cannot reproduce a handle, which is precisely the ABA + // HandleRecycleScenario reproduces. + // + // A VAO is kind VertexElementsCso: that is the gallium-shaped CSO a vertex array + // resolves to, and it is the only kind in MGPipeKind that names vertex-input state. + // Magma acquires the handle itself in P2 because the tracker does not emit object-class + // state yet (P2 emits for dirty bits 0-4 only); when it does, this becomes a read of what + // the client already sent. + inline MG_Pipe::MGPipeHandle MagmaPipeHandleOf(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) { + return MG_Pipe::MGPipeSlots().Acquire(kind, lifetimeId); + } #endif // MOBILEGL_PIPE_PUSH } // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp index 62b68c6dd..c1bc968f5 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp @@ -7,6 +7,7 @@ // End of Source File Header #include "VertexInputStateFactory.h" +#include "MagmaPipeArms.h" #include "MG_Util/Converters/MGToStr/DataTypeConverter.h" #include #include @@ -45,7 +46,31 @@ namespace MobileGL::MG_Backend::DirectVulkan { // capture came back holding a dead VAO's vertex data (0,0,0,1 - the previous // test's positions) instead of its own. // Zero for client memory (no buffer), which is a distinct identity of its own. - const Uint64 bufferKey = attr.Buffer ? attr.Buffer->GetLifetimeId() : 0; + // + // P2 D12.4 / ARCHITECTURE.md 9.5: under the handle arm the identity is the + // buffer's {slot, gen} rather than its lifetime id - "lifetimeId -> gen mixed + // into every server-side content hash". The two are equally ABA-proof (the + // allocator maps one onto the other and bumps Gen only on slot REUSE); what + // changes is that the key is now the identity the SERVER will be handed once + // buffers travel as handles, instead of a number only the client can mint. + Uint64 bufferKey = attr.Buffer ? attr.Buffer->GetLifetimeId() : 0; +#if MOBILEGL_PIPE_PUSH + if (attr.Buffer) { + if (MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + const MG_Pipe::MGPipeHandle handle = + MagmaPipeHandleOf(MG_Pipe::MGPipeKind::Buffer, attr.Buffer->GetLifetimeId()); + bufferKey = static_cast(handle.Slot) | (static_cast(handle.Gen) << 32); + } else if (MG_Config::Features.PipeHandleAbaControl) { + // Negative control C (P2 brief D18), and it applies to the PRE-HANDLE arm + // on purpose: hash the raw BufferObject* the way this did before the + // lifetime-id fix, so HandleRecycleScenario.AbaControl can reproduce the + // ABA and assert the WRONG pixels. That arm is what proves the reproducer + // still reproduces; if the allocator stops handing the address back, it + // fails instead of passing for the wrong reason. + bufferKey = static_cast(reinterpret_cast(attr.Buffer.get())); + } + } +#endif XXHASH_VERIFY(XXH64_update(m_hashState, &bufferKey, sizeof(bufferKey))); } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index f39c63467..02fdf4c51 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -3625,6 +3625,43 @@ void main() { if (m_vaoDrawMemoTable.empty()) { m_vaoDrawMemoTable.resize(kVaoDrawMemoSlotCount); } +#if MOBILEGL_PIPE_PUSH + // ---- P2 D12.4, the handle arm ---- + // + // The slot IS the index. No Fibonacci mix of an address, no two-way probe, no + // frame-serial recycling choice: slots are dense by construction (the allocator has a + // free list plus a high-water mark), so consecutive VAOs land in consecutive entries + // and the collision the address hash existed to spread does not arise below the table + // size. The whole validation is one handle compare, and a handle cannot alias - Gen + // moves on slot REUSE, so a deleted VAO's successor never matches its predecessor's + // entry even at the same address and with a byte-identical configuration. + if (MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + const MG_Pipe::MGPipeHandle handle = ResolveVaoHandle(*vao); + // Fixed table, so the index wraps rather than growing: nothing in P2 frees a + // VertexElementsCso slot yet (the frontend death notification is Espryt 0b's e2, + // and buffers are the only kind with one today), so a grow-on-demand vector would + // hold one ~1 KB VaoDrawMemo per VAO EVER created. Above the table size this + // degrades to a direct-mapped cache validated by the full {slot, gen}, which is + // strictly better than the address hash it replaces - never wrong, only colder. + const Uint32 index = handle.Slot & (kVaoDrawMemoSlotCount - 1); + VaoDrawMemo& entry = m_vaoDrawMemoTable[index]; + if (entry.vaoHandle == handle) { + return &entry; + } + entry.vaoHandle = handle; + entry.vaoKey = vao; + entry.vaoLifetimeId = vao->GetLifetimeId(); + entry.contentHash = 0; + entry.layoutFactsValid = false; + // Unmatchable until a resolve completes (same rule as the legacy arm: a bailed-out + // resolve must never leave stale contents matchable). + entry.bindings.frameSerial = 0; + entry.bindings.indexFrameSerial = 0; + entry.bindings.indexBuffer = nullptr; + return &entry; + } + MagmaPipeRequireLegacyArm("LookupVaoDrawMemo"); +#endif // Multiplicative mix of the (16-byte-aligned) address; take high bits, they // carry the most entropy of a multiply. const Uint64 mixed = static_cast(reinterpret_cast(vao) >> 4) * 0x9E3779B97F4A7C15ull; @@ -3634,12 +3671,22 @@ void main() { // its own is recycled, and a slot matched on a recycled address hands the new VAO // the dead one's resolved bindings. const Uint64 lifetimeId = vao->GetLifetimeId(); +#if MOBILEGL_PIPE_PUSH + // Negative control C (P2 brief D18): with MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1 the + // lifetime-id half of the compare is defeated, leaving the recycled address as the + // whole key - exactly the state this table was in before the ABA fix. That is what + // lets HandleRecycleScenario.AbaControl assert the WRONG pixels and so prove that its + // reproducer still reproduces. + const Bool compareLifetimeId = !MG_Config::Features.PipeHandleAbaControl; +#else + constexpr Bool compareLifetimeId = true; +#endif VaoDrawMemo& first = m_vaoDrawMemoTable[index]; - if (first.vaoKey == vao && first.vaoLifetimeId == lifetimeId) { + if (first.vaoKey == vao && (!compareLifetimeId || first.vaoLifetimeId == lifetimeId)) { return &first; } VaoDrawMemo& second = m_vaoDrawMemoTable[index ^ 1u]; - if (second.vaoKey == vao && second.vaoLifetimeId == lifetimeId) { + if (second.vaoKey == vao && (!compareLifetimeId || second.vaoLifetimeId == lifetimeId)) { return &second; } // Miss: recycle a slot. Prefer an empty one; otherwise evict the entry whose @@ -6234,9 +6281,23 @@ void main() { // draw of a VAO-cycling stream (Minecraft chunk rendering) through the full // path, re-resolving descriptors and texture layouts nothing invalidated. const auto& vao = *MGB_CTX->GetBoundVertexArray(); +#if MOBILEGL_PIPE_PUSH + // P2 D12.4: the handle replaces the (address, lifetime id) pair here too - one + // compare instead of two, and the same identity the VAO draw memo is keyed on, so + // the two cannot disagree about whether "the VAO moved". The config version stays: + // it answers a different question (did this same object's layout change). + const Bool vaoMoved = + MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemMagmaVertexInput) + ? (!(ResolveVaoHandle(vao) == snap.vaoHandle) || + vao.GetConfigVersion() != snap.vaoConfigVersion) + : (static_cast(&vao) != snap.vao || + vao.GetLifetimeId() != snap.vaoLifetimeId || + vao.GetConfigVersion() != snap.vaoConfigVersion); +#else const Bool vaoMoved = static_cast(&vao) != snap.vao || vao.GetLifetimeId() != snap.vaoLifetimeId || vao.GetConfigVersion() != snap.vaoConfigVersion; +#endif const auto& drawFbo = MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); if (static_cast(drawFbo.get()) != snap.drawFbo || @@ -6538,6 +6599,9 @@ void main() { snap.bindGeneration = bindGeneration; snap.vao = static_cast(&vao); snap.vaoLifetimeId = vao.GetLifetimeId(); +#if MOBILEGL_PIPE_PUSH + snap.vaoHandle = ResolveVaoHandle(vao); +#endif snap.vaoConfigVersion = vao.GetConfigVersion(); snap.vaoLayoutHash = vaoLayoutHash; snap.pipeline = pipeline; @@ -7119,6 +7183,9 @@ void main() { snap.programVersion = program.GetBackendStateVersion(); snap.vao = &vao; snap.vaoLifetimeId = vao.GetLifetimeId(); +#if MOBILEGL_PIPE_PUSH + snap.vaoHandle = ResolveVaoHandle(vao); +#endif snap.vaoConfigVersion = vao.GetConfigVersion(); snap.drawFbo = drawFbo.get(); snap.drawFboLifetimeId = drawFbo->GetLifetimeId(); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index 46a8a1629..f992b9e57 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -1045,6 +1045,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { // common shape), and "the VAO did not move" would then skip the layout // re-resolve for a different VAO. Uint64 vaoLifetimeId = 0; +#if MOBILEGL_PIPE_PUSH + // P2 D12.4: the handle arm's answer to the same question, and one compare rather + // than the pair above. Kept BESIDE them rather than replacing them because the + // pre-handle arm is still compiled (MOBILEGL_PIPE_LEGACY_MEMOS) and this snapshot + // is a value struct, not a wire type. + MG_Pipe::MGPipeHandle vaoHandle = MG_Pipe::kMGPipeNullHandle; +#endif Uint32 vaoConfigVersion = 0; const void* drawFbo = nullptr; // Never-reused lifetime id beside the raw pointer + Uint16 version: a @@ -1319,6 +1326,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { // - bindings revalidates per draw exactly as before (frame serial, content // hash, per-binding live buffer pointers and slice epochs). struct alignas(64) VaoDrawMemo { +#if MOBILEGL_PIPE_PUSH + // P2 D12.4: the handle arm's key, and the ONLY key it needs. {slot, gen} is an + // identity, so the pointer-plus-lifetime-id pair below stops being a key here; + // the slot also picks the table entry, so the address hash and the two-way probe + // go with it. Null in an entry that has never been claimed. + MG_Pipe::MGPipeHandle vaoHandle = MG_Pipe::kMGPipeNullHandle; +#endif const MG_State::GLState::VertexArrayObject* vaoKey = nullptr; // The VAO's never-reused lifetime id, checked alongside vaoKey. The pointer // ALONE is not an identity: a deleted VAO's heap address is handed straight @@ -1347,6 +1361,32 @@ namespace MobileGL::MG_Backend::DirectVulkan { // (m_currentDrawResolvedEntry) relies on. static constexpr Uint32 kVaoDrawMemoSlotCount = 2048; // power of two Vector m_vaoDrawMemoTable; +#if MOBILEGL_PIPE_PUSH + // One-entry memo in front of the slot allocator's lifetimeId -> handle map (P2 + // D12.4). Acquiring a handle is a hash probe, and LookupVaoDrawMemo runs per draw, so + // the arm would otherwise have swapped one probe (the address hash it deletes) for + // another. A run of draws over one VAO - the common intra-batch shape - pays a single + // Uint64 compare instead. + // + // A lifetime id is never reused, so a hit can only ever be this same object; the + // valid flag exists rather than a zero sentinel because nothing promises the frontend + // counter starts above zero. + Uint64 m_lastVaoHandleLifetimeId = 0; + MG_Pipe::MGPipeHandle m_lastVaoHandle = MG_Pipe::kMGPipeNullHandle; + Bool m_lastVaoHandleValid = false; + MG_Pipe::MGPipeHandle ResolveVaoHandle(const MG_State::GLState::VertexArrayObject& vao) { + const Uint64 lifetimeId = vao.GetLifetimeId(); + if (m_lastVaoHandleValid && m_lastVaoHandleLifetimeId == lifetimeId) { + return m_lastVaoHandle; + } + const MG_Pipe::MGPipeHandle handle = + MagmaPipeHandleOf(MG_Pipe::MGPipeKind::VertexElementsCso, lifetimeId); + m_lastVaoHandleLifetimeId = lifetimeId; + m_lastVaoHandle = handle; + m_lastVaoHandleValid = true; + return handle; + } +#endif // Finds the slot holding `vao`, or recycles the older of its two candidate // slots into an empty memo keyed on `vao`. Never returns null. VaoDrawMemo* LookupVaoDrawMemo(const MG_State::GLState::VertexArrayObject* vao); From 3594f03c4eeae985827b8e5678e96e8b8876b49d Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:31:13 -0400 Subject: [PATCH 119/529] [Refactor] (State, Magma): take the backend's raw pointers out of the frontend VAO - the hash and state memos become the factory's own per-slot fields - P2 D12.5 (ARCHITECTURE.md 9.5). VertexArrayObject carried three `mutable` memos for the backend: a content hash, a raw pointer into VertexInputStateFactory's heap-allocated cache entry plus that cache's eviction epoch, and two aux words. A frontend state object holding the backend's pointer is what P2 retires - under split the backend is in another process and its cache entry has no address a client could store. - The hash and state memos move into a slot-indexed table the FACTORY owns, keyed on the VAO's {slot, gen} and guarded by exactly the same config version, so nothing is recomputed more often than it was. Fixed and direct-mapped for the same reason m3's VaoDrawMemo table is: nothing frees a VertexElementsCso slot in P2, so a grow-on-demand table would keep one entry per VAO ever created. 2048 x 48 B is 96 KB. - The AUX memo is deleted rather than moved, as the brief says: its two words already live in VulkanRenderer::VaoDrawMemo (layoutHash / layoutAuxMasks) and GetBackendAuxMemo has no live reader anywhere in the tree - the only writer was the line this commit stops executing. - The eviction-epoch dance shrinks with them. The PROCESS-WIDE s_evictionEpochSource exists because the memos live on frontend VAOs and therefore outlive the factory; the handle arm's table dies with the factory, so a per-instance counter is enough there. The epoch itself stays - it guards the POINTEE, which is still a cache entry a frame boundary can erase, and moving the memo does not change that. (The brief reads as if a slot-indexed table removes the need for an epoch; it removes the need for a process-wide one.) - The three draw-path readers that asked the VAO "is your content hash already memoized?" now ask whichever side owns the memo, through a force-inlined wrapper so the PULL build's two loads stay two loads. - All three accessors and their storage are kept under MOBILEGL_PIPE_LEGACY_MEMOS rather than deleted from the file, because that is the arm the pre-handle A/B runs (D14) and because a pull build forces the option ON, where G1 admits no change at all. Configuring with -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF is what makes the deletion real, and that build compiles clean - which is the check that nothing else still reaches for them. - Verification: pull symbol_report --threshold 0 is 0 added / 0 removed / 0 renamed with the contract's four resizes and no fifth; ctest -L unit 1489/1489 in both the pull and the push build; ctest -L integration-gpu -R DirectVulkan 432/432 under the default bitmask and 432/432 under MOBILEGL_PIPE_PUSH=0. The LEGACY_MEMOS=OFF build compiles but cannot RUN on this tree, and that is the D14 gate working rather than a defect: no tracker binds a render-state CSO here, so the handle arm has no key and Fatal{PipeLegacyMemosDisabled} fires at the first draw instead of the memo quietly aliasing every render state onto one entry. Re-run it once p2/tracker has landed. --- .../Renderer/VertexInputStateFactory.cpp | 105 +++++++++++++++++- .../Renderer/VertexInputStateFactory.h | 58 ++++++++++ .../DirectVulkan/Renderer/VulkanRenderer.cpp | 6 +- .../DirectVulkan/Renderer/VulkanRenderer.h | 12 ++ .../VertexArrayState/VertexArrayObject.h | 22 ++++ 5 files changed, 199 insertions(+), 4 deletions(-) diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp index c1bc968f5..bb550ce0d 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp @@ -77,18 +77,114 @@ namespace MobileGL::MG_Backend::DirectVulkan { return XXH64_digest(m_hashState); } +#if MOBILEGL_PIPE_PUSH + VertexInputStateFactory::VaoBackendMemos& VertexInputStateFactory::MemosFor( + const MG_State::GLState::VertexArrayObject& vao) const { + if (m_vaoMemos.empty()) { + m_vaoMemos.resize(kVaoMemoSlotCount); + } + const Uint64 lifetimeId = vao.GetLifetimeId(); + if (!m_lastVaoHandleValid || m_lastVaoLifetimeId != lifetimeId) { + m_lastVaoHandle = MagmaPipeHandleOf(MG_Pipe::MGPipeKind::VertexElementsCso, lifetimeId); + m_lastVaoLifetimeId = lifetimeId; + m_lastVaoHandleValid = true; + } + const MG_Pipe::MGPipeHandle handle = m_lastVaoHandle; + VaoBackendMemos& memos = m_vaoMemos[handle.Slot & (kVaoMemoSlotCount - 1)]; + if (!(memos.Owner == handle)) { + // Someone else's entry (a colliding slot, or a slot whose Gen moved because the + // slot was REUSED for a different object). Claim it, contents cleared - never + // inherited, which is the whole point of keying on the generation. + memos = VaoBackendMemos{}; + memos.Owner = handle; + } + return memos; + } +#endif + +#if MOBILEGL_PIPE_PUSH + Bool VertexInputStateFactory::TryGetMemoizedHash(const MG_State::GLState::VertexArrayObject& vao, + Uint64& outHash) const { + if (MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + const VaoBackendMemos& memos = MemosFor(vao); + if (memos.HashConfigVersion != vao.GetConfigVersion()) return false; + outHash = memos.Hash; + return true; + } + MagmaPipeRequireLegacyArm("VertexInputStateFactory::TryGetMemoizedHash"); +#if MOBILEGL_PIPE_LEGACY_MEMOS + return vao.GetBackendHashMemo(outHash); +#else + return false; +#endif + } +#endif + VertexInputStateFactory::HashType VertexInputStateFactory::GetOrComputeHash( const MG_State::GLState::VertexArrayObject& vao) const { HashType hash = 0; +#if MOBILEGL_PIPE_PUSH + // P2 D12.5: the same memo, on the backend's side of the boundary. + if (MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + VaoBackendMemos& memos = MemosFor(vao); + if (memos.HashConfigVersion == vao.GetConfigVersion()) { + return memos.Hash; + } + hash = ComputeHash(vao); + memos.Hash = hash; + memos.HashConfigVersion = vao.GetConfigVersion(); + return hash; + } + MagmaPipeRequireLegacyArm("VertexInputStateFactory::GetOrComputeHash"); +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS if (!vao.GetBackendHashMemo(hash)) { hash = ComputeHash(vao); vao.SetBackendHashMemo(hash); } +#endif return hash; } const VertexInputStateFactory::BackendVertexInputState& VertexInputStateFactory::GetOrCreateVertexInputState( const MG_State::GLState::VertexArrayObject& vao) { +#if MOBILEGL_PIPE_PUSH + // P2 D12.5: the same per-draw fast path, but the resolved-entry pointer lives in this + // factory's slot-indexed table instead of on the frontend VAO. The eviction epoch + // survives the move and is still what stops a stale pointer being dereferenced: the + // POINTEE is a cache entry this factory can erase at a frame boundary, and moving the + // memo does not change that. + if (MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + VaoBackendMemos& memos = MemosFor(vao); + if (memos.StateConfigVersion == vao.GetConfigVersion() && memos.State != nullptr && + memos.StateEpoch == m_evictionEpoch) { + const auto* memoEntry = static_cast(memos.State); + memoEntry->lastUsedFrameBoundary = m_frameBoundaryCounter; + return *memoEntry; + } + const BackendVertexInputState& resolved = + GetOrCreateVertexInputState(vao, GetOrComputeHash(vao)); + // MemosFor is re-taken: GetOrComputeHash above went through it, and a colliding + // VAO could have claimed the entry in between (it cannot here, since both calls + // name the same VAO, but the reference is not worth keeping live across a call + // that can resize the table). + VaoBackendMemos& stamp = MemosFor(vao); + stamp.State = &resolved; + stamp.StateEpoch = m_evictionEpoch; + stamp.StateConfigVersion = vao.GetConfigVersion(); + // The AUX memo is deliberately NOT stamped here: its two words already live in + // VulkanRenderer::VaoDrawMemo (layoutHash / layoutAuxMasks) and its getter has no + // live reader anywhere, so the handle arm retires it rather than moving it. + return resolved; + } + MagmaPipeRequireLegacyArm("VertexInputStateFactory::GetOrCreateVertexInputState"); +#endif +#if !MOBILEGL_PIPE_LEGACY_MEMOS + // Unreachable: with no legacy arm compiled, MagmaPipeRequireLegacyArm above aborts. + // Written out rather than left to fall off the end so the function still has a return + // on every path a compiler can see. + return GetOrCreateVertexInputState(vao, GetOrComputeHash(vao)); +#else // Per-draw fast path: the VAO carries a pointer to its resolved entry, // valid while its config version and the cache's eviction epoch both // match - no re-hash, no map lookup. @@ -108,6 +204,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { vao.SetBackendAuxMemo(entry.layoutHash, PackVertexInputAuxMasks(entry.unsupportedAttribMask, entry.attributeLocationMask)); return entry; +#endif // MOBILEGL_PIPE_LEGACY_MEMOS } const VertexInputStateFactory::BackendVertexInputState& VertexInputStateFactory::GetOrCreateVertexInputState( @@ -341,8 +438,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Invalidate every VAO's state-pointer memo: the erased node's // address may be reused by a future insert. Advance through the // process-wide source so the value stays unique across factory - // instances (see the member comment). + // instances (see the member comment). With no legacy arm the memos + // live in this factory and die with it, so a per-instance bump is + // enough - P2 D12.5. +#if MOBILEGL_PIPE_LEGACY_MEMOS m_evictionEpoch = ++s_evictionEpochSource; +#else + ++m_evictionEpoch; +#endif } else { ++it; } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h index 66dd69582..83174c143 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h @@ -7,6 +7,9 @@ // End of Source File Header #pragma once +// MG_Pipe::MGPipeHandle for the P2 D12.5 memo table below. A header of constexpr constants, +// so the pull build gains nothing from it. +#include #include "Config.h" #include "VertexInputStateBuilder.h" @@ -86,6 +89,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Memoized ComputeHash: reuses the VAO's cached hash while its config version // is unchanged. Use this on per-draw paths. HashType GetOrComputeHash(const MG_State::GLState::VertexArrayObject& vao) const; +#if MOBILEGL_PIPE_PUSH + // The VAO's content hash IF it has already been memoized, without computing one. + // P2 D12.5: the three draw-path readers that used to ask the VAO object this + // question ask the factory instead, because that is where the memo lives once the + // frontend object stops carrying the backend's state. + Bool TryGetMemoizedHash(const MG_State::GLState::VertexArrayObject& vao, Uint64& outHash) const; +#endif const BackendVertexInputState& GetOrCreateVertexInputState( const MG_State::GLState::VertexArrayObject& vao, HashType hash); const BackendVertexInputState& GetOrCreateVertexInputState(const MG_State::GLState::VertexArrayObject& vao); @@ -112,6 +122,45 @@ namespace MobileGL::MG_Backend::DirectVulkan { static VkFormat ToFloat32VertexFormat(Int componentCount); Bool SupportsVertexBufferFormat(VkFormat format) const; +#if MOBILEGL_PIPE_PUSH + // ---- P2 D12.5: the backend's memos, off the frontend VAO and into the backend ---- + // + // The two facts that used to live as `mutable` fields on VertexArrayObject + // (Get/SetBackendHashMemo and Get/SetBackendStateMemo), kept here instead, keyed on + // the VAO's {slot, gen} and guarded by exactly the same config version. A frontend + // state object holding the backend's raw pointer is what P2 retires: under split the + // backend is in another process and its cache entry has no address a client could + // store, so the memo has to live on the side that owns the pointee. + // + // The AUX memo is not carried over: its two words moved into VaoDrawMemo::layoutHash + // and layoutAuxMasks long ago and its getter has no live reader anywhere in the tree, + // so the handle arm simply stops writing it (D12.5 says delete rather than move). + struct VaoBackendMemos { + // Whose memos these are. A slot is direct-mapped into the table below, so an + // entry can be claimed by a different VAO; the handle compare is what says the + // contents are this object's. + MG_Pipe::MGPipeHandle Owner = MG_Pipe::kMGPipeNullHandle; + Uint64 Hash = 0; + Uint32 HashConfigVersion = ~0u; + const void* State = nullptr; + Uint64 StateEpoch = 0; + Uint32 StateConfigVersion = ~0u; + }; + // Fixed and direct-mapped for the same reason VulkanRenderer's VaoDrawMemo table is + // (P2 m3): nothing frees a VertexElementsCso slot yet, so a grow-on-demand table + // would keep one entry per VAO ever created. 2048 x 48 B is 96 KB. + static constexpr Uint32 kVaoMemoSlotCount = 2048; // power of two + mutable Vector m_vaoMemos; + // One-entry memo in front of the allocator's lifetimeId -> handle probe, same shape + // and same reason as VulkanRenderer::ResolveVaoHandle. + mutable Uint64 m_lastVaoLifetimeId = 0; + mutable MG_Pipe::MGPipeHandle m_lastVaoHandle = MG_Pipe::kMGPipeNullHandle; + mutable Bool m_lastVaoHandleValid = false; + // The entry belonging to `vao`, claimed (and cleared) if the slot currently holds + // someone else's. + VaoBackendMemos& MemosFor(const MG_State::GLState::VertexArrayObject& vao) const; +#endif + const VulkanRendererConfig& m_config; VkPhysicalDevice m_physicalDevice = VK_NULL_HANDLE; // Values are heap-allocated: UnorderedMap is open-addressing, so INSERT @@ -137,8 +186,17 @@ namespace MobileGL::MG_Backend::DirectVulkan { // than anything a predecessor ever stamped, so a dead factory's memo can // never compare equal here - the same never-reused idiom as the lifetime ids. // Single-threaded like the rest of the factory (renderer-thread only). + // + // P2 D12.5: the process-wide source is the LEGACY arm's need. It exists because the + // memos live on the frontend VAOs and therefore outlive the factory. The handle arm's + // memo table is owned by this factory and dies with it, so a per-instance counter is + // enough there and the epoch shrinks back to what it looks like it should be. +#if MOBILEGL_PIPE_LEGACY_MEMOS static inline Uint64 s_evictionEpochSource = 0; Uint64 m_evictionEpoch = ++s_evictionEpochSource; +#else + Uint64 m_evictionEpoch = 1; +#endif static inline XXH64_state_t* m_hashState = XXH64_createState(); }; } // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 02fdf4c51..25e5ad06b 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -3749,7 +3749,7 @@ void main() { VaoDrawMemo* slot = nullptr; ResolvedVertexBindings* memo = nullptr; Uint64 vaoContentHash = 0; - const Bool vaoHashKnown = vao.GetBackendHashMemo(vaoContentHash); + const Bool vaoHashKnown = VaoContentHashIfKnown(vao, vaoContentHash); if (vaoHashKnown) { slot = LookupVaoDrawMemo(&vao); memo = &slot->bindings; @@ -6385,7 +6385,7 @@ void main() { Uint64 auxMasks = 0; Bool factsKnown = false; Uint64 contentHash = 0; - if (vao.GetBackendHashMemo(contentHash)) { + if (VaoContentHashIfKnown(vao, contentHash)) { const VaoDrawMemo* vaoMemo = LookupVaoDrawMemo(&vao); if (vaoMemo->layoutFactsValid && vaoMemo->contentHash == contentHash) { vaoLayoutHash = vaoMemo->layoutHash; @@ -6402,7 +6402,7 @@ void main() { auxMasks = VertexInputStateFactory::PackVertexInputAuxMasks( vertexInputState.unsupportedAttribMask, vertexInputState.attributeLocationMask); Uint64 stampedHash = 0; - if (vao.GetBackendHashMemo(stampedHash)) { + if (VaoContentHashIfKnown(vao, stampedHash)) { VaoDrawMemo* vaoMemo = LookupVaoDrawMemo(&vao); vaoMemo->contentHash = stampedHash; vaoMemo->layoutHash = vaoLayoutHash; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index f992b9e57..fa95d9096 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -1387,6 +1387,18 @@ namespace MobileGL::MG_Backend::DirectVulkan { return handle; } #endif + // "Is this VAO's content hash already memoized?", asked of whichever side owns the + // memo (P2 D12.5). Force-inlined and defined in the class body so that the PULL + // build's three readers keep compiling to the very same two loads they always did - + // G1 admits no resize, and an out-of-line call here would be one. + [[gnu::always_inline]] inline Bool VaoContentHashIfKnown( + const MG_State::GLState::VertexArrayObject& vao, Uint64& outHash) const { +#if MOBILEGL_PIPE_PUSH + return m_vertexInputStateFactory->TryGetMemoizedHash(vao, outHash); +#else + return vao.GetBackendHashMemo(outHash); +#endif + } // Finds the slot holding `vao`, or recycles the older of its two candidate // slots into an empty memo keyed on `vao`. Never returns null. VaoDrawMemo* LookupVaoDrawMemo(const MG_State::GLState::VertexArrayObject* vao); diff --git a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h index 41c5065b7..9322fd356 100644 --- a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h +++ b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h @@ -106,6 +106,22 @@ namespace MobileGL { // "any vertex-input state changed" with one compare. Uint32 GetConfigVersion() const { return m_configVersion; } +#if MOBILEGL_PIPE_LEGACY_MEMOS + // ---- THE BACKEND'S THREE MEMOS ON THE FRONTEND OBJECT ---- + // + // P2 D12.5 (ARCHITECTURE.md 9.5) retires all three: a frontend state object + // must not hold the backend's raw pointers, and under split it cannot - the + // backend is in another process and its cache entry has no address the client + // could store. Magma's handle arm keeps the same three facts in a slot-indexed + // table it owns itself (VertexInputStateFactory::VaoBackendMemos), keyed on the + // VAO's {slot, gen} and validated by the same config version, so nothing is + // recomputed more often than it was. + // + // They stay compiled under MOBILEGL_PIPE_LEGACY_MEMOS - which a PULL build + // forces ON - because that is the arm the pre-handle A/B runs, and because G1 + // admits no change to the pull build. They are deleted outright with the pull + // path at P13. + // // Backend-owned content-hash memo, valid while the config version matches // (same idea as ProgramObject's hash memo — avoids re-hashing all // attributes on every draw). @@ -154,6 +170,7 @@ namespace MobileGL { m_backendAuxMemo1 = aux1; m_backendAuxMemoVersion = m_configVersion; } +#endif // MOBILEGL_PIPE_LEGACY_MEMOS private: void BumpAttributeFormatVersion(Uint index); @@ -193,6 +210,10 @@ namespace MobileGL { Array m_attributeUsesBindingModel = {}; Uint32 m_configVersion = 0; +#if MOBILEGL_PIPE_LEGACY_MEMOS + // The storage behind the three accessors above; retired with them (D12.5). + // A pull build forces MOBILEGL_PIPE_LEGACY_MEMOS ON, so sizeof(this) does not + // move there and G1 sees no change. mutable Uint64 m_backendHashMemo = 0; mutable Uint32 m_backendHashMemoVersion = ~0u; mutable const void* m_backendStateMemo = nullptr; @@ -201,6 +222,7 @@ namespace MobileGL { mutable Uint64 m_backendAuxMemo0 = 0; mutable Uint64 m_backendAuxMemo1 = 0; mutable Uint32 m_backendAuxMemoVersion = ~0u; +#endif // MOBILEGL_PIPE_LEGACY_MEMOS }; } // namespace GLState } // namespace MG_State From 01179c54d2b641c1d6600861c8c0cd1664918ffc Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 09:59:00 -0400 Subject: [PATCH 120/529] [Fix] (Magma): make the legacy-memo lever a startup gate for Magma's own bit, bound the {slot, gen} mint, and keep the all-pull arm free of push-only cost - MOBILEGL_PIPE_LEGACY_MEMOS=0 no longer aborts a draw. D14 spends that lever at STARTUP and only on a Track-H subsystem, so MagmaPipeValidateSubsystemConfiguration runs once from VulkanRenderer::Initialize and checks bit 6 alone: an Espryt-side bitmask cannot kill a Magma run, and bit 0 - which is not Track H and not a memo re-key - is out of the lever's scope entirely. MOBILEGL_PIPE_PUSH=0x60 with the lever off went from 9/9 aborted to 432/432. - A pipeline memo with no render-state CSO bound falls back instead of aborting. delete_render_state clears the binding, so the null handle is reachable on any tree; the fallback is the pre-handle state hash where one is compiled, and the client's own MGPipeComputePipelineSubsetHash over the same 396 pipeline bytes where it is not - which is what makes -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF a runnable configuration (180/432 aborted before, 432/432 now) instead of a build that dies on its first draw. The fallback warns once, so a run that never keys on a CSO handle says so in its log instead of passing silently. - The handles are minted by a fixed-capacity, self-recycling identity table in the backend, not by MG_Impl's client allocator. Nothing in P2 frees a VertexElementsCso or Buffer slot - the frontend has no death notification Magma can hook - so the allocator's live Allocate and dead Free grew one SlotState plus one hash-map node per object ever created, for the life of the process. The table is 2-way set-associative with an LRU victim and a Gen bump on reuse: bounded (32 KB for VAOs, 128 KB for buffers), exactly as ABA-proof, and it takes MG_Backend's only include of MG_Impl back out. - Both per-slot memo tables are now a BIJECTION with that mint rather than a masked direct map, so two live VAOs cannot share an entry and the eviction decision lives once, in the identity table, instead of once per consumer table. The density claim the masked tables rested on was false while slots grew monotonically, and the masked form had also dropped the second candidate and the frame-serial victim choice the address-hashed table used to have. - snap.vaoHandle is stamped only when bit 6 is on. It was guarded by the compile switch alone, so MOBILEGL_PIPE_PUSH=0 - the all-pull control D14 defines as reproducing P1 exactly, and the arm D.4.3's T2 is measured on - paid a mint per new VAO and a compare per draw for a field that arm never reads. - Every re-keyed Track-H site now asks the same MagmaPipeTrackHArmIsHandles helper, including VertexInputStateFactory::ComputeHash, which decided for itself before and could key on the pre-handle identity while its neighbours keyed on the handle. - The pull build's two pipeline-memo sites keep the base ref's text statement for statement: G1 is back to the contract's four resized symbols, 0 added/removed. --- .../DirectVulkan/Renderer/MagmaPipeArms.h | 213 +++++++++++++++--- .../Renderer/VertexInputStateFactory.cpp | 50 ++-- .../Renderer/VertexInputStateFactory.h | 20 +- .../DirectVulkan/Renderer/VulkanRenderer.cpp | 159 +++++++------ .../DirectVulkan/Renderer/VulkanRenderer.h | 103 ++++++--- 5 files changed, 385 insertions(+), 160 deletions(-) diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h index 6ccb51dd1..ccb200796 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h @@ -11,15 +11,17 @@ #include #if MOBILEGL_PIPE_PUSH -// kMGPipeSubsystem* - the runtime bitmask's named bits - and the client slot allocator that -// mints every MGPipeHandle. Push-only, so the pull build's include graph is unchanged. -#include +// kMGPipeSubsystem* - the runtime bitmask's named bits - and MGPipeHandle itself. Both are +// header-only constant/POD declarations, and both are push-only, so the pull build's include +// graph is unchanged (G1). #include +#include #endif #include -// Magma's arm selector for the P2 Track H / render-state re-keys (P2 brief D14). +// Magma's arm selector for the P2 Track H / render-state re-keys (P2 brief D14), and the +// bounded {slot, gen} mint the re-keyed sites are written against. // // Two switches decide which arm a re-keyed site runs, and they are NOT the same switch: // @@ -33,6 +35,11 @@ // backend would otherwise still run the re-keyed code. So a clear bit selects the legacy // arm, and a run that has explicitly disabled the legacy arm may not fall into it. // +// D14 spends that last sentence at STARTUP, not per draw: "a Track-H subsystem whose bit is +// clear is a startup Fatal{PipeLegacyMemosDisabled}". Nothing in the draw path aborts, and +// nothing outside Track H consults the legacy-memo lever at all - see +// MagmaPipeValidateSubsystemConfiguration below for both halves of that rule. +// // The whole header is inert in a pull build: MOBILEGL_PIPE_PUSH is 0 there, every helper // below is behind it, and the pull build's translation units are byte-identical (G1). namespace MobileGL::MG_Backend::DirectVulkan { @@ -43,37 +50,191 @@ namespace MobileGL::MG_Backend::DirectVulkan { return (MG_Config::Features.PipePush & subsystemBit) != 0; } - // The legacy arm is about to be entered. Features.PipeLegacyMemos=0 is the operator - // asserting "the pre-handle arm is never entered in this run", which is the lever - // HandleRecycleScenario.Handles pulls (P2 brief D18): entering it anyway would make - // that arm green for the wrong reason, so it is Fatal rather than a fallback. - inline void MagmaPipeRequireLegacyArm(const char* site) { + // --------------------------------------------------------------------------------- + // D14's startup gate + // --------------------------------------------------------------------------------- + // + // Called once from VulkanRenderer::Initialize(), i.e. only when Magma is the backend + // that is actually running. It answers exactly one question and it answers it before the + // first draw: is there an arm for Magma's Track-H subsystem in this configuration? + // + // Three deliberate boundaries, each of which the per-draw shape this replaces got wrong: + // + // * ONLY Magma's own Track-H bit is checked. Espryt's bit 5 is Espryt's business (a + // DirectVulkan run does not execute one line of DirectGLES' re-key), so + // MOBILEGL_PIPE_PUSH=0x20 must not kill a Magma run, and MOBILEGL_PIPE_PUSH=0x40 must + // not kill an Espryt one. + // * bit 0 (kMGPipeSubsystemRenderState) is NOT Track H and is NOT checked. It is not a + // memo re-key at all: it decides where the pipeline memo's STATE KEY comes from, and + // a clear bit there simply means the client is not pushing render-state CSOs in this + // run, which GetOrCreatePipeline answers with its own state hash. D14 labels bits 5 + // and 6 "Track H" and labels bit 0 nothing of the sort. + // * it is Fatal at STARTUP, once, not on a draw. A per-draw abort inside + // GetOrCreatePipeline turns a configuration mistake into a mid-frame crash and puts a + // branch nobody needs on the hottest path in the backend. + inline void MagmaPipeValidateSubsystemConfiguration() { #if MOBILEGL_PIPE_LEGACY_MEMOS + // The pre-handle arm is compiled AND the operator has not forbidden entering it, so a + // clear bit is an ordinary, valid A/B: the site takes the legacy arm. if (MG_Config::Features.PipeLegacyMemos) return; - MGLOG_F("MGPipe: Fatal{PipeLegacyMemosDisabled} %s wanted the pre-handle arm but " - "MOBILEGL_PIPE_LEGACY_MEMOS=0 forbids entering it", - site); +#endif + if (MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) return; +#if MOBILEGL_PIPE_LEGACY_MEMOS + const char* const why = "this run has MOBILEGL_PIPE_LEGACY_MEMOS=0"; #else - MGLOG_F("MGPipe: Fatal{PipeLegacyMemosDisabled} %s wanted the pre-handle arm but this " - "build did not compile one (cmake -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF)", - site); + const char* const why = + "this build has cmake -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF, which compiles no such arm"; #endif + MGLOG_F("MGPipe: Fatal{PipeLegacyMemosDisabled} Magma's Track-H subsystem " + "(kMGPipeSubsystemMagmaVertexInput, bit 6 of MOBILEGL_PIPE_PUSH) is clear, so the " + "vertex-input cache and the VAO draw memo want the pre-handle arm - but %s. Set " + "bit 6 (MOBILEGL_PIPE_PUSH=0x%llx, or the default 0x%llx), or allow the legacy arm.", + why, + static_cast(MG_Config::Features.PipePush | + MG_Pipe::kMGPipeSubsystemMagmaVertexInput), + static_cast(MG_Pipe::kMGPipeSubsystemsMigratedAtP2)); std::abort(); } - // The {slot, gen} of a frontend object, minted on first sight and stable for that - // object's whole life (ARCHITECTURE.md 4.2). `lifetimeId` is the client's own identity - // for the object - never a GL name, never a heap address - so a deleted-and-recreated - // object at the same address cannot reproduce a handle, which is precisely the ABA - // HandleRecycleScenario reproduces. + // "Does this Track-H site run the handle arm?" - the ONE question every re-keyed Track-H + // site asks, so that they cannot disagree with each other or with the startup gate. + inline Bool MagmaPipeTrackHArmIsHandles(Uint64 trackHBit) { +#if MOBILEGL_PIPE_LEGACY_MEMOS + return MagmaPipeSubsystemOn(trackHBit); +#else + // No pre-handle arm exists in this build, and MagmaPipeValidateSubsystemConfiguration + // has already made a clear bit a startup Fatal, so the handle arm is the only arm a + // running process can be on. + (void)trackHBit; + return true; +#endif + } + + // --------------------------------------------------------------------------------- + // The {slot, gen} mint + // --------------------------------------------------------------------------------- // - // A VAO is kind VertexElementsCso: that is the gallium-shaped CSO a vertex array - // resolves to, and it is the only kind in MGPipeKind that names vertex-input state. - // Magma acquires the handle itself in P2 because the tracker does not emit object-class - // state yet (P2 emits for dirty bits 0-4 only); when it does, this becomes a read of what - // the client already sent. + // A FIXED-CAPACITY, SELF-RECYCLING identity table: 2-way set-associative, indexed by the + // frontend object's lifetime id, LRU victim within the set, and Gen incremented whenever + // a slot changes owner. It hands out slots in [kMGPipeFirstAllocatableSlot, Count], so a + // consumer's per-slot table is a BIJECTION with this one - one entry per slot, no + // masking, no collision, no probe. + // + // Why not MG_Impl/Pipe/SlotAllocator (the client's allocator, which is what mints handles + // in the finished design)? Because in P2 nothing on this side ever frees one. The tracker + // does not emit object-class state yet (P2 emits for dirty bits 0-4), so no create_*/ + // delete_* pair travels for a VAO or a buffer, and the frontend has no death notification + // Magma could hook: BufferBackendOps::OnDestroy is handed a BackendBufferResource, not the + // BufferObject, and fires only for a buffer that ever had one, while VertexArrayObject has + // no hook at all (adding one is D13's explicit-destroy work, and it covers Espryt's six + // kinds, not VertexElementsCso). An allocator with a live Allocate and a dead Free grows + // by one SlotState plus one hash-map node per object EVER created, for the life of the + // process, on a platform with an LMK - and its slot numbers then grow monotonically with + // objects ever created, which is exactly what would make a slot-indexed table collide. + // + // So Magma mints its own, bounded, and says so. This is a P2 STAND-IN either way (the + // client is what mints handles once object-class state travels); what it must not be is a + // leak. Recycling costs the same thing the address-hashed table it replaces cost: a + // colliding pair of live objects evicts each other and re-derives. It is strictly better + // than that table, because the {slot, gen} compare is an exact identity, so an eviction + // can only ever cost a recompute - never the ABA the lifetime-id compare was added for. + // + // Single-threaded, like MGPipeSlots() and like the rest of the renderer. + class MagmaPipeIdentityTable { + public: + explicit MagmaPipeIdentityTable(Uint32 entryCount) : m_entryCount(entryCount) {} + + // One entry per slot, so a consumer table sized Count() and indexed by + // MagmaPipeSlotIndex() has exactly one entry per handle this table can hand out. + Uint32 Count() const { return m_entryCount; } + + MG_Pipe::MGPipeHandle Acquire(Uint64 lifetimeId) { + if (lifetimeId == 0) return MG_Pipe::kMGPipeNullHandle; + if (m_entries.empty()) m_entries.resize(m_entryCount); + + // Lifetime ids are monotonic from 1, so the low bits ARE the dense index: object + // n and object n+1 land in adjacent sets. No mix, because there is no entropy to + // spread - a multiply here would only scatter a sequence that is already perfect. + const Uint32 set = static_cast(lifetimeId) & (SetCount() - 1u); + const Uint32 way0 = set * 2u; + const Uint32 way1 = way0 + 1u; + + if (m_entries[way0].LifetimeId == lifetimeId) return Touch(way0); + if (m_entries[way1].LifetimeId == lifetimeId) return Touch(way1); + + // Miss. Evict the set's least recently used way - the same victim rule the + // address-hashed VaoDrawMemo table used, kept here so that it lives in ONE place + // instead of once per consumer table. + const Uint32 victim = (m_entries[way0].LastUse <= m_entries[way1].LastUse) ? way0 : way1; + Entry& entry = m_entries[victim]; + // The one place Gen may move, and it moves on REUSE: a respecify of the same + // object keeps its {slot, gen} because its lifetime id still matches above. + MOBILEGL_ASSERT(entry.Gen != ~Uint32{0}, + "Magma handle generation wrapped on slot %u; {slot, gen} is no longer " + "unique", + victim + MG_Pipe::kMGPipeFirstAllocatableSlot); + ++entry.Gen; + entry.LifetimeId = lifetimeId; + return Touch(victim); + } + + private: + struct Entry { + Uint64 LifetimeId = 0; + Uint32 Gen = 0; + Uint32 LastUse = 0; + }; + + Uint32 SetCount() const { return m_entryCount / 2u; } + + MG_Pipe::MGPipeHandle Touch(Uint32 index) { + m_entries[index].LastUse = ++m_clock; + return MG_Pipe::MGPipeHandle{index + MG_Pipe::kMGPipeFirstAllocatableSlot, + m_entries[index].Gen}; + } + + Uint32 m_entryCount = 0; + // Wraps every 2^32 acquisitions. A wrapped clock can only ever pick the wrong victim + // inside one set - a cache decision, never a correctness one. + Uint32 m_clock = 0; + Vector m_entries; + }; + + // The table entry a handle names. Every per-slot table Magma keeps is sized Count() and + // indexed by this, so the index is exact and in range by construction. + inline Uint32 MagmaPipeSlotIndex(const MG_Pipe::MGPipeHandle& handle) { + return handle.Slot - MG_Pipe::kMGPipeFirstAllocatableSlot; + } + + // A VAO is kind VertexElementsCso: that is the gallium-shaped CSO a vertex array resolves + // to, and it is the only kind in MGPipeKind that names vertex-input state. 2048 entries + // is what the address-hashed VaoDrawMemo table it replaces held, so the working set this + // covers without eviction is unchanged; at 16 B/entry the table itself is 32 KB. + inline constexpr Uint32 kMagmaVaoIdentityEntries = 2048; + // Buffers are far more numerous than VAOs (Minecraft cycles chunk vertex/index buffers), + // and unlike the VAO table this one feeds a CONTENT hash: an eviction changes the key a + // vertex-input cache entry was built under, so it costs a rebuild rather than a lookup. + // It is only ever consulted when a VAO's configuration version moved (ComputeHash is + // memoised per VAO), so the price is paid per reconfiguration, not per draw - but the + // table is sized four times the VAO one anyway, 128 KB, to keep it rare. + inline constexpr Uint32 kMagmaBufferIdentityEntries = 8192; + + inline MagmaPipeIdentityTable& MagmaPipeVaoIdentity() { + static MagmaPipeIdentityTable table(kMagmaVaoIdentityEntries); + return table; + } + inline MagmaPipeIdentityTable& MagmaPipeBufferIdentity() { + static MagmaPipeIdentityTable table(kMagmaBufferIdentityEntries); + return table; + } + + // The {slot, gen} of a frontend object. `lifetimeId` is the client's own identity for the + // object - never a GL name, never a heap address - so a deleted-and-recreated object at + // the same address cannot reproduce a handle, which is precisely the ABA + // HandleRecycleScenario reproduces. inline MG_Pipe::MGPipeHandle MagmaPipeHandleOf(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) { - return MG_Pipe::MGPipeSlots().Acquire(kind, lifetimeId); + return kind == MG_Pipe::MGPipeKind::Buffer ? MagmaPipeBufferIdentity().Acquire(lifetimeId) + : MagmaPipeVaoIdentity().Acquire(lifetimeId); } #endif // MOBILEGL_PIPE_PUSH } // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp index bb550ce0d..a148ba23d 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp @@ -56,7 +56,10 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint64 bufferKey = attr.Buffer ? attr.Buffer->GetLifetimeId() : 0; #if MOBILEGL_PIPE_PUSH if (attr.Buffer) { - if (MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + // The SAME arm question the other four re-keyed sites ask, through the same + // helper: a site that decided for itself could silently key on the pre-handle + // identity while its neighbours keyed on the handle. + if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { const MG_Pipe::MGPipeHandle handle = MagmaPipeHandleOf(MG_Pipe::MGPipeKind::Buffer, attr.Buffer->GetLifetimeId()); bufferKey = static_cast(handle.Slot) | (static_cast(handle.Gen) << 32); @@ -80,21 +83,23 @@ namespace MobileGL::MG_Backend::DirectVulkan { #if MOBILEGL_PIPE_PUSH VertexInputStateFactory::VaoBackendMemos& VertexInputStateFactory::MemosFor( const MG_State::GLState::VertexArrayObject& vao) const { + static_assert(kVaoMemoSlotCount == kMagmaVaoIdentityEntries, + "this table is indexed directly by MagmaPipeSlotIndex, so it has to hold " + "exactly one entry per slot the VAO identity table can mint"); if (m_vaoMemos.empty()) { m_vaoMemos.resize(kVaoMemoSlotCount); } - const Uint64 lifetimeId = vao.GetLifetimeId(); - if (!m_lastVaoHandleValid || m_lastVaoLifetimeId != lifetimeId) { - m_lastVaoHandle = MagmaPipeHandleOf(MG_Pipe::MGPipeKind::VertexElementsCso, lifetimeId); - m_lastVaoLifetimeId = lifetimeId; - m_lastVaoHandleValid = true; - } - const MG_Pipe::MGPipeHandle handle = m_lastVaoHandle; - VaoBackendMemos& memos = m_vaoMemos[handle.Slot & (kVaoMemoSlotCount - 1)]; + const MG_Pipe::MGPipeHandle handle = + MagmaPipeHandleOf(MG_Pipe::MGPipeKind::VertexElementsCso, vao.GetLifetimeId()); + // One entry per mintable slot - see the static_assert on kVaoMemoSlotCount - so this + // index is exact and two live VAOs cannot share an entry. There is no probe in front + // of it because the mint itself is one: an array index and at most two Uint64 + // compares, which is less than the address hash the pre-handle arm ran. + VaoBackendMemos& memos = m_vaoMemos[MagmaPipeSlotIndex(handle)]; if (!(memos.Owner == handle)) { - // Someone else's entry (a colliding slot, or a slot whose Gen moved because the - // slot was REUSED for a different object). Claim it, contents cleared - never - // inherited, which is the whole point of keying on the generation. + // A slot whose Gen moved because the identity table recycled it for a different + // object. Claim it, contents cleared - never inherited, which is the whole point + // of keying on the generation. memos = VaoBackendMemos{}; memos.Owner = handle; } @@ -105,13 +110,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { #if MOBILEGL_PIPE_PUSH Bool VertexInputStateFactory::TryGetMemoizedHash(const MG_State::GLState::VertexArrayObject& vao, Uint64& outHash) const { - if (MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { const VaoBackendMemos& memos = MemosFor(vao); if (memos.HashConfigVersion != vao.GetConfigVersion()) return false; outHash = memos.Hash; return true; } - MagmaPipeRequireLegacyArm("VertexInputStateFactory::TryGetMemoizedHash"); #if MOBILEGL_PIPE_LEGACY_MEMOS return vao.GetBackendHashMemo(outHash); #else @@ -125,7 +129,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { HashType hash = 0; #if MOBILEGL_PIPE_PUSH // P2 D12.5: the same memo, on the backend's side of the boundary. - if (MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { VaoBackendMemos& memos = MemosFor(vao); if (memos.HashConfigVersion == vao.GetConfigVersion()) { return memos.Hash; @@ -135,7 +139,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { memos.HashConfigVersion = vao.GetConfigVersion(); return hash; } - MagmaPipeRequireLegacyArm("VertexInputStateFactory::GetOrComputeHash"); #endif #if MOBILEGL_PIPE_LEGACY_MEMOS if (!vao.GetBackendHashMemo(hash)) { @@ -154,7 +157,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // survives the move and is still what stops a stale pointer being dereferenced: the // POINTEE is a cache entry this factory can erase at a frame boundary, and moving the // memo does not change that. - if (MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { VaoBackendMemos& memos = MemosFor(vao); if (memos.StateConfigVersion == vao.GetConfigVersion() && memos.State != nullptr && memos.StateEpoch == m_evictionEpoch) { @@ -164,10 +167,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { } const BackendVertexInputState& resolved = GetOrCreateVertexInputState(vao, GetOrComputeHash(vao)); - // MemosFor is re-taken: GetOrComputeHash above went through it, and a colliding - // VAO could have claimed the entry in between (it cannot here, since both calls - // name the same VAO, but the reference is not worth keeping live across a call - // that can resize the table). + // MemosFor is re-taken rather than kept live across GetOrCreateVertexInputState: + // the reference is not worth holding across a call that can resize the table. VaoBackendMemos& stamp = MemosFor(vao); stamp.State = &resolved; stamp.StateEpoch = m_evictionEpoch; @@ -177,12 +178,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { // live reader anywhere, so the handle arm retires it rather than moving it. return resolved; } - MagmaPipeRequireLegacyArm("VertexInputStateFactory::GetOrCreateVertexInputState"); #endif #if !MOBILEGL_PIPE_LEGACY_MEMOS - // Unreachable: with no legacy arm compiled, MagmaPipeRequireLegacyArm above aborts. - // Written out rather than left to fall off the end so the function still has a return - // on every path a compiler can see. + // Unreachable: with no legacy arm compiled MagmaPipeTrackHArmIsHandles is a compile- + // time true, so the handle arm above always returns. Written out rather than left to + // fall off the end so the function still has a return on every path a compiler sees. return GetOrCreateVertexInputState(vao, GetOrComputeHash(vao)); #else // Per-draw fast path: the VAO carries a pointer to its resolved entry, diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h index 83174c143..6e5a4baac 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h @@ -136,9 +136,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { // and layoutAuxMasks long ago and its getter has no live reader anywhere in the tree, // so the handle arm simply stops writing it (D12.5 says delete rather than move). struct VaoBackendMemos { - // Whose memos these are. A slot is direct-mapped into the table below, so an - // entry can be claimed by a different VAO; the handle compare is what says the - // contents are this object's. + // Whose memos these are. The identity table can recycle a slot for a different + // VAO under LRU pressure, and the handle compare - Gen included - is what says + // the contents are this object's and not its predecessor's. MG_Pipe::MGPipeHandle Owner = MG_Pipe::kMGPipeNullHandle; Uint64 Hash = 0; Uint32 HashConfigVersion = ~0u; @@ -146,16 +146,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint64 StateEpoch = 0; Uint32 StateConfigVersion = ~0u; }; - // Fixed and direct-mapped for the same reason VulkanRenderer's VaoDrawMemo table is - // (P2 m3): nothing frees a VertexElementsCso slot yet, so a grow-on-demand table - // would keep one entry per VAO ever created. 2048 x 48 B is 96 KB. + // Fixed, and a BIJECTION with the identity table that mints the slots + // (MagmaPipeVaoIdentity): entry i is slot i + kMGPipeFirstAllocatableSlot, so the + // index is exact, no two live VAOs can share an entry, and the eviction decision lives + // once - in the identity table's 2-way LRU - instead of once per consumer table. + // Pinned against the mint by a static_assert in VertexInputStateFactory.cpp. + // 2048 x 48 B is 96 KB. static constexpr Uint32 kVaoMemoSlotCount = 2048; // power of two mutable Vector m_vaoMemos; - // One-entry memo in front of the allocator's lifetimeId -> handle probe, same shape - // and same reason as VulkanRenderer::ResolveVaoHandle. - mutable Uint64 m_lastVaoLifetimeId = 0; - mutable MG_Pipe::MGPipeHandle m_lastVaoHandle = MG_Pipe::kMGPipeNullHandle; - mutable Bool m_lastVaoHandleValid = false; // The entry belonging to `vao`, claimed (and cleared) if the slot currently holds // someone else's. VaoBackendMemos& MemosFor(const MG_State::GLState::VertexArrayObject& vao) const; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 25e5ad06b..275f680a8 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -432,6 +432,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { "move the pipeline version, not the parameters version, and the tail would stop " \ "being re-run for it") + // Viewports, DepthRanges and ScissorBoxes are asserted over the WHOLE array while the + // tail reads only element 0. That is deliberately stricter than the reader needs: the + // chunk table has no per-element granularity today, so an array that is dynamic at all + // is dynamic entirely, and asserting the whole of it says so. If a later phase ever + // splits a per-viewport chunk out, this is a build break by design - narrow the assert + // to element 0 then, and say why in the same commit. MAGMA_TAIL_INPUT_IS_DYNAMIC(Viewports); // ApplyGLViewportState: Viewports[0] MAGMA_TAIL_INPUT_IS_DYNAMIC(DepthRanges); // ApplyGLViewportState: DepthRanges[0] MAGMA_TAIL_INPUT_IS_DYNAMIC(BlendColor); // ApplyBlendConstants @@ -3098,6 +3104,13 @@ void main() { } void VulkanRenderer::Initialize() { +#if MOBILEGL_PIPE_PUSH + // P2 D14, and it belongs HERE rather than on a draw: "a Track-H subsystem whose bit is + // clear is a STARTUP Fatal{PipeLegacyMemosDisabled}". Checks Magma's own bit only, and + // only once this backend is the one being brought up, so an Espryt-side bitmask cannot + // kill a Magma run and vice versa. + MagmaPipeValidateSubsystemConfiguration(); +#endif CreateInstance(); CreateSurface(); PickPhysicalDevice(); @@ -3628,22 +3641,21 @@ void main() { #if MOBILEGL_PIPE_PUSH // ---- P2 D12.4, the handle arm ---- // - // The slot IS the index. No Fibonacci mix of an address, no two-way probe, no - // frame-serial recycling choice: slots are dense by construction (the allocator has a - // free list plus a high-water mark), so consecutive VAOs land in consecutive entries - // and the collision the address hash existed to spread does not arise below the table - // size. The whole validation is one handle compare, and a handle cannot alias - Gen - // moves on slot REUSE, so a deleted VAO's successor never matches its predecessor's - // entry even at the same address and with a byte-identical configuration. - if (MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + // The slot IS the index, exactly: this table and MagmaPipeVaoIdentity() hold the same + // number of entries and the mint hands out slot i + kMGPipeFirstAllocatableSlot for + // entry i, so the map from live handle to entry is a BIJECTION. No Fibonacci mix of an + // address, no two-way probe here, no frame-serial recycling choice here - not because + // eviction stopped being necessary, but because it happens ONE level down, in the + // identity table's 2-way LRU, where a single decision serves this table and the + // factory's. Two live VAOs cannot land on one entry of this table at all. + // + // The whole validation is one handle compare, and a handle cannot alias: Gen moves + // whenever a slot changes owner, so neither a deleted VAO's successor at the same heap + // address nor a VAO whose slot was recycled under LRU pressure can match a predecessor's + // entry, even with a byte-identical configuration. + if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { const MG_Pipe::MGPipeHandle handle = ResolveVaoHandle(*vao); - // Fixed table, so the index wraps rather than growing: nothing in P2 frees a - // VertexElementsCso slot yet (the frontend death notification is Espryt 0b's e2, - // and buffers are the only kind with one today), so a grow-on-demand vector would - // hold one ~1 KB VaoDrawMemo per VAO EVER created. Above the table size this - // degrades to a direct-mapped cache validated by the full {slot, gen}, which is - // strictly better than the address hash it replaces - never wrong, only colder. - const Uint32 index = handle.Slot & (kVaoDrawMemoSlotCount - 1); + const Uint32 index = MagmaPipeSlotIndex(handle); VaoDrawMemo& entry = m_vaoDrawMemoTable[index]; if (entry.vaoHandle == handle) { return &entry; @@ -3660,7 +3672,6 @@ void main() { entry.bindings.indexBuffer = nullptr; return &entry; } - MagmaPipeRequireLegacyArm("LookupVaoDrawMemo"); #endif // Multiplicative mix of the (16-byte-aligned) address; take high bits, they // carry the most entropy of a multiply. @@ -5041,6 +5052,21 @@ void main() { } #endif // MOBILEGL_PIPE_LEGACY_MEMOS +#if MOBILEGL_PIPE_PUSH && !MOBILEGL_PIPE_LEGACY_MEMOS + Uint64 VulkanRenderer::ComputePipelineSubsetStateHashFallback() const { + // The client's own hash, over the client's own definition of the pipeline subset - the + // seven pipeline chunks of the P2 chunk table, which is a strict SUPERSET of what + // ComputePipelineStateHash enumerated by hand. The render-pass facts it does not carry + // (colorAttachmentCount, the rasterization sample count, and through them the effective + // sample mask) are exactly the facts entry.renderPassHash separates, which is why the + // CSO handle can key this memo in the first place; this fallback inherits that argument + // unchanged. + // + // Only reached with no render-state CSO bound, and only in a build with no pre-handle + // arm to fall back to instead. + return MG_Pipe::MGPipeComputePipelineSubsetHash(MGB_CTX->GetRenderStateParameters()); + } +#endif // A program that runs a geometry shader AND captures transform feedback. Both halves are // link-time properties, so this is safe to fold into a pipeline keyed on the program hash. @@ -5123,32 +5149,29 @@ void main() { // real handle, the legacy arm a real hash and the null handle, and the probe compares // both components. const MG_Pipe::MGPipeHandle renderStateCso = ResolveBoundRenderStateCso(); -#endif -#if MOBILEGL_PIPE_LEGACY_MEMOS -#if MOBILEGL_PIPE_PUSH - if (MG_Pipe::MGPipeHandleIsNull(renderStateCso)) -#endif - { - if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion || - m_pipelineStateHashColorCount != renderPassEntry.colorAttachmentCount || - m_pipelineStateHashSampleCount != renderPassEntry.sampleCount) { - m_pipelineStateHash = - ComputePipelineStateHash(renderPassEntry.colorAttachmentCount, renderPassEntry.sampleCount); - m_pipelineStateHashVersion = renderStateVersion; - m_pipelineStateHashColorCount = renderPassEntry.colorAttachmentCount; - m_pipelineStateHashSampleCount = renderPassEntry.sampleCount; - m_pipelineStateHashValid = true; - } - } -#endif + // Exactly one of the two state keys is live per draw, and the ternary short-circuits, + // so a draw on the handle arm neither hashes nor touches the fallback cache. const Uint64 pipelineStateHash = -#if MOBILEGL_PIPE_PUSH - !MG_Pipe::MGPipeHandleIsNull(renderStateCso) ? 0 : -#endif -#if MOBILEGL_PIPE_LEGACY_MEMOS - m_pipelineStateHash; + !MG_Pipe::MGPipeHandleIsNull(renderStateCso) + ? 0 + : ResolveFallbackPipelineStateHash(renderStateVersion, + renderPassEntry.colorAttachmentCount, + renderPassEntry.sampleCount); #else - 0; + // THE PULL BUILD'S TEXT, statement for statement what the base ref has: G1 admits no + // resize of this function, and a helper the compiler merely inlines is not the same + // instruction schedule. + if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion || + m_pipelineStateHashColorCount != renderPassEntry.colorAttachmentCount || + m_pipelineStateHashSampleCount != renderPassEntry.sampleCount) { + m_pipelineStateHash = + ComputePipelineStateHash(renderPassEntry.colorAttachmentCount, renderPassEntry.sampleCount); + m_pipelineStateHashVersion = renderStateVersion; + m_pipelineStateHashColorCount = renderPassEntry.colorAttachmentCount; + m_pipelineStateHashSampleCount = renderPassEntry.sampleCount; + m_pipelineStateHashValid = true; + } + const Uint64 pipelineStateHash = m_pipelineStateHash; #endif for (Uint32 i = 0; i < m_pipelineMemoCount; ++i) { const PipelineMemoEntry& entry = m_pipelineMemo[i]; @@ -6287,7 +6310,7 @@ void main() { // the two cannot disagree about whether "the VAO moved". The config version stays: // it answers a different question (did this same object's layout change). const Bool vaoMoved = - MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemMagmaVertexInput) + MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput) ? (!(ResolveVaoHandle(vao) == snap.vaoHandle) || vao.GetConfigVersion() != snap.vaoConfigVersion) : (static_cast(&vao) != snap.vao || @@ -6528,32 +6551,24 @@ void main() { // fast path's copy of it, and the two must key identically or the fast path would // hand back a pipeline the full path would not have matched. const MG_Pipe::MGPipeHandle renderStateCso = ResolveBoundRenderStateCso(); -#endif -#if MOBILEGL_PIPE_LEGACY_MEMOS -#if MOBILEGL_PIPE_PUSH - if (MG_Pipe::MGPipeHandleIsNull(renderStateCso)) -#endif - { - if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion || - m_pipelineStateHashColorCount != snap.renderPassColorCount || - m_pipelineStateHashSampleCount != snap.renderPassSampleCount) { - m_pipelineStateHash = - ComputePipelineStateHash(snap.renderPassColorCount, snap.renderPassSampleCount); - m_pipelineStateHashVersion = renderStateVersion; - m_pipelineStateHashColorCount = snap.renderPassColorCount; - m_pipelineStateHashSampleCount = snap.renderPassSampleCount; - m_pipelineStateHashValid = true; - } - } -#endif const Uint64 pipelineStateHash = -#if MOBILEGL_PIPE_PUSH - !MG_Pipe::MGPipeHandleIsNull(renderStateCso) ? 0 : -#endif -#if MOBILEGL_PIPE_LEGACY_MEMOS - m_pipelineStateHash; + !MG_Pipe::MGPipeHandleIsNull(renderStateCso) + ? 0 + : ResolveFallbackPipelineStateHash(renderStateVersion, snap.renderPassColorCount, + snap.renderPassSampleCount); #else - 0; + // The pull build's text, statement for statement (see GetOrCreatePipeline). + if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion || + m_pipelineStateHashColorCount != snap.renderPassColorCount || + m_pipelineStateHashSampleCount != snap.renderPassSampleCount) { + m_pipelineStateHash = + ComputePipelineStateHash(snap.renderPassColorCount, snap.renderPassSampleCount); + m_pipelineStateHashVersion = renderStateVersion; + m_pipelineStateHashColorCount = snap.renderPassColorCount; + m_pipelineStateHashSampleCount = snap.renderPassSampleCount; + m_pipelineStateHashValid = true; + } + const Uint64 pipelineStateHash = m_pipelineStateHash; #endif const auto memoTransformFlags = ProgramFactory::CompileOptionFlags(snap.resolvedTransformFlags); @@ -6600,7 +6615,13 @@ void main() { snap.vao = static_cast(&vao); snap.vaoLifetimeId = vao.GetLifetimeId(); #if MOBILEGL_PIPE_PUSH - snap.vaoHandle = ResolveVaoHandle(vao); + // Guarded by the SUBSYSTEM, not only by the build switch: with bit 6 clear the field + // is dead (vaoMoved takes the address/lifetime-id branch), and minting a handle for it + // would put this package's cost inside MOBILEGL_PIPE_PUSH=0 - the all-pull control arm + // D14 defines as reproducing P1 exactly, and the arm D.4.3's T2 is measured on. + if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + snap.vaoHandle = ResolveVaoHandle(vao); + } #endif snap.vaoConfigVersion = vao.GetConfigVersion(); snap.vaoLayoutHash = vaoLayoutHash; @@ -7184,7 +7205,11 @@ void main() { snap.vao = &vao; snap.vaoLifetimeId = vao.GetLifetimeId(); #if MOBILEGL_PIPE_PUSH - snap.vaoHandle = ResolveVaoHandle(vao); + // Subsystem-guarded for the same reason as the other stamping site: the field + // is dead with bit 6 clear, and MOBILEGL_PIPE_PUSH=0 has to be P1 exactly. + if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + snap.vaoHandle = ResolveVaoHandle(vao); + } #endif snap.vaoConfigVersion = vao.GetConfigVersion(); snap.drawFbo = drawFbo.get(); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index fa95d9096..4ac576211 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -884,23 +884,72 @@ namespace MobileGL::MG_Backend::DirectVulkan { // The arm is live only when the render-state subsystem is migrated in this run AND the // client has actually bound a CSO. The second half is not belt and braces: a tree whose // tracker does not emit create/bind_render_state yet has no handle to key on, and - // keying every draw on the null handle would alias every render state onto one entry. + // delete_render_state clears the binding (MG_Pipe/PipeApply.cpp), so the null handle is + // reachable on any tree. Keying every draw on it would alias every render state onto + // one memo entry, so a null handle means "fall back to a state hash" - never an abort, + // and never a per-draw consultation of the legacy-memo lever: bit 0 is not a Track-H + // subsystem (D14 labels only bits 5 and 6 that), and the lever's Fatal is a STARTUP + // one, in MagmaPipeValidateSubsystemConfiguration. + // + // The fallback is warned ONCE rather than logged at debug, and that is deliberate: a + // silent fallback is what makes "the CSO arm never ran" easy to miss. W is compiled in + // at every shipped log level, _ONCE costs one static bool test, and its ABSENCE from a + // run's log is the positive evidence that every draw keyed on a handle. // // Push-only by construction: the pull build does not compile this function at all, so // its two callers are statement-for-statement what they were (G1). MG_Pipe::MGPipeHandle ResolveBoundRenderStateCso() const { if (!MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemRenderState)) { - MagmaPipeRequireLegacyArm("GetOrCreatePipeline"); return MG_Pipe::kMGPipeNullHandle; } const MG_Pipe::MGPipeHandle boundCso = MG_Pipe::MGPipeApplier().BoundRenderStateCso; if (MG_Pipe::MGPipeHandleIsNull(boundCso)) { - MGLOG_D_ONCE("MGPipe: kMGPipeSubsystemRenderState is on but no render-state CSO is " - "bound; the pipeline memo falls back to the pre-handle state hash"); - MagmaPipeRequireLegacyArm("GetOrCreatePipeline"); + MGLOG_W_ONCE("MGPipe: kMGPipeSubsystemRenderState is on but no render-state CSO is " + "bound; the pipeline memo is running on a state hash, not on the CSO " + "handle (no tracker on this build, or a draw between " + "delete_render_state and the next bind)"); } return boundCso; } + // The memo key's STATE-HASH half, for a draw that has no CSO handle to key on: the + // pre-handle arm, and the fallback of D12.1's handle arm. Cached on the pipeline-state + // version plus the two render-pass facts the hash's inputs depend on, so an unchanged + // (version, colorAttachmentCount, sampleCount) proves the bytes are unchanged. + // + // [deviation from D12.1] The brief deletes this gate and its cached fields outright. + // They cannot go while a no-CSO draw is reachable - and it is, on any tree: a draw + // between delete_render_state and the next bind has no handle. On a tree whose tracker + // binds a CSO these five words are written once and never read again; they retire for + // real when the pull path does, at P13. + Uint64 ResolveFallbackPipelineStateHash(Uint renderStateVersion, Uint32 colorAttachmentCount, + VkSampleCountFlagBits rasterizationSamples) { + if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion || + m_pipelineStateHashColorCount != colorAttachmentCount || + m_pipelineStateHashSampleCount != rasterizationSamples) { +#if MOBILEGL_PIPE_LEGACY_MEMOS + m_pipelineStateHash = + ComputePipelineStateHash(colorAttachmentCount, rasterizationSamples); +#else + m_pipelineStateHash = ComputePipelineSubsetStateHashFallback(); +#endif + m_pipelineStateHashVersion = renderStateVersion; + m_pipelineStateHashColorCount = colorAttachmentCount; + m_pipelineStateHashSampleCount = rasterizationSamples; + m_pipelineStateHashValid = true; + } + return m_pipelineStateHash; + } +#endif // MOBILEGL_PIPE_PUSH +#if MOBILEGL_PIPE_PUSH && !MOBILEGL_PIPE_LEGACY_MEMOS + // The same answer as ComputePipelineStateHash, computed from the P2 chunk table + // instead of from a hand-written field list, for the build that compiles no + // pre-handle arm (cmake -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF). It is the CLIENT's own + // hash function - MGPipeComputePipelineSubsetHash over the 396 pipeline bytes - so a + // draw keyed on it and a draw keyed on a CSO handle are keyed on the same equivalence + // class of state, and the render-pass facts stay separated by renderPassHash either + // way. This is what makes the no-legacy build RUNNABLE rather than a configuration + // that aborts on the first draw that arrives without a CSO. + Uint64 ComputePipelineSubsetStateHashFallback() const; #endif #if MOBILEGL_PIPE_LEGACY_MEMOS // THE PRE-HANDLE ARM (P2 brief D12.1 / D14). Hash of every fixed-function GL state the @@ -924,7 +973,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { // the re-key and keeps reading Multisample / SampleMask / SampleMaskValue out of the // working block. Uint32 ResolveEffectiveSampleMask(VkSampleCountFlagBits rasterizationSamples) const; -#if MOBILEGL_PIPE_LEGACY_MEMOS + // ResolveFallbackPipelineStateHash's cache. Written once and never read again on a + // build whose client binds a render-state CSO; see that function for why it survives + // the re-key at all. Uint m_pipelineStateHashVersion = 0; Uint32 m_pipelineStateHashColorCount = 0; // The sample count the cached hash was computed at. A pipeline-state input now depends on @@ -933,7 +984,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkSampleCountFlagBits m_pipelineStateHashSampleCount = VK_SAMPLE_COUNT_1_BIT; Uint64 m_pipelineStateHash = 0; Bool m_pipelineStateHashValid = false; -#endif // GetShaderTransformFlags memo. NOT pure in the pre-transform alone: the // function also reads whether the bound DRAW framebuffer is the default one // (only the default framebuffer gets the Y-flip and rotation bits - an FBO @@ -956,9 +1006,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { void InvalidatePipelineMemo() { m_pipelineMemoCount = 0; m_pipelineMemoNext = 0; -#if MOBILEGL_PIPE_LEGACY_MEMOS m_pipelineStateHashValid = false; -#endif } UnorderedMap m_computePipelines; UniquePtr m_programFactory; @@ -1359,32 +1407,25 @@ namespace MobileGL::MG_Backend::DirectVulkan { // fixed table also makes every VaoDrawMemo/ResolvedVertexBindings pointer // stable for the duration of a draw, which the EBO memo handoff // (m_currentDrawResolvedEntry) relies on. + // [deviation from D12.4] The brief asks for a grow-on-demand Vector. The table is + // FIXED, and under the handle arm it is sized to - and is a BIJECTION with - the + // identity table that mints the slots (MagmaPipeVaoIdentity), so entry i is slot + // i + kMGPipeFirstAllocatableSlot and no two live VAOs can ever share it. Growing on + // demand only makes sense against an allocator that frees, and nothing in P2 frees a + // VertexElementsCso slot; the eviction that has to happen somewhere happens once, in + // the identity table's LRU, instead of twice in two tables that could disagree. static constexpr Uint32 kVaoDrawMemoSlotCount = 2048; // power of two Vector m_vaoDrawMemoTable; #if MOBILEGL_PIPE_PUSH - // One-entry memo in front of the slot allocator's lifetimeId -> handle map (P2 - // D12.4). Acquiring a handle is a hash probe, and LookupVaoDrawMemo runs per draw, so - // the arm would otherwise have swapped one probe (the address hash it deletes) for - // another. A run of draws over one VAO - the common intra-batch shape - pays a single - // Uint64 compare instead. - // - // A lifetime id is never reused, so a hit can only ever be this same object; the - // valid flag exists rather than a zero sentinel because nothing promises the frontend - // counter starts above zero. - Uint64 m_lastVaoHandleLifetimeId = 0; - MG_Pipe::MGPipeHandle m_lastVaoHandle = MG_Pipe::kMGPipeNullHandle; - Bool m_lastVaoHandleValid = false; + static_assert(kVaoDrawMemoSlotCount == kMagmaVaoIdentityEntries, + "the VAO draw memo table is indexed directly by MagmaPipeSlotIndex, so it has " + "to hold exactly one entry per slot the VAO identity table can mint"); + // The VAO's {slot, gen}. An array probe (one mask, at most two Uint64 compares), so + // there is no memo in front of it: the address multiply plus two-way probe it replaces + // cost more than this does, and a cached handle could go stale behind the identity + // table's own eviction, which is a class of bug worth not having. MG_Pipe::MGPipeHandle ResolveVaoHandle(const MG_State::GLState::VertexArrayObject& vao) { - const Uint64 lifetimeId = vao.GetLifetimeId(); - if (m_lastVaoHandleValid && m_lastVaoHandleLifetimeId == lifetimeId) { - return m_lastVaoHandle; - } - const MG_Pipe::MGPipeHandle handle = - MagmaPipeHandleOf(MG_Pipe::MGPipeKind::VertexElementsCso, lifetimeId); - m_lastVaoHandleLifetimeId = lifetimeId; - m_lastVaoHandle = handle; - m_lastVaoHandleValid = true; - return handle; + return MagmaPipeHandleOf(MG_Pipe::MGPipeKind::VertexElementsCso, vao.GetLifetimeId()); } #endif // "Is this VAO's content hash already memoized?", asked of whichever side owns the From 46841ac7063938ad5c1ee55f00bf0f75073ed61a Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 10:00:59 -0400 Subject: [PATCH 121/529] [Fix] (Magma): assert rather than assume that a handle indexed into a per-slot table is non-null - MagmaPipeSlotIndex subtracted kMGPipeFirstAllocatableSlot unconditionally, so a null handle would have indexed a per-slot table at ~0u. It is unreachable - both lifetime-id sources start at 1, which is why Acquire's zero guard never fires - but the consequence of being wrong about that is an out-of-range write, not a wrong answer, so it is asserted and the index is pinned to 0 in a release build. --- .../DirectVulkan/Renderer/MagmaPipeArms.h | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h index ccb200796..dd0474979 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h @@ -149,6 +149,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint32 Count() const { return m_entryCount; } MG_Pipe::MGPipeHandle Acquire(Uint64 lifetimeId) { + // Unreachable: MG_State hands out lifetime ids from 1 precisely so that a + // zero-initialised memo slot cannot carry a live object's id. Guarded anyway so + // that a zero can never be minted into a slot and then indexed with. if (lifetimeId == 0) return MG_Pipe::kMGPipeNullHandle; if (m_entries.empty()) m_entries.resize(m_entryCount); @@ -202,8 +205,17 @@ namespace MobileGL::MG_Backend::DirectVulkan { // The table entry a handle names. Every per-slot table Magma keeps is sized Count() and // indexed by this, so the index is exact and in range by construction. + // + // A null handle has no slot, and it is unreachable here: both lifetime-id sources start at + // 1 (VertexArrayObject.cpp, BufferObject.cpp), so Acquire's zero guard never fires. + // Asserted rather than assumed, because being wrong about it would be an out-of-range + // index rather than a wrong answer. inline Uint32 MagmaPipeSlotIndex(const MG_Pipe::MGPipeHandle& handle) { - return handle.Slot - MG_Pipe::kMGPipeFirstAllocatableSlot; + MOBILEGL_ASSERT(!MG_Pipe::MGPipeHandleIsNull(handle), + "a null MGPipeHandle has no slot to index a per-slot table with"); + return MG_Pipe::MGPipeHandleIsNull(handle) + ? 0u + : handle.Slot - MG_Pipe::kMGPipeFirstAllocatableSlot; } // A VAO is kind VertexElementsCso: that is the gallium-shaped CSO a vertex array resolves From a174a06c79b406d45699805c7bc73e54e9d7cef5 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 11:16:25 -0400 Subject: [PATCH 122/529] [Fix] (Magma): size the {slot, gen} mint by the live working set instead of by a capacity, and give it to the renderer that uses it - MagmaPipeIdentityTable was a FIXED 2048/8192-entry, 2-way set-associative LRU. Above capacity it evicted LIVE objects, and every memo keyed on the handle died with them: a verbatim transcription of the previous Acquire lost 54% of uses' handles at 2500 live VAOs against 2048 entries, and 20% at 1024 live VAOs once the lifetime ids are sparse (an app that creates and destroys VAOs - the Minecraft chunk shape this exists for). - Two of the three memos it fed had NO capacity before this package: the content-hash memo and the resolved-state memo were unbounded mutable fields on VertexArrayObject. Eviction there turns one ComputeHash per VAO reconfiguration into one per DRAW; once the buffer table thrashes too, the vertex-input content hash becomes a per-draw value that inserts a fresh heap-allocated BackendVertexInputState into an unbounded map on every draw, swept only every 256 frame boundaries. That is a worse leak than the one the fixed table was introduced to avoid. - So the mint grows on demand and reclaims by AGE: a lifetime-id map with a one-entry front memo, a free list, and an OnFrameBoundary sweep on the same cadence and retirement age as the cache entries those slots key. Footprint tracks the live DRAWN working set instead of objects ever created, which is the property MG_Impl/Pipe/SlotAllocator cannot have here (nothing in P2 can call its Free). Re-run of the same workloads: handle churn is 0.0% at 512, 1024, 2048, 2500, 3000, 4000, 8192, 10000 and 16000 live objects, consecutive and sparse ids alike, at 1 and 5 acquisitions per use. - VertexInputStateFactory::m_vaoMemos follows the mint with no capacity of its own, through a chunked table whose entry addresses never move - which is what the fixed table's only real guarantee was, and D12.4's grow-on-demand ask without a relocating Vector. - VulkanRenderer::m_vaoDrawMemoTable deliberately keeps the base ref's 2048 entries and the base ref's older-frameSerial victim rule, and changes only its KEY. It is the one memo of the three that had a capacity before P2, a VaoDrawMemo is ~450 B, and losing one costs one vertex-binding re-resolve. Measured steady-state miss rate against the base ref's address-hashed table: 0.0% vs 6.4% at 512 live VAOs, 0.0% vs 24.0% at 1024, 0.0% vs 60.0% at 2048, 36.2% vs 69.5% at 2500, 63.5% vs 79.1% at 3000; both are ~100% at 4096 (2x capacity), where an LRU on a cyclic pattern cannot win. - The two tables are now a MagmaPipeIdentityTables member of VulkanRenderer, handed to its VertexInputStateFactory, instead of two function-local statics that outlived every context and shared one reclamation clock across two. - A Gen that reaches 2^32-1 retires its slot for good rather than wrapping. MOBILEGL_ASSERT is compiled out of every build P2 runs, and a DEBUG-level build of this tree does not compile at all (MG_Util/Types.h uses MOBILEGL_ASSERT before MGLOG_F is declared - untouched since the base ref, and not this package's file), so the defence has to be on the release path to exist. - The no-CSO pipeline-memo fallback stops using MGLOG_W_ONCE. MOBILEGL_LOG_ONCE_INTERNAL is an unconditional std::atomic_flag::test_and_set - a locked xchg per evaluation, not "one static bool test" - and this site is on the per-draw path in exactly the configuration that reaches it. A plain per-renderer bool replaces it, and the comment now says what the warning's absence does and does not prove (nothing at all while bit 0 is clear). - MOBILEGL_PIPE_LEGACY_MEMOS=0 with kMGPipeSubsystemRenderState clear still runs the pre-handle state hash - there is a correct answer there and bit 0 is not Track H, so it is not fatal - but it is no longer silent: the startup gate names the combination. - The D12.3 static_assert block now names D19's DynamicChunksCoverMagmasDynamicTailKey, whose ctest entry lives in package A's file, so the integrator can see which half is missing. --- .../DirectVulkan/Renderer/MagmaPipeArms.h | 335 ++++++++++++------ .../Renderer/VertexInputStateFactory.cpp | 18 +- .../Renderer/VertexInputStateFactory.h | 28 +- .../DirectVulkan/Renderer/VulkanRenderer.cpp | 90 +++-- .../DirectVulkan/Renderer/VulkanRenderer.h | 78 ++-- 5 files changed, 382 insertions(+), 167 deletions(-) diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h index dd0474979..34a46b571 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h @@ -21,7 +21,7 @@ #include // Magma's arm selector for the P2 Track H / render-state re-keys (P2 brief D14), and the -// bounded {slot, gen} mint the re-keyed sites are written against. +// {slot, gen} mint the re-keyed sites are written against. // // Two switches decide which arm a re-keyed site runs, and they are NOT the same switch: // @@ -64,7 +64,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // DirectVulkan run does not execute one line of DirectGLES' re-key), so // MOBILEGL_PIPE_PUSH=0x20 must not kill a Magma run, and MOBILEGL_PIPE_PUSH=0x40 must // not kill an Espryt one. - // * bit 0 (kMGPipeSubsystemRenderState) is NOT Track H and is NOT checked. It is not a + // * bit 0 (kMGPipeSubsystemRenderState) is NOT Track H and is NOT fatal. It is not a // memo re-key at all: it decides where the pipeline memo's STATE KEY comes from, and // a clear bit there simply means the client is not pushing render-state CSOs in this // run, which GetOrCreatePipeline answers with its own state hash. D14 labels bits 5 @@ -72,7 +72,27 @@ namespace MobileGL::MG_Backend::DirectVulkan { // * it is Fatal at STARTUP, once, not on a draw. A per-draw abort inside // GetOrCreatePipeline turns a configuration mistake into a mid-frame crash and puts a // branch nobody needs on the hottest path in the backend. + // + // [declared deviation from D14, review v2 minor 2] D14's runtime row reads "false: the + // legacy arm is never entered", and D14's compile-switch row names ComputePipelineStateHash + // as part of the pre-handle arm. Those two together would make MOBILEGL_PIPE_LEGACY_MEMOS=0 + // with bit 0 CLEAR a contradiction: the pipeline memo has no CSO handle to key on, so it + // keys on a state hash, and in a build that compiles the pre-handle arm that hash IS + // ComputePipelineStateHash. Magma does not make that fatal - bit 0 is not Track H, and + // there is a correct answer (the state hash) where for bits 5/6 there is none - but it no + // longer does it SILENTLY: the combination is named once, at startup, right here. inline void MagmaPipeValidateSubsystemConfiguration() { + if (!MG_Config::Features.PipeLegacyMemos && + !MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemRenderState)) { + MGLOG_W("MGPipe: MOBILEGL_PIPE_LEGACY_MEMOS=0 with kMGPipeSubsystemRenderState (bit 0 " + "of MOBILEGL_PIPE_PUSH) clear - Magma's pipeline memo has no CSO handle to key " + "on, so every draw whose pipeline-state version moved runs the pre-handle STATE " + "HASH instead. That is not a Track-H subsystem and not fatal, but it is not the " + "handle arm either: set bit 0 (MOBILEGL_PIPE_PUSH=0x%llx) if this run was meant " + "to measure it.", + static_cast(MG_Config::Features.PipePush | + MG_Pipe::kMGPipeSubsystemRenderState)); + } #if MOBILEGL_PIPE_LEGACY_MEMOS // The pre-handle arm is compiled AND the operator has not forbidden entering it, so a // clear bit is an ordinary, valid A/B: the site takes the legacy arm. @@ -114,102 +134,221 @@ namespace MobileGL::MG_Backend::DirectVulkan { // The {slot, gen} mint // --------------------------------------------------------------------------------- // - // A FIXED-CAPACITY, SELF-RECYCLING identity table: 2-way set-associative, indexed by the - // frontend object's lifetime id, LRU victim within the set, and Gen incremented whenever - // a slot changes owner. It hands out slots in [kMGPipeFirstAllocatableSlot, Count], so a - // consumer's per-slot table is a BIJECTION with this one - one entry per slot, no - // masking, no collision, no probe. + // Maps a frontend object's never-reused lifetime id to a dense {slot, gen}. Three + // properties, and the third is the one review v2 got wrong: + // + // 1. exact identity - Gen moves whenever a slot changes owner, so a stale handle can + // never match a live object even if the allocator hands back the same heap address + // (the ABA HandleRecycleScenario reproduces); + // 2. dense slots - the slot IS an index, so a consumer's per-slot table needs no hash, + // no probe and no mix; + // 3. NO CAPACITY CLIFF. A live object's handle never changes while the object is being + // drawn, whatever the working set size. + // + // Property 3 is why this is not the fixed 2-way set-associative LRU the previous round + // shipped. That structure evicted a LIVE object once the working set passed its capacity, + // and every consumer memo keyed on the handle died with it: measured on a verbatim + // transcription, 54% of uses lost their handle at 2500 live VAOs against 2048 entries, and + // 20% at 1024 live VAOs once the lifetime ids are sparse (an app that creates and destroys + // VAOs, which is the Minecraft chunk shape this exists for). Two of the three memos it + // fed - the content-hash memo and the resolved-state memo - had NO capacity before this + // package: they were unbounded mutable fields on VertexArrayObject. Introducing eviction + // there turns one ComputeHash per VAO reconfiguration into one per DRAW, and, once the + // buffer table thrashes too, makes the vertex-input content hash a per-draw value that + // inserts a fresh heap-allocated BackendVertexInputState into an unbounded map on every + // draw. That is a worse leak than the one it was introduced to avoid. // - // Why not MG_Impl/Pipe/SlotAllocator (the client's allocator, which is what mints handles - // in the finished design)? Because in P2 nothing on this side ever frees one. The tracker - // does not emit object-class state yet (P2 emits for dirty bits 0-4), so no create_*/ - // delete_* pair travels for a VAO or a buffer, and the frontend has no death notification - // Magma could hook: BufferBackendOps::OnDestroy is handed a BackendBufferResource, not the - // BufferObject, and fires only for a buffer that ever had one, while VertexArrayObject has - // no hook at all (adding one is D13's explicit-destroy work, and it covers Espryt's six - // kinds, not VertexElementsCso). An allocator with a live Allocate and a dead Free grows - // by one SlotState plus one hash-map node per object EVER created, for the life of the - // process, on a platform with an LMK - and its slot numbers then grow monotonically with - // objects ever created, which is exactly what would make a slot-indexed table collide. + // So: grow on demand, and reclaim by AGE instead of by capacity. // - // So Magma mints its own, bounded, and says so. This is a P2 STAND-IN either way (the - // client is what mints handles once object-class state travels); what it must not be is a - // leak. Recycling costs the same thing the address-hashed table it replaces cost: a - // colliding pair of live objects evicts each other and re-derives. It is strictly better - // than that table, because the {slot, gen} compare is an exact identity, so an eviction - // can only ever cost a recompute - never the ABA the lifetime-id compare was added for. + // * Acquire hits an UnorderedMap, in front of which sits a + // one-entry memo. Every re-keyed site in a draw asks about the SAME VAO, so the memo + // turns the five-or-six acquisitions a draw makes into one map probe plus five Uint64 + // compares - less than the address multiply plus two-way probe the pre-handle arm ran. + // * OnFrameBoundary retires slots whose object has not been drawn for + // kRetireAgeBoundaries boundaries and returns them to a free list, so the table's + // footprint tracks the LIVE DRAWN working set, not objects ever created. That is the + // property MG_Impl/Pipe/SlotAllocator cannot have here: nothing in P2 can call its + // Free (the tracker emits no object-class state, BufferBackendOps::OnDestroy is handed + // a BackendBufferResource rather than the BufferObject, and VertexArrayObject has no + // death hook at all - adding one is D13's explicit-destroy work, which covers Espryt's + // six kinds, not VertexElementsCso), so an allocator here would grow by one SlotState + // plus one map node per object EVER created, for the life of the process, on a + // platform with an LMK. Age-based reclamation is the stand-in for the death + // notification, and it is exactly as ABA-proof, because reuse bumps Gen. + // * A retire costs at most one memo recompute if the object is drawn again - the same + // price a cache miss costs - and it is charged only to objects that went idle for + // ~1024 frames, never to a hot one. // - // Single-threaded, like MGPipeSlots() and like the rest of the renderer. + // Memory: one map node plus one 24-byte Entry per live object, i.e. tens of bytes against + // the kilobyte a VertexArrayObject or a BufferObject already costs the frontend. There is + // no capacity to size off a device measurement because there is no capacity; what the + // device run in D.4.2 can still want is the number itself, so the high-water mark is + // logged at MGLOG_D on the allocate-a-new-slot branch (once per new object, never on a + // draw - ROADMAP.md:7). + // + // Single-threaded, like the rest of the renderer. Owned per VulkanRenderer (see + // MagmaPipeIdentityTables): a process-global would share one table, and one reclamation + // clock, across two live contexts. class MagmaPipeIdentityTable { public: - explicit MagmaPipeIdentityTable(Uint32 entryCount) : m_entryCount(entryCount) {} + explicit MagmaPipeIdentityTable(const char* kindName) : m_kindName(kindName) {} - // One entry per slot, so a consumer table sized Count() and indexed by - // MagmaPipeSlotIndex() has exactly one entry per handle this table can hand out. - Uint32 Count() const { return m_entryCount; } + // Slots ever minted. A consumer table indexed by MagmaPipeSlotIndex() needs this many + // entries; MagmaPipeSlotTable below grows itself, so nobody has to ask. + Uint32 Count() const { return static_cast(m_entries.size()); } + // Objects currently holding a slot - the live working set this table tracks. + Uint32 LiveCount() const { return static_cast(m_index.size()); } MG_Pipe::MGPipeHandle Acquire(Uint64 lifetimeId) { // Unreachable: MG_State hands out lifetime ids from 1 precisely so that a // zero-initialised memo slot cannot carry a live object's id. Guarded anyway so // that a zero can never be minted into a slot and then indexed with. if (lifetimeId == 0) return MG_Pipe::kMGPipeNullHandle; - if (m_entries.empty()) m_entries.resize(m_entryCount); - - // Lifetime ids are monotonic from 1, so the low bits ARE the dense index: object - // n and object n+1 land in adjacent sets. No mix, because there is no entropy to - // spread - a multiply here would only scatter a sequence that is already perfect. - const Uint32 set = static_cast(lifetimeId) & (SetCount() - 1u); - const Uint32 way0 = set * 2u; - const Uint32 way1 = way0 + 1u; - - if (m_entries[way0].LifetimeId == lifetimeId) return Touch(way0); - if (m_entries[way1].LifetimeId == lifetimeId) return Touch(way1); - - // Miss. Evict the set's least recently used way - the same victim rule the - // address-hashed VaoDrawMemo table used, kept here so that it lives in ONE place - // instead of once per consumer table. - const Uint32 victim = (m_entries[way0].LastUse <= m_entries[way1].LastUse) ? way0 : way1; - Entry& entry = m_entries[victim]; - // The one place Gen may move, and it moves on REUSE: a respecify of the same - // object keeps its {slot, gen} because its lifetime id still matches above. - MOBILEGL_ASSERT(entry.Gen != ~Uint32{0}, - "Magma handle generation wrapped on slot %u; {slot, gen} is no longer " - "unique", - victim + MG_Pipe::kMGPipeFirstAllocatableSlot); - ++entry.Gen; - entry.LifetimeId = lifetimeId; - return Touch(victim); + // The one-entry front memo. Cleared by any retire, so it can never serve a slot + // that has been handed back to the free list. + if (lifetimeId == m_lastLifetimeId) { + m_entries[m_lastIndex].LastUse = m_boundary; + return m_lastHandle; + } + Uint32 index = 0; + const auto it = m_index.find(lifetimeId); + if (it != m_index.end()) { + index = it->second; + } else { + index = ClaimSlot(); + m_entries[index].LifetimeId = lifetimeId; + m_index.emplace(lifetimeId, index); + } + Entry& entry = m_entries[index]; + entry.LastUse = m_boundary; + m_lastLifetimeId = lifetimeId; + m_lastIndex = index; + m_lastHandle = MG_Pipe::MGPipeHandle{index + MG_Pipe::kMGPipeFirstAllocatableSlot, + entry.Gen}; + return m_lastHandle; + } + + // Ages the table and returns idle slots to the free list. Same shape and the same + // self-gating as VertexInputStateFactory::OnFrameBoundary, which is what the reclaimed + // slots' consumers use. + void OnFrameBoundary() { + ++m_boundary; + if ((m_boundary % kSweepInterval) != 0) return; + SizeT retired = 0; + for (auto it = m_index.begin(); it != m_index.end();) { + Entry& entry = m_entries[it->second]; + if ((m_boundary - entry.LastUse) > kRetireAgeBoundaries) { + entry.LifetimeId = 0; + m_freeSlots.push_back(it->second); + it = m_index.erase(it); + ++retired; + } else { + ++it; + } + } + if (retired != 0) { + // A retired slot's Gen has not moved yet - it moves when the slot is reused - + // so a front memo pointing at one would still hand out a handle the consumer + // tables would accept. Drop it. + m_lastLifetimeId = 0; + m_lastHandle = MG_Pipe::kMGPipeNullHandle; + MGLOG_D("MagmaPipeIdentityTable(%s): retired %zu idle slots, %u live of %u minted", + m_kindName, retired, LiveCount(), Count()); + } } private: + // Sweep cadence and retirement age, deliberately the same numbers + // VertexInputStateFactory::OnFrameBoundary uses for the entries these slots key: a slot + // retired earlier than its cache entry would mint a new handle for an object whose + // entry is still live and still correct, which is a pure waste. + static constexpr Uint64 kSweepInterval = 256; + static constexpr Uint64 kRetireAgeBoundaries = 1024; + struct Entry { Uint64 LifetimeId = 0; + Uint64 LastUse = 0; + // Moves ONLY on slot reuse, never on respecify: an object that keeps its slot keeps + // its generation, which is what makes a memo survive a reconfiguration. Uint32 Gen = 0; - Uint32 LastUse = 0; }; - Uint32 SetCount() const { return m_entryCount / 2u; } - - MG_Pipe::MGPipeHandle Touch(Uint32 index) { - m_entries[index].LastUse = ++m_clock; - return MG_Pipe::MGPipeHandle{index + MG_Pipe::kMGPipeFirstAllocatableSlot, - m_entries[index].Gen}; + Uint32 ClaimSlot() { + while (!m_freeSlots.empty()) { + const Uint32 index = m_freeSlots.back(); + m_freeSlots.pop_back(); + // MGPipeHandles.h:52-58 defends the Gen wrap only in a debug allocator, and + // MOBILEGL_ASSERT is compiled out of every build P2 runs (Defines.h: asserts are + // live only at MOBILEGL_LOG_ACTIVE_LEVEL == DEBUG). So the wrap is handled on the + // RELEASE path instead of asserted: a slot that has been reused 2^32 times is + // permanently retired rather than wrapped, because a wrapped Gen would let a + // stale handle match a live object. It costs one slot. + if (m_entries[index].Gen == ~Uint32{0}) { + MGLOG_W("MagmaPipeIdentityTable(%s): slot %u reached generation 2^32-1 and is " + "retired for good; {slot, gen} stays unique", + m_kindName, index + MG_Pipe::kMGPipeFirstAllocatableSlot); + continue; + } + ++m_entries[index].Gen; + return index; + } + const Uint32 index = static_cast(m_entries.size()); + m_entries.push_back(Entry{}); + m_entries[index].Gen = 1; + // The high-water mark, at powers of two from 1024 up. Once per NEW slot, which is + // once per object this backend has ever seen - never on a draw. This is the number + // D.4.2 should read out of a device log to size anything that ever does need a + // capacity (ROADMAP.md:7: no instrumentation on the hot path). + const SizeT minted = m_entries.size(); + if (minted >= 1024 && (minted & (minted - 1)) == 0) { + MGLOG_D("MagmaPipeIdentityTable(%s): high-water %zu slots minted, %u live", + m_kindName, minted, LiveCount()); + } + return index; } - Uint32 m_entryCount = 0; - // Wraps every 2^32 acquisitions. A wrapped clock can only ever pick the wrong victim - // inside one set - a cache decision, never a correctness one. - Uint32 m_clock = 0; + const char* m_kindName = ""; + Uint64 m_boundary = 0; Vector m_entries; + Vector m_freeSlots; + UnorderedMap m_index; + // One-entry front memo (see Acquire). m_lastLifetimeId == 0 means "empty": a live + // object's lifetime id is never 0. + Uint64 m_lastLifetimeId = 0; + Uint32 m_lastIndex = 0; + MG_Pipe::MGPipeHandle m_lastHandle = MG_Pipe::kMGPipeNullHandle; }; - // The table entry a handle names. Every per-slot table Magma keeps is sized Count() and - // indexed by this, so the index is exact and in range by construction. + // The two mints one renderer owns. Per renderer, NOT process-global: two live contexts (or + // a context recreation, which destroys and rebuilds the renderer) would otherwise share one + // table and one reclamation clock, and both consumer tables are per-instance already. + class MagmaPipeIdentityTables { + public: + // A VAO is kind VertexElementsCso: that is the gallium-shaped CSO a vertex array + // resolves to, and the only kind in MGPipeKind that names vertex-input state. + MG_Pipe::MGPipeHandle HandleOf(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) { + return kind == MG_Pipe::MGPipeKind::Buffer ? m_buffers.Acquire(lifetimeId) + : m_vaos.Acquire(lifetimeId); + } + void OnFrameBoundary() { + m_vaos.OnFrameBoundary(); + m_buffers.OnFrameBoundary(); + } + const MagmaPipeIdentityTable& Vaos() const { return m_vaos; } + const MagmaPipeIdentityTable& Buffers() const { return m_buffers; } + + private: + MagmaPipeIdentityTable m_vaos{"VertexElementsCso"}; + MagmaPipeIdentityTable m_buffers{"Buffer"}; + }; + + // The table entry a handle names. Every per-slot table Magma keeps is indexed by this. // // A null handle has no slot, and it is unreachable here: both lifetime-id sources start at - // 1 (VertexArrayObject.cpp, BufferObject.cpp), so Acquire's zero guard never fires. - // Asserted rather than assumed, because being wrong about it would be an out-of-range - // index rather than a wrong answer. + // 1 (VertexArrayObject.cpp, BufferObject.cpp), so Acquire's zero guard never fires. The + // ternary, not the assertion, is what has effect in a shipped build (Defines.h compiles + // MOBILEGL_ASSERT out at INFO), and slot 0 of a consumer table is a real entry that a null + // handle can never match, because MGPipeHandleIsNull is also what the consumers compare. inline Uint32 MagmaPipeSlotIndex(const MG_Pipe::MGPipeHandle& handle) { MOBILEGL_ASSERT(!MG_Pipe::MGPipeHandleIsNull(handle), "a null MGPipeHandle has no slot to index a per-slot table with"); @@ -218,35 +357,31 @@ namespace MobileGL::MG_Backend::DirectVulkan { : handle.Slot - MG_Pipe::kMGPipeFirstAllocatableSlot; } - // A VAO is kind VertexElementsCso: that is the gallium-shaped CSO a vertex array resolves - // to, and it is the only kind in MGPipeKind that names vertex-input state. 2048 entries - // is what the address-hashed VaoDrawMemo table it replaces held, so the working set this - // covers without eviction is unchanged; at 16 B/entry the table itself is 32 KB. - inline constexpr Uint32 kMagmaVaoIdentityEntries = 2048; - // Buffers are far more numerous than VAOs (Minecraft cycles chunk vertex/index buffers), - // and unlike the VAO table this one feeds a CONTENT hash: an eviction changes the key a - // vertex-input cache entry was built under, so it costs a rebuild rather than a lookup. - // It is only ever consulted when a VAO's configuration version moved (ComputeHash is - // memoised per VAO), so the price is paid per reconfiguration, not per draw - but the - // table is sized four times the VAO one anyway, 128 KB, to keep it rare. - inline constexpr Uint32 kMagmaBufferIdentityEntries = 8192; - - inline MagmaPipeIdentityTable& MagmaPipeVaoIdentity() { - static MagmaPipeIdentityTable table(kMagmaVaoIdentityEntries); - return table; - } - inline MagmaPipeIdentityTable& MagmaPipeBufferIdentity() { - static MagmaPipeIdentityTable table(kMagmaBufferIdentityEntries); - return table; - } + // A grow-on-demand per-slot table whose ENTRY ADDRESSES NEVER MOVE. + // + // D12.4 asks for a grow-on-demand Vector, and with an unbounded mint that is what a + // consumer needs - but a Vector that grows relocates its elements, and the draw path holds + // references into these entries across nested calls. Chunks of kChunkEntries are appended + // instead: the Vector of owning pointers reallocates, the chunks never do, so an entry + // reference is valid for the life of the table. That is the same guarantee the fixed table + // it replaces gave, without the fixed capacity. + template + class MagmaPipeSlotTable { + public: + T& operator[](Uint32 index) { + const Uint32 chunk = index / kChunkEntries; + while (m_chunks.size() <= chunk) { + m_chunks.push_back(MakeUnique()); + } + return m_chunks[chunk]->Entries[index % kChunkEntries]; + } + SizeT Capacity() const { return m_chunks.size() * kChunkEntries; } - // The {slot, gen} of a frontend object. `lifetimeId` is the client's own identity for the - // object - never a GL name, never a heap address - so a deleted-and-recreated object at - // the same address cannot reproduce a handle, which is precisely the ABA - // HandleRecycleScenario reproduces. - inline MG_Pipe::MGPipeHandle MagmaPipeHandleOf(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) { - return kind == MG_Pipe::MGPipeKind::Buffer ? MagmaPipeBufferIdentity().Acquire(lifetimeId) - : MagmaPipeVaoIdentity().Acquire(lifetimeId); - } + private: + struct Chunk { + T Entries[kChunkEntries] = {}; + }; + Vector> m_chunks; + }; #endif // MOBILEGL_PIPE_PUSH } // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp index a148ba23d..c2ac4a368 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp @@ -61,7 +61,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // identity while its neighbours keyed on the handle. if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { const MG_Pipe::MGPipeHandle handle = - MagmaPipeHandleOf(MG_Pipe::MGPipeKind::Buffer, attr.Buffer->GetLifetimeId()); + m_identity->HandleOf(MG_Pipe::MGPipeKind::Buffer, attr.Buffer->GetLifetimeId()); bufferKey = static_cast(handle.Slot) | (static_cast(handle.Gen) << 32); } else if (MG_Config::Features.PipeHandleAbaControl) { // Negative control C (P2 brief D18), and it applies to the PRE-HANDLE arm @@ -83,18 +83,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { #if MOBILEGL_PIPE_PUSH VertexInputStateFactory::VaoBackendMemos& VertexInputStateFactory::MemosFor( const MG_State::GLState::VertexArrayObject& vao) const { - static_assert(kVaoMemoSlotCount == kMagmaVaoIdentityEntries, - "this table is indexed directly by MagmaPipeSlotIndex, so it has to hold " - "exactly one entry per slot the VAO identity table can mint"); - if (m_vaoMemos.empty()) { - m_vaoMemos.resize(kVaoMemoSlotCount); - } const MG_Pipe::MGPipeHandle handle = - MagmaPipeHandleOf(MG_Pipe::MGPipeKind::VertexElementsCso, vao.GetLifetimeId()); - // One entry per mintable slot - see the static_assert on kVaoMemoSlotCount - so this - // index is exact and two live VAOs cannot share an entry. There is no probe in front - // of it because the mint itself is one: an array index and at most two Uint64 - // compares, which is less than the address hash the pre-handle arm ran. + m_identity->HandleOf(MG_Pipe::MGPipeKind::VertexElementsCso, vao.GetLifetimeId()); + // One entry per mintable slot, grown on demand: the mint has no capacity, so neither + // does this, and no two live VAOs can share an entry however large the working set is. + // There is no probe in front of it because the mint itself is one - a one-entry memo + // hit for every acquisition after this draw's first, and a hash probe otherwise. VaoBackendMemos& memos = m_vaoMemos[MagmaPipeSlotIndex(handle)]; if (!(memos.Owner == handle)) { // A slot whose Gen moved because the identity table recycled it for a different diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h index 6e5a4baac..b93ad7719 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h @@ -12,6 +12,7 @@ #include #include "Config.h" +#include "MagmaPipeArms.h" #include "VertexInputStateBuilder.h" #include "MG_State/GLState/VertexArrayState/VertexArrayObject.h" #include @@ -73,8 +74,18 @@ namespace MobileGL::MG_Backend::DirectVulkan { }; }; +#if MOBILEGL_PIPE_PUSH + // The mint is the RENDERER's (MagmaPipeIdentityTables), not a process-global and not + // this factory's: VulkanRenderer::LookupVaoDrawMemo has to derive the same {slot, gen} + // for the same VAO, and a table that outlived the context it was minted for would share + // one reclamation clock across two live contexts (review v2 minor 4). + VertexInputStateFactory(const VulkanRendererConfig& config, VkPhysicalDevice physicalDevice, + MagmaPipeIdentityTables& identity): + m_config(config), m_physicalDevice(physicalDevice), m_identity(&identity) {} +#else VertexInputStateFactory(const VulkanRendererConfig& config, VkPhysicalDevice physicalDevice): m_config(config), m_physicalDevice(physicalDevice) {} +#endif ~VertexInputStateFactory() = default; VertexInputStateFactory(const VertexInputStateFactory&) = delete; @@ -146,14 +157,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint64 StateEpoch = 0; Uint32 StateConfigVersion = ~0u; }; - // Fixed, and a BIJECTION with the identity table that mints the slots - // (MagmaPipeVaoIdentity): entry i is slot i + kMGPipeFirstAllocatableSlot, so the - // index is exact, no two live VAOs can share an entry, and the eviction decision lives - // once - in the identity table's 2-way LRU - instead of once per consumer table. - // Pinned against the mint by a static_assert in VertexInputStateFactory.cpp. - // 2048 x 48 B is 96 KB. - static constexpr Uint32 kVaoMemoSlotCount = 2048; // power of two - mutable Vector m_vaoMemos; + // Grow-on-demand (D12.4), one entry per slot the renderer's mint has ever handed + // out, and NO CAPACITY: these two memos had none before this package either - they + // were unbounded mutable fields on the VertexArrayObject itself - and re-introducing + // eviction here is what review v2 rejected. MagmaPipeSlotTable grows in chunks so an + // entry reference stays valid across the nested GetOrCreateVertexInputState call. + // 48 B per live VAO, reclaimed with the slot when the object goes idle. + mutable MagmaPipeSlotTable m_vaoMemos; + // The renderer's {slot, gen} mint (see the constructor). Never null under push. + MagmaPipeIdentityTables* m_identity = nullptr; // The entry belonging to `vao`, claimed (and cleared) if the slot currently holds // someone else's. VaoBackendMemos& MemosFor(const MG_State::GLState::VertexArrayObject& vao) const; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 275f680a8..24bbab5b0 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -410,6 +410,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { // // The brief (P2 D12.3) expects every input to be dynamic; the tree says otherwise for // exactly one, and the tree is right - see the ScissorTestEnabledMask note below. + // + // These assertions ARE D19's DynamicChunksCoverMagmasDynamicTailKey, in the only file this + // package owns. D19 names it as a case in MG_Test/Pipe/RenderStateSpansTest.cpp, which + // belongs to package A (C.5). INTEGRATOR: make sure the outcome is not "neither" - if + // package A did not land that case, this static_assert block is the whole gate, and if it + // did, the two are redundant on purpose and both should stay. namespace { // Is [begin, begin + size) covered entirely by DYNAMIC chunks? constexpr Bool MagmaRenderStateRangeIsDynamic(SizeT begin, SizeT size) { @@ -3290,7 +3296,12 @@ void main() { m_physicalDevice.properties.limits.minUniformBufferOffsetAlignment, m_config.MaxFramesInFlight, maxProgramBindings, kDescriptorSetsPerFrame, m_textureManager.get(), m_samplerManager.get()); MOBILEGL_ASSERT(succeeded, "UniformDescriptorBinder initialization failed."); +#if MOBILEGL_PIPE_PUSH + m_vertexInputStateFactory = + MakeUnique(m_config, m_physicalDevice.handle, m_pipeIdentity); +#else m_vertexInputStateFactory = MakeUnique(m_config, m_physicalDevice.handle); +#endif MOBILEGL_ASSERT(m_vertexInputStateFactory != nullptr, "VertexInputStateFactory creation failed."); // Prime the first frame so Render() always targets an acquired swapchain image. @@ -3641,36 +3652,54 @@ void main() { #if MOBILEGL_PIPE_PUSH // ---- P2 D12.4, the handle arm ---- // - // The slot IS the index, exactly: this table and MagmaPipeVaoIdentity() hold the same - // number of entries and the mint hands out slot i + kMGPipeFirstAllocatableSlot for - // entry i, so the map from live handle to entry is a BIJECTION. No Fibonacci mix of an - // address, no two-way probe here, no frame-serial recycling choice here - not because - // eviction stopped being necessary, but because it happens ONE level down, in the - // identity table's 2-way LRU, where a single decision serves this table and the - // factory's. Two live VAOs cannot land on one entry of this table at all. + // The slot PICKS the entry, and the handle DECIDES whether the entry is this VAO's - + // the same division of labour the legacy arm below gives the address and the lifetime + // id, with two differences that are both improvements: + // + // * the slot is dense from 1, so below kVaoDrawMemoSlotCount live slots the map is a + // bijection and the two-way probe never collides at all, where an address hash + // collides by the birthday rule from the first few dozen VAOs; + // * the handle is an exact identity - Gen moves whenever a slot changes owner - so + // neither a deleted VAO's successor at the same heap address nor a VAO whose slot + // was recycled can match a predecessor's entry, even byte-identically configured. + // That is what makes the lifetime-id half of the legacy compare unnecessary here. // - // The whole validation is one handle compare, and a handle cannot alias: Gen moves - // whenever a slot changes owner, so neither a deleted VAO's successor at the same heap - // address nor a VAO whose slot was recycled under LRU pressure can match a predecessor's - // entry, even with a byte-identical configuration. + // The capacity and the victim rule are deliberately the base ref's, unchanged: this is + // the one memo of the three that HAD a capacity before P2, and an entry lost to a + // collision costs exactly what it cost then (one vertex-binding re-resolve). Above + // kVaoDrawMemoSlotCount live VAOs a set of two ways serves four slots, and degrades + // from there - never worse than the address-hashed table it replaces, which was already + // colliding. if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { const MG_Pipe::MGPipeHandle handle = ResolveVaoHandle(*vao); - const Uint32 index = MagmaPipeSlotIndex(handle); - VaoDrawMemo& entry = m_vaoDrawMemoTable[index]; - if (entry.vaoHandle == handle) { - return &entry; - } - entry.vaoHandle = handle; - entry.vaoKey = vao; - entry.vaoLifetimeId = vao->GetLifetimeId(); - entry.contentHash = 0; - entry.layoutFactsValid = false; + const Uint32 index = MagmaPipeSlotIndex(handle) & (kVaoDrawMemoSlotCount - 1u); + VaoDrawMemo& first = m_vaoDrawMemoTable[index]; + if (first.vaoHandle == handle) { + return &first; + } + VaoDrawMemo& second = m_vaoDrawMemoTable[index ^ 1u]; + if (second.vaoHandle == handle) { + return &second; + } + // Miss: recycle a slot. Prefer an unclaimed one; otherwise evict the entry whose + // bindings memo is older (its VAO is the one drawn less recently). + VaoDrawMemo* victim = &first; + if (!MG_Pipe::MGPipeHandleIsNull(first.vaoHandle) && + (MG_Pipe::MGPipeHandleIsNull(second.vaoHandle) || + second.bindings.frameSerial < first.bindings.frameSerial)) { + victim = &second; + } + victim->vaoHandle = handle; + victim->vaoKey = vao; + victim->vaoLifetimeId = vao->GetLifetimeId(); + victim->contentHash = 0; + victim->layoutFactsValid = false; // Unmatchable until a resolve completes (same rule as the legacy arm: a bailed-out // resolve must never leave stale contents matchable). - entry.bindings.frameSerial = 0; - entry.bindings.indexFrameSerial = 0; - entry.bindings.indexBuffer = nullptr; - return &entry; + victim->bindings.frameSerial = 0; + victim->bindings.indexFrameSerial = 0; + victim->bindings.indexBuffer = nullptr; + return victim; } #endif // Multiplicative mix of the (16-byte-aligned) address; take high bits, they @@ -12771,6 +12800,14 @@ void main() { if (m_vertexInputStateFactory) { m_vertexInputStateFactory->OnFrameBoundary(); } +#if MOBILEGL_PIPE_PUSH + // Reclaim {slot, gen} for objects that have not been drawn for a long time, on the same + // cadence and the same retirement age as the entries those slots key. This is the + // stand-in for the frontend death notification P2 has no hook for, and it is what keeps + // the mint's footprint the LIVE working set rather than every object ever created + // (review v2 MAJOR 1 / MAJOR 3). + m_pipeIdentity.OnFrameBoundary(); +#endif if (m_samplerManager) { m_samplerManager->OnFrameBoundary(); } @@ -13136,6 +13173,9 @@ void main() { InvalidateSetupDrawSnapshots(); } m_vertexInputStateFactory->OnFrameBoundary(); +#if MOBILEGL_PIPE_PUSH + m_pipeIdentity.OnFrameBoundary(); +#endif m_samplerManager->OnFrameBoundary(); auto& frame = m_frameContext.GetCurrent(); auto* activeRenderPass = VkRenderPassManager::GetActiveRenderPass(); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index 4ac576211..de43cb2cf 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -893,24 +893,49 @@ namespace MobileGL::MG_Backend::DirectVulkan { // // The fallback is warned ONCE rather than logged at debug, and that is deliberate: a // silent fallback is what makes "the CSO arm never ran" easy to miss. W is compiled in - // at every shipped log level, _ONCE costs one static bool test, and its ABSENCE from a - // run's log is the positive evidence that every draw keyed on a handle. + // at every shipped log level. + // + // The latch is a plain member bool, NOT MGLOG_W_ONCE. MOBILEGL_LOG_ONCE_INTERNAL + // (MG_Util/Debug/Log.h) is an UNCONDITIONAL std::atomic_flag::test_and_set - a locked + // xchg, executed on every evaluation, not "one static bool test" as an earlier round of + // this comment claimed - and this site is on the per-draw pipeline path in the very + // configuration that reaches it (no tracker: every draw). ROADMAP.md:7 forbids leaving + // instrumentation on a hot path, so the once-ness is one non-atomic, always-predicted + // load of a member that is false exactly once. Single-threaded like the rest of the + // renderer, and per renderer rather than per process, which is also the right scope: a + // second context that never binds a CSO deserves to say so. + // + // What the absence of this warning from a run's log proves, EXACTLY: that no draw took + // the fallback WHILE bit 0 was set. With kMGPipeSubsystemRenderState clear the function + // returns before the latch, so absence proves nothing at all - and no draw is keyed on a + // handle either. Grep the mask out of the log beside it (review v2 minor 3). // // Push-only by construction: the pull build does not compile this function at all, so // its two callers are statement-for-statement what they were (G1). + // + // [routed to the integrator, review v2 minor 11] MG_Pipe::MGPipeApplier() is ONE + // process-global applier (MG_Pipe/PipeApply.cpp), not the per-context CSO store D2 + // specifies. In a multi-context process this reads whatever CSO another context last + // bound. The defect is package A's and the fix belongs there; Magma is its only P2 + // consumer, so it is named here rather than left for both reviews to assume the other + // caught it. MG_Pipe::MGPipeHandle ResolveBoundRenderStateCso() const { if (!MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemRenderState)) { return MG_Pipe::kMGPipeNullHandle; } const MG_Pipe::MGPipeHandle boundCso = MG_Pipe::MGPipeApplier().BoundRenderStateCso; - if (MG_Pipe::MGPipeHandleIsNull(boundCso)) { - MGLOG_W_ONCE("MGPipe: kMGPipeSubsystemRenderState is on but no render-state CSO is " - "bound; the pipeline memo is running on a state hash, not on the CSO " - "handle (no tracker on this build, or a draw between " - "delete_render_state and the next bind)"); + if (MG_Pipe::MGPipeHandleIsNull(boundCso) && !m_pipelineCsoFallbackWarned) { + m_pipelineCsoFallbackWarned = true; + MGLOG_W("MGPipe: kMGPipeSubsystemRenderState is on but no render-state CSO is " + "bound; the pipeline memo is running on a state hash, not on the CSO " + "handle (no tracker on this build, or a draw between " + "delete_render_state and the next bind)"); } return boundCso; } + // Latch for the warning above. Mutable because the resolve is const and the latch is + // not part of the renderer's observable state. + mutable Bool m_pipelineCsoFallbackWarned = false; // The memo key's STATE-HASH half, for a draw that has no CSO handle to key on: the // pre-handle arm, and the fallback of D12.1's handle arm. Cached on the pipeline-state // version plus the two render-pass facts the hash's inputs depend on, so an unchanged @@ -1407,25 +1432,34 @@ namespace MobileGL::MG_Backend::DirectVulkan { // fixed table also makes every VaoDrawMemo/ResolvedVertexBindings pointer // stable for the duration of a draw, which the EBO memo handoff // (m_currentDrawResolvedEntry) relies on. - // [deviation from D12.4] The brief asks for a grow-on-demand Vector. The table is - // FIXED, and under the handle arm it is sized to - and is a BIJECTION with - the - // identity table that mints the slots (MagmaPipeVaoIdentity), so entry i is slot - // i + kMGPipeFirstAllocatableSlot and no two live VAOs can ever share it. Growing on - // demand only makes sense against an allocator that frees, and nothing in P2 frees a - // VertexElementsCso slot; the eviction that has to happen somewhere happens once, in - // the identity table's LRU, instead of twice in two tables that could disagree. + // + // [deviation from D12.4, deliberate and narrow] The brief asks for a grow-on-demand + // Vector. This one stays FIXED at exactly the capacity and exactly the 2-way victim + // rule it has on the base ref, and only its KEY changes (a {slot, gen} handle instead + // of a hashed heap address plus a lifetime id). Two reasons, and the second is the + // whole of review v2's MAJOR 1: + // * a VaoDrawMemo is ~450 B (ResolvedVertexBindings dominates), so growing this + // table with the live VAO set is megabytes on a platform with an LMK, where the + // other two memos are 48 B and can afford it; + // * this is the ONLY one of the three memos that had a capacity before this package. + // Losing an entry here costs a vertex-binding re-resolve, exactly what losing it + // cost on the base ref, so at any working-set size this table is no worse than what + // it replaces - and strictly better below capacity, where the handle is a bijection + // with the slot and the two-way probe never collides at all. The other two memos + // (VertexInputStateFactory::m_vaoMemos) had NO capacity, so they keep having none. static constexpr Uint32 kVaoDrawMemoSlotCount = 2048; // power of two Vector m_vaoDrawMemoTable; #if MOBILEGL_PIPE_PUSH - static_assert(kVaoDrawMemoSlotCount == kMagmaVaoIdentityEntries, - "the VAO draw memo table is indexed directly by MagmaPipeSlotIndex, so it has " - "to hold exactly one entry per slot the VAO identity table can mint"); - // The VAO's {slot, gen}. An array probe (one mask, at most two Uint64 compares), so - // there is no memo in front of it: the address multiply plus two-way probe it replaces - // cost more than this does, and a cached handle could go stale behind the identity - // table's own eviction, which is a class of bug worth not having. + // The renderer's {slot, gen} mint, shared with its VertexInputStateFactory so both + // derive the same handle for the same VAO. Per renderer, never a process-global: a + // global would share one table and one reclamation clock across two live contexts and + // outlive every one of them (review v2 minor 4). + MagmaPipeIdentityTables m_pipeIdentity; + // The VAO's {slot, gen}. A one-entry memo hit for every acquisition after a draw's + // first, so there is no second memo in front of it here. MG_Pipe::MGPipeHandle ResolveVaoHandle(const MG_State::GLState::VertexArrayObject& vao) { - return MagmaPipeHandleOf(MG_Pipe::MGPipeKind::VertexElementsCso, vao.GetLifetimeId()); + return m_pipeIdentity.HandleOf(MG_Pipe::MGPipeKind::VertexElementsCso, + vao.GetLifetimeId()); } #endif // "Is this VAO's content hash already memoized?", asked of whichever side owns the From e5c032c89e4939d0230e5613597ea1eb5349ffdf Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 11:29:34 -0400 Subject: [PATCH 123/529] [Fix] (Magma): log the mint's high-water at a level a shipped build keeps - The live-object high-water mark is the number review v2's MAJOR 1 wants measured on minecraft-1.21.4-in-world and ...-sodium-in-world, and no desktop gate can produce it. It was emitted at MGLOG_D, which is compiled out of every build that ships and of every build P2 measures, so the line existed only in a configuration nobody runs. - MGLOG_I instead, still only on the allocate-a-new-slot branch and still only at powers of two from 1024 up: at most a handful of lines for a whole session, never one on a draw (ROADMAP.md:7). Declared as a narrow deviation from D20's "MGLOG_D for anything non-critical" in the comment beside it. --- .../DirectVulkan/Renderer/MagmaPipeArms.h | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h index 34a46b571..f9591b46b 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h @@ -295,13 +295,21 @@ namespace MobileGL::MG_Backend::DirectVulkan { const Uint32 index = static_cast(m_entries.size()); m_entries.push_back(Entry{}); m_entries[index].Gen = 1; - // The high-water mark, at powers of two from 1024 up. Once per NEW slot, which is - // once per object this backend has ever seen - never on a draw. This is the number - // D.4.2 should read out of a device log to size anything that ever does need a - // capacity (ROADMAP.md:7: no instrumentation on the hot path). + // The high-water mark, at powers of two from 1024 up: at most a handful of lines + // for a whole session, emitted from the allocate-a-NEW-slot branch, i.e. once per + // object this backend has ever seen and never on a draw (ROADMAP.md:7). + // + // [narrow, declared deviation from D20's "MGLOG_D for anything non-critical"] This + // one is I, not D, because D is compiled out of every build that ships and of every + // build P2 measures, and this line IS the measurement review v2's MAJOR 1 asks for: + // the live-object high-water mark of minecraft-1.21.4-in-world and + // ...-sodium-in-world, which nothing on desktop reaches and no gate here can see. + // The structure no longer has a capacity to size off it, so the number is evidence + // rather than a tuning input - but D.4.2 should still read it out of the device log, + // and it cannot read a line that was compiled away. const SizeT minted = m_entries.size(); if (minted >= 1024 && (minted & (minted - 1)) == 0) { - MGLOG_D("MagmaPipeIdentityTable(%s): high-water %zu slots minted, %u live", + MGLOG_I("MagmaPipeIdentityTable(%s): high-water %zu slots minted, %u live", m_kindName, minted, LiveCount()); } return index; From f1780b90000d3f8daf8c8aade8d33f85d11a0f2c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:04:54 -0400 Subject: [PATCH 124/529] [Test] (Pipe): reproduce the handle ABA through public GL and pin the CSO content-addressing switch - HandleRecycleScenario builds the ABA the Track H re-key has to survive: an object is drawn for three frames so every per-object memo is armed against it, unbound so its last SharedPtr drops, deleted, and replaced immediately by one with a byte-identical configuration and different contents. Three kinds - a vertex array whose buffer is recycled with it, a texture, a framebuffer - and the readback must come from the replacement. - The reproducer is asserted, not assumed. TheReproducerRecyclesEveryName pins that the name allocators hand every deleted name straight back, and a case whose names were not recycled SKIPS as "inconclusive, not proven" rather than passing - the shape ObjectLifetimeIdTest already uses. - Three always-on arms, one ctest lane each, named by the harness marker MGITEST_HANDLE_ARM: Handles ({slot, gen} only), Legacy (today's lifetimeId + weak_ptr guards) and AbaControl (MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1, which expects the CORRUPTION so that a reproducer that stopped reproducing is a red rather than a quieter green). AbaControl is DirectVulkan only: the knob reverts two DirectVulkan guards and steers nothing on DirectGLES. - CsoContentAddressingScenario is the G12 control. A Blaze3D blend toggle - enable/draw/ disable/draw x 8 inside one frame - must mint a BOUNDED number of CSOs with content addressing on and exactly one per bind with bit 63 of MOBILEGL_PIPE_PUSH set, while the pixels do not move at all. csom == csob is the reading a dead switch cannot produce. - The counters are read from the library's own "MGPipe stats:" line, because PipeStats is internal and this module links the shipping library on Android. Each arm therefore gets MOBILEGL_PIPE_STATS_PERIOD=1 and a private MOBILEGL_LOG_FILE_PATH, the same per-lane rule the arming lane already follows, and the workload is bracketed by two swaps so the window covers itself and nothing else. - Both scenarios skip in the ambient entries, which configure none of the knobs their arms are about, and both name what is missing when the package they depend on has not landed. What decides that is the BUILD, not a hand-written guard: CMakeLists looks for DirectGLES/SlotTables.h, MG_Impl/Pipe/Tracker.cpp and the two markers inside VertexInputStateFactory.cpp, prints each verdict, and re-evaluates through CONFIGURE_DEPENDS - so the arms arm themselves when packages B, C and D land. --- MobileGL/MG_IntegrationTest/CMakeLists.txt | 227 +++++++ .../CsoContentAddressingScenario.cpp | 321 ++++++++++ .../Scenarios/HandleRecycleScenario.cpp | 582 ++++++++++++++++++ 3 files changed, 1130 insertions(+) create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index c8100d565..89238e6f1 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -129,6 +129,8 @@ add_executable(MobileGLIntegrationTest Scenarios/DualSourceBlendScenario.cpp Scenarios/PipeVerifyArmingScenario.cpp Scenarios/PoisonOmissionScenario.cpp + Scenarios/HandleRecycleScenario.cpp + Scenarios/CsoContentAddressingScenario.cpp ) target_include_directories(MobileGLIntegrationTest PRIVATE @@ -318,6 +320,75 @@ function(mgl_itest_join_environment outVar) set(${outVar} "${joined}" PARENT_SCOPE) endfunction() +# --- what THIS TREE implements, answered by the build rather than by a person ---------- +# +# Two P2 entries assert something that only EXISTS once another P2 package has landed: +# HandleRecycleScenario's Handles arm needs a backend keyed on {slot, gen} (packages C and D), +# its AbaControl arm needs a consumer for MOBILEGL_PIPE_HANDLE_ABA_CONTROL (package D), and +# CsoContentAddressingScenario needs the client-side tracker that mints CSOs at all (package B). +# The gates package is written and merged FIRST, against the P2 contract commit, precisely so +# that the AbaControl red is on the record before either backend is touched - so for a while +# those entries have nothing to assert. +# +# The honest report for that is a SKIP naming what is missing, never a deleted registration and +# never a green that means "the thing I test does not exist yet". What decides the skip is +# THIS block, so that nobody has to remember to remove a hand-written guard: +# +# * two of the three answers are pure EXISTENCE checks, through file(GLOB CONFIGURE_DEPENDS). +# Ninja re-evaluates such a glob before every build and reconfigures only when the RESULT +# changes, so these cost nothing until the file appears - and then they arm themselves. +# * the third has to read a file's CONTENTS, because package D re-keys inside an existing +# source rather than adding one. VertexInputStateFactory.cpp is the one file both of D's +# answers live in (ComputeHash's buffer key is what the re-key changes AND what the ABA +# knob reverts), it is small, and it is watched by name - so an edit to it reconfigures and +# an edit anywhere else in the backend does not. +# +# Every verdict is printed at configure time: a marker that silently answered "no" for a tree +# that does implement the thing would turn a real gate into a permanent skip. +set(MGL_ITEST_CAPABILITY_ENV "") + +file(GLOB MGL_ITEST_ESPRYT_SLOT_TABLES CONFIGURE_DEPENDS + "${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectGLES/SlotTables.h") +if (MGL_ITEST_ESPRYT_SLOT_TABLES) + message(STATUS "Integration tests: DirectGLES is keyed on {slot, gen} (SlotTables.h present)") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_REKEY_DirectGLES=1") +else() + message(STATUS "Integration tests: DirectGLES has no SlotTables.h - HandleRecycle.Handles will SKIP on it") +endif() + +file(GLOB MGL_ITEST_TRACKER_SOURCE CONFIGURE_DEPENDS + "${MGL_ITEST_ROOT}/MobileGL/MG_Impl/Pipe/Tracker.cpp") +if (MGL_ITEST_TRACKER_SOURCE) + message(STATUS "Integration tests: the MGPipe tracker is present, so CSOs are minted") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_TRACKER_PRESENT=1") +else() + message(STATUS "Integration tests: no MG_Impl/Pipe/Tracker.cpp - CsoContentAddressing will SKIP") +endif() + +set(MGL_ITEST_MAGMA_VERTEX_INPUT + "${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp") +if (EXISTS "${MGL_ITEST_MAGMA_VERTEX_INPUT}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${MGL_ITEST_MAGMA_VERTEX_INPUT}") + file(STRINGS "${MGL_ITEST_MAGMA_VERTEX_INPUT}" MGL_ITEST_MAGMA_REKEY_HITS + REGEX "kMGPipeSubsystemMagmaVertexInput") + file(STRINGS "${MGL_ITEST_MAGMA_VERTEX_INPUT}" MGL_ITEST_MAGMA_ABA_HITS + REGEX "PipeHandleAbaControl") + if (MGL_ITEST_MAGMA_REKEY_HITS) + message(STATUS "Integration tests: DirectVulkan's vertex input is keyed on {slot, gen}") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_REKEY_DirectVulkan=1") + else() + message(STATUS "Integration tests: DirectVulkan's vertex input is not re-keyed yet - " + "HandleRecycle.Handles will SKIP on it") + endif() + if (MGL_ITEST_MAGMA_ABA_HITS) + message(STATUS "Integration tests: MOBILEGL_PIPE_HANDLE_ABA_CONTROL has a consumer") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_ABA_IMPLEMENTED=1") + else() + message(STATUS "Integration tests: MOBILEGL_PIPE_HANDLE_ABA_CONTROL has no consumer - " + "HandleRecycle.AbaControl will SKIP") + endif() +endif() + mgl_itest_join_environment(MGL_ITEST_GLES_ENVIRONMENT "MOBILEGL_BACKEND_TYPE=DirectGLES" ${MGL_ITEST_COMMON_ENV}) mgl_itest_join_environment(MGL_ITEST_VULKAN_ENVIRONMENT @@ -665,6 +736,162 @@ gtest_discover_tests(MobileGLIntegrationTest # why the arming case has a lane and a log of its own below, and why neither this file nor CI # may read the ambient logs as evidence about the entries that ran before the last one. The # ambient path is kept for post-mortems (and to keep library chatter out of ctest's capture). +# --- G8: the handle ABA, three always-on arms ----------------------------------------- +# +# ALWAYS ON, in every build mode, which is deliberate: the Legacy arm asserts today's +# lifetimeId + weak_ptr guards and is meaningful in a pull build, and `ctest -R HandleRecycle` +# has to name the same entries whichever build directory it is pointed at (P2 brief G8 runs it +# against build-verify; D.3 part 1 runs it again as part of the interface-purity gate). +# +# One lane per arm, and each lane names MGITEST_HANDLE_ARM: the arm is not a property of the +# test body, it is the (MOBILEGL_PIPE_PUSH, MOBILEGL_PIPE_LEGACY_MEMOS, MOBILEGL_PIPE_HANDLE_ABA_CONTROL) +# triple the process was launched with, and the scenario skips in the ambient entries because +# none of that is configured there. +# +# Every list APPENDS the common/Vulkan environment for the reason spelled out above the verify +# block: a ctest ENVIRONMENT property REPLACES the job environment for the names it lists, so an +# entry naming only its own knobs would lose the EGL vendor and Vulkan ICD pinning. +# +# The AbaControl arm is DirectVulkan only. The knob reverts two DirectVulkan guards +# (VertexInputStateFactory::ComputeHash's key and LookupVaoDrawMemo's lifetimeId compare); it +# steers nothing on DirectGLES, and a lane that configured it there would be a permanent skip +# claiming to be a control. +mgl_itest_join_environment(MGL_ITEST_GLES_HANDLE_HANDLES_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_HANDLE_ARM=handles" "MOBILEGL_PIPE_LEGACY_MEMOS=0" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) +mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_HANDLES_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=handles" "MOBILEGL_PIPE_LEGACY_MEMOS=0" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) +mgl_itest_join_environment(MGL_ITEST_GLES_HANDLE_LEGACY_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_HANDLE_ARM=legacy" "MOBILEGL_PIPE_PUSH=0" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) +mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_LEGACY_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=legacy" "MOBILEGL_PIPE_PUSH=0" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) +mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_ABA_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=aba" "MOBILEGL_PIPE_PUSH=0" + "MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) + +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.HandleRecycle.Handles." + TEST_FILTER "HandleRecycleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_HANDLE_HANDLES_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.HandleRecycle.Handles." + TEST_FILTER "HandleRecycleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_HANDLES_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.HandleRecycle.Legacy." + TEST_FILTER "HandleRecycleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_HANDLE_LEGACY_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.HandleRecycle.Legacy." + TEST_FILTER "HandleRecycleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_LEGACY_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.HandleRecycle.AbaControl." + TEST_FILTER "HandleRecycleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_ABA_ENVIRONMENT}" +) + +# --- G12: the CSO content-addressing negative control --------------------------------- +# +# PUSH BUILDS ONLY, and that is the honest scope rather than a convenience: the two counters the +# control reads (CallClass::RenderStateCsoMints / RenderStateCsoBinds) and the `cso[...]` bracket +# of the summary line are both `#if MOBILEGL_PIPE_PUSH` (PipeStats.h, PipeStats.cpp), so in a pull +# build there is no channel to read and an entry here would be a permanent skip. +# +# Each arm gets a LOG PATH OF ITS OWN. The library opens its log fopen(path, "w") - every process +# in a lane truncates it - and these two cases READ that log, so a shared path would have them +# reading a neighbour's bring-up under `ctest -j 4`. Same rule as the arming lane below. +# +# MOBILEGL_PIPE_STATS_PERIOD=1 makes one summary line per eglSwapBuffers, which is what lets the +# workload be bracketed by two swaps and read back as a window covering exactly itself. +if (MOBILEGL_PIPE_PUSH) + mgl_itest_join_environment(MGL_ITEST_GLES_CSO_ON_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_CSO_LANE=content-addressed" + "MOBILEGL_PIPE_PUSH=0x7f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-content-addressed-DirectGLES.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + mgl_itest_join_environment(MGL_ITEST_GLES_CSO_OFF_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_CSO_LANE=no-content-addressing" + "MOBILEGL_PIPE_PUSH=0x800000000000007f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-no-content-addressing-DirectGLES.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + mgl_itest_join_environment(MGL_ITEST_VULKAN_CSO_ON_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_CSO_LANE=content-addressed" + "MOBILEGL_PIPE_PUSH=0x7f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-content-addressed-DirectVulkan.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) + mgl_itest_join_environment(MGL_ITEST_VULKAN_CSO_OFF_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_CSO_LANE=no-content-addressing" + "MOBILEGL_PIPE_PUSH=0x800000000000007f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-no-content-addressing-DirectVulkan.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) + + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.CsoContentAddressing.On." + TEST_FILTER "CsoContentAddressingScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_CSO_ON_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.CsoContentAddressing.Off." + TEST_FILTER "CsoContentAddressingScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_CSO_OFF_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.CsoContentAddressing.On." + TEST_FILTER "CsoContentAddressingScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_CSO_ON_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.CsoContentAddressing.Off." + TEST_FILTER "CsoContentAddressingScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_CSO_OFF_ENVIRONMENT}" + ) +endif() + if (MOBILEGL_PIPE_VERIFY) # 900s, not the ambient 120: the comparator re-reads every field of the fill mask at the verb # boundary and again at every accessor read, which the design budgets at 5-10x. diff --git a/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp new file mode 100644 index 000000000..53e2ea0b4 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp @@ -0,0 +1,321 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - THE CSO CONTENT-ADDRESSING NEGATIVE CONTROL (gate G12). +// +// P2's render-state CSO is content-addressed: the client hashes the 396 pipeline bytes, probes a +// 64-entry cache, memcmps a hash hit and reuses the handle. The whole design is measured against +// a knob that turns that off - kMGPipeBehaviourNoCsoContentAddressing, bit 63 of the runtime +// MOBILEGL_PIPE_PUSH bitmask - so that "push is slower" can be told apart from "the CSO design is +// slower" (P2 brief D.4.5). A measurement knob has one characteristic failure mode: it stops +// steering anything and every later number is quietly taken against a switch that does nothing. +// This file is the entry that cannot let that happen. +// +// WHAT IT ASSERTS, per arm, and why those are the right shapes: +// +// content-addressed (MOBILEGL_PIPE_PUSH=0x7f) +// A Blaze3D blend toggle - enable / draw / disable / draw, N times, which is the workload +// the CsoCache exists for (ARCHITECTURE.md 5.1: the push happens at validate rather than in +// the setter precisely because Blaze3D brackets every batch this way) - visits exactly TWO +// distinct pipeline subsets. So the mint count must stay small and BOUNDED while the bind +// count grows with the draws: csom << csob. +// +// no content addressing (MOBILEGL_PIPE_PUSH=0x800000000000007f) +// Every pipeline-version change mints a fresh CSO and the map is never probed, so mint and +// bind must move together: csom == csob. This is the assertion a dead switch fails - with +// the bit ignored, this arm would report csom << csob just like the other one. +// +// both arms +// The PIXELS must not move. The quad is drawn with alpha 1.0 through +// GL_SRC_ALPHA / GL_ONE_MINUS_SRC_ALPHA, so the blended and unblended draws produce the +// same colour by construction and the readback is the same image in both arms and after +// every toggle. "The counters moved and the picture did not" is the whole claim. +// +// HOW THE COUNTERS ARE READ. MG_Util::PipeStats is internal to the library and this module cannot +// link against it (ScenarioFixture.h explains why: on Android this binary links the SHIPPING +// libMobileGL.so, built -fvisibility=hidden). The library's own summary line is the only channel, +// so each lane sets MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 - one line per +// eglSwapBuffers - and a MOBILEGL_LOG_FILE_PATH of its OWN. The log path has to be private: the +// library opens it fopen(path, "w"), so every process in a lane truncates it, and a whole-file +// read in a shared lane races a neighbour's bring-up. That is the same rule, and the same +// remedy, as PipeVerifyArmingScenario's arming lane. +// +// The window a summary line reports is "since the previous line" (PipeStats::FormatWindowLine), so +// the workload runs inside ONE frame: a swap before it closes the setup window, and the swap after +// it emits a line whose csom / csob cover the toggle loop and nothing else. +// +// WHY IT CAN SKIP. The counters are minted by the client-side tracker (P2 package B), and this +// file is written against the P2 contract commit, before that package lands. Until the tracker +// exists there is no CSO to mint, csom is structurally 0 and an assertion about its ratio to csob +// would be a statement about nothing. The build answers the question rather than a hand-maintained +// list: MG_IntegrationTest/CMakeLists.txt looks for MG_Impl/Pipe/Tracker.cpp and passes the answer +// in as MGITEST_PIPE_TRACKER_PRESENT, with a CONFIGURE_DEPENDS on that directory so the answer +// cannot go stale. When the tracker lands the arms arm themselves; until then the entries are +// registered, visible and SKIPPED with the reason - never absent, and never green for having +// asserted nothing. + +#include +#include +#include +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // Set by the two CsoContentAddressing. ctest entries and by nothing else; a harness + // marker, never read by the library. Its absence means an ambient entry, where neither + // the stats channel nor a private log path is configured. + constexpr const char* kLaneMarker = "MGITEST_CSO_LANE"; + constexpr const char* kLaneContentAddressed = "content-addressed"; + constexpr const char* kLaneNoContentAddressing = "no-content-addressing"; + + // Toggle pairs per frame. 8 is small enough to keep the frame cheap and large enough that + // "mints stay bounded" and "mints track binds" are different numbers by a wide margin. + constexpr int kTogglePairs = 8; + constexpr int kDrawsPerFrame = kTogglePairs * 2; + // The blend toggle visits two distinct pipeline subsets, so two CSOs. The bound is + // deliberately a little looser than 2: a future chunk-table change could legitimately + // split one of them, and the claim being pinned here is "bounded, not per-draw". + constexpr long long kMaxDistinctCsos = 4; + + constexpr const char* kVS = R"(#version 330 core +in vec2 aPos; +void main() { gl_Position = vec4(aPos, 0.0, 1.0); } +)"; + + constexpr const char* kFS = R"(#version 330 core +out vec4 oColor; +void main() { oColor = vec4(0.0, 1.0, 0.0, 1.0); } +)"; + + constexpr int kInset = 2; + + bool BuildMarkerIsSet(const char* name) { + const char* value = std::getenv(name); + return value != nullptr && value[0] == '1' && value[1] == '\0'; + } + + std::string LaneName() { + const char* lane = std::getenv(kLaneMarker); + return lane != nullptr ? std::string(lane) : std::string(); + } + + std::string LibraryLogPath() { + const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH"); + return (path != nullptr && *path != '\0') ? std::string(path) : std::string(); + } + + std::string ReadWholeFile(const std::string& path) { + if (path.empty()) return {}; + std::ifstream file(path, std::ios::binary); + if (!file.good()) return {}; + return std::string((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + } + + // One window's CSO counters, as the library printed them. + struct CsoWindow { + bool found = false; + long long mints = -1; + long long binds = -1; + std::string line; + }; + + // Parses `... cso[csom= csob=] ...` out of the LAST "MGPipe stats:" line in the log. + // The last line, because the window a line reports is "since the previous line" and the + // caller closes the setup window with a swap before the workload. + CsoWindow LastCsoWindow(const std::string& log) { + CsoWindow window; + const std::string marker = "MGPipe stats:"; + std::size_t at = log.rfind(marker); + if (at == std::string::npos) return window; + const std::size_t end = log.find('\n', at); + window.line = log.substr(at, end == std::string::npos ? std::string::npos : end - at); + + const std::string mintKey = "csom="; + const std::string bindKey = "csob="; + const std::size_t mintAt = window.line.find(mintKey); + const std::size_t bindAt = window.line.find(bindKey); + if (mintAt == std::string::npos || bindAt == std::string::npos) return window; + window.mints = std::strtoll(window.line.c_str() + mintAt + mintKey.size(), nullptr, 10); + window.binds = std::strtoll(window.line.c_str() + bindAt + bindKey.size(), nullptr, 10); + window.found = true; + return window; + } + + class CsoContentAddressingScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + m_lane = LaneName(); + std::string error; + m_program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(m_program, 0u) << error; + + const float quad[12] = {-1.0f, -1.0f, 1.0f, -1.0f, 1.0f, 1.0f, + -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, 1.0f}; + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + glGenBuffers(1, &m_vbo); + glBindBuffer(GL_ARRAY_BUFFER, m_vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind"; + RecordProperty("lane", m_lane.empty() ? "ambient" : m_lane.c_str()); + } + + void TearDown() override { + if (!Ready()) return; + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + if (m_vbo != 0) glDeleteBuffers(1, &m_vbo); + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + if (m_program != 0) glDeleteProgram(m_program); + } + + // GTEST_SKIP() returns from the function it is written in, so this cannot report + // through a return value; every caller pairs it with `if (IsSkipped()) return;`. + void SkipUnlessTheLaneIsAssertableHere() { + if (m_lane.empty()) { + GTEST_SKIP() << "runs only in its own lane: the two CsoContentAddressing. ctest entries set " + "MGITEST_CSO_LANE together with the MOBILEGL_PIPE_PUSH bitmask, " + "MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 and a private " + "MOBILEGL_LOG_FILE_PATH. None of that is configured in the ambient " + "entries, and the ambient log is shared, so a read here would race."; + return; + } + if (!BuildMarkerIsSet("MGITEST_PIPE_TRACKER_PRESENT")) { + GTEST_SKIP() << "the CSO counters have no emitter in this build: MG_Impl/Pipe/Tracker.cpp " + "does not exist, so nothing mints or binds a render-state CSO and " + "csom / csob are structurally zero. P2 package B owns the tracker; this " + "entry arms itself when it lands."; + return; + } + if (LibraryLogPath().empty()) { + GTEST_SKIP() << "the lane configured no MOBILEGL_LOG_FILE_PATH, and the library's summary " + "line is the only channel this module has for reading PipeStats"; + return; + } + } + + // enable / draw / disable / draw, kTogglePairs times, entirely inside one frame. + // Returns the readback taken at the end of that frame, before the swap. + Image RunBlendToggleFrame() { + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(m_program); + glBindVertexArray(m_vao); + glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ZERO); + for (int i = 0; i < kTogglePairs; ++i) { + glEnable(GL_BLEND); + glDrawArrays(GL_TRIANGLES, 0, 6); + glDisable(GL_BLEND); + glDrawArrays(GL_TRIANGLES, 0, 6); + } + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + return image; + } + + std::string m_lane; + GLuint m_program = 0; + GLuint m_vao = 0; + GLuint m_vbo = 0; + }; + + // The plumbing, asserted on its own so that a counter-ratio failure below can never be + // confused with "the lane never turned the stats channel on". + TEST_F(CsoContentAddressingScenario, TheLibrarysSummaryLineCarriesTheCsoCounters) { + if (!Ready()) return; + SkipUnlessTheLaneIsAssertableHere(); + if (IsSkipped()) return; + + Gl().EndFrame(); // close the setup window + RunBlendToggleFrame(); + + const CsoWindow window = LastCsoWindow(ReadWholeFile(LibraryLogPath())); + ASSERT_TRUE(window.found) + << "no 'MGPipe stats:' line carrying cso[csom= csob=] in " << LibraryLogPath() + << ". Either MOBILEGL_PIPE_STATS/MOBILEGL_PIPE_STATS_PERIOD did not reach the process, or " + "this library was not built with MOBILEGL_PIPE_PUSH - the two counters and the cso[] " + "bracket are both #if MOBILEGL_PIPE_PUSH (PipeStats.h, PipeStats.cpp FormatWindowLine)."; + EXPECT_GE(window.binds, 0) << window.line; + EXPECT_GE(window.mints, 0) << window.line; + RecordProperty("cso_line", window.line.c_str()); + } + + // The control itself. + TEST_F(CsoContentAddressingScenario, TheBlendToggleMintsBoundedlyWithContentAddressingAndPerBindWithout) { + if (!Ready()) return; + SkipUnlessTheLaneIsAssertableHere(); + if (IsSkipped()) return; + + Gl().EndFrame(); // close the setup window + const Image first = RunBlendToggleFrame(); + const CsoWindow window = LastCsoWindow(ReadWholeFile(LibraryLogPath())); + ASSERT_TRUE(window.found) << "no CSO counters in " << LibraryLogPath() + << " - see TheLibrarysSummaryLineCarriesTheCsoCounters"; + RecordProperty("cso_line", window.line.c_str()); + + // Every draw in the frame changed the pipeline subset, so every draw is a bind. This + // is the denominator both arms are read against; without it, "csom == csob" would also + // be satisfied by a frame in which neither happened at all. + ASSERT_GE(window.binds, static_cast(kDrawsPerFrame)) + << "the toggle frame issued " << kDrawsPerFrame + << " draws whose pipeline subset alternates, so it must have issued at least that many " + "render-state binds. It reported: " + << window.line; + + if (m_lane == kLaneContentAddressed) { + EXPECT_LE(window.mints, kMaxDistinctCsos) + << "with content addressing on, enable/draw/disable/draw x " << kTogglePairs + << " visits two distinct pipeline subsets and must mint a bounded number of CSOs, then " + "reuse them. It reported: " + << window.line; + EXPECT_LT(window.mints, window.binds) + << "with content addressing on the cache must be answering binds it did not mint. " + << window.line; + } else if (m_lane == kLaneNoContentAddressing) { + EXPECT_EQ(window.mints, window.binds) + << "kMGPipeBehaviourNoCsoContentAddressing (bit 63 of MOBILEGL_PIPE_PUSH) must make every " + "bind mint a fresh CSO - the map is never probed and no handle is ever reused. Equal " + "counters are the only reading that proves the bit STEERED anything: if it were " + "ignored, this arm would report the same bounded mint count as the other one. It " + "reported: " + << window.line; + } else { + FAIL() << "unknown " << kLaneMarker << " value '" << m_lane << "'"; + } + + // ... and the picture is the same in both arms and after every toggle. The quad is + // opaque, so the blended and unblended draws agree by construction. + EXPECT_TRUE(RegionIsMostly(first, kInset, first.Width() - kInset, kInset, first.Height() - kInset, + "green", 0.0, "the blend-toggle frame [" + m_lane + "]")); + const Image second = RunBlendToggleFrame(); + EXPECT_TRUE(second == first) + << "the second toggle frame does not match the first: " << second.ByteDiffCount(first) + << " bytes differ. The CSO path must not change what is drawn."; + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp new file mode 100644 index 000000000..792682935 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp @@ -0,0 +1,582 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - THE HANDLE ABA (gate G8): a frontend object that dies and is replaced at the same +// heap address must not inherit the dead object's backend twin, its vertex-input state, or its +// draw memo. +// +// WHY THIS EXISTS. Every backend memo in the tree is keyed, today, on some property of a LIVE +// frontend object: a raw `void*` owner pointer (DirectGLES' StateBackendObjectRegistry and its +// three TwinLookupMemos), a `GetLifetimeId()` (DirectVulkan's VertexInputStateFactory::ComputeHash +// and VaoDrawMemo::vaoLifetimeId), or a weak_ptr expiry test. Track H replaces all of them with an +// {slot, gen} handle. The question this scenario asks is the only one that matters about that +// change: does the NEW key actually stop the aliasing the OLD key stopped? A re-key that quietly +// dropped a guard would produce pixels from a dead object's GPU resources, and there is no other +// gate in this tree that can see it - SSIM over a 40-trace corpus cannot, because no fixture +// destroys and immediately re-creates an object with a byte-identical configuration. +// +// HOW THE ABA IS BUILT, through public GL only: +// 1. an object is created, USED IN A DRAW, and used again for a few frames, so that every +// per-object memo in both backends is armed against it; +// 2. it is unbound (so the frontend's last SharedPtr drops - a still-bound object keeps living, +// TextureState.cpp) and deleted; +// 3. a replacement is created IMMEDIATELY, with a byte-identical configuration, so that a +// content hash over the configuration matches the dead object's, and so that the allocator +// is as likely as it can be made to hand back the address it just freed; +// 4. the replacement is given DIFFERENT CONTENTS - a different vertex buffer, different texels, +// a different attachment; +// 5. one draw, one readback. The pixels must come from the replacement. +// +// The allocator is not under our control, so step 3 is a likelihood, not a guarantee, and a +// scenario that silently passed because the address was never reused would prove nothing. The +// public-GL proxy for "the allocator repeated itself" is the GL NAME: MobileGL's name allocators +// hand a deleted name straight back, so `TheReproducerRecyclesEveryName` asserts the recycle +// happened and every other case asserts on the name it got. When a name is NOT recycled the case +// SKIPS with that reason rather than passing - the shape MG_Test/State/ObjectLifetimeIdTest.cpp +// already uses for exactly this ("inconclusive, not proven"). +// +// THREE ARMS, ALL ALWAYS ON (P2 brief D18). The arm is named by MGITEST_HANDLE_ARM, which is a +// HARNESS marker - the library never reads it - and the CMake wiring registers one lane per arm: +// +// Handles MOBILEGL_PIPE_PUSH default (Track H bits set), MOBILEGL_PIPE_LEGACY_MEMOS=0. +// The {slot, gen} key is the only key in the process. Expects correct pixels. +// Legacy MOBILEGL_PIPE_PUSH=0. Today's lifetimeId + weak_ptr guards. Expects correct +// pixels - they work, which is the point: the re-key is not fixing a live bug, it +// is replacing a guard, and the replacement has to be at least as strong. +// AbaControl MOBILEGL_PIPE_PUSH=0 AND MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1. The knob reverts +// exactly the two guards the re-key replaces (VertexInputStateFactory::ComputeHash +// hashes attr.Buffer.get() instead of GetLifetimeId(); LookupVaoDrawMemo skips the +// vaoLifetimeId compare), so this arm expects the CORRUPTION. It is what makes +// `HandleRecycleScenario green, and red before the re-key` an always-on CI fact +// instead of a one-off manual demonstration: if the reproducer ever stops +// reproducing the ABA, this arm fails. +// +// WHY AN ARM CAN SKIP, AND WHY THAT IS NOT A HOLE. Two of the three arms assert something that +// only EXISTS once another P2 package has landed: `Handles` needs the backend's {slot, gen} arm +// (packages C and D) and `AbaControl` needs the knob's consumer (package D). This file is written +// and merged FIRST, against the P2 contract commit, so that the AbaControl red is recorded before +// either backend is touched. Until then those arms have nothing to assert, and the honest report +// for that is a SKIP that names what is missing - never a silently-deleted registration and never +// a green that means "the thing I test does not exist yet". +// +// The skip is decided by the BUILD, not by a hand-maintained list: MG_IntegrationTest/CMakeLists.txt +// greps the backend sources for the subsystem constant and for the knob's name and passes the +// answer in as MGITEST_HANDLE_REKEY_ / MGITEST_HANDLE_ABA_IMPLEMENTED, with a +// CMAKE_CONFIGURE_DEPENDS on those files so the answer cannot go stale. When C and D land, the +// arms arm themselves. + +#include +#include +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // ---- the arm ------------------------------------------------------------------- + + enum class Arm { + Handles, // the {slot, gen} key is the only key + Legacy, // today's lifetimeId + weak_ptr guards + AbaControl // the guards deliberately defeated; the corruption is the assertion + }; + + // Set by the three HandleRecycle. ctest entries and by NOTHING else. It is a harness + // variable, not a library knob (hence the MGITEST_ prefix): the library never reads it. + // Its absence means "this process is one of the ~400 ambient entries", where the arm is + // undefined - MOBILEGL_PIPE_PUSH is at its build default there, which is neither the + // Legacy arm nor the Handles arm - so the cases skip rather than assert something the + // lane did not configure. Same shape, and the same reason, as + // PipeVerifyArmingScenario's MGITEST_PIPE_ARMING_LANE. + constexpr const char* kArmMarker = "MGITEST_HANDLE_ARM"; + + Arm CurrentArm() { + const char* name = std::getenv(kArmMarker); + if (name == nullptr) return Arm::Legacy; + if (std::strcmp(name, "handles") == 0) return Arm::Handles; + if (std::strcmp(name, "aba") == 0) return Arm::AbaControl; + return Arm::Legacy; + } + + bool RunningInAHandleRecycleLane() { return std::getenv(kArmMarker) != nullptr; } + + const char* ArmName(Arm arm) { + switch (arm) { + case Arm::Handles: return "Handles"; + case Arm::AbaControl: return "AbaControl"; + default: return "Legacy"; + } + } + + // A build-time marker set by MG_IntegrationTest/CMakeLists.txt. "1" means the thing it + // names is present in the sources this binary was built from. + bool BuildMarkerIsSet(const char* name) { + const char* value = std::getenv(name); + return value != nullptr && value[0] == '1' && value[1] == '\0'; + } + + // Whichever of the two backend re-keys applies to the process this binary is running as. + bool ThisBackendsRekeyHasLanded() { + const std::string& backend = HeadlessGL::Get().BackendName(); + if (backend == "DirectVulkan") return BuildMarkerIsSet("MGITEST_HANDLE_REKEY_DirectVulkan"); + return BuildMarkerIsSet("MGITEST_HANDLE_REKEY_DirectGLES"); + } + + // ---- the scene ----------------------------------------------------------------- + + constexpr const char* kColorVS = R"(#version 330 core +in vec2 aPos; +in vec3 aColor; +out vec3 vColor; +void main() { + vColor = aColor; + gl_Position = vec4(aPos, 0.0, 1.0); +} +)"; + + constexpr const char* kColorFS = R"(#version 330 core +in vec3 vColor; +out vec4 oColor; +void main() { oColor = vec4(vColor, 1.0); } +)"; + + constexpr const char* kSampleVS = R"(#version 330 core +in vec2 aPos; +out vec2 vUv; +void main() { + vUv = aPos * 0.5 + 0.5; + gl_Position = vec4(aPos, 0.0, 1.0); +} +)"; + + constexpr const char* kSampleFS = R"(#version 330 core +in vec2 vUv; +uniform sampler2D uTex; +out vec4 oColor; +void main() { oColor = texture(uTex, vUv); } +)"; + + struct Vertex { + float x, y; + float r, g, b; + }; + + // A full-viewport quad in one colour. Both buffers are the SAME SIZE and the SAME + // LAYOUT: only the colour bytes differ, which is what makes a content hash over the + // vertex-input CONFIGURATION identical between them. + std::vector Quad(float r, float g, float b) { + return { + {-1.0f, -1.0f, r, g, b}, {1.0f, -1.0f, r, g, b}, {1.0f, 1.0f, r, g, b}, + {-1.0f, -1.0f, r, g, b}, {1.0f, 1.0f, r, g, b}, {-1.0f, 1.0f, r, g, b}, + }; + } + + constexpr int kVertexCount = 6; + // Enough consecutive drawing frames that every per-object memo in both backends is armed + // against the first object before it is destroyed. + constexpr int kWarmupFrames = 3; + // How far inside the viewport the whole-region check starts. The quad covers everything, + // so the inset is only about primitive edges on the outermost pixel row/column. + constexpr int kInset = 2; + + void ExpectWholeViewportIs(const Image& image, const char* expected, const std::string& when) { + EXPECT_TRUE(RegionIsMostly(image, kInset, image.Width() - kInset, kInset, image.Height() - kInset, + expected, 0.0, when)); + } + + // The one thing the whole file turns on: did the pixels come from the REPLACEMENT + // (`fresh`) or from the object that died (`stale`)? The arm decides which is the pass. + void ExpectPixelsFor(Arm arm, bool armExpectsCorruption, const Image& image, const char* fresh, + const char* stale, const std::string& when) { + if (arm == Arm::AbaControl && armExpectsCorruption) { + // The corruption IS the assertion. If this ever goes green-by-being-correct the + // reproducer has stopped reproducing and the other two arms prove nothing. + ExpectWholeViewportIs(image, stale, when + " [AbaControl expects the STALE object's pixels: " + "the two guards are deliberately defeated]"); + return; + } + ExpectWholeViewportIs(image, fresh, + when + " [" + ArmName(arm) + " expects the replacement's pixels]"); + } + + class HandleRecycleScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + m_arm = CurrentArm(); + std::string error; + m_colorProgram = CompileProgram(kColorVS, kColorFS, &error); + ASSERT_NE(m_colorProgram, 0u) << error; + m_sampleProgram = CompileProgram(kSampleVS, kSampleFS, &error); + ASSERT_NE(m_sampleProgram, 0u) << error; + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "program setup left a GL error behind"; + RecordProperty("arm", ArmName(m_arm)); + } + + void TearDown() override { + if (!Ready()) return; + if (m_colorProgram != 0) glDeleteProgram(m_colorProgram); + if (m_sampleProgram != 0) glDeleteProgram(m_sampleProgram); + } + + // Skips the case when the arm it is running under has nothing to assert on THIS tree. + // GTEST_SKIP() returns from the function it is written in, so this cannot report + // through a return value; every caller pairs it with `if (IsSkipped()) return;`. + void SkipUnlessTheArmIsAssertableHere() { + if (!RunningInAHandleRecycleLane()) { + GTEST_SKIP() << "runs only in its own lane: the three HandleRecycle. ctest entries set " + "MGITEST_HANDLE_ARM (handles / legacy / aba) together with the " + "MOBILEGL_PIPE_PUSH and MOBILEGL_PIPE_LEGACY_MEMOS values that arm means. " + "The ambient entries configure none of that, so there is nothing here to " + "assert."; + } + switch (m_arm) { + case Arm::Handles: + if (!ThisBackendsRekeyHasLanded()) { + GTEST_SKIP() << "the Handles arm needs the backend's {slot, gen} re-key, and this " + "build does not have it: no source under MobileGL/MG_Backend/" + << Gl().BackendName() + << " mentions the Track H subsystem constant (P2 package C for " + "DirectGLES, package D for DirectVulkan). The arm is registered " + "and visible, and arms itself when that package lands."; + } + return; + case Arm::AbaControl: + if (!BuildMarkerIsSet("MGITEST_HANDLE_ABA_IMPLEMENTED")) { + GTEST_SKIP() << "the AbaControl arm needs MOBILEGL_PIPE_HANDLE_ABA_CONTROL to have a " + "consumer, and this build has none: MG_Config parses the knob " + "(ConfigLoader.cpp) but no source under MobileGL/MG_Backend/ reads " + "Features.PipeHandleAbaControl, so the two guards the knob is " + "supposed to defeat are still in force and the ABA cannot be " + "reproduced. P2 package D owns that consumer."; + } + return; + default: return; + } + } + + // A VBO holding one solid-colour quad. + GLuint MakeQuadBuffer(float r, float g, float b) { + const std::vector vertices = Quad(r, g, b); + GLuint buffer = 0; + glGenBuffers(1, &buffer); + glBindBuffer(GL_ARRAY_BUFFER, buffer); + glBufferData(GL_ARRAY_BUFFER, + static_cast(vertices.size() * sizeof(Vertex)), vertices.data(), + GL_STATIC_DRAW); + return buffer; + } + + // The attribute configuration, spelled once so the two VAOs are byte-identical. + void ConfigureQuadVao(GLuint vao, GLuint buffer) { + glBindVertexArray(vao); + glBindBuffer(GL_ARRAY_BUFFER, buffer); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(offsetof(Vertex, x))); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(offsetof(Vertex, r))); + } + + Image DrawQuadAndRead(GLuint vao) { + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(m_colorProgram); + glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + return image; + } + + // A 2x2 RGBA8 texture of one colour, with the sampling parameters spelled the same + // way both times so a parameter-shadow key matches too. + GLuint MakeSolidTexture(std::uint8_t r, std::uint8_t g, std::uint8_t b) { + const std::uint8_t texels[16] = {r, g, b, 255, r, g, b, 255, r, g, b, 255, r, g, b, 255}; + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0); + return texture; + } + + Image DrawTexturedQuadAndRead(GLuint vao, GLuint texture) { + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(m_sampleProgram); + glUniform1i(glGetUniformLocation(m_sampleProgram, "uTex"), 0); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + return image; + } + + Arm m_arm = Arm::Legacy; + GLuint m_colorProgram = 0; + GLuint m_sampleProgram = 0; + }; + + // ------------------------------------------------------------------------------------ + // The self-check. Without it the three cases below could all be green because the name + // allocator never repeated itself, i.e. because the ABA never happened. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, TheReproducerRecyclesEveryName) { + if (!Ready()) return; + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + GLuint vaoAgain = 0; + glGenVertexArrays(1, &vaoAgain); + EXPECT_EQ(vao, vaoAgain) << "glGenVertexArrays did not hand the deleted name back, so the " + "vertex-array case below cannot be constructing an ABA"; + glDeleteVertexArrays(1, &vaoAgain); + + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glBindTexture(GL_TEXTURE_2D, 0); + glDeleteTextures(1, &texture); + GLuint textureAgain = 0; + glGenTextures(1, &textureAgain); + EXPECT_EQ(texture, textureAgain) << "glGenTextures did not hand the deleted name back"; + glDeleteTextures(1, &textureAgain); + + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + BindDefaultFramebuffer(); + glDeleteFramebuffers(1, &fbo); + GLuint fboAgain = 0; + glGenFramebuffers(1, &fboAgain); + EXPECT_EQ(fbo, fboAgain) << "glGenFramebuffers did not hand the deleted name back"; + glDeleteFramebuffers(1, &fboAgain); + + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + } + + // ------------------------------------------------------------------------------------ + // 1. The vertex array. This is the case the AbaControl knob targets: DirectVulkan keys + // VertexInputStateFactory's cache on the attribute's buffer identity and VaoDrawMemo + // on the VAO's, and BOTH the VAO and the buffer are recycled here so that a key built + // out of raw addresses matches while the bytes behind it do not. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, AVertexArrayAtARecycledAddressDoesNotInheritItsPredecessorsVertexInput) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + + const GLuint redBuffer = MakeQuadBuffer(1.0f, 0.0f, 0.0f); + GLuint redVao = 0; + glGenVertexArrays(1, &redVao); + ConfigureQuadVao(redVao, redBuffer); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the first VAO left a GL error behind"; + + for (int frame = 0; frame < kWarmupFrames; ++frame) { + const Image warm = DrawQuadAndRead(redVao); + ExpectWholeViewportIs(warm, "red", "warm-up frame " + std::to_string(frame)); + } + + // Unbind FIRST: a still-bound object keeps living, so the last SharedPtr would not + // drop and there would be no freed block for the replacement to land in. + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glDeleteVertexArrays(1, &redVao); + GLuint doomedBuffer = redBuffer; + glDeleteBuffers(1, &doomedBuffer); + + // The replacement, immediately and in the reverse order of the frees, which is the + // order a size-classed allocator is most likely to answer from its free lists. + const GLuint greenBuffer = MakeQuadBuffer(0.0f, 1.0f, 0.0f); + GLuint greenVao = 0; + glGenVertexArrays(1, &greenVao); + ConfigureQuadVao(greenVao, greenBuffer); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the replacement VAO left a GL error behind"; + + if (greenVao != redVao || greenBuffer != redBuffer) { + GTEST_SKIP() << "inconclusive, not proven: the name allocator did not hand both names back " + "(vao " << redVao << " -> " << greenVao << ", buffer " << redBuffer << " -> " + << greenBuffer << "), so no ABA was constructed"; + } + RecordProperty("recycled_vao_name", static_cast(greenVao)); + + const Image image = DrawQuadAndRead(greenVao); + ExpectPixelsFor(m_arm, /*armExpectsCorruption=*/true, image, "green", "red", + "the draw after the VAO and its buffer were both recycled"); + + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glDeleteVertexArrays(1, &greenVao); + GLuint cleanup = greenBuffer; + glDeleteBuffers(1, &cleanup); + } + + // ------------------------------------------------------------------------------------ + // 2. The texture. DirectGLES keeps a backend twin per frontend texture in a registry + // keyed on the frontend object's address (StateBackendObjectRegistry + the + // UnitSamplerLookupMemo's weak_ptr test); a replacement at the same address must not + // sample the dead texture's driver object. + // + // The AbaControl knob does not steer this path, so this case expects the correct + // pixels in EVERY arm - stated explicitly rather than by omission. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, ATextureAtARecycledAddressDoesNotInheritItsPredecessorsTwin) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + + const GLuint buffer = MakeQuadBuffer(1.0f, 1.0f, 1.0f); + GLuint vao = 0; + glGenVertexArrays(1, &vao); + ConfigureQuadVao(vao, buffer); + + const GLuint redTexture = MakeSolidTexture(255, 0, 0); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the first texture left a GL error behind"; + for (int frame = 0; frame < kWarmupFrames; ++frame) { + const Image warm = DrawTexturedQuadAndRead(vao, redTexture); + ExpectWholeViewportIs(warm, "red", "warm-up frame " + std::to_string(frame)); + } + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, 0); + GLuint doomed = redTexture; + glDeleteTextures(1, &doomed); + + const GLuint greenTexture = MakeSolidTexture(0, 255, 0); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the replacement texture left a GL error"; + if (greenTexture != redTexture) { + GTEST_SKIP() << "inconclusive, not proven: glGenTextures returned " << greenTexture + << " rather than the deleted " << redTexture << ", so no ABA was constructed"; + } + RecordProperty("recycled_texture_name", static_cast(greenTexture)); + + const Image image = DrawTexturedQuadAndRead(vao, greenTexture); + ExpectPixelsFor(m_arm, /*armExpectsCorruption=*/false, image, "green", "red", + "the draw after the texture was recycled"); + + glBindTexture(GL_TEXTURE_2D, 0); + GLuint cleanupTexture = greenTexture; + glDeleteTextures(1, &cleanupTexture); + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glDeleteVertexArrays(1, &vao); + GLuint cleanupBuffer = buffer; + glDeleteBuffers(1, &cleanupBuffer); + } + + // ------------------------------------------------------------------------------------ + // 3. The framebuffer. The readback is deliberately NOT from the framebuffer under test: + // a clear that landed in the WRONG framebuffer would still read back green through + // that framebuffer. It is taken from the replacement's own attachment with + // glGetTexImage, so "the clear went somewhere else" is visible as a texture that + // never became green. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, AFramebufferAtARecycledAddressDoesNotInheritItsPredecessorsTwin) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + + // Two attachments that stay alive for the whole case, so the only recycled object is + // the framebuffer itself. + GLuint firstAttachment = 0; + GLuint secondAttachment = 0; + glGenTextures(1, &firstAttachment); + glBindTexture(GL_TEXTURE_2D, firstAttachment); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glGenTextures(1, &secondAttachment); + glBindTexture(GL_TEXTURE_2D, secondAttachment); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glBindTexture(GL_TEXTURE_2D, 0); + + GLuint firstFbo = 0; + glGenFramebuffers(1, &firstFbo); + glBindFramebuffer(GL_FRAMEBUFFER, firstFbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, firstAttachment, 0); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + for (int frame = 0; frame < kWarmupFrames; ++frame) { + glBindFramebuffer(GL_FRAMEBUFFER, firstFbo); + glViewport(0, 0, 4, 4); + ClearTo(1.0f, 0.0f, 0.0f, 1.0f); + BindDefaultFramebuffer(); + Gl().EndFrame(); + } + BindDefaultFramebuffer(); + glDeleteFramebuffers(1, &firstFbo); + + GLuint secondFbo = 0; + glGenFramebuffers(1, &secondFbo); + if (secondFbo != firstFbo) { + glDeleteFramebuffers(1, &secondFbo); + GLuint cleanup[2] = {firstAttachment, secondAttachment}; + glDeleteTextures(2, cleanup); + GTEST_SKIP() << "inconclusive, not proven: glGenFramebuffers returned " << secondFbo + << " rather than the deleted " << firstFbo << ", so no ABA was constructed"; + } + RecordProperty("recycled_framebuffer_name", static_cast(secondFbo)); + + glBindFramebuffer(GL_FRAMEBUFFER, secondFbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, secondAttachment, 0); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + glViewport(0, 0, 4, 4); + ClearTo(0.0f, 1.0f, 0.0f, 1.0f); + BindDefaultFramebuffer(); + Gl().EndFrame(); + + // Read the REPLACEMENT'S attachment, not the framebuffer: that is what makes "the + // clear landed in the dead framebuffer" visible. + std::vector texels(4 * 4 * 4, 0); + glBindTexture(GL_TEXTURE_2D, secondAttachment); + glGetTexImage(GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels.data()); + glBindTexture(GL_TEXTURE_2D, 0); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + + // Every texel of the replacement's attachment must be the green it was cleared to. + int offenders = 0; + for (std::size_t i = 0; i < texels.size(); i += 4) { + if (texels[i] != 0 || texels[i + 1] != 255 || texels[i + 2] != 0) ++offenders; + } + EXPECT_EQ(offenders, 0) << "the replacement framebuffer's own attachment is not the colour it was " + "cleared to, so the clear reached a framebuffer this one only shares an " + "address with (first texel rgba=" + << static_cast(texels[0]) << "," << static_cast(texels[1]) << "," + << static_cast(texels[2]) << "," << static_cast(texels[3]) << ")"; + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &secondFbo); + GLuint cleanup[2] = {firstAttachment, secondAttachment}; + glDeleteTextures(2, cleanup); + } + + } // namespace +} // namespace MGITest From e9499d38bd0998cfb17127bd9eee7e83d34c42e1 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:09:34 -0400 Subject: [PATCH 125/529] [Feat] (Trace): record per-frame thread CPU time beside wall time so a paired A/B can be read as CPU cost - The metric the disaggregation GO/NO-GO hangs on is per-thread CPU p50/p99, and the tree had no first-party collector for it: no CLOCK_THREAD_CPUTIME_ID, no getrusage, no RUSAGE_THREAD, no /proc/self/task anywhere under MobileGL/, tools/, android-plugin/ or scripts/. - The retrace loop is the cheapest honest place to take it. Retrace runs --singlethread and trace_benchmark states that Begin/OnFrameBoundary/End are only ever reached from that one thread, so that thread's CPU time IS the client-side CPU cost: one extra clock_gettime per frame, no root, no profiler, no debuggable build, no sampling - and no instrumentation committed to a hot path inside the library. - The CPU reading is taken before the wall reading at each boundary, so the syscall lands in the wall delta rather than hiding inside the CPU delta. An inflated wall number is visible; a deflated CPU number would not be. - Report::frameCpuMs is the same length as frameMs or it is EMPTY. A clock that started failing mid-run would otherwise be silently re-indexed and put frame N next to frame N+k, and an empty series and a series of zeroes are different claims about the platform. - SummarizeBenchmark is split into SummarizeSeries and reused verbatim for the CPU series rather than duplicated: same tail window, same even-count median rule, same nearest-rank p95, or the delta between the two series stops meaning anything. - benchmark.json gains meanFrameCpuMs / medianFrameCpuMs / p95FrameCpuMs and the WHOLE frameCpuTimesMs[] array beside frameTimesMs[]; result.json and the completion line gain the three headline numbers. p99 therefore needs no device change - it is a host-side reduction over an artefact that already exists, and run_android_retrace_local.py prints p50/p95/p99 off the same trailing window the device summarised. - Pre-flighted on the desktop CLI, which shares the same core: 2-frame run, cpu series aligned with the wall series and strictly below it (wall 1290.787/20.225 ms, cpu 535.271/8.261 ms). --- .../app/src/trace/cpp/trace_benchmark.cpp | 45 ++++++++++ .../app/src/trace/cpp/trace_benchmark.hpp | 23 +++++ .../app/src/trace/cpp/trace_replay_core.cpp | 88 +++++++++++++++---- .../app/src/trace/cpp/trace_replay_core.hpp | 11 +++ .../trace_replay/run_android_retrace_local.py | 53 ++++++++++- 5 files changed, 201 insertions(+), 19 deletions(-) diff --git a/android-plugin/app/src/trace/cpp/trace_benchmark.cpp b/android-plugin/app/src/trace/cpp/trace_benchmark.cpp index 669023ef6..fb67d0aa2 100644 --- a/android-plugin/app/src/trace/cpp/trace_benchmark.cpp +++ b/android-plugin/app/src/trace/cpp/trace_benchmark.cpp @@ -5,6 +5,16 @@ #include #include +// CLOCK_THREAD_CPUTIME_ID is POSIX and present on Linux and on every Android API this replays +// on; the guard exists so the desktop CLI still builds where it is not, and so that "no CPU +// series" is a compile-time fact rather than a silently-zero column. +#if defined(__unix__) || defined(__linux__) || defined(__ANDROID__) || defined(__APPLE__) +#include +#define MOBILEGL_TRACE_HAVE_THREAD_CPU_CLOCK 1 +#else +#define MOBILEGL_TRACE_HAVE_THREAD_CPU_CLOCK 0 +#endif + namespace mobilegl_trace { namespace benchmark { namespace { @@ -21,6 +31,23 @@ GlFinishFn gGlFinish = nullptr; Clock::time_point gStart; Clock::time_point gLastBoundary; std::vector gFrameMs; +std::vector gFrameCpuMs; +double gLastBoundaryCpuMs = 0.0; + +// Milliseconds of CPU time this thread has consumed, or -1 where the clock does not exist. +// Negative once means negative always, so End() reports an EMPTY cpu series rather than a +// column of zeroes. +double ThreadCpuMs() { +#if MOBILEGL_TRACE_HAVE_THREAD_CPU_CLOCK + struct timespec now; + if (clock_gettime(CLOCK_THREAD_CPUTIME_ID, &now) != 0) { + return -1.0; + } + return static_cast(now.tv_sec) * 1000.0 + static_cast(now.tv_nsec) / 1e6; +#else + return -1.0; +#endif +} // Same resolution order the glws layers use for MobileGL's entry points: the replay driver // already dlopen()ed the library with RTLD_GLOBAL before retrace started, so RTLD_NOLOAD @@ -49,6 +76,9 @@ GlFinishFn ResolveGlFinish() { void Begin(bool finishEachFrame) { gFrameMs.clear(); gFrameMs.reserve(kFrameReserve); + gFrameCpuMs.clear(); + gFrameCpuMs.reserve(kFrameReserve); + gLastBoundaryCpuMs = ThreadCpuMs(); gFinishEachFrame = finishEachFrame; gResolvedGlFinish = false; gGlFinish = nullptr; @@ -72,8 +102,16 @@ void OnFrameBoundary() { gGlFinish(); } } + // The CPU reading is taken FIRST and the wall reading second, so the wall delta contains the + // cost of the extra syscall rather than the CPU delta hiding inside it: an inflated wall + // number is visible, a deflated CPU number is not. + const double cpuNow = ThreadCpuMs(); const Clock::time_point now = Clock::now(); gFrameMs.push_back(std::chrono::duration(now - gLastBoundary).count()); + if (cpuNow >= 0.0 && gLastBoundaryCpuMs >= 0.0) { + gFrameCpuMs.push_back(cpuNow - gLastBoundaryCpuMs); + } + gLastBoundaryCpuMs = cpuNow; gLastBoundary = now; } @@ -87,6 +125,13 @@ Report End() { report.totalSeconds = std::chrono::duration(Clock::now() - gStart).count(); report.frameMs = std::move(gFrameMs); gFrameMs.clear(); + // Only hand back a CPU series that lines up frame-for-frame with the wall series. A short + // one would be a clock that started failing mid-run, and silently re-indexing it against + // frameMs would put frame N's wall time next to frame N+k's CPU time. + if (gFrameCpuMs.size() == report.frameMs.size()) { + report.frameCpuMs = std::move(gFrameCpuMs); + } + gFrameCpuMs.clear(); return report; } diff --git a/android-plugin/app/src/trace/cpp/trace_benchmark.hpp b/android-plugin/app/src/trace/cpp/trace_benchmark.hpp index 39410e705..9672a885d 100644 --- a/android-plugin/app/src/trace/cpp/trace_benchmark.hpp +++ b/android-plugin/app/src/trace/cpp/trace_benchmark.hpp @@ -11,6 +11,23 @@ namespace benchmark { // // Retrace runs --singlethread, so all of this is deliberately plain globals: Begin(), // OnFrameBoundary() and End() are only ever reached from the one retrace thread. +// +// That single-threadedness is also what makes the SECOND series below sound. Beside the wall +// clock, every frame boundary reads CLOCK_THREAD_CPUTIME_ID - the CPU time consumed by THIS +// thread - and because the retrace loop is the only thread that ever gets here, that number is +// the client-side CPU cost of the frame and nothing else. It is the metric the disaggregation +// GO/NO-GO hangs on (ROADMAP.md: per-thread CPU p50/p99, not wall time), and before P2 the tree +// had no first-party collector for it at all: no CLOCK_THREAD_CPUTIME_ID, no getrusage, no +// /proc/self/task anywhere under MobileGL/, tools/, android-plugin/ or scripts/. Collecting it +// here costs one extra clock_gettime per frame, needs no root, no profiler, no debuggable build +// and no sampling, and - unlike a timer inside the library - commits no instrumentation to a hot +// path. +// +// Wall time and CPU time answer different questions and both are kept: with --benchmark-no-finish +// the wall series still contains everything the thread WAITED for (driver submit, the compositor, +// a fence), while the CPU series contains only what it EXECUTED. A change that moves work off the +// retrace thread shows up as the two series diverging, which is exactly the confusion a single +// number invites. // Arms timing for the retrace that is about to run. // @@ -32,6 +49,12 @@ void OnFrameBoundary(); struct Report { // Wall time of every completed frame, in milliseconds. std::vector frameMs; + // Thread CPU time of every completed frame, in milliseconds, in the SAME ORDER and with the + // same length as frameMs - the two are pushed together at one frame boundary, so index i is + // one frame in both. Empty when the platform has no CLOCK_THREAD_CPUTIME_ID, which is the + // one honest reading of "this run collected no CPU series"; a vector of zeroes would be + // indistinguishable from a frame that genuinely burned no CPU. + std::vector frameCpuMs; // Begin() to End(), in seconds. Covers trace parsing and the leading partial frame too, // which is why it is reported next to the per-frame statistics rather than derived from // them. diff --git a/android-plugin/app/src/trace/cpp/trace_replay_core.cpp b/android-plugin/app/src/trace/cpp/trace_replay_core.cpp index f36d9b401..cce92e55f 100644 --- a/android-plugin/app/src/trace/cpp/trace_replay_core.cpp +++ b/android-plugin/app/src/trace/cpp/trace_replay_core.cpp @@ -827,6 +827,42 @@ std::string BenchmarkResultPath(const Request& request) { : request.benchmarkResultPath; } +// mean / median / nearest-rank p95 over the trailing `tail` entries of one per-frame series. +// +// Split out of SummarizeBenchmark rather than duplicated, because the wall series and the CPU +// series have to be reduced IDENTICALLY or the delta between them stops meaning anything: the same +// tail window, the same median rule for an even count, the same nearest-rank p95 (so the reported +// value is always an observed frame, never an interpolation). +struct SeriesSummary { + double meanMs = -1.0; + double medianMs = -1.0; + double p95Ms = -1.0; +}; + +SeriesSummary SummarizeSeries(const std::vector& series, std::size_t tail) { + SeriesSummary summary; + if (series.empty() || tail == 0 || tail > series.size()) { + return summary; + } + std::vector window(series.end() - static_cast(tail), series.end()); + double sum = 0.0; + for (double value : window) { + sum += value; + } + summary.meanMs = sum / static_cast(tail); + + std::sort(window.begin(), window.end()); + summary.medianMs = + (tail % 2 == 1) ? window[tail / 2] : 0.5 * (window[tail / 2 - 1] + window[tail / 2]); + // Nearest-rank p95, so the reported value is always an observed frame time. + std::size_t rank = static_cast(std::ceil(0.95 * static_cast(tail))); + if (rank == 0) { + rank = 1; + } + summary.p95Ms = window[rank - 1]; + return summary; +} + // Folds the recorded frame times into the headline numbers. Everything but totalSeconds and // the frame count is computed over the trailing benchmarkTailFrames frames only. void SummarizeBenchmark(const Request& request, const benchmark::Report& report, Result& result) { @@ -843,25 +879,20 @@ void SummarizeBenchmark(const Request& request, const benchmark::Report& report, std::min(static_cast(requestedTail), report.frameMs.size()); result.benchmarkTailFrames = static_cast(tail); - std::vector window(report.frameMs.end() - static_cast(tail), - report.frameMs.end()); - double sum = 0.0; - for (double frameMs : window) { - sum += frameMs; - } - result.benchmarkMeanMs = sum / static_cast(tail); - - std::sort(window.begin(), window.end()); - result.benchmarkMedianMs = (tail % 2 == 1) - ? window[tail / 2] - : 0.5 * (window[tail / 2 - 1] + window[tail / 2]); - // Nearest-rank p95, so the reported value is always an observed frame time. - std::size_t rank = static_cast(std::ceil(0.95 * static_cast(tail))); - if (rank == 0) { - rank = 1; - } - result.benchmarkP95Ms = window[rank - 1]; + const SeriesSummary wall = SummarizeSeries(report.frameMs, tail); + result.benchmarkMeanMs = wall.meanMs; + result.benchmarkMedianMs = wall.medianMs; + result.benchmarkP95Ms = wall.p95Ms; result.benchmarkFps = result.benchmarkMeanMs > 0.0 ? 1000.0 / result.benchmarkMeanMs : -1.0; + + // The CPU series is the same length as the wall series or it is empty (trace_benchmark.cpp + // refuses to hand back a partial one), so the same tail window applies unchanged. When it is + // empty the three CPU fields stay at -1, which is what the platform having no per-thread CPU + // clock looks like - and is not the same reading as a genuine 0.0. + const SeriesSummary cpu = SummarizeSeries(report.frameCpuMs, tail); + result.benchmarkMeanCpuMs = cpu.meanMs; + result.benchmarkMedianCpuMs = cpu.medianMs; + result.benchmarkP95CpuMs = cpu.p95Ms; } bool WriteBenchmarkJson(const Request& request, @@ -886,6 +917,9 @@ bool WriteBenchmarkJson(const Request& request, file << " \"medianFrameMs\": " << result.benchmarkMedianMs << ",\n"; file << " \"p95FrameMs\": " << result.benchmarkP95Ms << ",\n"; file << " \"fps\": " << result.benchmarkFps << ",\n"; + file << " \"meanFrameCpuMs\": " << result.benchmarkMeanCpuMs << ",\n"; + file << " \"medianFrameCpuMs\": " << result.benchmarkMedianCpuMs << ",\n"; + file << " \"p95FrameCpuMs\": " << result.benchmarkP95CpuMs << ",\n"; file << " \"frameTimesMs\": ["; for (std::size_t i = 0; i < report.frameMs.size(); ++i) { if (i > 0) { @@ -893,6 +927,18 @@ bool WriteBenchmarkJson(const Request& request, } file << report.frameMs[i]; } + file << "],\n"; + // The WHOLE per-frame CPU array, beside the whole per-frame wall array. This is what makes + // p99 - and any other percentile a later question wants - a host-side computation over an + // artefact that already exists, instead of a device change. Empty when this platform has no + // per-thread CPU clock; an empty array and an array of zeroes are different claims. + file << " \"frameCpuTimesMs\": ["; + for (std::size_t i = 0; i < report.frameCpuMs.size(); ++i) { + if (i > 0) { + file << ", "; + } + file << report.frameCpuMs[i]; + } file << "]\n"; file << "}\n"; return static_cast(file); @@ -965,6 +1011,9 @@ bool WriteResultJson(const Request& request, const Result& result) { file << " \"benchmarkMeanFrameMs\": " << result.benchmarkMeanMs << ",\n"; file << " \"benchmarkMedianFrameMs\": " << result.benchmarkMedianMs << ",\n"; file << " \"benchmarkP95FrameMs\": " << result.benchmarkP95Ms << ",\n"; + file << " \"benchmarkMeanFrameCpuMs\": " << result.benchmarkMeanCpuMs << ",\n"; + file << " \"benchmarkMedianFrameCpuMs\": " << result.benchmarkMedianCpuMs << ",\n"; + file << " \"benchmarkP95FrameCpuMs\": " << result.benchmarkP95CpuMs << ",\n"; file << " \"benchmarkFps\": " << result.benchmarkFps << "\n"; } else { file << "\n"; @@ -1045,6 +1094,9 @@ Result RunTraceReplay(const Request& request) { << ", meanMs=" << result.benchmarkMeanMs << ", medianMs=" << result.benchmarkMedianMs << ", p95Ms=" << result.benchmarkP95Ms + << ", meanCpuMs=" << result.benchmarkMeanCpuMs + << ", medianCpuMs=" << result.benchmarkMedianCpuMs + << ", p95CpuMs=" << result.benchmarkP95CpuMs << ", fps=" << result.benchmarkFps << ", benchmarkResultPath=" << result.benchmarkResultPath; result.message = message.str(); diff --git a/android-plugin/app/src/trace/cpp/trace_replay_core.hpp b/android-plugin/app/src/trace/cpp/trace_replay_core.hpp index 0f95c0398..0cbdca915 100644 --- a/android-plugin/app/src/trace/cpp/trace_replay_core.hpp +++ b/android-plugin/app/src/trace/cpp/trace_replay_core.hpp @@ -90,6 +90,17 @@ struct Result { double benchmarkMedianMs = -1.0; double benchmarkP95Ms = -1.0; double benchmarkFps = -1.0; + // The same three statistics over the retrace thread's CPU time instead of wall time, and the + // reason the CPU series is collected at all: the disaggregation GO/NO-GO is a per-thread CPU + // question, not a frame-rate one. Left at -1 when the platform has no per-thread CPU clock, + // which is distinguishable from a real 0.0. + // + // Only mean/median/p95 stop here. p99 - which is half of what the paired A/B publishes - is + // computed HOST-SIDE from the full frameCpuTimesMs[] array in benchmark.json, so asking for a + // different percentile later needs no device change and no reflash. + double benchmarkMeanCpuMs = -1.0; + double benchmarkMedianCpuMs = -1.0; + double benchmarkP95CpuMs = -1.0; }; Result RunTraceReplay(const Request& request); diff --git a/tools/trace_replay/run_android_retrace_local.py b/tools/trace_replay/run_android_retrace_local.py index 303377480..22c15c7d1 100644 --- a/tools/trace_replay/run_android_retrace_local.py +++ b/tools/trace_replay/run_android_retrace_local.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import argparse import json +import math import shutil import subprocess import sys @@ -225,8 +226,42 @@ def read_benchmark(case, backend, run_index): return report +def nearest_rank_percentile(values, fraction): + """Nearest-rank percentile, the same rule SummarizeSeries uses on the device. + + Nearest rank rather than an interpolating percentile so that every number printed here is a + frame that was actually observed, and so that a p95 computed on this side agrees exactly with + the p95 the device reported for the same window. + """ + if not values: + return -1.0 + ordered = sorted(values) + rank = math.ceil(fraction * len(ordered)) + if rank < 1: + rank = 1 + return ordered[rank - 1] + + +def cpu_tail(report): + """The trailing window of the per-frame CPU series, or [] when the run collected none. + + benchmark.json carries the WHOLE frameCpuTimesMs[] array precisely so that percentiles the + device does not compute - p50 and p99, which are what the paired A/B publishes - are a + host-side reduction over an artefact that already exists. The window is the same trailing + tailFrames the device summarised, so the numbers below sit beside the device's own without + being about a different set of frames. + """ + series = report.get("frameCpuTimesMs") or [] + if not series: + return [] + tail = report.get("tailFrames", 0) + if not isinstance(tail, int) or tail <= 0 or tail > len(series): + tail = len(series) + return series[-tail:] + + def format_benchmark(report): - return ( + line = ( f"frames={report.get('totalFrames', -1)}" f" total={report.get('totalSeconds', -1):.1f}s" f" tail={report.get('tailFrames', -1)}" @@ -235,6 +270,22 @@ def format_benchmark(report): f" p95={report.get('p95FrameMs', -1):.3f}ms" f" fps={report.get('fps', -1):.1f}" ) + # The CPU half. It is what the disaggregation A/B is actually read on - wall time under + # --benchmark-no-finish still contains everything the retrace thread waited for - so it is + # printed on the same line rather than left to whoever remembers to open the JSON. + window = cpu_tail(report) + if window: + line += ( + f" | cpu mean={report.get('meanFrameCpuMs', -1):.3f}ms" + f" p50={nearest_rank_percentile(window, 0.50):.3f}ms" + f" p95={report.get('p95FrameCpuMs', -1):.3f}ms" + f" p99={nearest_rank_percentile(window, 0.99):.3f}ms" + ) + else: + # Not "cpu=0": a run with no per-thread CPU clock and a run that burned no CPU are + # different claims, and only one of them is possible. + line += " | cpu unavailable (no per-thread CPU clock in this run)" + return line def run_benchmark_case(case, backend, args): From b9c137e14648face0f55efd835651fc58bafa583 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:14:35 -0400 Subject: [PATCH 126/529] [Feat] (Bench, Pipe): run the blend-toggle case in CI, give G7 a negative control, and record the two campaign devices - DriverBenchStateToggle runs mc_state_toggle as its own ctest entry. The case has been in kBenchCases since P0 and nothing executed it, so nothing would have noticed it rotting - and it is the exact enable/draw/disable/draw shape the microbenchmark P2 owes the GO/NO-GO measures. About 1.2 s inside an existing three-minute job. - scripts/g7_negative_control.sh breaks the pipeline/dynamic split on purpose: it inserts two boundaries so ColorMasks becomes a dynamic chunk of its own, which keeps the partition sorted, non-overlapping and complete - so it still COMPILES - while making glColorMask bump m_pipelineStateVersion without moving the pipeline-subset hash. A non-zero ctest is the pass. - Everything that could make that control lie is refused rather than reported: a missing SetterConsistency test exits 2 instead of reading "no tests matched" as a failure; a tree that is already red or already broken exits 2; a patched table that does not compile exits 2, since a build break would prove the static_asserts work rather than that the test still checks; and the restore is from byte-for-byte copies (never from git, so a dirty tree is given back intact), followed by a rebuild and a re-run that must be green. --verify-patch-only exercises the mechanism where the test does not exist yet and says explicitly that it is not a pass. - Profiles for the two campaign devices, and the guard that stops them being trusted early. Both carry PROFILE_VERIFIED=0 and every device-specific field is TODO_VERIFY_ON_DEVICE rather than a guess: the harness pins through MediaTek nodes and 35d0befa is a Qualcomm part, where `su -c 'echo ... > /proc/ppm/...'` fails with a zero exit and the run would report numbers it believes were pinned. bench.sh and session.sh now refuse an unverified profile unless --allow-unverified-profile is passed, which warns that the run is not comparable with a pinned one. The README records what earns PROFILE_VERIFIED=1. --- MobileGL/MG_Benchmark/Driver/CMakeLists.txt | 19 ++ scripts/g7_negative_control.sh | 242 ++++++++++++++++++ tools/device_bench/README.md | 19 ++ tools/device_bench/bench.sh | 29 +++ tools/device_bench/devices/oppo-mali.env | 48 ++++ .../device_bench/devices/xiaomi-adreno830.env | 54 ++++ tools/device_bench/session.sh | 30 ++- 7 files changed, 440 insertions(+), 1 deletion(-) create mode 100755 scripts/g7_negative_control.sh create mode 100644 tools/device_bench/devices/oppo-mali.env create mode 100644 tools/device_bench/devices/xiaomi-adreno830.env diff --git a/MobileGL/MG_Benchmark/Driver/CMakeLists.txt b/MobileGL/MG_Benchmark/Driver/CMakeLists.txt index ad38be81b..caad1a3d9 100644 --- a/MobileGL/MG_Benchmark/Driver/CMakeLists.txt +++ b/MobileGL/MG_Benchmark/Driver/CMakeLists.txt @@ -13,3 +13,22 @@ target_link_libraries(DriverBench PRIVATE dl) add_test(NAME DriverBench COMMAND DriverBench draw_tiny) set_tests_properties(DriverBench PROPERTIES LABELS benchmark) + +# The Blaze3D blend toggle, as its own entry. +# +# mc_state_toggle is glEnable(GL_BLEND) / glBlendFuncSeparate / glDrawElements / +# glDisable(GL_BLEND) / glDrawElements, 46 times - the measured vanilla-frame rate, and the exact +# shape ROADMAP.md writes down as the microbenchmark P2 owes the GO/NO-GO. It is the workload the +# whole "push at validate, not in the setter" decision was made for: a per-setter design pays for +# every toggle, and a CSO that is minted twice and then reused pays for none of them. +# +# The case has existed in kBenchCases since P0 and nothing ran it, so nothing noticed if it broke. +# Exposing it costs about 1.2 s inside an existing three-minute job, and it means the number the +# P2 report quotes comes from a case CI has been executing all along rather than from a code path +# whose first run is the day it is measured. +# +# Like the entry above, this runs against whatever $DRIVERBENCH_EGL_LIB names (the system driver +# when unset) - the ctest entry is a "does this case still run" gate, not the measurement. The +# measurement is run_driver_bench.sh against each of {native, espryt, magma}. +add_test(NAME DriverBenchStateToggle COMMAND DriverBench mc_state_toggle) +set_tests_properties(DriverBenchStateToggle PROPERTIES LABELS benchmark) diff --git a/scripts/g7_negative_control.sh b/scripts/g7_negative_control.sh new file mode 100755 index 000000000..5417747a3 --- /dev/null +++ b/scripts/g7_negative_control.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +# G7's negative control: break the pipeline/dynamic split on purpose and prove the +# setter-consistency test says so. +# +# WHAT G7 CLAIMS. MG_Pipe/MGPipeRenderStateSpans.h partitions RenderStateParameters into a +# pipeline half and a dynamic half by one rule - a byte is pipeline if and only if some public +# RenderState setter that calls BumpVersions() writes it - and +# MG_Test/Pipe/RenderStateSpansTest.cpp walks EVERY setter asserting that the pipeline-subset +# hash moves exactly when GetPipelineStateVersion() moves. +# +# WHY A CONTROL IS NEEDED AT ALL. That test is green on a correct table, and it would also be +# green on a table it had stopped looking at: a walk that silently drove no setters, a hash that +# stopped depending on the chunks, an assertion someone loosened. Green tells you nothing about +# whether the test can still fail. This script makes it fail, for the one reason it exists to +# catch, and reports a NON-zero ctest as the pass. +# +# THE BREAK. ColorMasks is moved out of pipeline chunk P1 into a dynamic chunk of its own, by +# inserting two boundaries - at ColorMasks and at FramebufferSrgbEnabled - into the boundary +# table. That is deliberately a break the compiler CANNOT catch on its own: the chunks still +# ascend, still do not overlap and still cover [0, sizeof(RenderStateParameters)) exactly, so +# every structural static_assert in the header still holds. What breaks is the meaning: +# glColorMask bumps m_pipelineStateVersion but no longer moves the pipeline-subset hash, and +# SetterConsistency has to name SetColorMask. +# +# The four measurement pins (7 / 8 chunks, 396 / 772 bytes) are relaxed by the same patch, +# because they pin the SHIPPED table rather than the invariant - leaving them would turn this +# into a build break, which proves the assertions compile rather than that the test still checks. +# +# WHY IT IS NOT A CI LANE. It rebuilds the library twice. It is run by hand, and by the +# integrator at the P2 five-part gate. +# +# Usage: +# scripts/g7_negative_control.sh [--verify-patch-only] +# +# a configured build directory carrying the push-only unit tests +# (MGPipeRenderStateSpans.cpp is compiled only under MOBILEGL_PIPE_PUSH, +# so a pull build has neither the table nor the test) +# --verify-patch-only apply the patch, rebuild, report whether it still compiles, and +# revert - WITHOUT requiring the test to exist. This is the mechanism +# check, not the control; it never reports the control as passed. +# +# Exit codes: 0 the control tripped (or, under --verify-patch-only, the patch compiled); +# 1 the control did NOT trip - the test stayed green on a demoted member, which is +# the finding, not an error in this script; +# 2 the script could not run the control at all (bad arguments, missing test, +# a build that was already broken, a failed restore). +set -u -o pipefail + +BUILD_DIR="" +PATCH_ONLY=0 +while [ $# -gt 0 ]; do + case "$1" in + --verify-patch-only) PATCH_ONLY=1; shift ;; + -*) echo "unknown arg: $1" >&2; exit 2 ;; + *) BUILD_DIR=$1; shift ;; + esac +done +[ -n "$BUILD_DIR" ] || { echo "usage: $0 [--verify-patch-only]" >&2; exit 2; } + +REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$REPO_ROOT" || exit 2 +[ -f "$BUILD_DIR/CMakeCache.txt" ] || { echo "$BUILD_DIR is not a configured build directory" >&2; exit 2; } + +HEADER=MobileGL/MG_Pipe/MGPipeRenderStateSpans.h +SOURCE=MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp +TEST_NAME='RenderStateSpans\.SetterConsistency' +LOG_DIR=$(mktemp -d) +BACKUP_DIR="$LOG_DIR/orig" +mkdir -p "$BACKUP_DIR" + +say() { echo "[g7] $*" >&2; } + +restore() { + # Restore from the byte-for-byte copies taken before the patch, never from git: a developer + # running this on a dirty tree must get their tree back, not HEAD. + if [ -f "$BACKUP_DIR/header" ]; then cp -f "$BACKUP_DIR/header" "$HEADER"; fi + if [ -f "$BACKUP_DIR/source" ]; then cp -f "$BACKUP_DIR/source" "$SOURCE"; fi +} +trap 'restore' EXIT + +cp -f "$HEADER" "$BACKUP_DIR/header" || exit 2 +cp -f "$SOURCE" "$BACKUP_DIR/source" || exit 2 + +# --- 0. the control has to have something to control ----------------------------------------- +# A missing test is NOT a pass. Without this the script would patch, watch ctest match no tests, +# read that as "the test failed" and report the control as tripped - a green that means the +# opposite of what it says. +if [ "$PATCH_ONLY" = 0 ]; then + matched=$(ctest --test-dir "$BUILD_DIR" -N -R "$TEST_NAME" 2>/dev/null | grep -cE '^ *Test *#[0-9]+:') + if [ "${matched:-0}" -eq 0 ]; then + say "no test matches $TEST_NAME in $BUILD_DIR." + say "That test is P2 package A's (MG_Test/Pipe/RenderStateSpansTest.cpp, commit c2 on p2/spans);" + say "until it exists this control has nothing to trip and cannot report a pass. Re-run against a" + say "tree that carries it, or use --verify-patch-only to exercise the patch mechanism alone." + exit 2 + fi + say "$matched matching test(s) before the patch" +fi + +# --- 1. the tree must be green BEFORE the break ---------------------------------------------- +# Otherwise a red after the patch says nothing: it could have been red already. +say "building $BUILD_DIR as it is" +if ! cmake --build "$BUILD_DIR" -j "$(nproc)" > "$LOG_DIR/build-before.log" 2>&1; then + say "the build is already broken before any patch - see $LOG_DIR/build-before.log" + tail -20 "$LOG_DIR/build-before.log" >&2 + exit 2 +fi +if [ "$PATCH_ONLY" = 0 ]; then + if ! ctest --test-dir "$BUILD_DIR" -R "$TEST_NAME" --no-tests=error --output-on-failure \ + > "$LOG_DIR/ctest-before.log" 2>&1; then + say "$TEST_NAME is already red before the patch - fix that first, the control proves nothing here" + tail -30 "$LOG_DIR/ctest-before.log" >&2 + exit 2 + fi + say "$TEST_NAME is green before the patch" +fi + +# --- 2. demote ColorMasks -------------------------------------------------------------------- +say "demoting ColorMasks out of the pipeline half" +python3 - "$HEADER" "$SOURCE" <<'PY' || exit 2 +import sys + +header_path, source_path = sys.argv[1], sys.argv[2] + + +def patch(path, pairs): + text = open(path, encoding='utf-8').read() + for old, new in pairs: + if text.count(old) != 1: + sys.stderr.write("[g7] cannot patch %s: %d matches for %r\n" + % (path, text.count(old), old[:70])) + sys.stderr.write("[g7] the chunk table has been rewritten since this control was " + "written; update the control, do not delete it.\n") + sys.exit(1) + text = text.replace(old, new) + open(path, 'w', encoding='utf-8', newline='\n').write(text) + + +# Two extra boundaries split pipeline chunk P1 into +# [BlendStates, ColorMasks) pipeline (odd index, unchanged parity) +# [ColorMasks, FramebufferSrgb) DYNAMIC - the demotion +# [FramebufferSrgb, ClearColor) pipeline +# Adding exactly TWO boundaries keeps every later chunk's index parity, so the alternating +# pipeline/dynamic rule still assigns every other chunk the half it had. +patch(header_path, [ + ("inline constexpr SizeT kMGPipeRenderStateChunkCount = 15;", + "inline constexpr SizeT kMGPipeRenderStateChunkCount = 17; // G7 NEGATIVE CONTROL"), + (""" offsetof(RenderStateParameters, BlendStates),""", + """ offsetof(RenderStateParameters, BlendStates), + // G7 NEGATIVE CONTROL: ColorMasks demoted to a dynamic chunk of its own. + offsetof(RenderStateParameters, ColorMasks), + offsetof(RenderStateParameters, FramebufferSrgbEnabled),"""), + ("static_assert(kMGPipePipelineChunkCount == 7);", + "static_assert(kMGPipePipelineChunkCount == 8); // G7 NEGATIVE CONTROL"), + ("static_assert(kMGPipeDynamicChunkCount == 8);", + "static_assert(kMGPipeDynamicChunkCount == 9); // G7 NEGATIVE CONTROL"), + ('static_assert(kMGPipePipelineChunkBytes == 396, "the pipeline subset is 396 bytes");', + 'static_assert(kMGPipePipelineChunkBytes == 364, "G7 NEGATIVE CONTROL: 396 - 32 for ColorMasks");'), + ('static_assert(kMGPipeDynamicChunkBytes == 772, "the dynamic subset is 772 bytes");', + 'static_assert(kMGPipeDynamicChunkBytes == 804, "G7 NEGATIVE CONTROL: 772 + 32 for ColorMasks");'), +]) + +patch(source_path, [ + (""" MGPipeRenderStateChunkAt(GlobalPipelineChunk(6)), + };""", + """ MGPipeRenderStateChunkAt(GlobalPipelineChunk(6)), + MGPipeRenderStateChunkAt(GlobalPipelineChunk(7)), // G7 NEGATIVE CONTROL + };"""), + (""" MGPipeRenderStateChunkAt(GlobalDynamicChunk(6)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(7)), + };""", + """ MGPipeRenderStateChunkAt(GlobalDynamicChunk(6)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(7)), + MGPipeRenderStateChunkAt(GlobalDynamicChunk(8)), // G7 NEGATIVE CONTROL + };"""), +]) +print("[g7] patched the chunk table") +PY + +# --- 3. it must still COMPILE ---------------------------------------------------------------- +# A build break here would mean the control proved the static_asserts work, not that the test +# still checks anything. +say "rebuilding with the demoted member" +if ! cmake --build "$BUILD_DIR" -j "$(nproc)" > "$LOG_DIR/build-after.log" 2>&1; then + say "the patched table did not compile - the control cannot distinguish 'the test failed' from" + say "'nothing was built'. See $LOG_DIR/build-after.log" + grep -m10 -E 'error:' "$LOG_DIR/build-after.log" >&2 + exit 2 +fi +say "the patched table still compiles, so the partition is still complete" + +if [ "$PATCH_ONLY" = 1 ]; then + # Restore AND rebuild before returning. Leaving the build directory holding a library built + # from the deliberately-broken table would be the nastiest thing this script could do: the + # sources would look clean, and the next `ctest` in that directory would be measuring the + # break. + restore + trap - EXIT + if ! cmake --build "$BUILD_DIR" -j "$(nproc)" > "$LOG_DIR/build-restored.log" 2>&1; then + say "the tree did NOT rebuild after the restore - see $LOG_DIR/build-restored.log" + exit 2 + fi + say "--verify-patch-only: the patch applies, compiles and reverts, and $BUILD_DIR is rebuilt from" + say "the restored sources. This is the MECHANISM check; it does NOT report the control as passed." + exit 0 +fi + +# --- 4. the test must now be RED ------------------------------------------------------------- +say "running $TEST_NAME against the broken table" +if ctest --test-dir "$BUILD_DIR" -R "$TEST_NAME" --no-tests=error --output-on-failure \ + > "$LOG_DIR/ctest-after.log" 2>&1; then + say "NEGATIVE CONTROL DID NOT TRIP: $TEST_NAME is still green with ColorMasks demoted to the" + say "dynamic half. glColorMask bumps m_pipelineStateVersion and no longer moves the pipeline" + say "subset hash, so the G7 invariant is violated and the test did not notice. The test is not" + say "checking what it claims to check." + cp -f "$LOG_DIR/ctest-after.log" ./g7-negative-control-failure.log + say "ctest output kept at ./g7-negative-control-failure.log" + exit 1 +fi + +if grep -q 'SetColorMask' "$LOG_DIR/ctest-after.log"; then + say "negative control tripped, naming SetColorMask" +else + say "negative control tripped, but its output does not name SetColorMask - the test failed for" + say "some other reason, so read $LOG_DIR/ctest-after.log before trusting it" + grep -m20 -E 'Failure|error|Expected|Actual' "$LOG_DIR/ctest-after.log" >&2 +fi + +# --- 5. put it back, and prove it went back -------------------------------------------------- +restore +trap - EXIT +say "restored; rebuilding" +if ! cmake --build "$BUILD_DIR" -j "$(nproc)" > "$LOG_DIR/build-restored.log" 2>&1; then + say "the tree did NOT rebuild after the restore - see $LOG_DIR/build-restored.log" + exit 2 +fi +if ! ctest --test-dir "$BUILD_DIR" -R "$TEST_NAME" --no-tests=error \ + > "$LOG_DIR/ctest-restored.log" 2>&1; then + say "the tree did NOT go back to green after the restore - see $LOG_DIR/ctest-restored.log" + exit 2 +fi + +say "negative control tripped and the tree is green again" +exit 0 diff --git a/tools/device_bench/README.md b/tools/device_bench/README.md index 990dd7b21..177831696 100644 --- a/tools/device_bench/README.md +++ b/tools/device_bench/README.md @@ -30,6 +30,25 @@ frequency-pin integrity. 5. Root required (frequency pinning, GPU busy sampling). 6. Write a device profile under `devices/` (see `devices/odinlite.env`). + A profile carries `PROFILE_VERIFIED=1` only once its sysfs nodes and OPPs have been read + off *that* device and one pinned window has been checked against them + (`big_cur`/`little_cur`/`gpu_cur_khz` in the result JSON must match the pins). Until then + it says `PROFILE_VERIFIED=0` and `bench.sh` / `session.sh` refuse to run against it unless + `--allow-unverified-profile` is passed, which labels the run unpinned in the warning. + + That refusal exists because the pin path is silent when it is wrong: the harness writes + through `/proc/ppm/policy/hard_userlimit_*` and `/proc/gpufreq/gpufreq_opp_freq`, which are + MediaTek nodes, and `su -c 'echo ... > /proc/...'` against a device that has neither fails + without a non-zero exit. The run then reports numbers it believes were taken under a pin. + +## Devices + +| profile | device | verified | +|---|---|---| +| `devices/odinlite.env` | AYN Odin Lite, MT6877 / Mali-G68 | yes | +| `devices/xiaomi-adreno830.env` | Xiaomi, Snapdragon 8 Elite / Adreno 830 (`35d0befa`) | **no** - Qualcomm pin path not yet taught to `bench.sh` | +| `devices/oppo-mali.env` | Oppo / ColorOS, MediaTek + Mali (`3B159D009VZ00000`) | **no** - OPPs and thermal zone not yet read off the device | + ## Usage ``` diff --git a/tools/device_bench/bench.sh b/tools/device_bench/bench.sh index 886b6a336..29bda024b 100755 --- a/tools/device_bench/bench.sh +++ b/tools/device_bench/bench.sh @@ -14,6 +14,7 @@ # Usage: # bench.sh --device devices/odinlite.env --backend magma [--samples 30] # [--warmup 180] [--label mylabel] [--no-pin] +# [--allow-unverified-profile] # backend: magma | espryt | mobileglues (reference) # # Output: one JSON line on stdout (also appended to results/results.jsonl) with @@ -37,6 +38,7 @@ SAMPLES=30 WARMUP=180 LABEL="" DO_PIN=1 +ALLOW_UNVERIFIED_PROFILE=0 WORLD_LOAD_TIMEOUT=420 while [ $# -gt 0 ]; do @@ -47,6 +49,7 @@ while [ $# -gt 0 ]; do --warmup) WARMUP=$2; shift 2 ;; --label) LABEL=$2; shift 2 ;; --no-pin) DO_PIN=0; shift ;; + --allow-unverified-profile) ALLOW_UNVERIFIED_PROFILE=1; shift ;; *) echo "unknown arg: $1" >&2; exit 2 ;; esac done @@ -55,6 +58,32 @@ done # shellcheck disable=SC1090 . "$DEVICE_ENV" +# A device profile that has not been read off its device yet is refused here rather than acted +# on. The failure it prevents is silent and expensive: the pin path below is MediaTek-specific +# (/proc/ppm, /proc/gpufreq), `su -c 'echo ... > /proc/...'` fails without a non-zero exit, and a +# run against a profile whose nodes do not exist reports numbers it believes were taken under a +# frequency pin. The pin-integrity fields sampled at window end are the only clue, and they are +# read after the run rather than before it. +# +# PROFILE_VERIFIED=1 means: somebody read the cpufreq policies, the GPU OPP and the thermal zone +# TYPE off THIS device, ran one pinned window, and checked big_cur/little_cur/gpu_cur_khz in the +# result JSON against the pins. Nothing else earns it. +require_verified_profile() { + if [ "${PROFILE_VERIFIED:-1}" = "1" ]; then return 0; fi + if [ "$ALLOW_UNVERIFIED_PROFILE" = "1" ]; then + echo "[warn] $DEVICE_ENV declares PROFILE_VERIFIED=0 and --allow-unverified-profile was passed:" >&2 + echo "[warn] the frequency pins and the thermal gate in it are UNCONFIRMED, so any number this" >&2 + echo "[warn] run produces is not comparable with a pinned one." >&2 + return 0 + fi + echo "$DEVICE_ENV declares PROFILE_VERIFIED=0: its sysfs nodes and OPPs have not been read off" >&2 + echo "the device, so pinning would fail silently and the run would look pinned but not be." >&2 + echo "Fill in the TODO_VERIFY_ON_DEVICE fields, confirm one pinned window, set PROFILE_VERIFIED=1 -" >&2 + echo "or pass --allow-unverified-profile to measure anyway and label the result unpinned." >&2 + exit 2 +} +require_verified_profile + case "$BACKEND" in espryt) RENDERER=$RENDERER_ESPRYT ;; magma) RENDERER=$RENDERER_MAGMA ;; diff --git a/tools/device_bench/devices/oppo-mali.env b/tools/device_bench/devices/oppo-mali.env new file mode 100644 index 000000000..69e81ec10 --- /dev/null +++ b/tools/device_bench/devices/oppo-mali.env @@ -0,0 +1,48 @@ +# Device profile: Oppo / ColorOS, Mali GPU, adb serial 3B159D009VZ00000. +# +# The second of the two devices the disaggregation campaign is measured on (the other is +# devices/xiaomi-adreno830.env). Same purpose: keep the pinning and thermal protocol in the +# repository rather than in one operator's shell history. +# +# ============================ NOT YET DEVICE-VERIFIED ============================ +# PROFILE_VERIFIED=0, and bench.sh / session.sh / profile.sh refuse to run against it unless +# --allow-unverified-profile is passed. This part is a MediaTek SoC, so unlike the Adreno +# profile the harness's existing /proc/ppm + /proc/gpufreq pin path is probably the right one - +# but "probably" is exactly the state a measurement profile must not ship in. The cluster +# indices, the available OPPs, the top GPU OPP and the thermal zone TYPE all differ between +# MediaTek generations, and odinlite.env's values are for an MT6877, not for this device. +# +# To promote it: read the four TODO fields off the device +# (`cat /sys/devices/system/cpu/cpufreq/policy*/scaling_available_frequencies`, +# `cat /proc/gpufreq/gpufreq_opp_dump`, `for tz in /sys/class/thermal/thermal_zone*; do +# echo "$tz $(cat $tz/type)"; done`), run one pinned window, check big_cur/little_cur/gpu_cur_khz +# in the result JSON against the pins, then set PROFILE_VERIFIED=1. +# +# ColorOS traps that belong with this device, and cost a run each when forgotten: +# * the first install of a not-yet-installed package blocks on +# com.oplus.appdetail InstallGuideActivity until "continue install" is tapped +# (`input tap 353 2349` on the 1272x2772 panel); +# * a foreign-signed APK has to be uninstalled before a rebuild will install; +# * pass MSYS_NO_PATHCONV=1 on every adb invocation from Git Bash, or a /data/... argument is +# rewritten into a Windows path. +# ================================================================================= +PROFILE_VERIFIED=0 +PIN_STYLE=ppm + +DEVICE_SERIAL=3B159D009VZ00000 + +# Campaign protocol constants (perf-test-protocol): big 1.96 GHz, little 1.55 GHz, GPU at its +# top OPP, 40 C start gate. As above, the kHz values are the protocol's targets and the nearest +# actual OPP has to be confirmed on the device. +CPU_BIG_POLICY=TODO_VERIFY_ON_DEVICE +CPU_BIG_FREQ=1958000 +CPU_LITTLE_POLICY=TODO_VERIFY_ON_DEVICE +CPU_LITTLE_FREQ=1550000 + +# MediaTek legacy gpufreq, same node family as odinlite. The top OPP is device-specific. +GPU_PIN_KHZ= +GPU_UTIL_NODE=/sys/kernel/ged/hal/gpu_utilization +GPU_CURFREQ_NODE=/sys/kernel/ged/hal/current_freqency + +THERMAL_ZONE_TYPE=TODO_VERIFY_ON_DEVICE +THERMAL_START_MAX_MC=40000 diff --git a/tools/device_bench/devices/xiaomi-adreno830.env b/tools/device_bench/devices/xiaomi-adreno830.env new file mode 100644 index 000000000..360a5407d --- /dev/null +++ b/tools/device_bench/devices/xiaomi-adreno830.env @@ -0,0 +1,54 @@ +# Device profile: Xiaomi, Snapdragon 8 Elite (Adreno 830), adb serial 35d0befa. +# +# One of the two devices the disaggregation campaign is measured on (the other is +# devices/oppo-mali.env). It exists so that the pinning and thermal protocol the campaign +# actually runs is written down in the repository instead of living in one operator's shell +# history, and so that a `--device` argument names something reviewable. +# +# ============================ NOT YET DEVICE-VERIFIED ============================ +# PROFILE_VERIFIED=0 below, and bench.sh / session.sh / profile.sh REFUSE to run against a +# profile that says so unless --allow-unverified-profile is passed. Two of the values here are +# protocol constants that are known (the campaign pins big 1.96 GHz / little 1.55 GHz and gates +# at 40 C), but the sysfs node names and the exact available OPPs are NOT: this is a Qualcomm +# part and the harness was written against MediaTek, where the pin goes through +# /proc/ppm/policy/hard_userlimit_* and the GPU through /proc/gpufreq/gpufreq_opp_freq. Neither +# path exists on this SoC - Adreno pins through /sys/class/kgsl/kgsl-3d0/devfreq/{min,max}_freq +# and its cpufreq policies are not policy6/policy0. +# +# A profile that quietly wrote MediaTek paths on this device would be the worst outcome +# available: `su -c 'echo ... > /proc/ppm/...'` fails silently, bench.sh would report a run it +# believes was pinned, and the pin-integrity fields it samples at window end would be the only +# clue. So the unknown fields are left EMPTY and marked, rather than guessed, and the refusal is +# the mechanism that keeps them from being used before somebody has read them off the device. +# +# To promote this profile: fill in the four TODO fields from the device +# (`cat /sys/devices/system/cpu/cpufreq/policy*/scaling_available_frequencies`, +# `ls /sys/class/kgsl/kgsl-3d0/devfreq/`, `for tz in /sys/class/thermal/thermal_zone*; do +# echo "$tz $(cat $tz/type)"; done`), teach bench.sh the Qualcomm pin path, run one pinned +# window, check big_cur/little_cur/gpu_cur_khz in the result JSON against the pins, and only +# then set PROFILE_VERIFIED=1 in the same commit as the bench.sh change. +# ================================================================================= +PROFILE_VERIFIED=0 +PIN_STYLE=qualcomm-kgsl + +DEVICE_SERIAL=35d0befa + +# Campaign protocol constants (perf-test-protocol): big 1.96 GHz, little 1.55 GHz, GPU at its +# top OPP, and a 40 C start gate. The kHz values are the protocol's targets; the nearest actual +# OPP has to be read off the device before they are used, because a cpufreq write that names a +# frequency the policy does not offer is rounded silently. +CPU_BIG_POLICY=TODO_VERIFY_ON_DEVICE +CPU_BIG_FREQ=1958400 +CPU_LITTLE_POLICY=TODO_VERIFY_ON_DEVICE +CPU_LITTLE_FREQ=1555200 + +# Adreno pins through the kgsl devfreq knobs, not /proc/gpufreq. Left empty deliberately: see +# the block above. +GPU_PIN_KHZ= +GPU_UTIL_NODE=/sys/class/kgsl/kgsl-3d0/gpubusy +GPU_CURFREQ_NODE=/sys/class/kgsl/kgsl-3d0/gpuclk + +# Thermal gate: 40 C, the campaign's threshold. The zone TYPE differs per SoC and bench.sh +# matches on it by name, so it has to be read off the device. +THERMAL_ZONE_TYPE=TODO_VERIFY_ON_DEVICE +THERMAL_START_MAX_MC=40000 diff --git a/tools/device_bench/session.sh b/tools/device_bench/session.sh index e42e6e35e..44040fd4a 100755 --- a/tools/device_bench/session.sh +++ b/tools/device_bench/session.sh @@ -7,6 +7,7 @@ # # Usage: session.sh --device devices/odinlite.env [--backend magma|espryt|mobileglues] # [--settle 150] [--retries 3] [--no-pin] +# [--allow-unverified-profile] # Exits 0 with the game in-world (after settle seconds), 1 otherwise. # NOTE: leaves the game running AND the frequency pins active (that is the # point of a session). When done: am force-stop the game and unpin via @@ -24,7 +25,7 @@ RENDERER_ESPRYT=5e273ee2-baca-4c81-8e48-b63feefb9ba8 RENDERER_MAGMA=2be0dc10-1eef-4ce2-b512-b266dd33fd9e RENDERER_MOBILEGLUES=com.fcl.plugin.mobileglues -DEVICE_ENV="" BACKEND="" SETTLE=150 RETRIES=3 DO_PIN=1 +DEVICE_ENV="" BACKEND="" SETTLE=150 RETRIES=3 DO_PIN=1 ALLOW_UNVERIFIED_PROFILE=0 while [ $# -gt 0 ]; do case "$1" in --device) DEVICE_ENV=$2; shift 2 ;; @@ -32,12 +33,39 @@ while [ $# -gt 0 ]; do --settle) SETTLE=$2; shift 2 ;; --retries) RETRIES=$2; shift 2 ;; --no-pin) DO_PIN=0; shift ;; + --allow-unverified-profile) ALLOW_UNVERIFIED_PROFILE=1; shift ;; *) echo "unknown arg: $1" >&2; exit 2 ;; esac done [ -n "$DEVICE_ENV" ] || { echo "need --device" >&2; exit 2; } # shellcheck disable=SC1090 . "$DEVICE_ENV" + +# A device profile that has not been read off its device yet is refused here rather than acted +# on. The failure it prevents is silent and expensive: the pin path below is MediaTek-specific +# (/proc/ppm, /proc/gpufreq), `su -c 'echo ... > /proc/...'` fails without a non-zero exit, and a +# run against a profile whose nodes do not exist reports numbers it believes were taken under a +# frequency pin. The pin-integrity fields sampled at window end are the only clue, and they are +# read after the run rather than before it. +# +# PROFILE_VERIFIED=1 means: somebody read the cpufreq policies, the GPU OPP and the thermal zone +# TYPE off THIS device, ran one pinned window, and checked big_cur/little_cur/gpu_cur_khz in the +# result JSON against the pins. Nothing else earns it. +require_verified_profile() { + if [ "${PROFILE_VERIFIED:-1}" = "1" ]; then return 0; fi + if [ "$ALLOW_UNVERIFIED_PROFILE" = "1" ]; then + echo "[warn] $DEVICE_ENV declares PROFILE_VERIFIED=0 and --allow-unverified-profile was passed:" >&2 + echo "[warn] the frequency pins and the thermal gate in it are UNCONFIRMED, so any number this" >&2 + echo "[warn] run produces is not comparable with a pinned one." >&2 + return 0 + fi + echo "$DEVICE_ENV declares PROFILE_VERIFIED=0: its sysfs nodes and OPPs have not been read off" >&2 + echo "the device, so pinning would fail silently and the run would look pinned but not be." >&2 + echo "Fill in the TODO_VERIFY_ON_DEVICE fields, confirm one pinned window, set PROFILE_VERIFIED=1 -" >&2 + echo "or pass --allow-unverified-profile to measure anyway and label the result unpinned." >&2 + exit 2 +} +require_verified_profile ADB="adb -s $DEVICE_SERIAL" log() { echo "[session] $*" >&2; } From 1a012f2820c9fbcdd539dd160c6f6cb7302812db Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:20:35 -0400 Subject: [PATCH 127/529] [Test] (Pipe): register the CSO control in the pull build too, so pull and push name the same tests - G2 requires `ctest -L integration-gpu` to be name-for-name IDENTICAL between the pull build and the push build, and the four CsoContentAddressing lanes were registered inside `if (MOBILEGL_PIPE_PUSH)`. That is four entries the push build has and the pull build does not, which breaks the comparison for this package and for every package that lands after it. - They now register unconditionally. What the pull build lacks is not the entry but the thing the entry is about, so the build passes MGITEST_PIPE_PUSH_BUILD in and the scenario skips saying exactly that: no render-state CSO exists, no cso[] bracket is compiled into the summary line, and the content-addressing bit steers nothing. - The marker also sharpens the plumbing assertion it guards. Past that skip the process is known to be a push build, and the cso[] bracket is unconditional inside that same #if - so a missing bracket can no longer mean "wrong build configuration" and the failure message stops offering that as an explanation. - Verified: build-linux and build-push now differ by zero ctest names (diff empty over 1408 entries each), and the pull lanes skip with the push-build reason while the push lanes skip with the tracker-not-landed reason. --- MobileGL/MG_IntegrationTest/CMakeLists.txt | 132 ++++++++++-------- .../CsoContentAddressingScenario.cpp | 16 ++- 2 files changed, 87 insertions(+), 61 deletions(-) diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 89238e6f1..18fc04811 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -347,6 +347,20 @@ endfunction() # that does implement the thing would turn a real gate into a permanent skip. set(MGL_ITEST_CAPABILITY_ENV "") +# Whether the library under test compiled the push arm. Passed in rather than inferred, because +# the two CSO counters and the cso[] bracket of the stats line are #if MOBILEGL_PIPE_PUSH: in a +# pull build there is no CSO to mint and no channel to read, so the control has nothing to say - +# and "nothing to say" must be a SKIP that names the reason, not an assertion failure about a +# missing bracket. +# +# The lanes themselves are registered in BOTH builds even so. `ctest -L integration-gpu` has to +# be name-for-name identical between the pull build and the push build (P2 gate G2), and a lane +# that exists in only one of them breaks that comparison for every future package - a much worse +# outcome than four entries that skip. +if (MOBILEGL_PIPE_PUSH) + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_PUSH_BUILD=1") +endif() + file(GLOB MGL_ITEST_ESPRYT_SLOT_TABLES CONFIGURE_DEPENDS "${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectGLES/SlotTables.h") if (MGL_ITEST_ESPRYT_SLOT_TABLES) @@ -832,65 +846,67 @@ gtest_discover_tests(MobileGLIntegrationTest # # MOBILEGL_PIPE_STATS_PERIOD=1 makes one summary line per eglSwapBuffers, which is what lets the # workload be bracketed by two swaps and read back as a window covering exactly itself. -if (MOBILEGL_PIPE_PUSH) - mgl_itest_join_environment(MGL_ITEST_GLES_CSO_ON_ENVIRONMENT - "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_CSO_LANE=content-addressed" - "MOBILEGL_PIPE_PUSH=0x7f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" - "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-content-addressed-DirectGLES.log" - ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) - mgl_itest_join_environment(MGL_ITEST_GLES_CSO_OFF_ENVIRONMENT - "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_CSO_LANE=no-content-addressing" - "MOBILEGL_PIPE_PUSH=0x800000000000007f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" - "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-no-content-addressing-DirectGLES.log" - ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) - mgl_itest_join_environment(MGL_ITEST_VULKAN_CSO_ON_ENVIRONMENT - "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_CSO_LANE=content-addressed" - "MOBILEGL_PIPE_PUSH=0x7f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" - "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-content-addressed-DirectVulkan.log" - ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) - mgl_itest_join_environment(MGL_ITEST_VULKAN_CSO_OFF_ENVIRONMENT - "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_CSO_LANE=no-content-addressing" - "MOBILEGL_PIPE_PUSH=0x800000000000007f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" - "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-no-content-addressing-DirectVulkan.log" - ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) +# +# Registered in EVERY build, including the pull build where there is no CSO at all, so that +# `ctest -L integration-gpu` stays name-for-name identical between pull and push (gate G2). In a +# pull build MGITEST_PIPE_PUSH_BUILD is absent and both cases skip saying so. +mgl_itest_join_environment(MGL_ITEST_GLES_CSO_ON_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_CSO_LANE=content-addressed" + "MOBILEGL_PIPE_PUSH=0x7f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-content-addressed-DirectGLES.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) +mgl_itest_join_environment(MGL_ITEST_GLES_CSO_OFF_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_CSO_LANE=no-content-addressing" + "MOBILEGL_PIPE_PUSH=0x800000000000007f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-no-content-addressing-DirectGLES.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) +mgl_itest_join_environment(MGL_ITEST_VULKAN_CSO_ON_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_CSO_LANE=content-addressed" + "MOBILEGL_PIPE_PUSH=0x7f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-content-addressed-DirectVulkan.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) +mgl_itest_join_environment(MGL_ITEST_VULKAN_CSO_OFF_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_CSO_LANE=no-content-addressing" + "MOBILEGL_PIPE_PUSH=0x800000000000007f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-no-content-addressing-DirectVulkan.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) - gtest_discover_tests(MobileGLIntegrationTest - TEST_PREFIX "DirectGLES.CsoContentAddressing.On." - TEST_FILTER "CsoContentAddressingScenario.*" - DISCOVERY_TIMEOUT 30 - PROPERTIES - LABELS integration-gpu - TIMEOUT ${MGL_ITEST_TIMEOUT} - ENVIRONMENT "${MGL_ITEST_GLES_CSO_ON_ENVIRONMENT}" - ) - gtest_discover_tests(MobileGLIntegrationTest - TEST_PREFIX "DirectGLES.CsoContentAddressing.Off." - TEST_FILTER "CsoContentAddressingScenario.*" - DISCOVERY_TIMEOUT 30 - PROPERTIES - LABELS integration-gpu - TIMEOUT ${MGL_ITEST_TIMEOUT} - ENVIRONMENT "${MGL_ITEST_GLES_CSO_OFF_ENVIRONMENT}" - ) - gtest_discover_tests(MobileGLIntegrationTest - TEST_PREFIX "DirectVulkan.CsoContentAddressing.On." - TEST_FILTER "CsoContentAddressingScenario.*" - DISCOVERY_TIMEOUT 30 - PROPERTIES - LABELS integration-gpu - TIMEOUT ${MGL_ITEST_TIMEOUT} - ENVIRONMENT "${MGL_ITEST_VULKAN_CSO_ON_ENVIRONMENT}" - ) - gtest_discover_tests(MobileGLIntegrationTest - TEST_PREFIX "DirectVulkan.CsoContentAddressing.Off." - TEST_FILTER "CsoContentAddressingScenario.*" - DISCOVERY_TIMEOUT 30 - PROPERTIES - LABELS integration-gpu - TIMEOUT ${MGL_ITEST_TIMEOUT} - ENVIRONMENT "${MGL_ITEST_VULKAN_CSO_OFF_ENVIRONMENT}" - ) -endif() +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.CsoContentAddressing.On." + TEST_FILTER "CsoContentAddressingScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_CSO_ON_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.CsoContentAddressing.Off." + TEST_FILTER "CsoContentAddressingScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_CSO_OFF_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.CsoContentAddressing.On." + TEST_FILTER "CsoContentAddressingScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_CSO_ON_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.CsoContentAddressing.Off." + TEST_FILTER "CsoContentAddressingScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_CSO_OFF_ENVIRONMENT}" +) if (MOBILEGL_PIPE_VERIFY) # 900s, not the ambient 120: the comparator re-reads every field of the fill mask at the verb diff --git a/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp index 53e2ea0b4..9a1d727bb 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp @@ -204,6 +204,14 @@ void main() { oColor = vec4(0.0, 1.0, 0.0, 1.0); } "entries, and the ambient log is shared, so a read here would race."; return; } + if (!BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) { + GTEST_SKIP() << "this library was built without MOBILEGL_PIPE_PUSH, so there is no " + "render-state CSO to mint, no cso[] bracket in the summary line and " + "nothing for the content-addressing bit to steer. The entry is " + "registered here anyway so that `ctest -L integration-gpu` names the " + "same tests in the pull build and the push build (gate G2)."; + return; + } if (!BuildMarkerIsSet("MGITEST_PIPE_TRACKER_PRESENT")) { GTEST_SKIP() << "the CSO counters have no emitter in this build: MG_Impl/Pipe/Tracker.cpp " "does not exist, so nothing mints or binds a render-state CSO and " @@ -256,9 +264,11 @@ void main() { oColor = vec4(0.0, 1.0, 0.0, 1.0); } const CsoWindow window = LastCsoWindow(ReadWholeFile(LibraryLogPath())); ASSERT_TRUE(window.found) << "no 'MGPipe stats:' line carrying cso[csom= csob=] in " << LibraryLogPath() - << ". Either MOBILEGL_PIPE_STATS/MOBILEGL_PIPE_STATS_PERIOD did not reach the process, or " - "this library was not built with MOBILEGL_PIPE_PUSH - the two counters and the cso[] " - "bracket are both #if MOBILEGL_PIPE_PUSH (PipeStats.h, PipeStats.cpp FormatWindowLine)."; + << ". This IS a push build (the lane checked MGITEST_PIPE_PUSH_BUILD before getting " + "here) and the cso[] bracket is unconditional inside that #if, so the bracket cannot " + "be missing for a build reason: either MOBILEGL_PIPE_STATS / " + "MOBILEGL_PIPE_STATS_PERIOD did not reach the process, or no summary line was " + "emitted at all because nothing reached PipeStats::OnPresent."; EXPECT_GE(window.binds, 0) << window.line; EXPECT_GE(window.mints, 0) << window.line; RecordProperty("cso_line", window.line.c_str()); From ce9f44a24c65918f85f2eaa4c5ead1af847c1baa Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:20:35 -0400 Subject: [PATCH 128/529] [CI] (Pipe): make the dirty-surface report a gate and run the push-only unit tests on the verify runtime - pipe-gates stops printing gen_pipe_dirty_surface.py --summary and runs --check && --self-test. --check fails both directions - a scanned mutator with no row in MG_Pipe/DirtySurface.def, and a row naming a mutator the scan no longer finds - so a deleted mutator cannot leave a stale row behind claiming coverage. --self-test is what keeps --check honest: a completeness check that silently stopped checking is indistinguishable from a complete mapping, so two canned negative controls must both trip. Same shape as gen_pipe.py --self-test next to it. - integration-verify gains `ctest -L unit`. G6's chunk-table walk and G10's residual assertions live in MG_Test/Pipe, compiled only under MOBILEGL_PIPE_PUSH, and the `test` job builds the PULL library - so before this those tests ran in no CI job at all. The artifact already carries them (the packaging step tars MobileGL/MG_Test whole), so the whole cost is the run: ~14 s for ~1490 entries, measured locally on this tree. - integration-verify also runs the two always-on negative controls by name. They are labelled integration-gpu rather than integration-verify - they are about the handle key and the CSO switch, not the comparator - and this is the only CI job that unpacks a push build, which CsoContentAddressingScenario needs because both counters and the cso[] bracket are #if MOBILEGL_PIPE_PUSH. - build-linux-verify's arming check accepts the per-verb entry point under either of its two names. P2 renames MGPipeFillForVerb to MGPipeValidateForVerb, and a check that named only the old one would go red on the rename for a reason unrelated to what it tests. What it tests is unchanged: the artifact has a per-verb entry point, and it still fails when there is none. --- .github/workflows/test.yml | 82 ++++++++++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2fd1d717d..f3984cf79 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -420,13 +420,20 @@ jobs: echo "::error::nm --defined-only sees only ${defined} symbols in ${BUILD_DIR}/libMobileGL.so - it looks stripped, so the two checks below could not have failed honestly" exit 1 fi - for entry in MGPipeVerifyInputs MGPipeFillForVerb; do - if ! nm --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -q "${entry}"; then - echo "::error::libMobileGL.so defines no ${entry}: -DMOBILEGL_PIPE_VERIFY=ON did not take, and every lane that consumes this artifact would run the comparator-free library and pass having compared nothing" - exit 1 - fi - done - echo "libMobileGL.so defines MGPipeVerifyInputs and MGPipeFillForVerb (${defined} defined symbols)" + if ! nm --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -q "MGPipeVerifyInputs"; then + echo "::error::libMobileGL.so defines no MGPipeVerifyInputs: -DMOBILEGL_PIPE_VERIFY=ON did not take, and every lane that consumes this artifact would run the comparator-free library and pass having compared nothing" + exit 1 + fi + # The per-verb entry point, under EITHER of its two names. P2 renames + # MGPipeFillForVerb to MGPipeValidateForVerb (the body becomes the tracker's walk and + # the fill is one of its five steps), so this check has to accept both or it goes red on + # the rename for a reason that has nothing to do with what it tests. What it tests is + # unchanged: that the library HAS a per-verb entry point compiled in. + if ! nm --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -qE "MGPipeValidateForVerb|MGPipeFillForVerb"; then + echo "::error::libMobileGL.so defines neither MGPipeValidateForVerb nor MGPipeFillForVerb: there is no per-verb entry point in this artifact, so nothing fills the block the comparator compares" + exit 1 + fi + echo "libMobileGL.so defines MGPipeVerifyInputs and a per-verb entry point (${defined} defined symbols)" - name: Show ccache stats if: always() @@ -542,6 +549,42 @@ jobs: ctest --output-on-failure -L integration-verify --no-tests=error fi + # The push-only unit tests, on the verify runtime. + # + # WHY HERE AND NOT IN `test`. The `test` job builds the PULL library, and G6's chunk-table + # walk and G10's residual assertions live in MG_Test/Pipe, compiled only under + # MOBILEGL_PIPE_PUSH (MGPipeRenderStateSpans.cpp and PipeApply.cpp are appended to + # SOURCE_FILES inside the `if (MOBILEGL_PIPE_PUSH)` block, which is exactly how the pull + # build stays symbol-identical). So before P2 those tests ran in no CI job at all: they + # existed, they were green locally, and CI never executed one of them. + # + # This artifact already carries them - the packaging step above tars + # ${BUILD_DIR}/MobileGL/MG_Test whole - so the whole cost is the run, which is ~14 s for + # ~1490 entries. --no-tests=error, because a packaging change that stopped shipping the + # unit binaries would otherwise report a green run of nothing. + - name: Unit tests on the verify runtime (G6, G10) + working-directory: build-verify + run: ctest --output-on-failure -L unit --no-tests=error -j "$(nproc)" + + # The two always-on P2 negative controls (G8, G12), which are labelled integration-gpu and + # not integration-verify - they are about the handle key and the CSO switch, not about the + # comparator - so the lane above does not reach them. They are run HERE because this is the + # only CI job that unpacks a MOBILEGL_PIPE_PUSH build: CsoContentAddressingScenario reads + # the two CSO counters out of the library's summary line and both the counters and the + # cso[] bracket are #if MOBILEGL_PIPE_PUSH, so in the pull `integration` job the entries do + # not exist at all. + # + # An arm whose subsystem has not landed on this tree SKIPS with the reason (never absent, + # never a green that asserted nothing), so this step is green through the P2 landing order + # and starts asserting as each package arrives. + - name: The handle-ABA and CSO-content-addressing controls (G8, G12) + working-directory: build-verify + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + run: | + ctest --output-on-failure -L integration-gpu \ + -R 'HandleRecycle|CsoContentAddressing' --no-tests=error -j 4 + # The arming lanes' logs, and ONLY those. Each lane shares one MOBILEGL_LOG_FILE_PATH and the # library opens it fopen(path, "w"), so after an ambient lane of 400-odd processes the file # holds the LAST one - grepping it would say nothing about the other 405 and would red a @@ -1525,11 +1568,26 @@ jobs: fi echo "no fprintf(stderr/stdout / printf( / puts( / std::cout|cerr under MobileGL/MG_Backend or MobileGL/MG_State" - # Informational: the frontend mutation surface an MGPipe aggregate generation has to - # cover. It becomes a gate in P2, when the mapping file exists to diff against - # (ROADMAP.md:18 puts the first mapping round in P2, not P1). - - name: MGPipe dirty-surface report - run: python3 scripts/gen_pipe_dirty_surface.py --summary + # A GATE as of P2, which is when MG_Pipe/DirtySurface.def exists to diff the scan against + # (ROADMAP.md:18 puts the first mapping round in P2). --check fails BOTH directions: a + # mutator the scanner finds with no row in the def, and a row naming a mutator the scan no + # longer finds - so a deleted mutator cannot leave a stale row behind claiming coverage. + # + # --self-test is the half that keeps --check honest, and it is not optional. A completeness + # check that silently stopped checking produces exactly the same green as a complete + # mapping; the self-test feeds it two canned negative controls (a mutator withheld from the + # def, a row naming a function that does not exist) and fails if either fails to trip. Same + # shape as gen_pipe.py --self-test and check_include_closure.py above. + # + # What this gate does NOT cover is written into DirtySurface.def's header rather than left + # implicit: the scanner attributes a mutation inside a lambda to the enclosing function, + # reads a mutation published through a helper as deferred, and scans only MG_Impl/GLImpl - + # so the four MGP_NOTE_MUTATION sites in MG_State are outside it entirely. This is a + # completeness gate over what the scanner can see; the semantic proof is the verify lane. + - name: MGPipe dirty-surface mapping is complete (G9) + run: | + python3 scripts/gen_pipe_dirty_surface.py --check + python3 scripts/gen_pipe_dirty_surface.py --self-test # Warning only for now: the disaggregation documents are still being written, and a # lint that fails a rewrite in progress teaches people to ignore it. It becomes From d704401a561c898676ffeb7fe3845838a7a8fe7a Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:36:02 -0400 Subject: [PATCH 129/529] [Test] (Pipe): one case per CSO lane, because two of them would race on the lane's log - CsoContentAddressingScenario reads the library's own summary line, and a log is a per-LANE resource: the library opens it fopen(path, "w"), so every process in a lane truncates it. The file had TWO cases in each lane, which under `ctest -j` is a race whose failure mode is an empty read - indistinguishable from "the counters were never emitted", which is precisely the thing the case exists to report on. - The separate plumbing case is folded into the control as its first ASSERT, keeping its own message, so nothing is lost but the flake. Splitting it out bought a clearer failure message and paid for it with a flake in the mechanism that message is about. - This is the same hazard the file's existing comments describe for the arming lane; it is worth saying out loud that the rule is "a log-reading case owns its lane", not "a log-reading case owns its log path". - Verified at -j 4: 44/44 on build-verify and 24/24 on build-push, and the pull/push ctest name lists are still identical (1402 entries each; 0 names removed against the contract tree, 34 added). --- .../CsoContentAddressingScenario.cpp | 38 ++++++++----------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp index 9a1d727bb..47d6538a5 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp @@ -251,40 +251,32 @@ void main() { oColor = vec4(0.0, 1.0, 0.0, 1.0); } GLuint m_vbo = 0; }; - // The plumbing, asserted on its own so that a counter-ratio failure below can never be - // confused with "the lane never turned the stats channel on". - TEST_F(CsoContentAddressingScenario, TheLibrarysSummaryLineCarriesTheCsoCounters) { + // ONE case per lane, and that is a hard constraint rather than a style choice. + // + // This case READS the library log, and the log is a per-LANE resource: the library opens it + // fopen(path, "w"), so every process in a lane truncates it. A second case in this lane would + // therefore race this one under `ctest -j`, and the shape of the failure is a silent, empty + // read that looks exactly like "the counters were never emitted". Splitting the plumbing + // assertion into its own case would have bought a clearer failure message and paid for it + // with a flake in the thing the message is about. The plumbing is asserted first, with its + // own message, inside this one process instead. + TEST_F(CsoContentAddressingScenario, TheBlendToggleMintsBoundedlyWithContentAddressingAndPerBindWithout) { if (!Ready()) return; SkipUnlessTheLaneIsAssertableHere(); if (IsSkipped()) return; Gl().EndFrame(); // close the setup window - RunBlendToggleFrame(); - + const Image first = RunBlendToggleFrame(); const CsoWindow window = LastCsoWindow(ReadWholeFile(LibraryLogPath())); + // The plumbing first, with its own message, so a counter-ratio failure below can never + // be confused with "the lane never turned the stats channel on". ASSERT_TRUE(window.found) << "no 'MGPipe stats:' line carrying cso[csom= csob=] in " << LibraryLogPath() << ". This IS a push build (the lane checked MGITEST_PIPE_PUSH_BUILD before getting " - "here) and the cso[] bracket is unconditional inside that #if, so the bracket cannot " - "be missing for a build reason: either MOBILEGL_PIPE_STATS / " + "here) and the cso[] bracket is unconditional inside that #if, so it cannot be " + "missing for a build reason: either MOBILEGL_PIPE_STATS / " "MOBILEGL_PIPE_STATS_PERIOD did not reach the process, or no summary line was " "emitted at all because nothing reached PipeStats::OnPresent."; - EXPECT_GE(window.binds, 0) << window.line; - EXPECT_GE(window.mints, 0) << window.line; - RecordProperty("cso_line", window.line.c_str()); - } - - // The control itself. - TEST_F(CsoContentAddressingScenario, TheBlendToggleMintsBoundedlyWithContentAddressingAndPerBindWithout) { - if (!Ready()) return; - SkipUnlessTheLaneIsAssertableHere(); - if (IsSkipped()) return; - - Gl().EndFrame(); // close the setup window - const Image first = RunBlendToggleFrame(); - const CsoWindow window = LastCsoWindow(ReadWholeFile(LibraryLogPath())); - ASSERT_TRUE(window.found) << "no CSO counters in " << LibraryLogPath() - << " - see TheLibrarysSummaryLineCarriesTheCsoCounters"; RecordProperty("cso_line", window.line.c_str()); // Every draw in the frame changed the pipeline subset, so every draw is a bind. This From a5d1136c028981b740c28d39a023ca9d817d6b20 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 09:05:57 -0400 Subject: [PATCH 130/529] [Fix] (Test, Pipe): ask the BUILD, not the source tree, whether a control's arm exists, and probe the CSO emitter by content - the three capability markers were decided from source-tree file existence / file text alone, so after packages C and D land they would have armed the PULL build too, where every arm they name is compiled out: the AbaControl lane would have gone hard red on `ctest -L integration-gpu` (gate G2 requires it green in both builds) and the Handles lane green against a library with no {slot, gen} key at all. The whole block now sits under the same `if (MOBILEGL_PIPE_PUSH)` as MGITEST_PIPE_PUSH_BUILD, and HandleRecycleScenario re-checks that marker before either push arm asserts, so a hand-forced environment cannot arm an arm this build does not have either - the two push-only knobs of those lanes (MOBILEGL_PIPE_LEGACY_MEMOS=0, MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1) are set only in a push build. In a pull build the legacy arm is the only arm and every subsystem bit is clear, which is D14's startup Fatal{PipeLegacyMemosDisabled} - the process would abort before the scenario could report its skip. Test NAMES are unaffected, so G2 still compares equal - the CSO control armed itself off `MG_Impl/Pipe/Tracker.cpp`, a file the owning package does not create: it implements the tracker and the cache header-only, so all four CsoContentAddressing entries would have kept skipping after it landed, with a reason that had become false. The probe now greps every source under MG_Impl/Pipe/ for the two counters the control actually reads (RenderStateCsoMints / RenderStateCsoBinds), watching the directory and each file, so the owning package keeps control of its file layout - an unrecognised MGITEST_HANDLE_ARM is a FAIL in SetUp instead of a silent downgrade to the Legacy arm, which would have passed while claiming to be the lane it was not --- MobileGL/MG_IntegrationTest/CMakeLists.txt | 133 +++++++++++++----- .../CsoContentAddressingScenario.cpp | 24 ++-- .../Scenarios/HandleRecycleScenario.cpp | 56 +++++++- 3 files changed, 161 insertions(+), 52 deletions(-) diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 18fc04811..6c9c6b0e1 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -361,46 +361,85 @@ if (MOBILEGL_PIPE_PUSH) list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_PUSH_BUILD=1") endif() -file(GLOB MGL_ITEST_ESPRYT_SLOT_TABLES CONFIGURE_DEPENDS - "${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectGLES/SlotTables.h") -if (MGL_ITEST_ESPRYT_SLOT_TABLES) - message(STATUS "Integration tests: DirectGLES is keyed on {slot, gen} (SlotTables.h present)") - list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_REKEY_DirectGLES=1") -else() - message(STATUS "Integration tests: DirectGLES has no SlotTables.h - HandleRecycle.Handles will SKIP on it") -endif() - -file(GLOB MGL_ITEST_TRACKER_SOURCE CONFIGURE_DEPENDS - "${MGL_ITEST_ROOT}/MobileGL/MG_Impl/Pipe/Tracker.cpp") -if (MGL_ITEST_TRACKER_SOURCE) - message(STATUS "Integration tests: the MGPipe tracker is present, so CSOs are minted") - list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_TRACKER_PRESENT=1") -else() - message(STATUS "Integration tests: no MG_Impl/Pipe/Tracker.cpp - CsoContentAddressing will SKIP") -endif() - -set(MGL_ITEST_MAGMA_VERTEX_INPUT - "${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp") -if (EXISTS "${MGL_ITEST_MAGMA_VERTEX_INPUT}") - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${MGL_ITEST_MAGMA_VERTEX_INPUT}") - file(STRINGS "${MGL_ITEST_MAGMA_VERTEX_INPUT}" MGL_ITEST_MAGMA_REKEY_HITS - REGEX "kMGPipeSubsystemMagmaVertexInput") - file(STRINGS "${MGL_ITEST_MAGMA_VERTEX_INPUT}" MGL_ITEST_MAGMA_ABA_HITS - REGEX "PipeHandleAbaControl") - if (MGL_ITEST_MAGMA_REKEY_HITS) - message(STATUS "Integration tests: DirectVulkan's vertex input is keyed on {slot, gen}") - list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_REKEY_DirectVulkan=1") +# THE THREE MARKERS BELOW ANSWER A QUESTION ABOUT THE SOURCE TREE, so each is only a true +# statement about THIS LIBRARY while this build compiles the arm the source implements - and all +# three arms are `#if MOBILEGL_PIPE_PUSH`. A pull build has no {slot, gen} key (the slot tables +# and the re-keyed memos are push-only) and no Features.PipeHandleAbaControl at all (Config.h +# declares the field inside `#if MOBILEGL_PIPE_PUSH` and ConfigLoader parses it in the same arm). +# A source-only probe would therefore arm the PULL build's lanes the moment packages C and D +# land: the AbaControl lane would go hard red on a gate G2 requires green (the guards it means to +# defeat are still in force, so the scenario's "expect the stale pixels" assertion fails), and the +# Handles lane would report green against a library that contains no re-key at all - the +# "test that cannot fail" this scenario exists to avoid. +# +# So the whole block sits under the same `if (MOBILEGL_PIPE_PUSH)` as MGITEST_PIPE_PUSH_BUILD, and +# HandleRecycleScenario re-checks that marker before either arm asserts, so a hand-forced +# environment cannot arm an arm this build does not have either. +if (MOBILEGL_PIPE_PUSH) + file(GLOB MGL_ITEST_ESPRYT_SLOT_TABLES CONFIGURE_DEPENDS + "${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectGLES/SlotTables.h") + if (MGL_ITEST_ESPRYT_SLOT_TABLES) + message(STATUS "Integration tests: DirectGLES is keyed on {slot, gen} (SlotTables.h present)") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_REKEY_DirectGLES=1") else() - message(STATUS "Integration tests: DirectVulkan's vertex input is not re-keyed yet - " - "HandleRecycle.Handles will SKIP on it") + message(STATUS "Integration tests: DirectGLES has no SlotTables.h - HandleRecycle.Handles will SKIP on it") endif() - if (MGL_ITEST_MAGMA_ABA_HITS) - message(STATUS "Integration tests: MOBILEGL_PIPE_HANDLE_ABA_CONTROL has a consumer") - list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_ABA_IMPLEMENTED=1") + + # The CSO counters' EMITTER, probed by content rather than by a filename. The tracker package + # owns its own file layout and may implement the tracker and the cache header-only - today it + # does (MG_Impl/Pipe/{Tracker,CsoCache}.h, no Tracker.cpp) - so a glob for `Tracker.cpp` is a + # probe for a file nobody promised to create, and it would answer "no" forever AFTER the + # package landed, leaving the CSO control skipping with a reason that had become false. What + # the control actually needs is something that emits the two counters it reads, so that is + # what is looked for: any client-side pipe source naming RenderStateCsoMints / Binds. The glob + # is CONFIGURE_DEPENDS (a new file re-runs it) and every file it finds is added to + # CMAKE_CONFIGURE_DEPENDS (an edit to one re-runs it), so neither half can go stale. + file(GLOB MGL_ITEST_PIPE_CLIENT_SOURCES CONFIGURE_DEPENDS + "${MGL_ITEST_ROOT}/MobileGL/MG_Impl/Pipe/*.h" + "${MGL_ITEST_ROOT}/MobileGL/MG_Impl/Pipe/*.cpp") + set(MGL_ITEST_CSO_EMITTER "") + foreach(mglItestPipeSource IN LISTS MGL_ITEST_PIPE_CLIENT_SOURCES) + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${mglItestPipeSource}") + file(STRINGS "${mglItestPipeSource}" MGL_ITEST_CSO_HITS REGEX "RenderStateCso(Mints|Binds)") + if (MGL_ITEST_CSO_HITS AND NOT MGL_ITEST_CSO_EMITTER) + set(MGL_ITEST_CSO_EMITTER "${mglItestPipeSource}") + endif() + endforeach() + if (MGL_ITEST_CSO_EMITTER) + message(STATUS "Integration tests: the CSO counters have an emitter (${MGL_ITEST_CSO_EMITTER})") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_TRACKER_PRESENT=1") else() - message(STATUS "Integration tests: MOBILEGL_PIPE_HANDLE_ABA_CONTROL has no consumer - " - "HandleRecycle.AbaControl will SKIP") + message(STATUS "Integration tests: no MG_Impl/Pipe source emits RenderStateCsoMints/Binds - " + "CsoContentAddressing will SKIP") endif() + + set(MGL_ITEST_MAGMA_VERTEX_INPUT + "${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp") + if (EXISTS "${MGL_ITEST_MAGMA_VERTEX_INPUT}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${MGL_ITEST_MAGMA_VERTEX_INPUT}") + file(STRINGS "${MGL_ITEST_MAGMA_VERTEX_INPUT}" MGL_ITEST_MAGMA_REKEY_HITS + REGEX "kMGPipeSubsystemMagmaVertexInput") + file(STRINGS "${MGL_ITEST_MAGMA_VERTEX_INPUT}" MGL_ITEST_MAGMA_ABA_HITS + REGEX "PipeHandleAbaControl") + if (MGL_ITEST_MAGMA_REKEY_HITS) + message(STATUS "Integration tests: DirectVulkan's vertex input is keyed on {slot, gen}") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_REKEY_DirectVulkan=1") + else() + message(STATUS "Integration tests: DirectVulkan's vertex input is not re-keyed yet - " + "HandleRecycle.Handles will SKIP on it") + endif() + if (MGL_ITEST_MAGMA_ABA_HITS) + message(STATUS "Integration tests: MOBILEGL_PIPE_HANDLE_ABA_CONTROL has a consumer") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_ABA_IMPLEMENTED=1") + else() + message(STATUS "Integration tests: MOBILEGL_PIPE_HANDLE_ABA_CONTROL has no consumer - " + "HandleRecycle.AbaControl will SKIP") + endif() + endif() +else() + message(STATUS "Integration tests: pull build - HandleRecycle.{Handles,AbaControl} and " + "CsoContentAddressing stay registered (G2) and SKIP: every arm they assert is " + "compiled only under MOBILEGL_PIPE_PUSH") endif() mgl_itest_join_environment(MGL_ITEST_GLES_ENVIRONMENT @@ -770,11 +809,27 @@ gtest_discover_tests(MobileGLIntegrationTest # (VertexInputStateFactory::ComputeHash's key and LookupVaoDrawMemo's lifetimeId compare); it # steers nothing on DirectGLES, and a lane that configured it there would be a permanent skip # claiming to be a control. +# +# The two PUSH-ONLY knobs of those arms are set only in a push build, and the lane NAMES are +# unaffected by that (an ENVIRONMENT property is not part of a test's name, so G2 still sees the +# same list in both builds). MOBILEGL_PIPE_LEGACY_MEMOS=0 says "never enter the legacy arm"; in a +# pull build the legacy arm is the ONLY arm and every Track-H subsystem bit is clear, which is +# precisely D14's startup Fatal{PipeLegacyMemosDisabled} condition - so a lane that set it there +# would abort the process before the scenario could report its skip. MOBILEGL_PIPE_HANDLE_ABA_CONTROL +# has no field to parse into in a pull build at all (Config.h declares it under #if MOBILEGL_PIPE_PUSH). +if (MOBILEGL_PIPE_PUSH) + set(MGL_ITEST_HANDLES_ARM_KNOBS "MOBILEGL_PIPE_LEGACY_MEMOS=0") + set(MGL_ITEST_ABA_ARM_KNOBS "MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1") +else() + set(MGL_ITEST_HANDLES_ARM_KNOBS "") + set(MGL_ITEST_ABA_ARM_KNOBS "") +endif() + mgl_itest_join_environment(MGL_ITEST_GLES_HANDLE_HANDLES_ENVIRONMENT - "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_HANDLE_ARM=handles" "MOBILEGL_PIPE_LEGACY_MEMOS=0" + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_HANDLE_ARM=handles" ${MGL_ITEST_HANDLES_ARM_KNOBS} ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_HANDLES_ENVIRONMENT - "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=handles" "MOBILEGL_PIPE_LEGACY_MEMOS=0" + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=handles" ${MGL_ITEST_HANDLES_ARM_KNOBS} ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) mgl_itest_join_environment(MGL_ITEST_GLES_HANDLE_LEGACY_ENVIRONMENT "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_HANDLE_ARM=legacy" "MOBILEGL_PIPE_PUSH=0" @@ -784,7 +839,7 @@ mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_LEGACY_ENVIRONMENT ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_ABA_ENVIRONMENT "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=aba" "MOBILEGL_PIPE_PUSH=0" - "MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1" + ${MGL_ITEST_ABA_ARM_KNOBS} ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) gtest_discover_tests(MobileGLIntegrationTest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp index 47d6538a5..b13f09a71 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp @@ -53,11 +53,15 @@ // file is written against the P2 contract commit, before that package lands. Until the tracker // exists there is no CSO to mint, csom is structurally 0 and an assertion about its ratio to csob // would be a statement about nothing. The build answers the question rather than a hand-maintained -// list: MG_IntegrationTest/CMakeLists.txt looks for MG_Impl/Pipe/Tracker.cpp and passes the answer -// in as MGITEST_PIPE_TRACKER_PRESENT, with a CONFIGURE_DEPENDS on that directory so the answer -// cannot go stale. When the tracker lands the arms arm themselves; until then the entries are -// registered, visible and SKIPPED with the reason - never absent, and never green for having -// asserted nothing. +// list: MG_IntegrationTest/CMakeLists.txt greps every source under MG_Impl/Pipe/ for the two +// counters' names and passes the answer in as MGITEST_PIPE_TRACKER_PRESENT, with a +// CONFIGURE_DEPENDS on that directory and on each file it finds so the answer cannot go stale. +// It is a CONTENT probe, not a filename probe, precisely so that the owning package keeps control +// of its own file layout - it implements the tracker and the cache header-only today, and a glob +// for `Tracker.cpp` would have kept this control skipping forever after that package landed, with +// a reason that had become false. When an emitter lands the arms arm themselves; until then the +// entries are registered, visible and SKIPPED with the reason - never absent, and never green for +// having asserted nothing. #include #include @@ -213,10 +217,12 @@ void main() { oColor = vec4(0.0, 1.0, 0.0, 1.0); } return; } if (!BuildMarkerIsSet("MGITEST_PIPE_TRACKER_PRESENT")) { - GTEST_SKIP() << "the CSO counters have no emitter in this build: MG_Impl/Pipe/Tracker.cpp " - "does not exist, so nothing mints or binds a render-state CSO and " - "csom / csob are structurally zero. P2 package B owns the tracker; this " - "entry arms itself when it lands."; + GTEST_SKIP() << "the CSO counters have no emitter in this build: no source under " + "MobileGL/MG_Impl/Pipe/ names RenderStateCsoMints or " + "RenderStateCsoBinds, so nothing mints or binds a render-state CSO " + "and csom / csob are structurally zero. P2 package B owns the tracker " + "and the CSO cache; this entry arms itself when they land, whatever " + "files that package chooses to put them in."; return; } if (LibraryLogPath().empty()) { diff --git a/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp index 792682935..9ef37e27b 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp @@ -69,6 +69,14 @@ // answer in as MGITEST_HANDLE_REKEY_ / MGITEST_HANDLE_ABA_IMPLEMENTED, with a // CMAKE_CONFIGURE_DEPENDS on those files so the answer cannot go stale. When C and D land, the // arms arm themselves. +// +// Those two markers are a statement about the SOURCE TREE, and they are set only in a push build, +// because that is the only build in which the thing they name is compiled: the {slot, gen} re-key +// and Features.PipeHandleAbaControl are both `#if MOBILEGL_PIPE_PUSH`. In a pull build the two +// push arms therefore skip on MGITEST_PIPE_PUSH_BUILD before they ever look at a per-arm marker - +// otherwise, once C and D landed, the pull build would run AbaControl against guards that are +// still in force (a hard red on `ctest -L integration-gpu`, which G2 requires green in BOTH +// builds) and Handles against a library with no re-key in it (a green that asserts nothing). #include #include @@ -116,6 +124,17 @@ namespace MGITest { return Arm::Legacy; } + // Whether the lane named an arm this file knows. A value that is set but unrecognised is a + // FAILURE (SetUp below), never a quiet fall-through to Legacy: a typo in a lane's + // MGITEST_HANDLE_ARM would otherwise downgrade that lane's Handles or AbaControl assertion + // to the Legacy one, which passes - a lane reporting green for an arm it never ran. Same + // shape as CsoContentAddressingScenario's FAIL() on an unknown MGITEST_CSO_LANE. + bool ArmNameIsRecognised() { + const char* name = std::getenv(kArmMarker); + return name == nullptr || std::strcmp(name, "handles") == 0 || + std::strcmp(name, "legacy") == 0 || std::strcmp(name, "aba") == 0; + } + bool RunningInAHandleRecycleLane() { return std::getenv(kArmMarker) != nullptr; } const char* ArmName(Arm arm) { @@ -222,6 +241,13 @@ void main() { oColor = texture(uTex, vUv); } void SetUp() override { ScenarioTest::SetUp(); if (!Ready()) return; + if (!ArmNameIsRecognised()) { + const char* raw = std::getenv(kArmMarker); + FAIL() << "unknown " << kArmMarker << " value '" << (raw != nullptr ? raw : "") + << "': the arms are handles / legacy / aba. Reading an unrecognised name " + "as Legacy would make this lane assert the pre-re-key guards while " + "claiming to test something else, and it would pass."; + } m_arm = CurrentArm(); std::string error; m_colorProgram = CompileProgram(kColorVS, kColorFS, &error); @@ -249,15 +275,37 @@ void main() { oColor = texture(uTex, vUv); } "The ambient entries configure none of that, so there is nothing here to " "assert."; } + // Both push arms are compiled only under MOBILEGL_PIPE_PUSH, so in a pull build + // neither has anything to say whatever the source tree contains. This check comes + // BEFORE the per-arm markers deliberately: those answer "does the source tree + // implement it", which stops being a statement about this library the moment the + // library is the pull one. Without it, a pull build would run the Handles arm + // against a library with no {slot, gen} key (a green asserting nothing) and the + // AbaControl arm against one whose guards are still in force (a hard red on + // `ctest -L integration-gpu`, which G2 requires green in BOTH builds). + // MG_IntegrationTest/CMakeLists.txt already withholds the markers in a pull build; + // this is the second lock, so a hand-forced environment cannot arm them either. + if (m_arm != Arm::Legacy && !BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) { + GTEST_SKIP() << "the " << ArmName(m_arm) + << " arm needs a library built with MOBILEGL_PIPE_PUSH, and this one " + "was not: the {slot, gen} re-key and Features.PipeHandleAbaControl " + "are both #if MOBILEGL_PIPE_PUSH (Config.h, ConfigLoader.cpp), so " + "there is nothing here for either arm to assert against. The lane " + "stays registered so that `ctest -L integration-gpu` names the same " + "tests in the pull build and the push build (gate G2); the Legacy " + "arm is the one that is meaningful here, and it runs."; + } switch (m_arm) { case Arm::Handles: if (!ThisBackendsRekeyHasLanded()) { GTEST_SKIP() << "the Handles arm needs the backend's {slot, gen} re-key, and this " - "build does not have it: no source under MobileGL/MG_Backend/" + "build does not have it: the build's capability probe found no " + "slot table and no Track H subsystem constant under " + "MobileGL/MG_Backend/" << Gl().BackendName() - << " mentions the Track H subsystem constant (P2 package C for " - "DirectGLES, package D for DirectVulkan). The arm is registered " - "and visible, and arms itself when that package lands."; + << " (P2 package C for DirectGLES, package D for DirectVulkan). The " + "arm is registered and visible, and arms itself when that " + "package lands in a push build."; } return; case Arm::AbaControl: From af20dba6db880adfa965998a1752a7d78d634356 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 09:08:04 -0400 Subject: [PATCH 131/529] [Fix] (Trace, Bench, CI): compute p50 by the device's own median rule, fail the profile guard closed, and give the new control step its sibling's environment - format_benchmark printed a p50 taken with the nearest-rank rule beside a medianFrameCpuMs the device computes as the average of the two middle frames, and documented the two as one rule; on an even window they differ (the pre-flight printed p50=8.261ms next to medianCpuMs=271.766). p50 now goes through series_median, which is SummarizeSeries' rule transcribed; p95 and p99 stay nearest rank, which is the device's rule for p95 and the honest extension of it for the p99 the device does not compute at all - require_verified_profile treated a profile that simply omits PROFILE_VERIFIED as verified, which is the fail-open default a profile written by copying another one inherits - exactly the case the guard exists for. It defaults to unverified now, odinlite.env carries PROFILE_VERIFIED=1 explicitly (it is the one profile that earned it), and the refusal says "says 0, or says nothing" - the two new profiles claimed profile.sh refuses an unverified profile; it has no such check and needs none - it records a simpleperf profile and pins nothing. The claim is corrected in both profiles and in the README rather than a guard added where there is nothing to guard - the handle-ABA / CSO control step in test.yml set only MOBILEGL_ITEST_REQUIRE_GPU while its sibling verify step sets the three MOBILEGL_MAGMA_* fixes and arms core dumps. It runs the same DirectVulkan binary on the same runner, so a crash there left no core; it now carries both --- .github/workflows/test.yml | 11 ++++++ tools/device_bench/README.md | 3 ++ tools/device_bench/bench.sh | 13 +++++-- tools/device_bench/devices/odinlite.env | 6 +++ tools/device_bench/devices/oppo-mali.env | 5 ++- .../device_bench/devices/xiaomi-adreno830.env | 5 ++- tools/device_bench/session.sh | 13 +++++-- .../trace_replay/run_android_retrace_local.py | 37 +++++++++++++++---- 8 files changed, 73 insertions(+), 20 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f3984cf79..efc8aaf36 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -577,11 +577,22 @@ jobs: # An arm whose subsystem has not landed on this tree SKIPS with the reason (never absent, # never a green that asserted nothing), so this step is green through the P2 landing order # and starts asserting as each package arrives. + # + # The environment is the sibling step's, deliberately and in full: these entries run the + # same DirectVulkan binary through the same runner, so the three MOBILEGL_MAGMA_* fixes it + # needs apply here too, and a crash here has to leave a core for the same black-box flow. + # The step above is the only reason those lines exist in this job; a control that crashed + # without one would be the hardest failure in the job to diagnose. - name: The handle-ABA and CSO-content-addressing controls (G8, G12) working-directory: build-verify env: MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH: "1" + MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS: "1" + MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER: "1" run: | + ulimit -c unlimited + sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p' ctest --output-on-failure -L integration-gpu \ -R 'HandleRecycle|CsoContentAddressing' --no-tests=error -j 4 diff --git a/tools/device_bench/README.md b/tools/device_bench/README.md index 177831696..a83fcaa75 100644 --- a/tools/device_bench/README.md +++ b/tools/device_bench/README.md @@ -35,6 +35,9 @@ frequency-pin integrity. (`big_cur`/`little_cur`/`gpu_cur_khz` in the result JSON must match the pins). Until then it says `PROFILE_VERIFIED=0` and `bench.sh` / `session.sh` refuse to run against it unless `--allow-unverified-profile` is passed, which labels the run unpinned in the warning. + **A profile that omits the key entirely is refused the same way** - the guard defaults to + unverified, so copying a verified profile and editing the serial cannot inherit its verdict. + (`profile.sh` pins nothing - it records a simpleperf profile - so it carries no such guard.) That refusal exists because the pin path is silent when it is wrong: the harness writes through `/proc/ppm/policy/hard_userlimit_*` and `/proc/gpufreq/gpufreq_opp_freq`, which are diff --git a/tools/device_bench/bench.sh b/tools/device_bench/bench.sh index 29bda024b..6410ce3f6 100755 --- a/tools/device_bench/bench.sh +++ b/tools/device_bench/bench.sh @@ -69,15 +69,20 @@ done # TYPE off THIS device, ran one pinned window, and checked big_cur/little_cur/gpu_cur_khz in the # result JSON against the pins. Nothing else earns it. require_verified_profile() { - if [ "${PROFILE_VERIFIED:-1}" = "1" ]; then return 0; fi + # The default is UNVERIFIED. A profile that simply omits the key is a profile nobody has + # confirmed against its device, and defaulting it to "verified" would hand exactly the + # fail-open behaviour this guard exists to prevent to the most likely way a new profile is + # written - by copying an existing one and editing the serial. + if [ "${PROFILE_VERIFIED:-0}" = "1" ]; then return 0; fi if [ "$ALLOW_UNVERIFIED_PROFILE" = "1" ]; then - echo "[warn] $DEVICE_ENV declares PROFILE_VERIFIED=0 and --allow-unverified-profile was passed:" >&2 + echo "[warn] $DEVICE_ENV does not carry PROFILE_VERIFIED=1 and --allow-unverified-profile was passed:" >&2 echo "[warn] the frequency pins and the thermal gate in it are UNCONFIRMED, so any number this" >&2 echo "[warn] run produces is not comparable with a pinned one." >&2 return 0 fi - echo "$DEVICE_ENV declares PROFILE_VERIFIED=0: its sysfs nodes and OPPs have not been read off" >&2 - echo "the device, so pinning would fail silently and the run would look pinned but not be." >&2 + echo "$DEVICE_ENV does not carry PROFILE_VERIFIED=1 (it says 0, or says nothing at all): its" >&2 + echo "sysfs nodes and OPPs have not been read off the device, so pinning would fail silently" >&2 + echo "and the run would look pinned but not be." >&2 echo "Fill in the TODO_VERIFY_ON_DEVICE fields, confirm one pinned window, set PROFILE_VERIFIED=1 -" >&2 echo "or pass --allow-unverified-profile to measure anyway and label the result unpinned." >&2 exit 2 diff --git a/tools/device_bench/devices/odinlite.env b/tools/device_bench/devices/odinlite.env index ff4ce6501..bc3b22ea6 100644 --- a/tools/device_bench/devices/odinlite.env +++ b/tools/device_bench/devices/odinlite.env @@ -3,6 +3,12 @@ # Panel: 1080x1920 @ 60Hz (presented FPS caps at 60 - render-side FPS comes from the # FCLFPS logcat tag, which counts eglSwapBuffers; it is NOT vsync-capped when the # game runs with vsync off). +# Read off this device and confirmed against one pinned window (big_cur/little_cur/gpu_cur_khz +# in the result JSON matched the pins), which is what earns the key. bench.sh / session.sh refuse +# a profile without it: the default is UNVERIFIED, so a profile written by copying this one starts +# out refused until somebody repeats that check on the new device. +PROFILE_VERIFIED=1 + DEVICE_SERIAL=MTK0002207301023500 # Frequency pins, enforced via /proc/ppm/policy/hard_userlimit_* (plain cpufreq diff --git a/tools/device_bench/devices/oppo-mali.env b/tools/device_bench/devices/oppo-mali.env index 69e81ec10..323b63877 100644 --- a/tools/device_bench/devices/oppo-mali.env +++ b/tools/device_bench/devices/oppo-mali.env @@ -5,8 +5,9 @@ # repository rather than in one operator's shell history. # # ============================ NOT YET DEVICE-VERIFIED ============================ -# PROFILE_VERIFIED=0, and bench.sh / session.sh / profile.sh refuse to run against it unless -# --allow-unverified-profile is passed. This part is a MediaTek SoC, so unlike the Adreno +# PROFILE_VERIFIED=0, and bench.sh / session.sh refuse to run against it - or against a profile +# that omits the key - unless --allow-unverified-profile is passed. (profile.sh only records a +# simpleperf profile and pins nothing, so it carries no such guard.) This part is a MediaTek SoC, so unlike the Adreno # profile the harness's existing /proc/ppm + /proc/gpufreq pin path is probably the right one - # but "probably" is exactly the state a measurement profile must not ship in. The cluster # indices, the available OPPs, the top GPU OPP and the thermal zone TYPE all differ between diff --git a/tools/device_bench/devices/xiaomi-adreno830.env b/tools/device_bench/devices/xiaomi-adreno830.env index 360a5407d..a1c2ad92f 100644 --- a/tools/device_bench/devices/xiaomi-adreno830.env +++ b/tools/device_bench/devices/xiaomi-adreno830.env @@ -6,8 +6,9 @@ # history, and so that a `--device` argument names something reviewable. # # ============================ NOT YET DEVICE-VERIFIED ============================ -# PROFILE_VERIFIED=0 below, and bench.sh / session.sh / profile.sh REFUSE to run against a -# profile that says so unless --allow-unverified-profile is passed. Two of the values here are +# PROFILE_VERIFIED=0 below, and bench.sh / session.sh REFUSE to run against a profile that says +# so - or that omits the key - unless --allow-unverified-profile is passed. (profile.sh is not in +# that list: it records a simpleperf profile and pins nothing, so it has nothing to pin wrongly.) Two of the values here are # protocol constants that are known (the campaign pins big 1.96 GHz / little 1.55 GHz and gates # at 40 C), but the sysfs node names and the exact available OPPs are NOT: this is a Qualcomm # part and the harness was written against MediaTek, where the pin goes through diff --git a/tools/device_bench/session.sh b/tools/device_bench/session.sh index 44040fd4a..80323b712 100755 --- a/tools/device_bench/session.sh +++ b/tools/device_bench/session.sh @@ -52,15 +52,20 @@ done # TYPE off THIS device, ran one pinned window, and checked big_cur/little_cur/gpu_cur_khz in the # result JSON against the pins. Nothing else earns it. require_verified_profile() { - if [ "${PROFILE_VERIFIED:-1}" = "1" ]; then return 0; fi + # The default is UNVERIFIED. A profile that simply omits the key is a profile nobody has + # confirmed against its device, and defaulting it to "verified" would hand exactly the + # fail-open behaviour this guard exists to prevent to the most likely way a new profile is + # written - by copying an existing one and editing the serial. + if [ "${PROFILE_VERIFIED:-0}" = "1" ]; then return 0; fi if [ "$ALLOW_UNVERIFIED_PROFILE" = "1" ]; then - echo "[warn] $DEVICE_ENV declares PROFILE_VERIFIED=0 and --allow-unverified-profile was passed:" >&2 + echo "[warn] $DEVICE_ENV does not carry PROFILE_VERIFIED=1 and --allow-unverified-profile was passed:" >&2 echo "[warn] the frequency pins and the thermal gate in it are UNCONFIRMED, so any number this" >&2 echo "[warn] run produces is not comparable with a pinned one." >&2 return 0 fi - echo "$DEVICE_ENV declares PROFILE_VERIFIED=0: its sysfs nodes and OPPs have not been read off" >&2 - echo "the device, so pinning would fail silently and the run would look pinned but not be." >&2 + echo "$DEVICE_ENV does not carry PROFILE_VERIFIED=1 (it says 0, or says nothing at all): its" >&2 + echo "sysfs nodes and OPPs have not been read off the device, so pinning would fail silently" >&2 + echo "and the run would look pinned but not be." >&2 echo "Fill in the TODO_VERIFY_ON_DEVICE fields, confirm one pinned window, set PROFILE_VERIFIED=1 -" >&2 echo "or pass --allow-unverified-profile to measure anyway and label the result unpinned." >&2 exit 2 diff --git a/tools/trace_replay/run_android_retrace_local.py b/tools/trace_replay/run_android_retrace_local.py index 22c15c7d1..9d48a919c 100644 --- a/tools/trace_replay/run_android_retrace_local.py +++ b/tools/trace_replay/run_android_retrace_local.py @@ -226,12 +226,32 @@ def read_benchmark(case, backend, run_index): return report +def series_median(values): + """The median, by the rule SummarizeSeries uses on the device. + + trace_replay_core.cpp's SeriesSummary takes the middle element of an odd window and the + AVERAGE of the two middle elements of an even one, so p50 has to be computed the same way or + the line would print a p50 next to a medianFrameCpuMs that disagreed with it for a reason + nobody could see. (It is the only one of the three that is not a nearest rank: the device's + p95 is.) + """ + if not values: + return -1.0 + ordered = sorted(values) + middle = len(ordered) // 2 + if len(ordered) % 2 == 1: + return ordered[middle] + return 0.5 * (ordered[middle - 1] + ordered[middle]) + + def nearest_rank_percentile(values, fraction): - """Nearest-rank percentile, the same rule SummarizeSeries uses on the device. + """Nearest-rank percentile, the rule SummarizeSeries uses on the device for p95. Nearest rank rather than an interpolating percentile so that every number printed here is a frame that was actually observed, and so that a p95 computed on this side agrees exactly with - the p95 the device reported for the same window. + the p95 the device reported for the same window. The device computes no p99 at all - that is + the whole reason benchmark.json carries the full series - so p99 is this rule extended, and + p50 is NOT computed here (see series_median). """ if not values: return -1.0 @@ -245,11 +265,12 @@ def nearest_rank_percentile(values, fraction): def cpu_tail(report): """The trailing window of the per-frame CPU series, or [] when the run collected none. - benchmark.json carries the WHOLE frameCpuTimesMs[] array precisely so that percentiles the - device does not compute - p50 and p99, which are what the paired A/B publishes - are a - host-side reduction over an artefact that already exists. The window is the same trailing - tailFrames the device summarised, so the numbers below sit beside the device's own without - being about a different set of frames. + benchmark.json carries the WHOLE frameCpuTimesMs[] array precisely so that p99 - which the + device does not compute, and which the paired A/B publishes beside p50 - is a host-side + reduction over an artefact that already exists. The window is the same trailing tailFrames the + device summarised, so the numbers below sit beside the device's own without being about a + different set of frames; p50 is recomputed here by the device's own median rule, so it agrees + with medianFrameCpuMs on the same run rather than merely sitting next to it. """ series = report.get("frameCpuTimesMs") or [] if not series: @@ -277,7 +298,7 @@ def format_benchmark(report): if window: line += ( f" | cpu mean={report.get('meanFrameCpuMs', -1):.3f}ms" - f" p50={nearest_rank_percentile(window, 0.50):.3f}ms" + f" p50={series_median(window):.3f}ms" f" p95={report.get('p95FrameCpuMs', -1):.3f}ms" f" p99={nearest_rank_percentile(window, 0.99):.3f}ms" ) From 08d14d85efec19dbd513a6c5b986dffe2a9406a8 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 10:09:49 -0400 Subject: [PATCH 132/529] [Fix] (Bench): make the blend-toggle gate go red when the case it names stops running - DriverBenchStateToggle was an entry that could not fail for the reason it was added. A case name matching nothing in kBenchCases selected nothing, run_case is void, and main returned 0 unconditionally, so renaming or dropping mc_state_toggle left the entry green while measuring nothing - the exact state it was landed to end (ROADMAP.md:7). - DriverBench now refuses an unknown case name before any GL work (exit 2, listing the cases it does have), so a caller that names a case - run_driver_bench.sh included - learns the case is gone instead of getting an empty CSV. - Both ctest entries additionally require the case's own output row via PASS_REGULAR_EXPRESSION, so the gate stands on the evidence rather than on that check staying in the binary. The toggle entry pins the ops-per-frame column to 46, because the mc_* cases are deliberately excluded from the DRIVERBENCH_DRAWS scaling and 46 toggles per frame is part of what "this case still runs" means. A PASS_REGULAR_EXPRESSION makes ctest ignore the exit code, which is why the row is what is checked; the comment says so. - Verified: renaming mc_state_toggle in kBenchCases -> DriverBenchStateToggle FAILS; setting its ops-per-frame to 45 -> FAILS; restored -> both entries pass again. --- MobileGL/MG_Benchmark/Driver/CMakeLists.txt | 31 +++++++++++++++++++-- MobileGL/MG_Benchmark/Driver/DriverBench.c | 22 +++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/MobileGL/MG_Benchmark/Driver/CMakeLists.txt b/MobileGL/MG_Benchmark/Driver/CMakeLists.txt index caad1a3d9..5144f0c97 100644 --- a/MobileGL/MG_Benchmark/Driver/CMakeLists.txt +++ b/MobileGL/MG_Benchmark/Driver/CMakeLists.txt @@ -11,8 +11,29 @@ endif() add_executable(DriverBench DriverBench.c) target_link_libraries(DriverBench PRIVATE dl) +# WHY EVERY ENTRY HERE CARRIES A PASS_REGULAR_EXPRESSION. +# +# DriverBench prints one CSV row per case it ran and exits 0 whatever it ran. Before this, a ctest +# entry naming a case therefore could not answer the only question it exists to ask: an argument +# matching nothing in kBenchCases selected no case, printed only the header row, and still exited +# 0. DriverBench.c now refuses an unknown case name (exit 2), which closes it at the source - but +# the entry must be able to go red for the reason it exists WITHOUT depending on that check +# staying in the binary, so each entry also requires the case's own output row to appear. +# +# The regex is what a healthy run of that case prints and nothing else does: the case name at the +# start of a line, then the frames / ops-per-frame / median-ms / ns-per-op / fps columns +# (run_case()). A rename, a drop from kBenchCases, a boot_egl() failure or +# a crash part-way through the case all remove that row and turn the entry red. +# +# Note that a PASS_REGULAR_EXPRESSION makes ctest ignore the process exit code (cmCTestRunTest: +# success is `retVal == 0 || !RequiredRegularExpressions.empty()`), which is why the row itself +# has to be the evidence rather than a companion to the rc. add_test(NAME DriverBench COMMAND DriverBench draw_tiny) -set_tests_properties(DriverBench PROPERTIES LABELS benchmark) +# draw_tiny's a/ops scale with $DRIVERBENCH_DRAWS (main()), so only the shape of +# the row is pinned here, not the column values. +set_tests_properties(DriverBench PROPERTIES + LABELS benchmark + PASS_REGULAR_EXPRESSION "(^|\n)draw_tiny,[0-9]+,[0-9]+,[0-9.]+,[0-9.]+,[0-9.]+") # The Blaze3D blend toggle, as its own entry. # @@ -31,4 +52,10 @@ set_tests_properties(DriverBench PROPERTIES LABELS benchmark) # when unset) - the ctest entry is a "does this case still run" gate, not the measurement. The # measurement is run_driver_bench.sh against each of {native, espryt, magma}. add_test(NAME DriverBenchStateToggle COMMAND DriverBench mc_state_toggle) -set_tests_properties(DriverBenchStateToggle PROPERTIES LABELS benchmark) +# The ops-per-frame column is pinned to 46 here, unlike the entry above: the mc_* cases are +# excluded from the $DRIVERBENCH_DRAWS scaling on purpose ("the mc_* rates are measured and must +# not move, or the numbers stop being comparable", main()), so 46 toggles per frame +# is part of what "this case still runs" means. Change the workload and this entry says so. +set_tests_properties(DriverBenchStateToggle PROPERTIES + LABELS benchmark + PASS_REGULAR_EXPRESSION "(^|\n)mc_state_toggle,[0-9]+,46,[0-9.]+,[0-9.]+,[0-9.]+") diff --git a/MobileGL/MG_Benchmark/Driver/DriverBench.c b/MobileGL/MG_Benchmark/Driver/DriverBench.c index 6f9667942..8eb20db7d 100644 --- a/MobileGL/MG_Benchmark/Driver/DriverBench.c +++ b/MobileGL/MG_Benchmark/Driver/DriverBench.c @@ -476,6 +476,28 @@ int main(int argc, char** argv) { if (getenv("DRIVERBENCH_FRAMES")) g_frames = atoi(getenv("DRIVERBENCH_FRAMES")); if (getenv("DRIVERBENCH_SPRITES")) g_mixSprites = atol(getenv("DRIVERBENCH_SPRITES")); + /* A requested case name that matches nothing used to select nothing, print the header row and + * exit 0 - so a caller that names a case (run_driver_bench.sh, and the two ctest entries in + * CMakeLists.txt) could not tell "the case ran" from "the case has been renamed or deleted". + * Refuse it here, before any GL work, so the refusal reaches a caller that has no display + * either, and name what does exist so the fix is obvious. */ + int unknownCases = 0; + for (int j = 1; j < argc; ++j) { + int known = 0; + for (int i = 0; i < kBenchCaseCount; ++i) + if (strcmp(argv[j], kBenchCases[i].name) == 0) known = 1; + if (!known) { + fprintf(stderr, "DriverBench: no case named '%s'\n", argv[j]); + unknownCases = 1; + } + } + if (unknownCases) { + fprintf(stderr, "DriverBench: the %d cases in kBenchCases are:\n", kBenchCaseCount); + for (int i = 0; i < kBenchCaseCount; ++i) + fprintf(stderr, " %s\n", kBenchCases[i].name); + return 2; + } + if (boot_egl()) return 1; build_resources(); From e5603f9a4668145fc04244073c55b9020e7c68d1 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 10:16:15 -0400 Subject: [PATCH 133/529] [Fix] (Test, Pipe): probe every arm by content, and let the G7 control's exit status carry what it already knows - All four capability markers are now content probes over the directory the owning package owns, through one helper. The magma pair still read a single hard-coded VertexInputStateFactory.cpp while package D already keeps one of its two Features.PipeHandleAbaControl consumers in Renderer/VulkanRenderer.cpp, so one file move on D's side was a permanent AbaControl skip - the same defect the CSO probe was rewritten for. The DirectGLES probe stops asking whether SlotTables.h exists and asks for kMGPipeSubsystemEsprytSlots, the bit the arm is actually gated on. Every globbed file stays in CMAKE_CONFIGURE_DEPENDS, and the glob is CONFIGURE_DEPENDS. - Verified: with one throwaway header naming each symbol, build-push configures to "keyed on {slot, gen}" / "has an emitter" / "has a consumer" and all four MGITEST_* markers appear 24 times in the generated ctest environments; with the headers gone, all four are back to 0 and the four "will SKIP" verdicts return. The magma sim sat in Renderer/, not in the path the old probe hard-coded. - g7_negative_control.sh no longer exits 0 when the control trips for the wrong reason. A SetterConsistency that had gone red for an unrelated reason satisfied "ctest failed" and never named SetColorMask, and the integrator's D.3 reads this script's rc. The verdict is now taken after the restore and the rebuild - a broken build directory is worse than any exit status - and reported as rc 1 with the output kept, alongside the existing "did not trip" rc 1. - HandleRecycleScenario writes down what the name-recycle proxy costs: the AbaControl arm asserts corruption that needs the heap BLOCK back, sees only the NAME, and so can red an always-on integration-gpu lane for an allocator reason. That trade is deliberate - the alternative is an arm that is green on the day the reproducer stops reproducing - and the consequence is now written both in the header and at the skip that is the last thing standing between the two. --- MobileGL/MG_IntegrationTest/CMakeLists.txt | 113 ++++++++++-------- .../Scenarios/HandleRecycleScenario.cpp | 19 +++ scripts/g7_negative_control.sh | 31 ++++- 3 files changed, 111 insertions(+), 52 deletions(-) mode change 100755 => 100644 scripts/g7_negative_control.sh diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 6c9c6b0e1..29fd7654d 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -375,36 +375,54 @@ endif() # So the whole block sits under the same `if (MOBILEGL_PIPE_PUSH)` as MGITEST_PIPE_PUSH_BUILD, and # HandleRecycleScenario re-checks that marker before either arm asserts, so a hand-forced # environment cannot arm an arm this build does not have either. +# +# ALL FOUR MARKERS ARE CONTENT PROBES, AND NONE OF THEM NAMES A FILE. A probe for a filename asks +# the wrong question: the owning package chooses its own file layout, so the moment it moves the +# code the probe answers "no" forever and the arm skips with a reason that has become false - a +# test quietly measuring nothing, which is the one outcome this whole scenario exists to prevent. +# The CSO probe was rewritten for exactly that reason once already; the magma probe still read one +# hard-coded .cpp, and package D already keeps one of its two Features.PipeHandleAbaControl +# consumers in a different file of the same directory (Renderer/VulkanRenderer.cpp), so it was one +# refactor away from a permanent AbaControl skip. So all four now ask "does any source in the +# directory the owning package owns name this symbol?", which is the thing each arm actually needs. +# +# Staleness cannot creep in from either side: the GLOB is CONFIGURE_DEPENDS (a file added or +# removed re-runs it) and every file it finds is appended to CMAKE_CONFIGURE_DEPENDS (an edit to +# one re-runs it). +function(mgl_itest_probe_for_symbol outVar directory symbolRegex) + file(GLOB_RECURSE mglItestProbeSources CONFIGURE_DEPENDS + "${directory}/*.h" "${directory}/*.hpp" "${directory}/*.cpp" "${directory}/*.c") + set(mglItestProbeHit "") + foreach(mglItestProbeSource IN LISTS mglItestProbeSources) + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${mglItestProbeSource}") + file(STRINGS "${mglItestProbeSource}" mglItestProbeLines REGEX "${symbolRegex}") + if (mglItestProbeLines AND NOT mglItestProbeHit) + set(mglItestProbeHit "${mglItestProbeSource}") + endif() + endforeach() + set(${outVar} "${mglItestProbeHit}" PARENT_SCOPE) +endfunction() + if (MOBILEGL_PIPE_PUSH) - file(GLOB MGL_ITEST_ESPRYT_SLOT_TABLES CONFIGURE_DEPENDS - "${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectGLES/SlotTables.h") - if (MGL_ITEST_ESPRYT_SLOT_TABLES) - message(STATUS "Integration tests: DirectGLES is keyed on {slot, gen} (SlotTables.h present)") + # DirectGLES' Track H arm, probed by the subsystem bit it is gated on rather than by + # SlotTables.h existing: the bit is declared in the contract (MG_Pipe/MGPipe.h:77) and the + # backend has to name it to honour MOBILEGL_PIPE_PUSH's default mask, whatever files package C + # spreads the slot tables across. + mgl_itest_probe_for_symbol(MGL_ITEST_ESPRYT_SLOTS + "${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectGLES" "kMGPipeSubsystemEsprytSlots") + if (MGL_ITEST_ESPRYT_SLOTS) + message(STATUS "Integration tests: DirectGLES is keyed on {slot, gen} (${MGL_ITEST_ESPRYT_SLOTS})") list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_REKEY_DirectGLES=1") else() - message(STATUS "Integration tests: DirectGLES has no SlotTables.h - HandleRecycle.Handles will SKIP on it") + message(STATUS "Integration tests: no DirectGLES source names kMGPipeSubsystemEsprytSlots - " + "HandleRecycle.Handles will SKIP on it") endif() - # The CSO counters' EMITTER, probed by content rather than by a filename. The tracker package - # owns its own file layout and may implement the tracker and the cache header-only - today it - # does (MG_Impl/Pipe/{Tracker,CsoCache}.h, no Tracker.cpp) - so a glob for `Tracker.cpp` is a - # probe for a file nobody promised to create, and it would answer "no" forever AFTER the - # package landed, leaving the CSO control skipping with a reason that had become false. What - # the control actually needs is something that emits the two counters it reads, so that is - # what is looked for: any client-side pipe source naming RenderStateCsoMints / Binds. The glob - # is CONFIGURE_DEPENDS (a new file re-runs it) and every file it finds is added to - # CMAKE_CONFIGURE_DEPENDS (an edit to one re-runs it), so neither half can go stale. - file(GLOB MGL_ITEST_PIPE_CLIENT_SOURCES CONFIGURE_DEPENDS - "${MGL_ITEST_ROOT}/MobileGL/MG_Impl/Pipe/*.h" - "${MGL_ITEST_ROOT}/MobileGL/MG_Impl/Pipe/*.cpp") - set(MGL_ITEST_CSO_EMITTER "") - foreach(mglItestPipeSource IN LISTS MGL_ITEST_PIPE_CLIENT_SOURCES) - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${mglItestPipeSource}") - file(STRINGS "${mglItestPipeSource}" MGL_ITEST_CSO_HITS REGEX "RenderStateCso(Mints|Binds)") - if (MGL_ITEST_CSO_HITS AND NOT MGL_ITEST_CSO_EMITTER) - set(MGL_ITEST_CSO_EMITTER "${mglItestPipeSource}") - endif() - endforeach() + # The CSO counters' EMITTER. The tracker package may implement the tracker and the cache + # header-only - today it does (MG_Impl/Pipe/{Tracker,CsoCache}.h, no Tracker.cpp) - so what is + # looked for is what the control actually reads: a source emitting the two counters. + mgl_itest_probe_for_symbol(MGL_ITEST_CSO_EMITTER + "${MGL_ITEST_ROOT}/MobileGL/MG_Impl/Pipe" "RenderStateCso(Mints|Binds)") if (MGL_ITEST_CSO_EMITTER) message(STATUS "Integration tests: the CSO counters have an emitter (${MGL_ITEST_CSO_EMITTER})") list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_TRACKER_PRESENT=1") @@ -413,28 +431,29 @@ if (MOBILEGL_PIPE_PUSH) "CsoContentAddressing will SKIP") endif() - set(MGL_ITEST_MAGMA_VERTEX_INPUT - "${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp") - if (EXISTS "${MGL_ITEST_MAGMA_VERTEX_INPUT}") - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${MGL_ITEST_MAGMA_VERTEX_INPUT}") - file(STRINGS "${MGL_ITEST_MAGMA_VERTEX_INPUT}" MGL_ITEST_MAGMA_REKEY_HITS - REGEX "kMGPipeSubsystemMagmaVertexInput") - file(STRINGS "${MGL_ITEST_MAGMA_VERTEX_INPUT}" MGL_ITEST_MAGMA_ABA_HITS - REGEX "PipeHandleAbaControl") - if (MGL_ITEST_MAGMA_REKEY_HITS) - message(STATUS "Integration tests: DirectVulkan's vertex input is keyed on {slot, gen}") - list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_REKEY_DirectVulkan=1") - else() - message(STATUS "Integration tests: DirectVulkan's vertex input is not re-keyed yet - " - "HandleRecycle.Handles will SKIP on it") - endif() - if (MGL_ITEST_MAGMA_ABA_HITS) - message(STATUS "Integration tests: MOBILEGL_PIPE_HANDLE_ABA_CONTROL has a consumer") - list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_ABA_IMPLEMENTED=1") - else() - message(STATUS "Integration tests: MOBILEGL_PIPE_HANDLE_ABA_CONTROL has no consumer - " - "HandleRecycle.AbaControl will SKIP") - endif() + # DirectVulkan's Track H arm, and the ABA knob's consumer. Both over the whole backend + # directory: the re-key is subsystem 4's bit wherever package D reads it, and the knob has a + # consumer if ANY DirectVulkan source reverts a guard on it - today two do, in two files. + mgl_itest_probe_for_symbol(MGL_ITEST_MAGMA_REKEY + "${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectVulkan" "kMGPipeSubsystemMagmaVertexInput") + if (MGL_ITEST_MAGMA_REKEY) + message(STATUS "Integration tests: DirectVulkan's vertex input is keyed on {slot, gen} " + "(${MGL_ITEST_MAGMA_REKEY})") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_REKEY_DirectVulkan=1") + else() + message(STATUS "Integration tests: no DirectVulkan source names kMGPipeSubsystemMagmaVertexInput - " + "HandleRecycle.Handles will SKIP on it") + endif() + + mgl_itest_probe_for_symbol(MGL_ITEST_MAGMA_ABA + "${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectVulkan" "PipeHandleAbaControl") + if (MGL_ITEST_MAGMA_ABA) + message(STATUS "Integration tests: MOBILEGL_PIPE_HANDLE_ABA_CONTROL has a consumer " + "(${MGL_ITEST_MAGMA_ABA})") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_ABA_IMPLEMENTED=1") + else() + message(STATUS "Integration tests: no DirectVulkan source names PipeHandleAbaControl - " + "HandleRecycle.AbaControl will SKIP") endif() else() message(STATUS "Integration tests: pull build - HandleRecycle.{Handles,AbaControl} and " diff --git a/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp index 9ef37e27b..cc873a28f 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp @@ -40,6 +40,19 @@ // SKIPS with that reason rather than passing - the shape MG_Test/State/ObjectLifetimeIdTest.cpp // already uses for exactly this ("inconclusive, not proven"). // +// WHAT THAT PROXY COSTS THE CI LANE, WRITTEN DOWN ON PURPOSE. The name is only a proxy: the +// corruption the AbaControl arm asserts needs the freed HEAP BLOCK to be handed back, and public +// GL cannot see that. So on a run where the allocator returns the name but not the block, the two +// arms behave differently - the correctness arms (Handles, Legacy) still expect correct pixels and +// still pass, but AbaControl expects the corruption and FAILS. It does that inside +// `ctest -L integration-gpu`, a lane P2 requires green (gate G2), so this scenario can red a +// required lane for an allocator reason. That is chosen, not overlooked: an arm that skipped +// whenever it could not prove the ABA would also be green on the day the reproducer stopped +// reproducing one, and "green because nothing was tested" is precisely what this file exists to +// prevent. ObjectLifetimeIdTest makes the opposite choice because it is a unit test with no +// always-on lane behind it. If the arm ever does flake, the fix is a stronger address-reuse proxy +// - a backend counter for "a recycled slot was handed back out" - and not a looser assertion. +// // THREE ARMS, ALL ALWAYS ON (P2 brief D18). The arm is named by MGITEST_HANDLE_ARM, which is a // HARNESS marker - the library never reads it - and the CMake wiring registers one lane per arm: // @@ -472,6 +485,12 @@ void main() { oColor = texture(uTex, vUv); } ConfigureQuadVao(greenVao, greenBuffer); ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the replacement VAO left a GL error behind"; + // The skip below is the LAST thing that can save a run in which the allocator did not + // repeat itself, and it only sees half of what matters: the names. If the names come + // back but the heap blocks do not, execution continues into an assertion the + // AbaControl arm expects to see corrupted pixels from - and that arm then FAILS + // rather than skipping, in an always-on integration-gpu lane. The header says why that + // trade is taken deliberately; this is where the consequence lands. if (greenVao != redVao || greenBuffer != redBuffer) { GTEST_SKIP() << "inconclusive, not proven: the name allocator did not hand both names back " "(vao " << redVao << " -> " << greenVao << ", buffer " << redBuffer << " -> " diff --git a/scripts/g7_negative_control.sh b/scripts/g7_negative_control.sh old mode 100755 new mode 100644 index 5417747a3..a551a2689 --- a/scripts/g7_negative_control.sh +++ b/scripts/g7_negative_control.sh @@ -12,7 +12,9 @@ # green on a table it had stopped looking at: a walk that silently drove no setters, a hash that # stopped depending on the chunks, an assertion someone loosened. Green tells you nothing about # whether the test can still fail. This script makes it fail, for the one reason it exists to -# catch, and reports a NON-zero ctest as the pass. +# catch, and reports a NON-zero ctest THAT NAMES THE DEMOTED MEMBER'S SETTER as the pass. A red +# for any other reason is reported as inconclusive (rc 1), not as a pass: the script knows the +# difference, so its exit status has to carry it. # # THE BREAK. ColorMasks is moved out of pipeline chunk P1 into a dynamic chunk of its own, by # inserting two boundaries - at ColorMasks and at FramebufferSrgbEnabled - into the boundary @@ -39,9 +41,12 @@ # revert - WITHOUT requiring the test to exist. This is the mechanism # check, not the control; it never reports the control as passed. # -# Exit codes: 0 the control tripped (or, under --verify-patch-only, the patch compiled); -# 1 the control did NOT trip - the test stayed green on a demoted member, which is -# the finding, not an error in this script; +# Exit codes: 0 the control tripped AND named SetColorMask (or, under --verify-patch-only, the +# patch compiled); +# 1 the control did not answer: either the test stayed green on a demoted member, or +# it went red without ever naming SetColorMask, so the red cannot be attributed to +# the demotion. Both are findings about the test, not errors in this script - and +# both leave the tree restored and rebuilt; # 2 the script could not run the control at all (bad arguments, missing test, # a build that was already broken, a failed restore). set -u -o pipefail @@ -216,11 +221,19 @@ if ctest --test-dir "$BUILD_DIR" -R "$TEST_NAME" --no-tests=error --output-on-fa exit 1 fi +# A red is not yet a pass. The control's claim is "demoting ColorMasks makes the setter-consistency +# test fail AND the failure names glColorMask's setter"; a SetterConsistency that had started +# failing for an unrelated reason would satisfy the first half and none of the second, and the +# caller (the integrator's D.3 reads this script's rc) would record it as "the negative control +# passed". So the answer is remembered here and decided at the end - AFTER the restore, because +# leaving a build directory holding the broken table is worse than any exit status. +TRIPPED_FOR_THE_RIGHT_REASON=1 if grep -q 'SetColorMask' "$LOG_DIR/ctest-after.log"; then say "negative control tripped, naming SetColorMask" else + TRIPPED_FOR_THE_RIGHT_REASON=0 say "negative control tripped, but its output does not name SetColorMask - the test failed for" - say "some other reason, so read $LOG_DIR/ctest-after.log before trusting it" + say "some other reason, so this is NOT a pass. Restoring first, then reporting it." grep -m20 -E 'Failure|error|Expected|Actual' "$LOG_DIR/ctest-after.log" >&2 fi @@ -238,5 +251,13 @@ if ! ctest --test-dir "$BUILD_DIR" -R "$TEST_NAME" --no-tests=error \ exit 2 fi +if [ "$TRIPPED_FOR_THE_RIGHT_REASON" = 0 ]; then + cp -f "$LOG_DIR/ctest-after.log" ./g7-negative-control-wrong-reason.log + say "INCONCLUSIVE: $TEST_NAME went red under the demotion but its output never names" + say "SetColorMask, so the red cannot be attributed to the demoted member. The tree is restored" + say "and green again; the failing output is kept at ./g7-negative-control-wrong-reason.log." + exit 1 +fi + say "negative control tripped and the tree is green again" exit 0 From b1c37699b1547e3f9a58bf82cdeea56e78c098f9 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 10:20:44 -0400 Subject: [PATCH 134/529] [Fix] (Bench, Trace, CI): let only the profile answer for itself, name an unreadable profile, and describe the CI step by the mechanism the tree has - The verified-profile guard read the process environment as well as the profile: the test ran after the source, so PROFILE_VERIFIED=1 exported in an operator's shell re-opened the fail-open hole for every profile that says nothing. Both scripts now set PROFILE_VERIFIED=0 immediately before sourcing, so the file is the only thing that can answer. - A --device path that cannot be sourced was diagnosed as an unverified profile, because both scripts cd to their own directory first and neither checked readability. The path is now also tried relative to the directory the script was invoked from (which is what a repo-root-relative --device means), and an unreadable one is reported as unreadable, naming both places tried. - Verified: exported PROFILE_VERIFIED=1 + an unverified profile -> rc 2; exported 1 + a profile with no key -> rc 2; a repo-root-relative path -> resolved, then refused for its own reason; a missing file -> "cannot read the device profile"; odinlite.env -> past the guard; --allow-unverified-profile -> the three warnings, then proceeds. - test.yml's new step described a mechanism the tree does not have. G6's and G10's entries are registered in the pull build too - they must be, for G2's name-for-name comparison - and skip inside their bodies. The step's value is unchanged and its comment now says the true thing: the `test` job runs those names as a column of skips, and this is the first CI job that unpacks a build which compiled the assertions. - trace_benchmark takes the wall baseline before the CPU baseline, the order OnFrameBoundary already reads them in, so frame 0 stops reporting a CPU delta biased upward against its own wall delta; and it includes rather than for the POSIX names it uses. --- .github/workflows/test.yml | 15 ++++++++----- .../app/src/trace/cpp/trace_benchmark.cpp | 15 +++++++++++-- tools/device_bench/README.md | 4 ++++ tools/device_bench/bench.sh | 22 ++++++++++++++++++- tools/device_bench/session.sh | 18 ++++++++++++++- 5 files changed, 64 insertions(+), 10 deletions(-) mode change 100755 => 100644 tools/device_bench/bench.sh mode change 100755 => 100644 tools/device_bench/session.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index efc8aaf36..709615686 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -551,12 +551,15 @@ jobs: # The push-only unit tests, on the verify runtime. # - # WHY HERE AND NOT IN `test`. The `test` job builds the PULL library, and G6's chunk-table - # walk and G10's residual assertions live in MG_Test/Pipe, compiled only under - # MOBILEGL_PIPE_PUSH (MGPipeRenderStateSpans.cpp and PipeApply.cpp are appended to - # SOURCE_FILES inside the `if (MOBILEGL_PIPE_PUSH)` block, which is exactly how the pull - # build stays symbol-identical). So before P2 those tests ran in no CI job at all: they - # existed, they were green locally, and CI never executed one of them. + # WHY HERE AND NOT IN `test`. The entries themselves are registered in EVERY build - they + # have to be, or `ctest -N` would stop matching name-for-name between the pull and the push + # build (gate G2). What is push-only is what they assert about: MGPipeRenderStateSpans.cpp + # and PipeApply.cpp are appended to SOURCE_FILES inside the `if (MOBILEGL_PIPE_PUSH)` block, + # which is exactly how the pull build stays symbol-identical, so in a pull build each case + # opens with `#if !MOBILEGL_PIPE_PUSH GTEST_SKIP() << "push not compiled in"`. The `test` + # job therefore runs G6's chunk-table walk and G10's residual assertions as a column of + # skips: CI executes the NAMES and never one of the assertions. This job unpacks a build + # that compiled them, so it is the first place in CI where they actually run. # # This artifact already carries them - the packaging step above tars # ${BUILD_DIR}/MobileGL/MG_Test whole - so the whole cost is the run, which is ~14 s for diff --git a/android-plugin/app/src/trace/cpp/trace_benchmark.cpp b/android-plugin/app/src/trace/cpp/trace_benchmark.cpp index fb67d0aa2..973496a15 100644 --- a/android-plugin/app/src/trace/cpp/trace_benchmark.cpp +++ b/android-plugin/app/src/trace/cpp/trace_benchmark.cpp @@ -8,8 +8,14 @@ // CLOCK_THREAD_CPUTIME_ID is POSIX and present on Linux and on every Android API this replays // on; the guard exists so the desktop CLI still builds where it is not, and so that "no CPU // series" is a compile-time fact rather than a silently-zero column. +// +// , not : clock_gettime, CLOCK_THREAD_CPUTIME_ID and struct timespec are POSIX +// names, and only is required to put them at global scope - guarantees the C++ +// subset in namespace std and leaves the rest to the implementation. glibc and bionic both happen +// to provide them either way; this file is built for both by two different toolchains, so it asks +// for the header that actually promises what it uses. #if defined(__unix__) || defined(__linux__) || defined(__ANDROID__) || defined(__APPLE__) -#include +#include #define MOBILEGL_TRACE_HAVE_THREAD_CPU_CLOCK 1 #else #define MOBILEGL_TRACE_HAVE_THREAD_CPU_CLOCK 0 @@ -78,12 +84,17 @@ void Begin(bool finishEachFrame) { gFrameMs.reserve(kFrameReserve); gFrameCpuMs.clear(); gFrameCpuMs.reserve(kFrameReserve); - gLastBoundaryCpuMs = ThreadCpuMs(); gFinishEachFrame = finishEachFrame; gResolvedGlFinish = false; gGlFinish = nullptr; + // Wall baseline FIRST, CPU baseline second - the same order OnFrameBoundary reads them in, + // and for the same reason. Frame 0's CPU interval then sits strictly inside its wall interval, + // so whatever this function costs between the two readings lands in the wall number where it + // can be seen, instead of inflating the CPU number where it cannot. Taken the other way round + // (as this was), frame 0 alone reported a CPU delta biased upward against its own wall delta. gStart = Clock::now(); gLastBoundary = gStart; + gLastBoundaryCpuMs = ThreadCpuMs(); gEnabled = true; } diff --git a/tools/device_bench/README.md b/tools/device_bench/README.md index a83fcaa75..dfa92e3a0 100644 --- a/tools/device_bench/README.md +++ b/tools/device_bench/README.md @@ -37,6 +37,10 @@ frequency-pin integrity. `--allow-unverified-profile` is passed, which labels the run unpinned in the warning. **A profile that omits the key entirely is refused the same way** - the guard defaults to unverified, so copying a verified profile and editing the serial cannot inherit its verdict. + Only the file can answer: both scripts reset `PROFILE_VERIFIED=0` immediately before sourcing + it, so `PROFILE_VERIFIED=1` exported in your shell does not re-open the hole. Nor does a + profile path that cannot be read get mistaken for an unverified one - it is reported as + unreadable, and a path relative to the directory you ran the script from is resolved. (`profile.sh` pins nothing - it records a simpleperf profile - so it carries no such guard.) That refusal exists because the pin path is silent when it is wrong: the harness writes diff --git a/tools/device_bench/bench.sh b/tools/device_bench/bench.sh old mode 100755 new mode 100644 index 6410ce3f6..09bfbb025 --- a/tools/device_bench/bench.sh +++ b/tools/device_bench/bench.sh @@ -22,6 +22,11 @@ # Screenshots (pre/post measurement) land in results/-