From 343ca2fa68f807a2120948006dc12d052aa96059 Mon Sep 17 00:00:00 2001 From: tei Date: Mon, 12 Oct 2026 23:27:06 +0900 Subject: [PATCH] fix: secure Symbol Retained drawable lifetime and record investigations --- ...R0_Placement跨段归因只读调查_Codex任务_v1.md | 161 ++++++ ...果等价与独立刷新可行性只读调查_Codex任务_v1.md | 41 ++ .../STEP-119_SymbolRetained生命周期修复_v1.md | 41 ++ ...mbolRetained生命周期修复_Codex完成报告_v1.md | 128 +++++ src/mln/gfx/symbol_drawable_data.hpp | 17 +- .../renderer/layers/render_symbol_layer.cpp | 204 ++++++-- .../renderer/layers/render_symbol_layer.hpp | 15 + .../renderer/retained_underlay_cache.test.cpp | 472 ++++++++++++++++++ 粒子开发记录.md | 9 + 9 files changed, 1043 insertions(+), 45 deletions(-) create mode 100644 docs/40_任务/STEP-110_R0_Placement跨段归因只读调查_Codex任务_v1.md create mode 100644 docs/40_任务/STEP-111_R段Placement结果等价与独立刷新可行性只读调查_Codex任务_v1.md create mode 100644 docs/50_Step记录/STEP-119_SymbolRetained生命周期修复_v1.md create mode 100644 docs/报告/STEP-119_SymbolRetained生命周期修复_Codex完成报告_v1.md diff --git a/docs/40_任务/STEP-110_R0_Placement跨段归因只读调查_Codex任务_v1.md b/docs/40_任务/STEP-110_R0_Placement跨段归因只读调查_Codex任务_v1.md new file mode 100644 index 000000000..0e86db924 --- /dev/null +++ b/docs/40_任务/STEP-110_R0_Placement跨段归因只读调查_Codex任务_v1.md @@ -0,0 +1,161 @@ +# STEP-110 R0 Placement 跨段归因与缓存安全边界只读调查 — Codex 任务 v1 + +## 一、任务性质与核心问题 + +**性质:Particle Native / Shared Core 专项,只读调查;不实现、不修复、不编译、不上机。** + +用户要求粒子项目独立回答: + +> Native 能否区分“R0 自己的 Placement 变化”和“其他 RenderSegment 引起的 Placement 变化”?如果能区分,是否足以在**不破坏 Symbol 碰撞、文字/图标位置及图层顺序**的条件下让未变化的 R0 复用缓存? + +必须把以下不同概念分开审查: +1. **事件来源**:哪个 source/layer/segment 引发了 placement/repaint/revision; +2. **实际影响范围**:全局碰撞计算是否使 R0 的 Symbol 位置、可见性、fade 或绘制结果改变; +3. **缓存安全判据**:能否证明 R0 内容/Placement 仍与已缓存画面等价; +4. **当前实现**:上述信息目前是否已有、是否按 Segment 使用、还是仅采用全局保守 Gate。 + +禁止预设“全局 Placement 变化一定是 Bug”,也禁止以“R0 自己没有提交数据”直接推导“R0 视觉必然没有变化”。 + +## 二、仓库、版本与事实优先级 + +- 项目:`/Users/tei/dev/MapLibreParticleLab`,正式分支 `particle/main`、远端 `origin/particle/main`。 +- 创建任务时只读确认:`particle/main` HEAD `2df504cdc0403bdad85d3f31f16fd54a8744abac`、干净工作树。执行时必须**重新读取 Git 实际状态**,不得把此 HEAD 当作永恒基线。 +- NavSea 产品实机所用 APK 内 `assets/component-manifest.json` 记录:RN / Android Native AAR `13.6.1-particle.23`。V23 正式来源对应 `refs/tags/maplibre-particle/rn/13.6.1-particle.23`、GSRL Git commit `87f3d1c59a52581fb8339f0ea7d106b9f4b94fef`。 +- **必须分别标明 V23 发布源码与当前 `particle/main` 的事实**。如当前 main 有未发行后续改动,不能以 main 代替 V23,不能以 V23 APK 推断未发行修复已经生效。 +- 跨项目事实遵循 `D:\React\navsea-particle-integration\instructions\NAVSEA_PROJECT_INSTRUCTIONS.md` 及最新 COORD / RFC / ADR / Contract / Status;当前既有外部契约 C-001。此任务不新增/更改公共 API 或契约。 +- 粒子项目根 `AGENTS.md`、`PARTICLE_BASELINE.md`、`docs/00_项目规范/`、`docs/00_项目总则/README.md` 与四份有效总则必须先读,按当前项目事实处理过时的历史环境/发布坐标。 +- 必读既有证据:STEP-088、089、090、092、094、099、100、102、106、108、109;重点对比 STEP-090 早期缓存/快路成功条件、STEP-102 placement-free 分段隔离范围、STEP-106 T1/T2 Symbol 与 Annotation 边界的真实验收。 + +## 三、来自 NavSea 的问题证据(仅作为输入,不由本任务修改 NavSea) + +FZG1-2 / Android 13 / V23 当前 APK,NavSea 采用**两段 R-C**: + +- R0:RETAINED,31 层,`background` 至 `place-labels-land-ja`,包含有 Placement 能力的 Symbol; +- C1:CONTINUOUS,82 层,`rn-weather-color` 至 `chartDangerScanCenterLineLayer`,包含天气/粒子和其他业务层。 + +固定同一风场、同一 map 生命周期,连续约 15.24 秒,`mapReactTag=864`、`generation=4`、`configRevision=1`、`topologyRevision=8`、113 层均不变: +- 风场期间进程 CPU 样本均值约 98.6% / 99.1%,关闭天气后约 4%~8%; +- `fastPathHit Δ0`、`fastPathMiss Δ452`、`fullCpuFrame Δ453`; +- R0 `build Δ0`、`reuse Δ0`、`invalidation Δ453`、`fallbackCount Δ453`、texture/composite create `Δ0`; +- 两端 `valid=true`、`fallback=false`、`sourceReady=true`、`sourceStable=true`、`placementChanged=true`、`placementGatePassed=false`、`cache.supported=false`、`cacheState=fallback`、`lastInvalidationReason=static-placement`; +- `mainBufferExecutionMode=mixed`、`globalLiveFallbackReason=none`、`fastPathFallbackReason=cache-invalid`。 + +**这些是症状,不代表已证明 C1 导致 R0 Placement 改变。** 此外,历史较早的 V23 短时快照曾观察到 `placementChanged=false` 但 R0 记录仍为 `static-placement`,需要区分“当前门槛值”和“上次失效原因/诊断时序”,避免误读。 + +Mac 粒子团队如无法访问 Windows 原始快照,可依据上述冻结事实做 Native 只读审计;缺失证据须列为“需要后续验证”,不得自行编造 NavSea 图层或运行结果。 + +## 四、必须回答的调查问题 + +### Q1:Placement 的产生与传播 + +从真实 V23 Native 代码绘制完整的因果链: +- 触发 RenderTree / Placement 计算的入口与时机是什么? +- `placementChanged`、`symbolFadeChange`、`placementTransitionActive`、`crossSourceCollisions` 分别表示**当前帧/上帧/全局/局部**的什么事实?何时清零或稳定? +- Source/Layer 的 layout、tile、visibility、camera、animation 等变化,分别可能影响哪些 Placement 状态? +- R0 以外的 C1 Symbol/Annotation/业务层变化,**在哪些条件下**可能影响 R0 的符号碰撞与最终视觉? +- 如果 RenderTree 每次都重新算 Placement 但画面没有变化,如何区分“执行过计算”和“R0 最终结果发生变化”? + +### Q2:现有 R0 Gate 的作用域 + +调查 V23 实际调用链及数据结构,至少解释: +- R0 结构性 `placementCapable` 如何判定,与动态 `needsPlacement()` 有何区别; +- R0 `placementGatePassed` 如何使用全局 `placementChanged`、fade、transition; +- R0 `cache.supported`、`static-placement`、`actualExecution=live` 的完整因果关系; +- R0 的 source-local mutation、segment fingerprint、retained revision 与 placement gate 是否真正各自独立; +- STEP-102 对 **placement-free** R 的隔离已解决什么、**placement-bearing** R 仍未解决什么; +- `lastInvalidationReason` 与当前失败条件是否可能属于不同采样/缓存生命周期。 + +### Q3:能否证明“R0 不受 C1 变化影响” + +针对以下反事实场景逐项作出有证据的判断: +1. R0 含静态地名,C1 只有普通非 Symbol 动画/颜色/粒子更新; +2. R0 含静态地名,C1 的 Symbol 内容变化,但不同 Source 且跨源碰撞关闭; +3. R0 含静态地名,C1 的 Symbol 内容变化且跨源碰撞开启; +4. R0 Symbol 自身 layout/tile/visibility/zoom 发生变化; +5. Camera/pan/zoom/rotation/pitch 变化; +6. Symbol Fade/Placement Transition 活跃或刚结束; +7. C1 有内部自动插入的 Annotation Symbol(参照 STEP-106)。 + +每行必须分别给出: +- “谁触发 Placement”能否识别; +- “R0 最终 Placement/可见性是否改变”能否独立证明; +- **允许 R0 Reuse 的必要安全条件**; +- 当前 V23 是否具备这种判定能力及证据来源; +- 无法证明时为什么必须继续 fail-closed。 + +严禁把“不同 Segment”简单等同于“碰撞和 Placement 相互独立”。 + +### Q4:对 Fast Path 的影响(仅限前置依赖) + +解释“R0 缓存有效”是否为 Fast Path 的必要条件但**非充分条件**。只读标出 R0 Gate 与单 C / unsupported continuous layer / revision / pending changes 等后续 Gate 的先后关系。不要扩大成 C1 全图层性能调查,不实施多 C 优化。 + +## 五、只读检查范围与方法 + +允许读取: +- 当前 Particle Core 的 Renderer、RenderOrchestrator、PlacementController、RenderTree、Symbol/Collision、Retained Cache、RenderSegment Plan、Diagnostics 与 Fast Path 相关源码; +- V23 tag 与当前 `particle/main` 的只读 `git show`、`git diff`、`git log`; +- STEP-090/102/106 等历史设计、报告、设备验证结果; +- 现有 C++ 定向测试源码与 Validation 场景源码(只读,不执行、不修改)。 + +建议从当前实际路径 `src/mln/renderer/` 与相关 Shared Core 源码定位,**不得沿用旧 STEP 中过时的路径假设**。 + +每一条重要结论必须给出:**文件路径 + 类/函数或结构名称 + 适当行范围或 commit + 相关调用顺序**。核心论断不得只引用历史文档或本任务描述。 + +允许纯只读 Git/文本搜索,不要求也不允许额外建立实验分支、修改 Native/JS/Kotlin/测试代码、插桩、添加日志、运行 Jest、运行测试、Gradle、CMake/Ninja/Bazel、ADB 或重新安装 App。不得改 NavSea、Integration 或正式 release/tag。 + +## 六、最终必须提供的结论矩阵 + +**每个问题必须选择明确判定**,而不是只罗列建议: + +| 维度 | 可选结论 | +|---|---| +| 能否定位 Placement 事件来源 | 现有数据可区分 / 现有数据不足 / 条件性可区分 | +| 能否证明 R0 的实际视觉结果未变化 | 已能证明 / 需扩展内部安全证据 / 不能按段安全证明 | +| 现有 Gate 是什么性质 | 正确的设计保守限制 / 明确可证的过度阻塞 Bug / 混合 / 证据不足 | +| 缓存失效的主要责任边界 | Native Placement 数据模型 / Native Gate 判定 / 当前跨段碰撞语义 / Host Style 组合 / 多因素 / 未确定 | +| 可选后续方向 | 无需代码调整 / Native 内部诊断补强 / Native 内部安全判据设计 / 宿主层序方案需协调 / 需真机对照实验 | + +如果判断是 Bug,必须指出**违背了哪项既有语义/可复现的状态不变量**;如果判断是安全限制,必须指出**缺少何种证明才不能放宽**。如有多个阻塞项,按必要性和因果顺序排序,避免“修第一个之后才继续猜下一个”。 + +## 七、报告与交接产物 + +只允许新建/更新以下本 Step 文档(不改现有源码): + +1. `docs/20_调查/R0_Placement跨段归因与缓存安全边界调查_v1.md`(主报告); +2. `docs/50_Step记录/STEP-110_R0_Placement跨段归因只读调查_v1.md`(状态及完成项); +3. `docs/报告/STEP-110_R0_Placement跨段归因只读调查_Codex完成报告_v1.md`(执行报告); +4. `粒子开发记录.md`(简短追加式记录)。 + +调查主报告必备章节: +- V23 tag / 当前 main 基线对照,说明是否有相关行为变化; +- Q1~Q4 的逐条答案及代码证据; +- **数据流/状态流**:Placement 事件 → 全局/局部状态 → R0 安全判定 → Cache Build/Reuse/Fallback → Fast Path; +- 七类反事实场景矩阵,明确跨源碰撞、Fade、Annotation 的影响; +- 已确定事实、假设、证据不足之处三栏; +- 结论矩阵、根因优先级、最小下一步验证建议; +- 若需调整公共 Contract / API,则仅提出 Integration 阻塞,不自行实施。 + +完成报告必须逐项按“**已完成 / 未完成 / 无法验证**”回答任务项并说明原因;记录实际检查范围、当前 branch/HEAD、Git 提交及推送信息。不得用“源码已读”代替代码证据。 + +**状态判定**:只读因果审计证据完整才可标为 `调查 PASS(不等于修复 PASS)`;否则 `PARTIAL/BLOCKED` 并列出缺失项。严禁声称“Native 已修复”、“R0 已缓存”或“CPU 已下降”。 + +## 八、Git 收尾与执行界限 + +- 本任务**只负责调查**。Codex/粒子项目未经用户后续明确授权,不得实施任何缓存、Placement、Fast Path 逻辑修改。 +- 完成相关文档后,仅提交本 Step 新增报告、Step 记录及追加的开发记录;保留所有无关 dirty/untracked 文件,不得强制清理。 +- 将本任务相关文档 scoped commit 到正式主线 `particle/main` 并 push 到公开远端 `origin/particle/main`。禁止 force push、改写历史、推送 upstream 或增加无关文件。 +- 完成报告必须给出**实际 commit hash、commit message、push 结果、远端分支**;push 失败不得宣称完成,应明确报告阻塞。 +- 本 MD 是交给粒子项目执行的**待办合同**。写入任务书本身不代表调查已执行、已验证或已得出结论。 +- 本任务不包含 Jest,不涉及原生编译、测试执行或 Android 设备操作。 + +## 九、停止条件 + +遇到以下情形须停留在调查层,提交 PARTIAL/BLOCKED 事实,不得扩大实现: +1. V23 tag 与当前代码差异导致无法确认实际执行版本; +2. Placement 影响关系必须依赖运行时数据,静态源码无法证明; +3. 证明缓存安全需新增 per-segment collision/placement fingerprint 或改写 Core 算法; +4. 需要改变对外 Native/RN 公共接口或跨项目责任; +5. 需要 NavSea 配合修改 Style、增加调试或重新编译/测试; +6. 所需证据缺失或与 STEP-090/102/106 的历史结论相冲突。 + +出现以上情况,在报告中给出**最小下一步任务建议**,等待用户/Integration 决定;不得在本任务中直接实施。 diff --git a/docs/40_任务/STEP-111_R段Placement结果等价与独立刷新可行性只读调查_Codex任务_v1.md b/docs/40_任务/STEP-111_R段Placement结果等价与独立刷新可行性只读调查_Codex任务_v1.md new file mode 100644 index 000000000..866333eee --- /dev/null +++ b/docs/40_任务/STEP-111_R段Placement结果等价与独立刷新可行性只读调查_Codex任务_v1.md @@ -0,0 +1,41 @@ +# STEP-111 R 段 Placement 结果等价与独立刷新可行性只读调查 — Codex 任务 v1 + +## 一、用户目标 +用户希望 Native 对 R-C-R-C-R-C(简称 RCRCRC)中的每一个 RETAINED R 段单独决定是否更新。若该段本身最终视觉输出未变化,不应仅因另一段 Symbol/Placement/Fade 全局状态更新而被动重建;确实受跨段碰撞影响的 R 段必须正确更新,不能破坏文字、图标或层序。此任务是下一步架构/可实现性调查,不是授权实施优化。 + +**核心区别**:A. 哪个 Layer/Source/Segment 触发重新计算;B. 全图 Placement 后每一个 R 段的最终 Symbol 视觉结果是否改变;C. Retained R 段缓存能否安全复用;D. 是否可进一步省略 R 段 prepare/placement 计算。A≠B、B≠C、C≠D。不要把各 R 分段直接当作 Collision Domain,也不要承诺每段无需参与全图 Placement 计算。 + +## 二、正式边界与必读 +- Mac /Users/tei/dev/MapLibreParticleLab,particle/main,origin/particle/main;执行时重新记录 HEAD、branch、dirty、远端基线;保留未跟踪 STEP-110 任务书。 +- 先读仓库 AGENTS.md、PARTICLE_BASELINE.md、docs/00_项目规范/、docs/00_项目总则/README.md 和全部现行总则;读 STEP-110 主报告和 Codex 完成报告,以及 STEP-102、106、109、Fast Path 相关历史证据;以当前源码事实为准。 +- 先检查 /Users/tei/dev/navsea-particle-integration 的 main 与 origin/main 同步状态,读取其 README、WORKFLOW、TEAM_MODEL、contracts、rfc、adr、status、compatibility;已有跨项目结论优先。本 Step 只讨论 Shared Core Native,不直接修改 Integration/NavSea/Android/iOS。 +- STEP-110 已证明:PlacementController 仍是整图语义;R 的 placementCapable=true 时 Gate 消费 global placementChanged、symbolFadeChange、placementTransitionActive;跨 Source Collision 可能影响 R0,source≠segment。STEP-102/106 只验收过 placement-free R 的安全复用。本 Step 不能简单重复这些事实,重点给出**新增的判等/判无变化能力**具体可行性。 + +## 三、只读调查工作项 +1. **从源码定位可实现性**:追踪 CrossTileSymbolIndex、Placement::placeLayers/commit、CollisionIndex/CollisionGroups、symbol buckets、placed icon/text、opacity/fade、RenderTree、RenderLayer、RenderSegment/Retained Cache 的真实控制与数据所有权。指出计算结束何处可以读取每一个 R 的最终 Placement 输出,是否已有稳定 ID、可见性/位置/opacity;写明路径、函数、行号和生命周期。辨别层间/Source 间/段间影响。 +2. **定义每段结果等价契约候选**:提出具体 per-R PlacementResultKey/Signature/Revision 候选字段(Symbol 实例稳定 ID、tile/source/bucket generation、placement result、collision visible/hidden、text/icon anchor/offset、paint opacity/fade/transition、zoom/camera、glyph/image atlas、style/layer order、render target boundary 等)。逐项解释哪些是 Placement 专属,哪些已有其他 cache gate 覆盖,哪些必须动态比较,哪些不需要加入,避免对每个 Symbol 每帧做过度 O(N) 或漏比较;比较发生于何时、如何处理首次帧、transition、reset、missing data。 +3. **RCRCRC 示例与判定表**:至少 R0/C0/R1/C1/R2/C2,其中 R0/R1/R2 都有 Symbol。分别分析: + - C1 仅粒子/颜色动画,R 均无可见变化; + - C1 Symbol 变动且只影响 R2; + - C1 Symbol 变动,碰撞也影响 R0/R1; + - R1 自己改变但 R0/R2 不变; + - 同一 Source 跨段、不同 Source 且 crossSourceCollisions=true/false; + - Annotation Symbol 自动插入,layer 顺序、camera/zoom/pitch、tile/glyph fade/opacity transition; + - 不能证明者 fail-closed。 + 结论表需区分“依然执行全局 Placement”、“该段仅避免 cache rebuild/composite 是否可复用”、“能否避免 render/prepare”、“Fast Path 仍被哪些其它 Gate 阻断”。 +4. **方案对比与难度**:对 A 只加 tracing/per-R 指标(不改变判定)、B 新增 per-R result fingerprint 但 shadow compare(仅记录真假)、C 利用已证明等价替换 global-only Symbol Gate 允许单段 Reuse、D 深改 Placement/Collision 成分段增量计算,逐一评估前提、复杂度(1-5)、改动范围、CPU/GPU/内存开销、风险、能否只改 Shared Core、对 Android OpenGL/iOS Metal 的影响。不能因为理论上可 hash 就声称 hash 一定安全。 +5. **执行与验收计划**:给出最小分阶段实现建议和真实可验证指标,包括 per-R signature stable/change、false invalidation、false reuse(严格 0)、build/reuse/fallback、视觉 A/B、Fade 长尾、CPU/frame、same frame draw ordering;指出先做诊断或原型是否需要新 Native 内部接口,哪些行为必须同时覆盖 Android/iOS。分别评估改善 retained cache 命中与改善整帧 CPU/Fast Path 的边界。 +6. **明确推荐结论**:回答每个 R 段可否“自身未变就不刷新”、可做到的最小粒度、不能保证的情形、最佳第一阶段(原型)与中长期方案,给出具体源码位置和工程量区间;区分源码已证实 vs 推断 vs 必须运行时验证。 + +## 四、禁止项 +调查只允许读取 Native/历史 Git/本机历史实验文件以及新增本 Step 文档。禁止修改任何 C++/JS/Kotlin/Swift/编译脚本/测试代码;禁止执行编译、链接、测试、Jest、Gradle、ADB、Bazel、CMake、设备操作;禁止更改 NavSea、Integration、公用 Contract/API、GSRL、release/Source Lock、发行 Tag;禁止放宽当前 fail-closed Gate。禁止重置/清除现有 dirty、禁止 force push。除非明确用户授权,不执行后续实施。 + +## 五、必做输出(实际生成) +1. docs/20_调查/STEP-111_R段Placement结果等价与独立刷新可行性调查_v1.md — 主报告:代码路径/行号、RCRCRC 场景矩阵、Signature 草案字段逐项依据与生命周期、可行方案 A-D 对比、复杂度与工程量估计、风险、可执行的后续 Step 拆分、源码已证/待验证标识。 +2. docs/50_Step记录/STEP-111_R段Placement结果等价调查_v1.md — 简短中文 Step 记录,状态 PASS(调查完整)/PARTIAL/BLOCKED,记录未验证项与下一步。 +3. docs/报告/STEP-111_R段Placement结果等价调查_Codex完成报告_v1.md — 完成报告:实际模型与 reasoning effort(仅凭运行会话证据)、读取资料、逐任务结果、调查起/终 HEAD、branch、dirty、文件修改、未运行构建和测试、检查命令、限制、Git/Push 状态如实记录。 +4. 粒子开发记录.md — 本阶段简短追加(不要重写历史)。 +Codex 仅负责内容调查和上述报告落盘,Git scoped commit/push/远端验证由 GPT 指挥 RDC 独立负责,完成报告不得预报未执行 Git 结果。原有未跟踪 STEP-110 任务文件不纳入提交。本任务书自身保持未跟踪,避免误提交。 + +## 六、验收与停止 +只在调查结果有对应源码位置/函数/调用顺序,明确安全条件、未证实风险、工程阶段与复杂度对比时,记“调查 PASS”;如生产实际视觉等价无法仅通过源码证明,可记“调查 PASS / 运行验证未执行”,不得声称优化已通过。必要的实机状态/插桩超出只读范围时明确给出下一阶段最小需求,不得扩大当前范围。不得把历史 STEP-110 的 PARTIAL 当成本次已经实现结果。任务退出前向 GPT 汇报生成文件、最后状态和未完事项。 diff --git a/docs/50_Step记录/STEP-119_SymbolRetained生命周期修复_v1.md b/docs/50_Step记录/STEP-119_SymbolRetained生命周期修复_v1.md new file mode 100644 index 000000000..20aee9c70 --- /dev/null +++ b/docs/50_Step记录/STEP-119_SymbolRetained生命周期修复_v1.md @@ -0,0 +1,41 @@ +# STEP-119 SymbolRetained 生命周期修复 v1 + +日期:2026-10-12(JST)。Step 状态:进行中。代码交付:**IMPLEMENTED / WAITING_RDC_VALIDATION**。这是此前中断任务的续作;不关闭整体 STEP-119,不构成编译、GTest、GPU 或设备验收 PASS。 + +## 目标与基线 + +按照 `/tmp/fzg1_astra_guidance_20261012.md` 修复内部 Symbol Drawable 的 RenderTile 同源绑定、旧 Drawable 更新及 Bucket/Atlas 更换生命周期;保留原缓存、证书和 Full → Build → 成功 Full Reuse → Fast 顺序。 + +- 机器:Mac.lan / Darwin 24.6.0 / x86_64;仓库:`/Users/tei/dev/MapLibreParticleLab`。 +- 开工及交付分支:`particle/main`;HEAD:`badb515d590fbdf221751b6677de3da0a5650d7e`。未切换分支/worktree,未提交或操作远端。 +- 接续已有四个源码修改;起点另有受保护 STEP-110/111 两份未跟踪任务书。 +- 用户本轮明确指定模型 `GPT-6.1 Sol High`(`gpt-6.1-sol / high`)。当前工具未提供执行器模型元数据,且本机当日 sessions 目录不存在,无法独立核验实际运行参数;不宣称已从日志核实,没有主动切换模型或启动子代理。 +- 当前发行锁为 Android/RN `13.6.1-particle.25`、iOS `6.29.0-particle.25`,构建冻结源 `f32f6f45fd359007e8b3c77911b66d9e7fc93848`;本修复不改 Source Lock/发行物,不声称已进入 v25 制品。 + +## 必做项 + +- [x] 读取 Astra 指导、AGENTS、PARTICLE_BASELINE、规范、四份当前总则、当前 STEP-119 任务/实现/测试资料,核对现有四文件 diff。 +- [x] 同次 source prepare 获取 raw owner 和 Y 排序引用列表;仅比较地址完成同源校验,再复制当前 ID/atlas。 +- [x] 同 Bucket/Atlas 的完整 tile/pass 组先检查,再全部重新绑定当前 owner/RenderTile;不逐帧重建 Drawable/tweaker。 +- [x] Bucket/Atlas/组内成员失配或缺 atlas 时移除整组,走原创建路径;Text/IconRGBA/IconSDF/Halo 使用同一 atlas 与实际 tweaker 配对。 +- [x] Retained 使用内部强引用 atlas 和当前地址映射,不沿旧 Drawable 裸指针读取 atlas/Tile;缺证立即 complete=false。 +- [x] 保活 RenderTile → renderData → LayoutResult 的证据资源,不把每次 prepare 的 owner 地址写入比较字节。 +- [x] 编写并静态核对 13 项 SymbolRetainedLifetime GTest 源码,修正 atlas-only TileRenderData 查询默认 assert 的测试替身缺口。 +- [x] 中文 Step、完成报告及开发记录末尾追加;四源码路径 scoped diff 空白检查 exit0。 +- [ ] RDC 实际编译/链接并发现、运行生命周期与 STEP-119 定向 GTest。 +- [ ] Android OpenGL、Metal 实际路径验证与 Vulkan 编译兼容性证据。 +- [ ] 新源码锁/APK/SO 身份闭环、FZ-G1 动态生命周期、原24Gate、Full/Fast 视觉及释放验收。 + +## 修改范围与当前结果 + +生产修改限 `src/mln/gfx/symbol_drawable_data.hpp`、`src/mln/renderer/layers/render_symbol_layer.cpp`、`src/mln/renderer/layers/render_symbol_layer.hpp`;测试限 `test/renderer/retained_underlay_cache.test.cpp`。沿用此前修改,续作只补未知 Symbol 类型拒绝、测试替身空查询覆盖、Text/Icon/SDF/Halo 连续 prepare 稳定性与证书资源释放检查。 + +未改 RN/JNI 公共 API、原24Gate/证书判据、Placement、缓存开关、发行物、Integration、旧报告或两份保护任务书。未运行 CMake、编译/链接/语法编译、GTest、Gradle/APK、ADB/安装、Jest;没有新制品或设备数据。 + +## 阻塞与下一步 + +实现已落盘,可编译性与运行正确性仍待 RDC。现 verifier 会拒绝 tracked dirty,后续 Git 来源锁须在独立审计和另行授权后处理;本轮禁止 Git 收尾,不能绕过 verifier 或借旧 APK。历史 Metal/libuv 缺口未重新验证,不安装升级依赖。 + +RenderTile 容器不拥有底层 `Tile&`;强引用 atlas/texture 也不能证明原地像素或槽位不变,继续依赖既有 content epoch 和真实绑定复核。原始寄存器 tombstone 缺失,不能断言 `0x8cdc7c` 唯一根因为本缺陷;保留原 SO Build ID `485459ba44f806ef0a0c08633c717bfd5d82ad8f` 与匹配符号交 RDC。 + +准确命令、测试覆盖限制、停止门槛和交接证据见 [本轮完成报告](../报告/STEP-119_SymbolRetained生命周期修复_Codex完成报告_v1.md)。Codex 到此交接,RDC 独立审计后逐条验证;失败保留首错与现场,不放宽条件。 diff --git a/docs/报告/STEP-119_SymbolRetained生命周期修复_Codex完成报告_v1.md b/docs/报告/STEP-119_SymbolRetained生命周期修复_Codex完成报告_v1.md new file mode 100644 index 000000000..20c838021 --- /dev/null +++ b/docs/报告/STEP-119_SymbolRetained生命周期修复_Codex完成报告_v1.md @@ -0,0 +1,128 @@ +# STEP-119 SymbolRetained 生命周期修复 Codex 完成报告 v1 + +日期:2026-10-12(JST)。状态:**IMPLEMENTED / WAITING_RDC_VALIDATION**。接续此前因用量限制中断的同一任务,四个既有修改经静态复审及明确缺口修正后交付。没有运行编译或测试,整体 STEP-119 仍未验收,不报告整体 PASS。 + +## 1. 模型、环境与 Git 基线 + +- 用户本轮明确指定 `GPT-6.1 Sol High`,即 `gpt-6.1-sol / high`。当前工具没有执行器模型/推理参数元数据;轻量查找 `/Users/tei/.codex/sessions/2026/10/12` 返回目录不存在,实际运行参数无法独立核验。记录用户指定值,不把它写成已从执行器日志核实。未主动升级模型、启动第二实例或子代理。 +- `Mac.lan`,Darwin 24.6.0,x86_64;目录 `/Users/tei/dev/MapLibreParticleLab`。 +- 开工/交付 branch=`particle/main`,HEAD=`badb515d590fbdf221751b6677de3da0a5650d7e`。基线为四个已有 tracked 修改及 STEP-110/111 两份 untracked 任务书;不从头复写,不改分支/worktree/HEAD。 +- `git submodule status` 已只读检查,没有变更前缀;无子模块修改。未 commit/push/tag/fetch/pull/rebase/reset/clean,未回读远端 HEAD(本轮禁止远端操作)。 +- 只读当前 v25 Source Lock:Android/RN `13.6.1-particle.25`,iOS `6.29.0-particle.25`,冻结构建源 `f32f6f45fd359007e8b3c77911b66d9e7fc93848`。发行记录与制品未修改,本轮未生成制品,不能以现有 v25 包验证本工作区修复。 +- 首先读取 Astra 原文 `/tmp/fzg1_astra_guidance_20261012.md`,再读取仓库规则、当前资料和现有 diff。当前用户续作授权优先于旧任务中的历史基线/跨项目调查和 Git 收尾安排。 + +## 2. 实际修改文件及生命周期不变量 + +| 文件 | 实际变更与不变量 | +|---|---| +| `src/mln/gfx/symbol_drawable_data.hpp` | 内部数据保存强类型 atlas 与实际原生 atlas tweaker;创建时两者来自同一份 atlas,增加内部 Drawable 类型标记。它们不拥有 RenderTile 或底层 Tile。 | +| `src/mln/renderer/layers/render_symbol_layer.hpp` | 私有同源/绑定/整组复用/采集辅助方法及当前地址→ID/atlas 映射;仅保存当前代次,不建立历史容器列表。friend 测试入口不修改公共 API。 | +| `src/mln/renderer/layers/render_symbol_layer.cpp` | prepare 从同次 source 获取 raw owner 与原 Y 排序列表,先校验成员地址再读 ID/atlas,保留 fading tile。成功复用先检查 tile/pass 整组,再全部刷新当前 RenderTile/owner;Bucket、atlas、layer tweaker、数据/类型或成员失配整组移除重建,atlas 缺失移除旧绘制。Retained 从强引用 atlas 取证,旧指针只作当前地址映射查找,使用 Drawable 自己的 ID;类型未知/绑定不符/未上传立即拒绝。owner 只放入 resources,不序列化 owner 地址。 | +| `test/renderer/retained_underlay_cache.test.cpp` | 13 项 `SymbolRetainedLifetime` 源码调用生产 prepare/bind/rebind/retained 辅助路径与原生 atlas tweaker;真实类接口/签名和友元访问逐项静态核对。atlas-only TileRenderData 显式覆盖空 Bucket/LayerRenderData 查询,避免基类 assert;补组内类型失配、Text/IconRGBA/IconSDF/Halo 稳定身份、未知类型及证书释放检查。 | +| `docs/50_Step记录/STEP-119_SymbolRetained生命周期修复_v1.md`(新增) | 本阶段目标、基线、完成/待验清单及交接。 | +| `docs/报告/STEP-119_SymbolRetained生命周期修复_Codex完成报告_v1.md`(新增) | 本报告,记录事实、准确命令、风险和停止条件。 | +| `粒子开发记录.md` | 仅末尾追加本次实现与未验证事实,保留历史内容。 | + +两个新文档路径创建前已确认不存在,同主题无旧版本,选择 v1;使用独占创建,未覆盖旧报告。前三个生产文件及测试均沿用开工时修改;本续作生产补丁仅增加 Retained 对未知 SymbolType 的拒绝,测试补丁只处理上述明确缺口及覆盖不足。 + +### 所有权与稳定性 + +1. Drawable 只由通过同源校验的 owner/元素绑定。复用检查不从旧 RenderTile 读取 ID/Bucket/atlas;任何一个 Text/Icon/Halo 成员不兼容时整组移除,避免 `updateTile()` 的“任一成功”留下半新半旧组。 +2. 同 Bucket/atlas 保持 Drawable 和 tweaker 身份,仅刷新 owner/RenderTile 与既有动态属性;source 每次产生新容器不应自行造成每帧重建或证书字节变化。 +3. 当前容器拥有 RenderTile;RenderTile 独占 renderData;真实 GeometryTileRenderData 强持有 LayoutResult/atlas,LayoutResult 负责槽位释放。证据 resources 保活容器,但不调用 `result.retain(renderTilesOwner)`,因此 owner 地址不进入比较字节。证书 candidate/completed 替换或 invalidate 后旧证据可退出,未新增无界容器。 +4. Retained 先验证当前地址/保存的 ID、内部数据类型、atlas 来源、实际 tweaker 和 shader,再检查真实 glyph/icon 纹理绑定及上传状态。任何缺证立即返回 incomplete,不沿失效裸指针追读 renderData/atlas。 +5. RenderTile 内 `Tile&` 的寿命独立;保活容器不保活 Tile,不允许把旧 Tile 上的 getOverscaledTileID/holdForFade 当成安全操作。测试分别计数 Tile 与 TileRenderData,旧 Tile 销毁后仅比较旧地址及读取 atlas 强引用。 +6. 同 atlas 的 texture 替换经原生 tweaker 更新实际绑定,前后证据不同;原地像素更新即使上传后指针不变,仍依赖真实 content epoch。既有 Build 前/后及提交后复核、证书版本/R cache/prepared Fast 保护保留,未改变准入顺序或放宽任何判据。 + +## 3. 静态审查与测试源码范围 + +13 项生命周期源码覆盖:连续 prepare 后刷新/释放旧 renderData、owner/元素错配、atlas 替换整组清理与新纹理绑定、组内单成员失配、atlas 缺失、同 atlas texture 更换及重新 Build/Reuse 状态、未上传原地更新拒绝、owner 身份不进入完整空组结果字节、Tile 与 renderData 寿命分离、Layer 移除及证书失效释放 atlas、同名 Source 替换、tile 退出清理、Text/Icon/SDF/Halo 连续三代保持身份、未知 Symbol 类型拒绝(其中相关情形合并在同一用例)。 + +测试使用真实 RenderSource/Tile/TileRenderData/RenderTile、SymbolBucket、Drawable/TileLayerGroup 接口和生产辅助方法;GPU Texture/Drawable/Shader 使用 CPU 替身。真实 API 的纯虚函数、构造参数、Mutable→Immutable 转换、SymbolLayer.baseImpl 可见性及派生类 friend 访问继承 protected 成员已静态核对,没有用悬空指针解引用构造普通反例。 + +**覆盖限制**:atlas-only fixture 不生成真实 Placement/顶点/GPU 命令;atlasResult 是生产采集的局部证据,不能代表完整渲染证书。整组失配测试走生产移除入口,但重新创建由 fixture 添加 Drawable,仍需 RDC 实际 update/builder/GPU 链验证。tile 退出测试直接调用正常 update 使用的清理入口,没有伪造 Context 或声称执行完整 update。owner 稳定证书测试走完整生产空组采集;非空 Text/Icon/Halo 稳定性只检查绑定输入和身份。原地 image patch 上传后的 epoch、Style/context 清理引用既有 `Step119SymbolResults.RealTileGlyphImageStyleContextCallbacksRejectBuiltCertificate` 等回调测试源码,不等同真实槽位释放、Source/Style/context 完整运行验收。 + +## 4. 已做及未做检查 + +已做:必要文件阅读、开工 Git/机器/版本及 diff 审查;真实 C++ 接口/生命周期调用静态核对;确认测试文件在 `test/CMakeLists.txt` 已登记;源码静态计数 `SymbolRetainedLifetime` 为13项;新增文档版本/路径检查;STEP-110/111 SHA-256 保护检查;开发记录追加与文件范围检查。 + +已执行四源码路径 scoped `git diff --check -- src/mln/gfx/symbol_drawable_data.hpp src/mln/renderer/layers/render_symbol_layer.cpp src/mln/renderer/layers/render_symbol_layer.hpp test/renderer/retained_underlay_cache.test.cpp`:**exit=0,stdout/stderr 空**。最终全工作区及七任务路径空白检查由本轮落盘后再次执行,结果见下方交付记录。空白检查不证明 C++ 编译或测试通过。 + +未执行:CMake 配置、Native/NDK 编译、`-fsyntax-only`、链接、GTest、Metal/iOS/GPU运行、Gradle/APK、ADB/安装、Jest、Bazel/codegen、发行、Git写操作及远端读取。没有新编译失败日志、设备日志、截图、APK/SO SHA 或 Build/Reuse/Fast 数据;既有 libuv 环境失败只作为历史限制,未重试也未修改环境。 + +保护的未跟踪任务书 SHA-256: + +- STEP-110:`33037df71b4b30aa7956d59f4b99f77af3085405b24111bc0fe11ec857a93db0`。 +- STEP-111:`f3a5806f25384bf46055db2edfea0d21bfbc4124184bc813b46e38b8079c1ec4`。 + +## 5. RDC 准确编译与定向测试命令(Codex 未执行) + +独立审查本报告和实际 diff 后,在同一工作区逐条执行;每条保存 command/exit/耗时/完整日志路径,失败立即停止下一步,回传首个真实 error、target 与必要上下文。PATH 找不到 cmake 时可使用既有 `/usr/local/opt/cmake/bin/cmake`;不得安装升级依赖或关闭宏绕过 libuv/工具链错误。 + +```bash +cd /Users/tei/dev/MapLibreParticleLab +cmake --preset macos-metal +cmake --build build-macos-metal --target mbgl-test-runner --parallel 3 +build-macos-metal/mbgl-test-runner --gtest_filter='SymbolRetainedLifetime.*:Step119SymbolResults.*' --gtest_list_tests +build-macos-metal/mbgl-test-runner --gtest_filter='SymbolRetainedLifetime.*:Step119SymbolResults.*:RetainedUnderlayCache.*:RenderOrchestratorError.*:RenderOrchestratorTeardown.*:CrossTileSymbolLayerIndex.*' --gtest_color=no +``` + +必须发现本轮13项 `SymbolRetainedLifetime` 与既有13项 `Step119SymbolResults`(后者分布两个已登记文件),真实执行数量非零。零匹配、旧 runner、仅语法检查不能写 GTest PASS。Metal 与 Android OpenGL 分别保留编译及运行事实;Android 实际 compile_commands 的类型核对须保持原 `-Werror`,不加 `-Wno-error`。本轮未生成额外编译脚本。 + +Vulkan 至少取得生产修改的兼容性编译证据;已有 macOS Vulkan 环境可用以下真实 preset/target,环境缺失则记未验证/阻塞,不升级环境: + +```bash +cmake --preset macos-vulkan +cmake --build build-macos-vulkan --target mbgl-core --parallel 3 +``` + +Vulkan Symbol provenance 既有保守边界保持,编译成功不等于其 Symbol retained 运行通过。可在既有 sanitizer 配置下补查 UAF,不改造构建体系;Host Metal/GTest 也不替代 iOS 真机。 + +## 6. 后续 FZ-G1 验收命令与证据(Codex 未执行) + +**前置门槛**:独立审计/Native验证完成、后续 Git 来源锁和 Android/设备操作获得授权。当前 tracked dirty,verifier 的 `SOURCE_NOT_LOCKED` 会拒绝本工作区;本轮禁止提交,故这是后续验收前置条件,不能改 verifier、伪造 build-info 或用旧 APK。本报告仅交命令,不代执行 Android/ADB,也不代授权 Git。 + +用户/RDC 按其阶段授权完成一次标准新 APK 构建与安装,固定 FZG1-2、serial=`192.168.200.139:5555`、OpenGL Debug/x86_64: + +```bash +cd /Users/tei/dev/MapLibreParticleLab +./tools/particle_android_validation/build_validation_apk.sh +adb -s 192.168.200.139:5555 install -r artifacts/maplibre-particle/android-validation/MapLibreAndroidTestApp-validation-opengl-debug-x86_64.apk +python3 -B tools/particle_android_validation/verify_step113_fast_path.py --serial 192.168.200.139:5555 --preflight-only +python3 -B tools/particle_android_validation/verify_step113_fast_path.py --serial 192.168.200.139:5555 --scenario STATIC_RC_NO_TIDE --seconds 90 +python3 -B tools/particle_android_validation/verify_step113_fast_path.py --serial 192.168.200.139:5555 --scenario RCRR_C_NARROW_NO_TIDE --seconds 90 +python3 -B tools/particle_android_validation/verify_step113_fast_path.py --serial 192.168.200.139:5555 --scenario STATIC_RC --seconds 90 +``` + +正向缓存保持开启,每次稳定后须真实 Build>0、Reuse>0、FastDelta>0,完整120帧24Gate mismatch=0;原63层 STATIC_RC 缺 tide 继续作为安全拒绝负对照,验证器可能返回 NO_FAST_OBSERVED/exit2,不能当正向 PASS,也不能添加假 tide。正向静止90秒不是生命周期验收。 + +RDC/用户还需反复 pan/zoom/rotate、跨 tile/world wrap、Source同名替换、Style清理/重载、glyph/icon/atlas更新、后台恢复/context reset;每次变化保存失效 → Full → 新 Build → 成功 Full Reuse → Fast 的顺序证据,Text/Icon/SDF/Halo 不消失、残影或错位。相同相机的 Full/Fast 图像对照、资源随循环最终释放均须实测。保持正式 Style/Source 和公共契约,不以禁用 Retained、每帧重建或放宽24Gate替代验证。 + +必须保存:新锁定源码 HEAD/diff、build-info/完整构建日志、本地及安装 APK SHA-256、实际 libmaplibre.so Build ID与匹配符号、测试发现/执行数、Build/Reuse/Full/Fast/Invalidate 区间增量、完整24Gate快照、操作前后截图及视觉判断、资源数量/释放证据和全部未验证项。证据写新目录,不覆盖 ignored 历史证据。 + +异常先保存 crash 日志/bugreport,不先清日志;由获授权执行者执行: + +```bash +STEP119_EVIDENCE_DIR=$(mktemp -d /tmp/step119-symbol-lifetime-XXXXXX) +adb -s 192.168.200.139:5555 logcat -b crash -d > "$STEP119_EVIDENCE_DIR/crash-logcat.txt" +adb -s 192.168.200.139:5555 bugreport "$STEP119_EVIDENCE_DIR/bugreport.zip" +``` + +## 7. 风险、证据缺口与停止门槛 + +- 缺原始寄存器 tombstone;原 Build ID `485459ba44f806ef0a0c08633c717bfd5d82ad8f` 和匹配符号应保留,不能断言 `0x8cdc7c` 唯一原因是本 UAF 链。 +- 当前模型运行参数没有可独立回读的执行器元数据,RDC报告须补该身份事实;不伪造“已核实”。 +- 真实 C++ 编译、GTest、Metal/OpenGL/Vulkan、底层 Tile 寿命及真实 atlas 槽位/像素变化、异步线程边界、释放/内存开销均未验收。静态检查只能排除部分明显问题。 +- 若必须改变公共契约、重构全部 Tile 所有权、永久禁用缓存、放宽测试/证书或修改发行物才能继续,停止扩大修改并标 BLOCKED。 +- 编译/链接/GTest失败、SIGSEGV、24Gate mismatch、Fast永久归零、资源循环单调增长、Symbol视觉异常均保留现场,不能通过降门槛、旧APK或隐藏失败制造 PASS。 + +本轮实现交接到此,后续由 RDC 独立验证;失败按 Codex 修复 → RDC 验证闭环返回,不继续后续开发阶段。 + +## 8. 最终交付静态检查记录 + +- 完整命令 `git diff --check`:exit=0,stdout/stderr 空。 +- 七个任务文件显式路径的 `git diff --check -- ...`:exit=0,stdout/stderr 空。新建 Step/报告尚未跟踪,Git diff 不包含其内容;另以轻量 Python 文件检查确认两份 UTF-8 文档状态、末尾换行及无行尾空白,不借此声称 Git 已检查 untracked 内容。 +- `git status --short`:仅四个接续源码、开发记录、两份新增本任务文档及两份原有保护任务书;没有其它修改。branch/HEAD 与开工一致。 +- 最终 STEP-110/111 SHA-256 与开工值一致;开发记录内容以 HEAD 原文的完整字节为前缀,只有末尾追加。 +- 静态计数:生命周期13项,既有 Step119SymbolResults 为9+4=13项。没有发现/执行 GTest,不能声称实际26项通过。 +- 没有新的编译/测试/设备验收结论;状态维持 IMPLEMENTED / WAITING_RDC_VALIDATION。 diff --git a/src/mln/gfx/symbol_drawable_data.hpp b/src/mln/gfx/symbol_drawable_data.hpp index c2207b374..d5f9100c0 100644 --- a/src/mln/gfx/symbol_drawable_data.hpp +++ b/src/mln/gfx/symbol_drawable_data.hpp @@ -5,14 +5,19 @@ #include #include +#include +#include namespace mln { +class TileAtlasTextures; namespace style { enum class SymbolType : uint8_t; } // namespace style namespace gfx { +class DrawableAtlasesTweaker; struct SymbolDrawableData : public DrawableData { + static constexpr std::size_t drawableType = 1; SymbolDrawableData(const bool isHalo_, const bool bucketVariablePlacement_, const style::SymbolType symbolType_, @@ -20,7 +25,9 @@ struct SymbolDrawableData : public DrawableData { const style::AlignmentType rotationAlignment_, const style::SymbolPlacementType placement_, const style::IconTextFitType textFit_, - const bool isOffset_) + const bool isOffset_, + std::shared_ptr atlases_, + std::shared_ptr atlasTweaker_) : isHalo(isHalo_), bucketVariablePlacement(bucketVariablePlacement_), symbolType(symbolType_), @@ -28,7 +35,9 @@ struct SymbolDrawableData : public DrawableData { rotationAlignment(rotationAlignment_), placement(placement_), textFit(textFit_), - isOffset(isOffset_) {} + isOffset(isOffset_), + atlases(std::move(atlases_)), + atlasTweaker(std::move(atlasTweaker_)) {} ~SymbolDrawableData() override = default; const bool isHalo; @@ -39,6 +48,10 @@ struct SymbolDrawableData : public DrawableData { const style::SymbolPlacementType placement; const style::IconTextFitType textFit; const bool isOffset; + // Paired at creation with the actual native atlas tweaker. Neither + // resource owns RenderTile; its current container is bound separately. + const std::shared_ptr atlases; + const std::shared_ptr atlasTweaker; }; using UniqueSymbolDrawableData = std::unique_ptr; diff --git a/src/mln/renderer/layers/render_symbol_layer.cpp b/src/mln/renderer/layers/render_symbol_layer.cpp index 2b8ed5e86..f11f43705 100644 --- a/src/mln/renderer/layers/render_symbol_layer.cpp +++ b/src/mln/renderer/layers/render_symbol_layer.cpp @@ -34,6 +34,7 @@ #include #include +#include namespace mln { @@ -195,14 +196,96 @@ bool RenderSymbolLayer::hasCrossfade() const { return false; } +void RenderSymbolLayer::setRenderTiles(Immutable> owner, RenderTiles sorted) { + renderTilesOwner = std::move(owner); + renderTiles = std::move(sorted); + currentTileBindings.clear(); + renderTilesSameSource = false; + if (!renderTilesOwner.get() || !renderTiles) return; + + std::unordered_set addresses; + addresses.reserve(renderTilesOwner->size()); + for (const auto& tile : *renderTilesOwner) addresses.insert(&tile); + for (const auto& ref : *renderTiles) { + if (!addresses.contains(&ref.get())) { + // Do not dereference a reference from another prepare generation. + renderTiles.reset(); + return; + } + } + for (const auto& ref : *renderTiles) { + const auto& tile = ref.get(); + currentTileBindings.emplace(&tile, TileBinding{tile.getOverscaledTileID(), tile.getAtlasTextures()}); + } + renderTilesSameSource = true; +} + +bool RenderSymbolLayer::bindRenderTile(gfx::Drawable& drawable, const RenderTile& tile) { + const auto it = currentTileBindings.find(&tile); + if (!renderTilesSameSource || it == currentTileBindings.end() || + !drawable.getTileID() || *drawable.getTileID() != it->second.id) return false; + drawable.setRenderTile(renderTilesOwner, &tile); + return true; +} + +bool RenderSymbolLayer::rebindTileDrawables(const RenderTile& tile, + const std::shared_ptr& bucket, + const TileAtlasTexturesPtr& atlases) { + const auto binding = currentTileBindings.find(&tile); + if (!renderTilesSameSource || binding == currentTileBindings.end()) return false; + const auto& tileID = binding->second.id; + auto* group = static_cast(layerGroup.get()); + if (!group) return false; + const auto count = group->getDrawableCount(passes, tileID); + bool compatible = bucket && layerTweaker && atlases && binding->second.atlases == atlases; + group->visitDrawables(passes, tileID, [&](gfx::Drawable& drawable) { + if (drawable.getLayerTweaker() != layerTweaker || drawable.getBucket() != bucket || + drawable.getType() != gfx::SymbolDrawableData::drawableType || !drawable.getData() || + !drawable.getTileID() || *drawable.getTileID() != tileID || + drawable.getRenderPass() != passes) { + compatible = false; + return; + } + const auto& data = static_cast(*drawable.getData()); + if (!data.atlasTweaker || data.atlases != atlases || drawable.getTweakers().size() != 1 || + drawable.getTweakers().front() != data.atlasTweaker) compatible = false; + if (!atlases || (data.symbolType == SymbolType::Text ? !atlases->glyph : !atlases->icon) || + (data.symbolType != SymbolType::Text && data.symbolType != SymbolType::IconRGBA && + data.symbolType != SymbolType::IconSDF)) compatible = false; + }); + if (!compatible || group->getDrawableCount(passes, tileID) != count) { + // updateTile() means ANY updated. Preflight the entire text/icon/halo + // group before changing any member, so partial reuse cannot survive. + removeTile(passes, tileID); + return false; + } + if (!count) return false; + group->visitDrawables(passes, tileID, [&](gfx::Drawable& drawable) { + if (!bindRenderTile(drawable, tile)) compatible = false; + }); + if (!compatible || group->getDrawableCount(passes, tileID) != count) { + removeTile(passes, tileID); + return false; + } + return true; +} + void RenderSymbolLayer::prepare(const LayerPrepareParameters& params) { - renderTiles = params.source->getRenderTilesSortedByYPosition(); + // Keep the Y order and fading tiles of the Symbol source path, while + // taking both views from the same completed source prepare. + auto owner = params.source->getRawRenderTiles(); + auto sorted = params.source->getRenderTilesSortedByYPosition(); + setRenderTiles(std::move(owner), std::move(sorted)); + placementData.clear(); + if (!renderTilesSameSource) { + renderTileIDs.clear(); + removeAllDrawables(); + return; + } updateRenderTileIDs(); addRenderPassesFromTiles(); - placementData.clear(); - for (const RenderTile& renderTile : *renderTiles) { auto* bucket = static_cast(renderTile.getBucket(*baseImpl)); if (bucket && bucket->bucketLeaderID == getID() && static_cast(bucket)->check(SYM_GUARD_LOC)) { @@ -475,6 +558,53 @@ std::size_t RenderSymbolLayer::removeAllDrawables() { return stats.drawablesRemoved - oldValue; } +bool RenderSymbolLayer::appendRetainedTileResult(SymbolRenderResult& result, const gfx::Drawable& drawable) const { + // Only compare a possibly old pointer as an address. IDs were copied while + // source prepare owned a live Tile; never call through the old RenderTile. + const auto it = currentTileBindings.find(drawable.getRenderTile()); + if (!renderTilesSameSource || it == currentTileBindings.end() || !drawable.getTileID() || + *drawable.getTileID() != it->second.id || + drawable.getType() != gfx::SymbolDrawableData::drawableType || !drawable.getData()) { + result.complete = false; + return false; + } + const auto& data = static_cast(*drawable.getData()); + const auto& atlases = data.atlases; + if ((data.symbolType != SymbolType::Text && data.symbolType != SymbolType::IconRGBA && + data.symbolType != SymbolType::IconSDF) || + !atlases || atlases != it->second.atlases || !data.atlasTweaker || + drawable.getTweakers().size() != 1 || drawable.getTweakers().front() != data.atlasTweaker || + !drawable.getShader()) { + result.complete = false; + return false; + } + const auto& expected = data.symbolType == SymbolType::Text ? atlases->glyph : atlases->icon; + if (!expected || expected->needsUpload() || + drawable.getTexture(shaders::idSymbolImageTexture) != expected || + (drawable.getShader()->getSamplerLocation(shaders::idSymbolImageIconTexture) && + (!atlases->icon || atlases->icon->needsUpload() || + drawable.getTexture(shaders::idSymbolImageIconTexture) != atlases->icon))) { + result.complete = false; + return false; + } + result.retain(atlases); + result.retain(atlases->glyph); + result.retain(atlases->icon); + for (std::size_t id = 0; id < shaders::maxTextureCountPerShader; ++id) { + const auto& texture = drawable.getTexture(id); + if (texture && texture->needsUpload()) { + result.complete = false; + return false; + } + result.retain(texture); + if (texture) { + result.append(texture->getSize()); + result.append(texture->getFormat()); + } + } + return result.complete; +} + SymbolRenderResult RenderSymbolLayer::retainedRenderResult(float fadeChange, uint64_t frame) { SymbolRenderResult result; result.append(layerIndex); @@ -483,7 +613,13 @@ SymbolRenderResult RenderSymbolLayer::retainedRenderResult(float fadeChange, uin // A hidden layer is admitted only after processChanges has removed both // groups (the orchestrator verifies registry membership separately). if (!isLayerRenderable()) return result; - if (collisionTileLayerGroup && !collisionTileLayerGroup->empty()) result.complete = false; + if (!renderTilesSameSource || (collisionTileLayerGroup && !collisionTileLayerGroup->empty())) { + result.complete = false; + return result; + } + // Keep TileRenderData/LayoutResult (including atlas slots) alive across + // completed Full evidence. The per-prepare owner address is NOT an input. + result.resources.emplace_back(std::make_shared>>(renderTilesOwner)); if (!layerGroup || layerGroup->empty()) { // Empty is provable only if there are no renderable Symbol buckets. if (renderTiles) for (const auto& tileRef : *renderTiles) { @@ -501,7 +637,10 @@ SymbolRenderResult RenderSymbolLayer::retainedRenderResult(float fadeChange, uin // RenderSymbolLayer is final and creates only this tweaker and the native // atlas tweaker. Unknown placement-capable layers never call this method. const auto& tweaker = static_cast(*layerTweaker); - if (tweaker.getRenderResultFrame() != frame) result.complete = false; + if (tweaker.getRenderResultFrame() != frame) { + result.complete = false; + return result; + } result.appendResult(tweaker.getRenderResult()); result.retain(layerGroup); result.append(layerGroup->getEnabled()); @@ -509,12 +648,14 @@ SymbolRenderResult RenderSymbolLayer::retainedRenderResult(float fadeChange, uin std::size_t visited = 0; visitLayerGroupDrawables(*layerGroup, [&](gfx::Drawable& drawable) { ++visited; + if (!result.complete) return; if (drawable.getIsCustom() || drawable.getLayerTweaker() != layerTweaker || !drawable.getBucket() || !drawable.getTileID() || !drawable.getData() || !drawable.getShader() || drawable.getTweakers().size() != 1) { result.complete = false; return; } + if (!appendRetainedTileResult(result, drawable)) return; result.append(drawable.getID()); if (!SymbolShaderProvenance::get().supports(*drawable.getShader())) result.complete = false; result.retain(drawable.getTweakers().front()); // immutable native atlas/sampler policy @@ -588,29 +729,6 @@ SymbolRenderResult RenderSymbolLayer::retainedRenderResult(float fadeChange, uin if (indices->empty()) result.complete = false; } } - const auto* tile = drawable.getRenderTile(); - const auto& atlases = tile ? tile->getAtlasTextures() : TileAtlasTexturesPtr{}; - if (!atlases) { - result.complete = false; - return; - } - result.retain(atlases); - result.retain(atlases->glyph); - result.retain(atlases->icon); - for (std::size_t id = 0; id < shaders::maxTextureCountPerShader; ++id) { - const auto& texture = drawable.getTexture(id); - result.retain(texture); - if (texture) { - result.append(texture->getSize()); - result.append(texture->getFormat()); - if (texture->needsUpload()) result.complete = false; - } - } - const auto& expected = data.symbolType == SymbolType::Text ? atlases->glyph : atlases->icon; - if (!expected || expected->needsUpload() || - drawable.getTexture(shaders::idSymbolImageTexture) != expected) result.complete = false; - if (drawable.getShader()->getSamplerLocation(shaders::idSymbolImageIconTexture) && - drawable.getTexture(shaders::idSymbolImageIconTexture) != atlases->icon) result.complete = false; }); if (visited != layerGroup->getDrawableCount()) result.complete = false; return result; @@ -623,7 +741,7 @@ void RenderSymbolLayer::update(gfx::ShaderRegistry& shaders, const PaintParameters&, const RenderTree&, UniqueChangeRequestVec& changes) { - if (!renderTiles || renderTiles->empty() || passes == RenderPass::None) { + if (!renderTilesSameSource || !renderTiles || renderTiles->empty() || passes == RenderPass::None) { removeAllDrawables(); return; } @@ -732,6 +850,12 @@ void RenderSymbolLayer::update(gfx::ShaderRegistry& shaders, const auto& renderData = *optRenderData; const auto& bucket = static_cast(*renderData.bucket); const auto& evaluated = getEvaluated(renderData.layerProperties); + const auto& atlases = tile.getAtlasTextures(); + if (!atlases || (bucket.hasTextData() && !atlases->glyph) || + ((bucket.hasIconData() || bucket.hasSdfIconData() || bucket.iconsInText) && !atlases->icon)) { + removeTile(passes, tileID); + continue; + } const auto prevBucketID = getRenderTileBucketID(tileID); if (prevBucketID != util::SimpleIdentity::Empty && prevBucketID != bucket.getID()) { @@ -807,18 +931,14 @@ void RenderSymbolLayer::update(gfx::ShaderRegistry& shaders, // If we already have drawables for this tile, update them. // Just update the drawables we already created auto updateExisting = [&](gfx::Drawable& drawable) { - if (drawable.getLayerTweaker() != layerTweaker) { - // This drawable was produced on a previous style/bucket, and should not be updated. - return false; - } - propertiesAsUniforms.first.clear(); propertiesAsUniforms.second.clear(); updateTileDrawable(drawable, bucket, bucketPaintProperties, evaluated); return true; }; - if (updateTile(passes, tileID, std::move(updateExisting))) { + if (rebindTileDrawables(tile, renderData.bucket, atlases) && + updateTile(passes, tileID, std::move(updateExisting))) { // re-create collision drawables if (collisionTileLayerGroup) { collisionTileLayerGroup->removeDrawables(passes, tileID); @@ -855,11 +975,6 @@ void RenderSymbolLayer::update(gfx::ShaderRegistry& shaders, } }; - const auto& atlases = tile.getAtlasTextures(); - if (!atlases) { - continue; - } - if (bucket.hasIconData() && atlases->icon) { addRenderables(bucket.icon, SymbolType::IconRGBA); } @@ -879,7 +994,7 @@ void RenderSymbolLayer::update(gfx::ShaderRegistry& shaders, using RawVertexVec = std::vector; // raw buffer for vertexes of struct TileInfo { RawVertexVec textVertices, iconVertices; - gfx::DrawableTweakerPtr textTweaker, iconTweaker; + std::shared_ptr textTweaker, iconTweaker; }; mln::unordered_map tileCache; @@ -1031,13 +1146,14 @@ void RenderSymbolLayer::update(gfx::ShaderRegistry& shaders, for (auto& drawable : builder->clearDrawables()) { drawable->setTileID(tileID); drawable->setLayerTweaker(layerTweaker); - drawable->setRenderTile(renderTilesOwner, &tile); + if (!bindRenderTile(*drawable, tile)) continue; PaintPropertyBindersBase& textBinders = bucketPaintProperties.textBinders; PaintPropertyBindersBase& iconBinders = bucketPaintProperties.iconBinders; drawable->setBinders(renderable.renderData.bucket, isText ? &textBinders : &iconBinders); const bool hasVariablePlacement = bucket.hasVariablePlacement; + drawable->setType(gfx::SymbolDrawableData::drawableType); drawable->setData(std::make_unique( /*.isHalo=*/isHalo, /*.bucketVaraiblePlacement=*/hasVariablePlacement, @@ -1046,7 +1162,9 @@ void RenderSymbolLayer::update(gfx::ShaderRegistry& shaders, /*.rotationAlignment=*/values.rotationAlignment, /*.placement=*/bucketLayout.get(), /*.textFit=*/bucketLayout.get(), - /*.isOffset=*/isOffset)); + /*.isOffset=*/isOffset, + atlases, + isText ? tileInfo.textTweaker : tileInfo.iconTweaker)); tileLayerGroup->addDrawable(passes, tileID, std::move(drawable)); ++stats.drawablesAdded; diff --git a/src/mln/renderer/layers/render_symbol_layer.hpp b/src/mln/renderer/layers/render_symbol_layer.hpp index 6dc9c8cfd..9062a89d8 100644 --- a/src/mln/renderer/layers/render_symbol_layer.hpp +++ b/src/mln/renderer/layers/render_symbol_layer.hpp @@ -11,6 +11,7 @@ #include namespace mln { +class TileAtlasTextures; namespace style { @@ -82,6 +83,20 @@ protected: std::size_t removeTile(RenderPass, const OverscaledTileID &) override; private: + friend struct SymbolRetainedLifetimeTestAccess; + // Validate addresses before reading any reference-based tile input. + void setRenderTiles(Immutable>, RenderTiles); + bool bindRenderTile(gfx::Drawable&, const RenderTile&); + bool appendRetainedTileResult(SymbolRenderResult&, const gfx::Drawable&) const; + bool rebindTileDrawables(const RenderTile&, const std::shared_ptr&, + const std::shared_ptr&); + struct TileBinding { + OverscaledTileID id; + std::shared_ptr atlases; + }; + std::unordered_map currentTileBindings; + bool renderTilesSameSource = false; + void transition(const TransitionParameters &) override; void evaluate(const PropertyEvaluationParameters &) override; bool hasTransition() const override; diff --git a/test/renderer/retained_underlay_cache.test.cpp b/test/renderer/retained_underlay_cache.test.cpp index 44e2b66fd..01c647514 100644 --- a/test/renderer/retained_underlay_cache.test.cpp +++ b/test/renderer/retained_underlay_cache.test.cpp @@ -10,6 +10,22 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include @@ -22,6 +38,40 @@ using namespace mln; +namespace mln { +// Exercise the same internal methods used by prepare/update/result collection, +// without a GPU Context or a second copy of their compatibility predicates. +struct SymbolRetainedLifetimeTestAccess { + static void prepare(RenderSymbolLayer& layer, const LayerPrepareParameters& params) { layer.prepare(params); } + static void setTiles(RenderSymbolLayer& layer, Immutable> owner, RenderTiles refs) { + layer.setRenderTiles(std::move(owner), std::move(refs)); + } + static void setup(RenderSymbolLayer& layer, std::shared_ptr group) { + layer.layerGroup = std::move(group); + layer.passes = RenderPass::Translucent; + layer.layerTweaker = std::make_shared(layer.getID(), layer.evaluatedProperties); + layer.isRenderable = true; + } + static const LayerTweakerPtr& tweaker(const RenderSymbolLayer& layer) { return layer.layerTweaker; } + static bool bind(RenderSymbolLayer& layer, gfx::Drawable& drawable, const RenderTile& tile) { + return layer.bindRenderTile(drawable, tile); + } + static bool reuse(RenderSymbolLayer& layer, const RenderTile& tile, const std::shared_ptr& bucket, + const std::shared_ptr& atlases) { + return layer.rebindTileDrawables(tile, bucket, atlases); + } + static SymbolRenderResult atlasResult(const RenderSymbolLayer& layer, const gfx::Drawable& drawable) { + SymbolRenderResult result; + layer.appendRetainedTileResult(result, drawable); + return result; + } + static void removeLayer(RenderSymbolLayer& layer) { + UniqueChangeRequestVec changes; + layer.layerRemoved(changes); + } +}; +} // namespace mln + namespace { RetainedUnderlayKey key(Size size = {640, 480}, int32_t boundary = 4) { RetainedUnderlayKey result{size, 0, boundary, {}}; @@ -2109,3 +2159,425 @@ TEST(FastGateTrace, RetainedSamplesHaveDistinctPhasesAndExplicitLimit) { EXPECT_EQ(frame.post.retained[0].cache.build, 1u); EXPECT_EQ(frame.post.retained[0].cache.sourceRevision, 7u); } + +namespace { +class LifetimeUniformBuffers final : public gfx::UniformBufferArray { +public: + void bind(gfx::RenderPass&) override {} +protected: + std::unique_ptr copy(const gfx::UniformBuffer&) override { return nullptr; } +}; + +class LifetimeDrawable final : public gfx::Drawable { +public: + LifetimeDrawable() : Drawable("symbol-lifetime") {} + void draw(PaintParameters&) const override {} + void updateVertexAttributes(gfx::VertexAttributeArrayPtr, std::size_t, gfx::DrawMode, + gfx::IndexVectorBasePtr, const SegmentBase*, std::size_t) override {} + void setVertices(std::vector&&, std::size_t, gfx::AttributeDataType) override {} + void setIndexData(gfx::IndexVectorBasePtr, std::vector) override {} + const gfx::UniformBufferArray& getUniformBuffers() const override { return buffers; } + gfx::UniformBufferArray& mutableUniformBuffers() override { return buffers; } +private: + LifetimeUniformBuffers buffers; +}; + +class LifetimeGroup final : public TileLayerGroup { +public: + LifetimeGroup() : TileLayerGroup(0, 4, "symbol-lifetime") {} + const gfx::UniformBufferArray& getUniformBuffers() const override { return buffers; } + gfx::UniformBufferArray& mutableUniformBuffers() override { return buffers; } +private: + LifetimeUniformBuffers buffers; +}; + +class LifetimeTexture final : public gfx::Texture2D { +public: + Texture2D& setSamplerConfiguration(const SamplerState&) noexcept override { return *this; } + Texture2D& setFormat(gfx::TexturePixelType, gfx::TextureChannelDataType) noexcept override { return *this; } + Texture2D& setSize(Size value) noexcept override { size = value; return *this; } + Texture2D& setImage(std::shared_ptr) noexcept override { dirty = true; return *this; } + gfx::TexturePixelType getFormat() const noexcept override { return gfx::TexturePixelType::RGBA; } + Size getSize() const noexcept override { return size; } + size_t getDataSize() const noexcept override { return size.width * size.height * 4; } + size_t getPixelStride() const noexcept override { return 4; } + size_t numChannels() const noexcept override { return 4; } + void create() override {} + void upload(const void*, const Size&) override { dirty = false; } + void uploadSubRegion(const void*, const Size&, uint16_t, uint16_t) override { dirty = true; } + void upload() override { dirty = false; } + bool needsUpload() const noexcept override { return dirty; } + bool dirty = false; +private: + Size size{8, 8}; +}; + +class LifetimeTileData final : public TileRenderData { +public: + LifetimeTileData(std::shared_ptr atlases, std::shared_ptr live_) + : TileRenderData(std::move(atlases)), live(std::move(live_)) { ++*live; } + ~LifetimeTileData() override { --*live; } +private: + // This fixture supplies atlas-only render data. The base implementations + // assert: prepare() and the empty-group collector must dispatch here. + const LayerRenderData* getLayerRenderData(const style::Layer::Impl&) const override { return nullptr; } + Bucket* getBucket(const style::Layer::Impl&) const override { return nullptr; } + std::shared_ptr live; +}; + +class LifetimeTile final : public Tile { +public: + LifetimeTile(std::shared_ptr liveData_, std::shared_ptr liveTiles_) + : Tile(Kind::Geometry, OverscaledTileID{2, 0, {2, 1, 1}}, "symbol-lifetime-source"), + liveData(std::move(liveData_)), liveTiles(std::move(liveTiles_)) { ++*liveTiles; } + ~LifetimeTile() override { --*liveTiles; } + std::unique_ptr createRenderData() override { + return std::make_unique(atlases, liveData); + } + void cancel() override {} + bool layerPropertiesUpdated(const Immutable&) override { return true; } + bool holdForFade() const override { return true; } + std::shared_ptr atlases = std::make_shared(); +private: + std::shared_ptr liveData, liveTiles; +}; + +class LifetimeSource final : public RenderSource { +public: + LifetimeSource(std::shared_ptr liveData, std::shared_ptr liveTiles) + : RenderSource(makeMutable( + "symbol-lifetime-source", style::GeoJSONOptions::defaultOptions())), + tile(std::make_unique(std::move(liveData), std::move(liveTiles))) {} + bool isLoaded() const override { return true; } + void update(Immutable, const std::vector>&, + bool, bool, const TileParameters&) override {} + std::unique_ptr createRenderItem() override { return nullptr; } + void prepare(const SourcePrepareParameters& params) override { + auto tiles = makeMutable>(); + tiles->emplace_back(tile->id.toUnwrapped(), *tile); + tiles->back().prepare(params); + owner = std::move(tiles); + sorted = std::make_shared>>( + std::initializer_list>{owner->front()}); + } + Immutable> getRawRenderTiles() const override { return owner; } + RenderTiles getRenderTilesSortedByYPosition() const override { return sorted; } + // Deliberately excludes the fading tile, unlike the Symbol path. + RenderTiles getRenderTiles() const override { + return std::make_shared>>(); + } + void updateFadingTiles() override {} + bool hasFadingTiles() const override { return true; } + std::unordered_map> queryRenderedFeatures( + const ScreenLineString&, const TransformState&, const std::unordered_map&, + const RenderedQueryOptions&, const mat4&) const override { return {}; } + std::vector querySourceFeatures(const SourceQueryOptions&) const override { return {}; } + void reduceMemoryUse() override {} + void dumpDebugLogs() const override {} + std::unique_ptr tile; + Immutable> owner = makeMutable>(); + RenderTiles sorted; +}; + +class SymbolRetainedLifetime : public ::testing::Test { +protected: + SymbolRetainedLifetime() + : styleLayer("symbol-lifetime", "symbol-lifetime-source"), + layer(staticImmutableCast(styleLayer.baseImpl)) { + state.setSize({640, 480}); + source = std::make_unique(liveData, liveTiles); + source->tile->atlases->glyph = std::make_shared(); + source->tile->atlases->icon = std::make_shared(); + SymbolRetainedLifetimeTestAccess::setup(layer, group); + prepare(); + } + void prepare() { + TransformParameters transform(state); + const auto debug = MapDebugOptions::NoDebug; + source->prepare({transform, debug, *images, "symbol-lifetime-source"}); + SymbolRetainedLifetimeTestAccess::prepare(layer, {source.get(), *images, patterns, lines, state}); + } + std::shared_ptr makeBucket() { + return std::make_shared(makeMutable(), + std::map>{}, 16.0f, 1.0f, 0.0f, false, false, + "symbol-lifetime", std::vector{}, std::vector{}, 1.0f, false, + std::vector{}, false); + } + LifetimeDrawable& add(const std::shared_ptr& bucket, style::SymbolType type = style::SymbolType::Text, + bool halo = false) { + auto drawable = std::make_unique(); + auto& ref = *drawable; + ref.setTileID(source->tile->id); + ref.setRenderPass(RenderPass::Translucent); + ref.setLayerTweaker(SymbolRetainedLifetimeTestAccess::tweaker(layer)); + ref.setBinders(bucket, nullptr); // The production rebind path never reads binders. + ref.setShader(std::make_shared()); + const auto atlases = source->tile->atlases; + auto tweaker = std::make_shared(atlases, + shaders::idSymbolImageIconTexture, shaders::idSymbolImageTexture, type == style::SymbolType::Text, + type == style::SymbolType::IconSDF, style::AlignmentType::Viewport, false, true); + ref.addTweaker(tweaker); + ref.setType(gfx::SymbolDrawableData::drawableType); + ref.setData(std::make_unique(halo, false, type, style::AlignmentType::Viewport, + style::AlignmentType::Viewport, style::SymbolPlacementType::Point, style::IconTextFitType::None, + false, atlases, tweaker)); + EXPECT_TRUE(SymbolRetainedLifetimeTestAccess::bind(layer, ref, source->owner->front())); + tweaker->init(ref); // Actual production texture binding, CPU texture backend only. + group->addDrawable(RenderPass::Translucent, source->tile->id, std::move(drawable)); + return ref; + } + bool reuse(const std::shared_ptr& bucket) { + return SymbolRetainedLifetimeTestAccess::reuse(layer, source->owner->front(), bucket, source->tile->atlases); + } + void releaseTiles() { + SymbolRetainedLifetimeTestAccess::setTiles(layer, makeMutable>(), + std::make_shared>>()); + source.reset(); + } + std::shared_ptr liveData = std::make_shared(0), liveTiles = std::make_shared(0); + style::SymbolLayer styleLayer; + RenderSymbolLayer layer; + std::shared_ptr group = std::make_shared(); + TransformState state; + std::shared_ptr images = ImageManager::create(); + PatternAtlas patterns; + LineAtlas lines; + std::unique_ptr source; +}; +} // namespace + +TEST_F(SymbolRetainedLifetime, ConsecutiveSourcePrepareRebindsAndReleasesOldRenderData) { + const auto bucket = makeBucket(); + auto& drawable = add(bucket); + const auto* oldTile = drawable.getRenderTile(); + const auto drawableID = drawable.getID(); + const auto atlasTweaker = drawable.getTweakers().front(); + prepare(); + EXPECT_NE(oldTile, &source->owner->front()); // Old owner is still held by drawable. + EXPECT_EQ(*liveData, 2); + EXPECT_TRUE(reuse(bucket)); + EXPECT_EQ(drawable.getRenderTile(), &source->owner->front()); + EXPECT_EQ(*liveData, 1); // No old tree/owner remains after rebind. + EXPECT_EQ(drawable.getID(), drawableID); + EXPECT_EQ(drawable.getTweakers().front(), atlasTweaker); + EXPECT_TRUE(SymbolRetainedLifetimeTestAccess::atlasResult(layer, drawable).complete); + EXPECT_TRUE(drawable.getRenderTile()->getAtlasTextures()); // Valid new renderData. +} + +TEST_F(SymbolRetainedLifetime, MismatchedOwnerAndElementAreRejectedBeforeReading) { + const auto bucket = makeBucket(); + auto& drawable = add(bucket); + const auto validOldOwner = source->owner; + const auto oldRefs = source->sorted; + prepare(); + EXPECT_FALSE(SymbolRetainedLifetimeTestAccess::bind(layer, drawable, validOldOwner->front())); + EXPECT_FALSE(SymbolRetainedLifetimeTestAccess::atlasResult(layer, drawable).complete); + SymbolRetainedLifetimeTestAccess::setTiles(layer, source->owner, oldRefs); + EXPECT_FALSE(SymbolRetainedLifetimeTestAccess::bind(layer, drawable, source->owner->front())); + EXPECT_FALSE(layer.retainedRenderResult(1, 0).complete); + // Keep the counterexample owner live; no dangling pointer is dereferenced. +} + +TEST_F(SymbolRetainedLifetime, AtlasReplacementDropsWholeTextIconHaloGroupAndBindsNewTextures) { + const auto bucket = makeBucket(); + add(bucket); + add(bucket, style::SymbolType::Text, true); + add(bucket, style::SymbolType::IconRGBA); + source->tile->atlases = std::make_shared(); + source->tile->atlases->glyph = std::make_shared(); + source->tile->atlases->icon = std::make_shared(); + prepare(); + EXPECT_FALSE(reuse(bucket)); + EXPECT_TRUE(group->empty()); + auto& rebuilt = add(bucket); + EXPECT_EQ(rebuilt.getTexture(shaders::idSymbolImageTexture), source->tile->atlases->glyph); + EXPECT_TRUE(SymbolRetainedLifetimeTestAccess::atlasResult(layer, rebuilt).complete); +} + +TEST_F(SymbolRetainedLifetime, OneIncompatibleMemberDropsWholeGroup) { + for (unsigned mismatch = 0; mismatch < 5; ++mismatch) { + const auto bucket = makeBucket(); + add(bucket); + auto& halo = add(bucket, style::SymbolType::Text, true); + add(bucket, style::SymbolType::IconRGBA); + if (mismatch == 0) halo.setBinders(makeBucket(), nullptr); + if (mismatch == 1) halo.setLayerTweaker(nullptr); + if (mismatch == 2) halo.clearTweakers(); + if (mismatch == 3) halo.setData(nullptr); + if (mismatch == 4) halo.setType(0); + EXPECT_FALSE(reuse(bucket)); + EXPECT_TRUE(group->empty()); + } +} + +TEST_F(SymbolRetainedLifetime, MissingAtlasDropsOldDrawables) { + const auto bucket = makeBucket(); + add(bucket); + source->tile->atlases.reset(); + prepare(); + EXPECT_FALSE(reuse(bucket)); + EXPECT_TRUE(group->empty()); +} + +TEST_F(SymbolRetainedLifetime, SameAtlasTextureReplacementRejectsOldBindingAndRequiresNewBuild) { + const auto bucket = makeBucket(); + auto& drawable = add(bucket); + auto original = SymbolRetainedLifetimeTestAccess::atlasResult(layer, drawable); + ASSERT_TRUE(original.complete); + SymbolResultCertificate certificate; + const auto binding = step119Binding(); + step119Settle(certificate, original, binding); + const auto atlasTweaker = drawable.getTweakers().front(); + source->tile->atlases->glyph = std::make_shared(); + EXPECT_TRUE(reuse(bucket)); // Same atlas policy is retained and will run normally. + auto unbound = SymbolRetainedLifetimeTestAccess::atlasResult(layer, drawable); + EXPECT_FALSE(unbound.complete); + EXPECT_FALSE(certificate.matchesCandidate(unbound)); + atlasTweaker->init(drawable); + auto rebound = SymbolRetainedLifetimeTestAccess::atlasResult(layer, drawable); + EXPECT_TRUE(rebound.complete); + EXPECT_NE(rebound.bytes, original.bytes); + EXPECT_FALSE(certificate.observeFull(rebound, binding, true)); + certificate.completeFull(binding, true, false); + EXPECT_TRUE(certificate.observeFull(rebound, binding, true)); + EXPECT_FALSE(certificate.candidateHasBuild(binding)); + certificate.completeFull(binding, true, true); + EXPECT_TRUE(certificate.observeFull(rebound, binding, true)); + certificate.completeFull(binding, true, false, true); + EXPECT_TRUE(certificate.canFast(binding)); + EXPECT_EQ(drawable.getTweakers().front(), atlasTweaker); +} + +TEST_F(SymbolRetainedLifetime, InPlaceTextureUploadRejectsEvidenceUntilUploaded) { + const auto bucket = makeBucket(); + auto& drawable = add(bucket); + const auto texture = std::static_pointer_cast(source->tile->atlases->glyph); + texture->uploadSubRegion(nullptr, {1, 1}, 0, 0); + EXPECT_FALSE(SymbolRetainedLifetimeTestAccess::atlasResult(layer, drawable).complete); + texture->upload(); + EXPECT_TRUE(SymbolRetainedLifetimeTestAccess::atlasResult(layer, drawable).complete); + // Pixel updates AFTER upload additionally require the real content epoch; + // Step119SymbolResults.RealTileGlyphImageStyleContextCallbacksRejectBuiltCertificate covers it. +} + +TEST_F(SymbolRetainedLifetime, OwnerIdentityDoesNotEnterCertificateAndEvidenceKeepsRenderData) { + // Empty native Symbol group still exercises the complete production collector. + auto first = layer.retainedRenderResult(1, 0); + ASSERT_TRUE(first.complete); + prepare(); + auto second = layer.retainedRenderResult(1, 0); + ASSERT_TRUE(second.complete); + EXPECT_EQ(first.bytes, second.bytes); + EXPECT_EQ(*liveData, 2); + SymbolResultCertificate certificate; + const auto binding = step119Binding(); + EXPECT_FALSE(certificate.observeFull(first, binding, true)); + certificate.completeFull(binding, true, false); + EXPECT_TRUE(certificate.observeFull(second, binding, true)); + certificate.completeFull(binding, true, true); + EXPECT_TRUE(certificate.observeFull(second, binding, true)); + certificate.completeFull(binding, true, false, true); + EXPECT_TRUE(certificate.canFast(binding)); + first = {}; // Old renderData can now leave, despite the old ref-list having existed. + EXPECT_EQ(*liveData, 1); + second = {}; + releaseTiles(); + EXPECT_EQ(*liveTiles, 0); // Container evidence does not own Tile&. + EXPECT_EQ(*liveData, 1); // But it owns RenderTile -> TileRenderData. + certificate.invalidate(); + EXPECT_EQ(*liveData, 0); +} + +TEST_F(SymbolRetainedLifetime, LayerRemovalAndCertificateInvalidationReleaseResources) { + const auto bucket = makeBucket(); + auto& drawable = add(bucket); + std::weak_ptr atlas = source->tile->atlases; + SymbolResultCertificate certificate; + step119Settle(certificate, SymbolRetainedLifetimeTestAccess::atlasResult(layer, drawable), step119Binding()); + SymbolRetainedLifetimeTestAccess::removeLayer(layer); + EXPECT_TRUE(group->empty()); + releaseTiles(); + EXPECT_EQ(*liveTiles, 0); + EXPECT_EQ(*liveData, 0); + EXPECT_FALSE(atlas.expired()); // Retained atlas evidence outlives the removed group. + certificate.invalidate(); + EXPECT_TRUE(atlas.expired()); +} + +TEST_F(SymbolRetainedLifetime, SameSourceIDReplacementRejectsOldAtlasAndReleasesOldContainer) { + const auto bucket = makeBucket(); + add(bucket); + std::weak_ptr oldAtlas = source->tile->atlases; + source = std::make_unique(liveData, liveTiles); // Same source/tile ID, new Tile and atlas. + source->tile->atlases->glyph = std::make_shared(); + source->tile->atlases->icon = std::make_shared(); + prepare(); // Never invoke a method on the old Tile&, which has already gone. + EXPECT_FALSE(reuse(bucket)); + EXPECT_TRUE(group->empty()); + EXPECT_TRUE(oldAtlas.expired()); + EXPECT_EQ(*liveData, 1); + auto& rebuilt = add(bucket); + EXPECT_TRUE(SymbolRetainedLifetimeTestAccess::atlasResult(layer, rebuilt).complete); +} + +TEST_F(SymbolRetainedLifetime, TileLeavesCoverCleanupRemovesDrawable) { + const auto bucket = makeBucket(); + add(bucket); + releaseTiles(); + // The early update branch has no Context dependencies. Use removeAllDrawables, + // the production branch's cleanup entry, instead of fabricating a Context reference. + EXPECT_EQ(layer.removeAllDrawables(), 1u); + EXPECT_TRUE(group->empty()); + EXPECT_EQ(*liveData, 0); + EXPECT_EQ(*liveTiles, 0); +} + +TEST_F(SymbolRetainedLifetime, TextIconSdfAndHalosKeepIdentityAcrossRepeatedPrepare) { + const auto bucket = makeBucket(); + add(bucket); + add(bucket, style::SymbolType::Text, true); + add(bucket, style::SymbolType::IconRGBA); + add(bucket, style::SymbolType::IconSDF); + add(bucket, style::SymbolType::IconSDF, true); + std::vector identities; + std::vector tweakers; + std::vector> inputs; + group->visitDrawables([&](gfx::Drawable& drawable) { + identities.push_back(drawable.getID()); + tweakers.push_back(drawable.getTweakers().front()); + const auto result = SymbolRetainedLifetimeTestAccess::atlasResult(layer, drawable); + EXPECT_TRUE(result.complete); + inputs.push_back(result.bytes); + }); + for (unsigned generation = 0; generation < 3; ++generation) { + prepare(); + ASSERT_TRUE(reuse(bucket)); + ASSERT_EQ(group->getDrawableCount(), identities.size()); + std::size_t index = 0; + group->visitDrawables([&](gfx::Drawable& drawable) { + EXPECT_EQ(drawable.getID(), identities[index]); + EXPECT_EQ(drawable.getTweakers().front(), tweakers[index]); + EXPECT_EQ(drawable.getRenderTile(), &source->owner->front()); + const auto result = SymbolRetainedLifetimeTestAccess::atlasResult(layer, drawable); + EXPECT_TRUE(result.complete); + EXPECT_EQ(result.bytes, inputs[index]); + ++index; + }); + EXPECT_EQ(*liveData, 1); + } + // This checks production binding evidence, not complete GPU scene equivalence. +} + +TEST_F(SymbolRetainedLifetime, UnknownSymbolTypeRejectsEvidenceAndDropsWholeGroup) { + const auto bucket = makeBucket(); + add(bucket); + auto& drawable = add(bucket, style::SymbolType::Text, true); + const auto atlases = source->tile->atlases; + const auto tweaker = std::static_pointer_cast(drawable.getTweakers().front()); + drawable.setData(std::make_unique(true, false, static_cast(255), + style::AlignmentType::Viewport, style::AlignmentType::Viewport, style::SymbolPlacementType::Point, + style::IconTextFitType::None, false, atlases, tweaker)); + EXPECT_FALSE(SymbolRetainedLifetimeTestAccess::atlasResult(layer, drawable).complete); + EXPECT_FALSE(reuse(bucket)); + EXPECT_TRUE(group->empty()); +} diff --git a/粒子开发记录.md b/粒子开发记录.md index 9dcfd0768..cf924528f 100644 --- a/粒子开发记录.md +++ b/粒子开发记录.md @@ -1208,3 +1208,12 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2` - 2026-10-12 v25 GSRL 发行记录准备:跳过 v24,Android/RN 13.6.1-particle.25、iOS 6.29.0-particle.25,同源构建提交 f32f6f45fd359007e8b3c77911b66d9e7fc93848。统一构建 exit0、六项制品 NAS 公网回读逐字节 PASS、SwiftPM 发布 PASS;新增 Source Lock 与使用说明,最终 Tag/Push/remote verify 须以后续 Pipeline 结果为准。iOS arm64 预编译未变化资源 Assets.car 的来源/Hash 已另文记录;v25 真机运行 NOT_TESTED,STEP-119 CPU 结果不冒充 v25 运行时验证。 - 2026-10-12 v25 正式发行 CLOSED / GSRL PASS:Build Source=f32f6f45fd359007e8b3c77911b66d9e7fc93848;统一 Build/Publish/Finalize 均 exit0,六项公网制品重复回读 SHA/size PASS,SwiftPM 6.29.0-particle.25 已发布;正式 Tag maplibre-particle/rn/13.6.1-particle.25 指向 3774d0a8418daa36d7a988b2ce9650a6a1178fc4,远端 particle/main 与 Tag 在 Finalize 后相同。Source Lock 路径 release/maplibre-particle/13.6.1-particle.25/SOURCE_LOCK.json。v25 正式包真机运行、RN Host 联调 NOT_TESTED;不借 STEP119 旧 APK 的 CPU 结果替代。 + + +### 2026-10-12 STEP-119 SymbolRetained 生命周期修复续作 + +- 状态 IMPLEMENTED / WAITING_RDC_VALIDATION,接续此前中断四文件修改;Mac.lan particle/main@badb515d590fbdf221751b6677de3da0a5650d7e 保持不变。用户指定 GPT-6.1 Sol High(gpt-6.1-sol/high);本轮工具无执行器元数据、当日 sessions 路径不存在,实际参数未能独立核验,不宣称日志核实,无模型升级/子代理。 +- 读取 Astra 指导及当前规则/STEP119资料、现有 diff;沿用同源 owner/RenderTile 校验、整组 Bucket/Atlas 失配重建、稳定组刷新绑定与 Retained 强引用取证。证据保活 RenderTile/renderData/LayoutResult,不把 owner 地址写入稳定字节;底层 Tile& 寿命仍独立,原24Gate/证书/缓存与原地内容 epoch 保持。 +- 续作补 Retained 未知 Symbol 类型 fail-closed;修测试替身未覆盖 TileRenderData 查询导致基类 assert 的缺口,强化 Text/IconRGBA/IconSDF/Halo 连续 prepare 身份、组内类型失配及证书失效释放。SymbolRetainedLifetime 共13项测试源码,真实 GPU/Placement/完整 update 与资源释放仍待 RDC,不能声称实际测试 PASS。 +- 四源码路径 git diff --check exit0;只做轻量静态检查,未 CMake/编译/语法编译/链接/GTest/Gradle/APK/ADB/安装/Jest,无 commit/push/远端操作。保护 STEP110/111 两份未跟踪任务书及 v25 Source Lock/制品/API/Integration。 +- 新增 docs/50_Step记录/STEP-119_SymbolRetained生命周期修复_v1.md 与 docs/报告/STEP-119_SymbolRetained生命周期修复_Codex完成报告_v1.md(未覆盖旧版),报告含准确 RDC 命令、源码锁前置条件、FZ-G1 正负及动态生命周期/视觉/释放证据、风险与停止门槛;本记录仅末尾追加。立即交 RDC 独立验证,整体 STEP119 未关闭。