fix: localize retained segment source invalidation
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
tei
2026-10-06 17:18:09 +09:00
parent c4b481fd2c
commit 524aaf4af5
16 changed files with 1736 additions and 71 deletions

View File

@@ -0,0 +1,690 @@
# STEP-100 Source → RenderSegment 局部 Revision / Invalidation / Readiness 与独立 Fallback — Codex 任务 v2
## 0. 任务性质
这是 STEP-099 之后的正式修复任务。
STEP-099 已经完成纯诊断并 CLOSED / PASS,已确认 V17 的核心问题:
1. 动态 GeoJSON Source/Tile mutation 会推进 global retained content revision;
2. 即使 R0 不依赖该 GeoJSON,R0 retained cache 仍跟着 invalid/build;
3. GeoJSON 更新期间约 200 ms 的 transient `isLoaded=false` 会通过 global `RenderOrchestrator::isLoaded()` 把整个 retained path 拉成 `style-not-loaded`;
4. 当前 renderer 还存在 `useRetainedRanges` all-or-none gate:一个 RETAINED Segment fallback 时,会使其他本来可安全 reuse 的 RETAINED Segment 一起退出 retained path;
5. STEP-099 diagnostics 已能正确给 source mutation 计算 `affectedSegmentIndices`,但该依赖关系仍只是诊断,不参与正式 invalidation。
本任务目标是把 **Source/Tile 变化对 RETAINED Segment 的影响正式局部化**。
本任务不是 V18 发布任务。Codex 只负责实现与源码级说明;Android/iOS 构建、APK、ADB、设备测试、最终 Git push、V18 release 由 RDC/ChatGPT 在 Codex 完成后独立执行。
未经用户明确要求,本任务 **不加入、不运行 Jest**。
---
## 1. 冻结基线
工作目录:
`/Users/tei/dev/MapLibreParticleLab-step100`
分支:
`step100-segment-local-source-invalidation`
开始基线必须重新核对,当前预期:
- `origin/particle/main`
- HEAD:`c4b481fd2c5b5da20d31f6ae54011f0552d7dbd0`
- STEP-099 implementation:`8a5214d92dd2f8899db76d2194da42999fa0c87d`
- V17 frozen tag:`maplibre-particle/rn/13.4.1-particle.17`
- V17 frozen commit:`e8b451fe70054107773e82484c80e90df3889147`
禁止修改、移动、覆盖 V17 tag / release worktree。
STEP-100 是 V17 之后的新开发,最终只有 RDC 验收通过后才允许进入 V18 发布。
开始前必须读取:
- 当前仓库实际 HEAD / status / diff;
- 全局 Codex AGENTS;
- `ARCHITECTURE.md`(若存在);
- STEP-092 / 094 / 099 的任务、Step 记录、完成报告;
- 当前:
- `renderer_impl.cpp`
- `render_orchestrator.*`
- `retained_underlay_cache.*`
- `retained_underlay_runtime_state.hpp`
- `render_segment.*`
- STEP-099 diagnostics 与 Android fixture;
- 当前 RenderSegment / retained cache / particle fast path tests。
不得凭历史印象修改。
---
## 2. 最终架构原则
长期 RenderSegment 模型继续保持通用模型:
```text
Style Z-order
↓
R / C / R / C / R / ...
```
其中:
- R = RETAINED
- C = CONTINUOUS
本任务只用 **R-C-R** 做最小验证,不把 R-C-R 固化成最终架构。
必须保持:
1. Style layer 顺序是唯一 Z-order 真相;
2. Renderer 不理解 AIS / Route / Weather / GeoJSON 等业务名;
3. Source → Segment 依赖由当前实际 Style / RenderLayer / Source 关系推导;
4. 一个 Source 可依赖多个 Segment;
5. source-less layer 不制造假 dependency;
6. unknown/global dependency 必须 fail closed;
7. 相邻 Segment、多个 C、多个 R 的通用模型不能被破坏;
8. 本任务不实现 multi-continuous Fast Path。
---
## 3. 本任务必须实现的核心目标
### 3.1 Source/Tile mutation 局部化
假设:
```text
R0 depends [staticSource]
C depends [particleField]
R1 depends [dynamicGeoJson]
```
当:
```text
dynamicGeoJson mutation
```
正式 renderer 必须达到:
```text
R0:
content/revision 不因该 source mutation 改变
cache 不 invalid
cache 不 rebuild
持续 reuse
R1:
revision/invalidation 正常变化
按需要 build/reuse/fallback
C:
正常 continuous render
```
禁止继续出现:
```text
dynamicGeoJson mutation
↓
global revision 变化
↓
R0 + R1 一起 rebuild
```
### 3.2 保留 global revision 兼容信号
STEP-100 不要求删除现有 global `retainedUnderlayContentRevision`。
为了保持:
- diagnostics;
- 现有 fast-path correctness;
- 旧 API / compatibility;
- conservative fallback;
允许并推荐继续维护 global revision。
但 **RETAINED Segment cache 的正式内容 revision / invalidation 不能再只依赖这个 global source/tile revision**。
当前 `retainedUnderlayRevisionTracker` 同时消费 global content revision 与 `renderTreeParameters.loaded`。STEP-100 必须明确拆成两种用途:
```text
global retained revision / tracker
-> compatibility / Fast Path / diagnostics
segment-local retained content state
-> full CPU renderer 的每个 RETAINED Segment Build / Reuse / Fallback
```
**Segment-local cache content state 禁止继续直接消费:**
- global source/tile content revision;
- global `renderTreeParameters.loaded`。
否则即使 source-scoped revision 已实现,R0 仍会被无关 GeoJSON transient unloaded 或 global revision 连带失效。
允许 global revision / global tracker继续按原逻辑变化;禁止为了让测试 PASS 而停止 global revision 增长或放宽 Fast Path。
### 3.3 per-RETAINED-Segment readiness
不得修改任何 Source `isLoaded()` 定义。
不得修改:
```cpp
renderTreeParameters->loaded = updateParameters->styleLoaded && isLoaded();
```
这个 global loaded 仍作为 MapLibre 现有全局兼容事实。
但 retained segment 的 cache Build/Reuse/Fallback 必须有自己的安全 readiness 判断。
最低语义:
- Style 本身未 ready:fail closed;
- image manager / sprite / glyph 等无法安全定位到单 Segment 的全局前置条件未 ready:fail closed;
- Segment 已知依赖的 source 中任一缺失或 `isLoaded()==false`:仅该 Segment fail closed;
- Segment 已知依赖的 source `hasFadingTiles()==true`:仅该 Segment视为 unstable;
- **不被该 Segment 引用的 source unloaded 或 fading,不得让该 Segment cache fallback/rebuild**;
- `retainedRangeIsStable(begin,end)` 的 source fading 检查必须按该 range 的真实 source dependency 缩小,不能继续遍历全部 `renderSources` 作为 full-CPU retained gate;
- Fast Path 原有 global fading gate 保持不变;
- source ID 无法解析、dependency 缺失或真正无法证明安全时 fail closed。
必须区分:
1. **已知 Source dependency**;
2. **symbol/global prerequisite**(glyph/image/placement 等);
3. **真正 unknown dependency**。
STEP-099 的 `unknownDependencies` 目前会把 symbol 相关条件标为 unknown。STEP-100 不得机械地把“存在 symbol”解释成永久 fallback;symbol 所需 glyph/image/placement 应作为明确的 global prerequisite/fail-closed 条件处理。
因此 T3 中一个:
```text
no layer
enabled=false
isLoaded=false
```
的 RasterArray source 可以继续让 global `RenderOrchestrator::isLoaded()==false`,但不得因此让与它无依赖关系的 R0/R1 cache 全部 `style-not-loaded`。
本 Step 的运行态硬验收范围是 **MapMode::Continuous**(粒子正式实时渲染模式)。当前非 Continuous / still 路径如果在 global unloaded 时提前返回,本 Step 不强行改变其兼容语义;完成报告必须明确该适用范围。
### 3.4 RETAINED Segment 独立决策
当前 V17 路径存在类似:
```text
useRetainedRanges = all retained segments supported
```
的 all-or-none 语义。
STEP-100 必须改为每个 RETAINED Segment 独立拥有:
- readiness/support decision;
- Build / Reuse / Fallback;
- content/source revision;
- invalidation reason。
如果:
```text
R0 = Reuse
R1 = Fallback
```
则本帧必须允许:
```text
R0 composite cached texture
C live render
R1 live render fallback
```
而不是退化为“整个 Style 全部 live render”。
如果某 retained cache 自身 build 失败,应优先只 fallback 该 Segment;只有无法证明局部安全的 context/global resource failure 才允许全 retained fail closed。
---
## 4. Source-scoped revision 设计要求
Codex 必须基于当前代码选择最小、稳定设计,并在完成报告说明实际方案。
推荐方向但不强制具体类名:
- 为 Source/Tile mutation 维护 source-scoped monotonic revision;
- keyed by stable sourceId,但必须处理 source 删除后同 ID recreate,不能让新 source 错误继承可复用的旧 cache identity;可用 generation/epoch 或保留单调 tombstone 语义实现;
- source diff、feature-state mutation、tile changed 等所有当前会进入 Source/Tile retained content invalidation 的正式路径都必须覆盖;
- 能定位 sourceId 的 mutation 只推进对应 source revision;无法定位 sourceId 的 mutation继续 global fail closed;
- Segment content revision 由:
- 该 Segment 的 dependent source revisions;
- 当前 Segment layer fingerprint / layer mutation;
- 必须全局影响 retained 的 global revision 类别;
- topology / camera / viewport 等既有安全条件
共同决定。
Source/Tile mutation 不应推进“所有 Segment 都消费的 retained cache revision”。
必须避免:
- 用屏幕位置猜 dependency;
- 在 renderer 中硬编码 source/layer ID;
- 从 STEP-099 diagnostics JSON 反向驱动正式 renderer;
- 建立 diagnostics 与 production 两套互相漂移的 dependency 真相。
如果需要提取 production-grade dependency helper,应让 diagnostics 和正式逻辑共享同一只读推导源。正式 renderer 不得从 diagnostics JSON 或 `RetainedUnderlayRuntimeState` 反向取 dependency。
Source-scoped revision 属正式 correctness state,不得沿用 STEP-099 diagnostics 的“best-effort 记录失败可忽略”语义。正式 revision/dependency state 若无法可靠更新,必须 conservative global invalidation/fail closed。
---
## 5. Global mutation 仍可保守处理
本任务只要求 **Source/Tile mutation** 与明确 Source readiness 局部化。
以下无法安全局部化的事件,本轮允许继续 invalidate/fail closed 全 retained:
- context reset;
- style reload / topology change;
- image mutation;
- glyph mutation;
- sprite/image manager 全局 readiness;
- placement / symbol fade / glyph/image/sprite 全局状态;
- camera / transform / viewport / resize / pixel ratio;
- debug mode;
- 3D / unsupported / legacy ordering;
- 无法定位 dependency 的 mutation;
- 真正 unknown/global dependency。
注意:**Source 自身的 loaded / fading 不属于这里的 global fail-closed 项**;若 source dependency 可定位,必须按 Segment 局部判断。Fast Path 的 global source/fading gate除外,本轮保持原状。
完成报告必须列出:
- 已局部化的 mutation 类型;
- 仍为 global 的 mutation 类型;
- 为什么保守处理。
---
## 6. Fast Path 本轮严格不改
STEP-099 已确认 Fast Path 仍有 global gates:
- global update signature(sources/layers/images 等);
- global content revision;
- global fade;
- pendingChanges;
- placement/light/non-particle transition/repaint;
- annotation mutation;
- topology/boundary/revision/cache validity;
- `singleContinuousSegment`。
STEP-100 **禁止修改这些 gate 的语义**。
允许:
- Source mutation 后 Fast Path miss;
- transient source readiness 时 Fast Path fail closed;
- full CPU renderer 被调用。
但进入 full CPU renderer 后,必须能做到:
```text
R0 retained cache reuse
R1 rebuild/fallback
```
这正是本任务验收目标。
Multi-continuous Fast Path 明确不在本任务。
---
## 7. Render 顺序与混合 Build/Reuse/Fallback
实现 per-segment mixed decisions 时必须保持原 MapLibre 绘制语义:
- Style Z-order;
- global layerIndex;
- opaque reverse traversal;
- translucent forward traversal;
- currentLayer;
- depthRangeSize;
- legacy item 不双绘;
- debug overlay 不重复;
- retained texture composite 不改变 segment 顺序。
推荐的逻辑行为:
```text
for segment in Style order:
RETAINED + Build:
build cache
composite cache
RETAINED + Reuse:
composite cache
RETAINED + Fallback:
live render only this range
CONTINUOUS:
live render this range
```
实际代码可按现有 RenderPass 安全实现,不要求机械照搬伪代码。
必须特别检查:
- 一个 R fallback 不得导致另一个 R live redraw;
- resource prepare 在 Build 前失败时,必须先把该 Segment 本帧 decision 改为 Fallback,再进入主 buffer 绘制;
- retained build 中途失败时,必须 invalidate/标记该 Segment失败并把该 Segment 本帧 decision 改为 Fallback,禁止 composite 旧纹理;
- 如果失败意味着 context / shared GPU state 已不可信,允许全局 fail closed;
- fallback range 的 legacy items 只绘一次,不得因 per-segment 主循环重复 render;
- debug overlay 全帧只绘一次;
- Segment 间 Opaque/Translucent 语义不得被破坏;
- 相邻 R、多个 C、透明叠加与 legacy scene 不能因 mixed execution 出现漏绘/双绘。
推荐执行顺序:先完成各 Segment resource prepare 与必要离屏 Build;再 clear 主 buffer;最后按 Style Segment 顺序逐段选择 cached composite 或 live range render。
---
## 8. Diagnostics 必须继续可证明局部性
保留 STEP-099 diagnostics,并 additive 增加足够字段,使 RDC 能验证正式行为。
每个 RETAINED Segment 至少应能观察:
- dependentSourceIds;
- segment readiness / support;
- segment-local source/content revision 或等价可证明值;
- Build / Reuse / Fallback 的**最终本帧 execution decision**;
- build/reuse/fallback/invalidation count;
- last invalidation/fallback reason。
Source mutation 继续输出:
- sourceId;
- sequence/reason/action;
- affectedSegmentIndices。
要求:
```text
step099-dynamic mutation
affected=[R1]
R0 revision unchanged
R0 cache reuse++
R0 build unchanged
R0 invalidation unchanged
```
Diagnostics 仍是 pull/event low-frequency,不允许逐帧高频 spam。
---
## 9. Android Test App
复用 STEP-099,不另建独立 App。
至少保留并完善:
### T1 STATIC
验证静态 R-C-R:
- loaded settle;
- R0/R1 build 一次后稳定 reuse;
- 不产生持续 rebuild。
### T2 GEOJSON_1HZ
两个 GeoJSON source 交错,各约 1 Hz。
重点:
- global loaded transient false window 可以继续存在;
- 只引用某 source 的 retained Segment 才允许因该 source mutation 改变;
- 无关 retained Segment build/invalidation 不得跟随增长。
### T3 PERMANENT_UNREADY
保留一个:
- no layer;
- enabled=false;
- isLoaded=false;
- no field submission
的 RasterArray source。
预期:
- global renderOrchestratorLoaded 仍可为 false;
- 但无关 R0/R1 retained cache 不应因它持续 `style-not-loaded` fallback;
- 能安全判断依赖的 retained Segment应继续 build/reuse。
### T4 R-C-R(主验收)
必须保持清晰依赖:
```text
R0 -> dedicated static source only
C -> WeatherColor/Particle field
R1 -> dedicated dynamic GeoJSON source
```
运行动态 GeoJSON 约 1 Hz。
必须调整 fixture,避免 STEP-099 中 R1 再复用 R0 的 static source:
- R0 static source 只出现在 R0;
- R1 dynamic source 只出现在 R1;
- 若 MapLibre 内建 annotation source/layer 自动进入 R1,必须在 diagnostics 中列明,并保证验收窗口内 annotation 不发生 mutation;
- 不允许用业务 ID 特判 renderer。
RDC 验收区间必须支持“settled baseline → 60 s pure dynamic-source mutation window → final”对比。每个 aggregate sample 同时记录 mutation source/sequence、各 Segment local revision、readiness、最终 execution decision 与 cache counters。
---
## 10. STEP-100 硬验收标准
### A. R0 locality
T4 settle 后记录 baseline。
随后至少 60 s dynamic GeoJSON:
- R0 `buildDelta == 0`
- R0 `invalidationDelta == 0`
- R0 `reuseDelta > 0`
- R0 segment-local revision/content state 不因 R1 dynamic source mutation 改变
- 即使某采样点 global `renderOrchestratorLoaded=false`,只要 R0 自身 prerequisites/dependencies ready,R0 final decision 仍可为 Reuse
如果为了安全出现明确 global event,应在日志中可解释并重新选择纯动态 mutation 区间;不能把 global event 混入后声称 PASS。
### B. R1 reacts
同一时间窗:
- R1 必须能观察到 source mutation;
- R1 revision / build / invalidation / fallback 中至少有与动态 source 对应的实际变化;
- `affectedSegmentIndices` 必须只包含真实依赖 Segment。
### C. global revision compatibility
- global `retainedUnderlayContentRevision` 允许并预期继续增长;
- 不能通过停止 global revision 增长来制造 R0 PASS。
### D. readiness locality
T2 transient unloaded 或 T3 permanent unrelated unloaded 时:
- global loaded 可 false;
- R0 不得仅因无关 source 的 unloaded 而 invalid/rebuild/fallback;
- R0 cache 必须继续可复用。
### E. correctness
- 无 crash / ANR;
- 无 stale cache composite;
- 无 Segment Z-order 错乱;
- 无 fallback range 双绘;
- Cache OFF 仍安全走 live renderer;
- plan invalid 仍 fail closed。
任一硬门槛失败,STEP-100 不得宣布 PASS,不得进入 V18。
---
## 11. Native 非 Jest 测试要求
Codex 必须新增或扩展现有 C++ 测试源码,至少覆盖:
1. source A mutation 只改变依赖 A 的 Segment revision;
2. source A 被多个 Segment 共享时,对应多个 Segment 都变化;
3. unrelated Segment revision 不变;
4. source-less Segment 不制造 dependency;
5. unknown/global dependency fail closed;
6. unrelated source unloaded 不使安全 Segment readiness false;
7. dependent source unloaded 使对应 Segment readiness false;
8. R0 Reuse + R1 Fallback 的 mixed decision;
9. 单 Segment build failure 不 composite stale cache;
10. query diagnostics 不改变 revision/cache state;
11. global non-source invalidation 仍影响所有 retained;
12. source 删除后同 ID recreate 不误复用旧 Segment cache identity;
13. dependent source fading 只影响对应 Segment;unrelated source fading 不影响安全 Segment;
14. Cache OFF / invalid plan 行为不回归;
15. 至少有一组能证明实际 mixed render 没有漏绘/双绘的 renderer-level draw-count、pixel 或现有等价测试入口;不能只用 decision unit test 代替。
不得修改测试断言去适应错误实现。
---
## 12. 允许修改范围
允许:
- RenderOrchestrator source mutation/readiness bookkeeping;
- RenderSegment dependency production helper;
- Renderer retained per-segment revision/readiness/decision;
- RetainedUnderlayCache/RuntimeState 为局部状态增加必要字段;
- additive diagnostics JSON/TS;
- Android Validation Test App STEP-099 fixture/log;
- 相关 Native C++ tests;
- STEP-100 Step 记录/完成报告/开发记录。
禁止:
- NavSea;
- Integration;
- V17 tag/release worktree;
- Source `isLoaded()` 定义;
- Particle field/provider/network;
- Fast Path gate semantic;
- `singleContinuousSegment`;
- multi-continuous Fast Path;
- 自动改变 Style Z-order;
- provider/business-specific source/layer ID;
- unrelated cleanup/refactor;
- Jest。
---
## 13. Codex 执行边界
Codex 本轮负责:
1. 读取实际源码和任务书;
2. 实现代码;
3. 编写 Native 非 Jest 测试源码;
4. 更新 Android Test App 验证入口;
5. 执行轻量静态检查,如 `git diff --check`;
6. 生成中文完成报告。
Codex **不得执行**:
- Android Gradle / APK;
- ADB / 安装 / 实机;
- Xcode / iOS device;
- V18 release;
- tag;
- push;
- Jest。
本轮 Codex 完成报告固定:
`docs/报告/STEP-100_SourceScoped_RenderSegment局部失效_Codex完成报告_v1.md`
Step 记录固定:
`docs/50_Step记录/STEP-100_SourceScoped_RenderSegment局部失效_v1.md`
完成报告必须写清:
- 实际 Codex model / reasoning effort;
- start HEAD;
- 修改文件;
- 生产设计;
- source revision/readiness 规则;
- mixed retained decision 规则;
- Fast Path 明确未改;
- 测试源码新增情况;
- 实际执行 / NOT RUN;
- 风险与 RDC 验收点;
- 未 commit / 未 push(如果确实如此)。
Codex 不 commit、不 push。由 RDC 验证通过后统一 Git 收尾。
---
## 14. RDC / ChatGPT 后续独立验收
Codex 返回后由 RDC 负责:
1. source/diff 审计;
2. Native Mac 编译;
3. Native focused + broad related C++ tests;
4. Android x86_64 OpenGL validation APK 单次构建;
5. Mac ADB 直连 FZG1-2;
6. 自动跑 T1/T2/T3/T4;
7. 解析 R0/R1 build/reuse/invalidation/revision/readiness;
8. 确认硬验收标准 A~E;
9. 若失败,回到 STEP-100 修复,不发布;
10. 若全部 PASS,完成 STEP-100 Git push;
11. 再按现有统一发行流程升级到:
`maplibre-particle/rn/13.4.1-particle.18`
12. V18 build/release/public artifact 验证通过后才宣布 V18 发布完成。
---
## 15. 停止条件
Codex 遇到以下任一情况必须停止并报告,不得扩大范围:
- 要修改 Source `isLoaded()` 才能继续;
- 需要放宽 Fast Path gate;
- 必须改变 singleContinuous / multi-continuous;
- production dependency 无法可靠从现有 Style/RenderLayer/Source 推导;
- per-segment fallback 会破坏 RenderPass/Z-order 且无法在当前范围安全解决;
- 发现需要 NavSea/Integration 配合;
- 需要业务 layer/source ID;
- 需要改变 V17 release。
---
## 16. 本任务最终原则
STEP-100 的目标不是让“全局世界看起来 loaded”。
目标是:
> **即使某个 GeoJSON Source 正在变化或短暂 unready,只要 R0 与它没有依赖关系,R0 retained cache 就必须继续安全 reuse,不能被迫一起刷新。**
Fast Path 是否还能 miss,是后续独立问题。
只有这一点经过 Native + Android R-C-R 实测成立,才允许进入 V18 发布。

View File

@@ -0,0 +1,132 @@
# STEP-100 SourceScoped RenderSegment 局部失效记录 v1
状态:**CLOSED / PASS(Android runtime)**
日期:2026-10-06
## 基线
- worktree:`/Users/tei/dev/MapLibreParticleLab-step100`
- branch:`step100-segment-local-source-invalidation`
- start HEAD:`c4b481fd2c5b5da20d31f6ae54011f0552d7dbd0`
- V17 tag:`maplibre-particle/rn/13.4.1-particle.17`
- V17 frozen commit:`e8b451fe70054107773e82484c80e90df3889147`
## 实现
- Source/Tile mutation 增加 source-scoped revision;
- global retained content revision 继续保留;
- production renderer 与 diagnostics 共用 source→segment dependency;
- RETAINED Segment 独立 readiness / stability / Build / Reuse / Fallback;
- unrelated source unloaded/fading 不再拖垮该 Segment;
- full CPU mixed execution 支持 R0 Reuse + C Live + R1 Fallback;
- Fast Path gate、singleContinuous、多 Continuous语义未改;
- Source::isLoaded 与 global renderTree loaded 语义未改。
## Android Build
标准 validation pipeline 最终:
```text
RC=0
BUILD SUCCESS
variant=OpenglDebug
abi=x86_64
apk_bytes=61956640
libmaplibre_bytes=39636472
sha256=86e22bf081bf7d3196f0bf0e7c56af51bde01e9f0b01e0bab238cb794273315a
```
RDC 另修复 validation script 的 pipefail/SIGPIPE 141:
`awk ... {print $1; exit}` → 完整消费管道。
## FZG1-2
- Android 13
- API 33
- x86_64
- ADB:`192.168.200.139:5555`
### T1 STATIC
- false window:1
- avg/max:203 ms
- R0/R1 build=1 后持续 reuse
### T4 R-C-R 主验收
窗口:
- baseline:tick 8
- final:tick 130
- 约 61.6 s
global revision:
`128 → 973`
R0:
```text
build 1 → 1
invalidation 20 → 20
reuse 132 → 4042
sourceRevision unchanged
```
R1:
```text
build 5 → 135
invalidation 26 → 211
```
结论:
- R0 buildDelta=0
- R0 invalidationDelta=0
- R0 reuseDelta=+3910
- global revision 仍增长
- dynamic source affected=[2]
**主硬门槛 PASS。**
### T2 GEOJSON 1HZ
- 约 35 s
- falseWindowCount=6
- avg=203 ms
- max=205 ms
- R0 build=1 / invalidation=18 保持稳定,reuse 增长
### T3 PERMANENT UNREADY
global loaded=false 持续约 21.468 s。
unready source:
- no layer
- enabled=false
- isLoaded=false
R0/R1:
- build=1 不变
- invalidation=19 不变
- reuse:11 → 1200
- decision=reuse
说明 global loaded 兼容事实仍为 false,但无关 retained cache 继续安全复用。
## Native Host Test
Codex 已新增 `RenderSegmentLocality.*` 测试源码。
当前 Intel Mac 缺 `libuv`,Metal host configure 被环境阻塞。RDC 曾尝试 Homebrew 安装,但发现会连带升级 CMake/OpenSSL/Python 且 Intel 无 bottle 支持,已主动停止。
因此:
- Native host GTest:ENV BLOCKED / NOT RUN
- Android full native build:PASS
- Android runtime:PASS
Jest:NOT RUN。
## 结论
STEP-100 功能目标成立:
> GeoJSON Source 更新只刷新依赖它的 retained segment;无关 retained segment 不再一起刷新。
允许进入 V18 unified release。

View File

@@ -0,0 +1,454 @@
# STEP-100 SourceScoped RenderSegment 局部失效 — 完成报告 v1
## 1. 最终结论
**CLOSED / PASS(功能与 Android 运行态目标确认)**。
Codex 实现使用:
- model:`gpt-6-sol`
- reasoning effort:`high`
RDC 独立完成:
- 源码 diff 审计;
- Android x86_64 OpenGL 全量 native build;
- Validation APK;
- FZG1-2 自动安装与 T1/T2/T3/T4;
- STEP-100 60 秒 locality window;
- validation build script 小型工程修复;
- Git diff/status 审计。
本 Step 核心目标已确认:
> 动态 GeoJSON Source 更新只影响依赖它的 RETAINED Segment;无关 R0 cache 不再跟着 invalid/rebuild,并且在 global loaded=false 时仍可安全 reuse。
Native host GTest 因当前 Intel Mac 缺 `libuv` 未执行;曾尝试 Homebrew 安装,但 Homebrew 要连带升级 CMake/OpenSSL/Python 且 Intel 已无 bottle 支持,因此 RDC 主动终止,未把环境变化作为任务依赖。Android 最终 C++ native build 与设备运行态用于本轮功能验收。
Jest:**NOT RUN**,用户未要求。
## 2. Git / 基线
- worktree:`/Users/tei/dev/MapLibreParticleLab-step100`
- branch:`step100-segment-local-source-invalidation`
- start HEAD:`c4b481fd2c5b5da20d31f6ae54011f0552d7dbd0`
- start remote:`origin/particle/main = c4b481fd2c5b5da20d31f6ae54011f0552d7dbd0`
- frozen V17 tag:`maplibre-particle/rn/13.4.1-particle.17`
- frozen V17 commit:`e8b451fe70054107773e82484c80e90df3889147`
- V17 tag/release worktree:未修改。
最终 STEP-100 commit / push / remote HEAD 在 Git 收尾后由最终交付回读记录,避免文档自引用。
## 3. 实际修改
生产源码:
- `include/mbgl/renderer/render_segment.hpp`
- `src/mbgl/renderer/render_orchestrator.hpp`
- `src/mbgl/renderer/render_orchestrator.cpp`
- `src/mbgl/renderer/renderer_impl.cpp`
- `src/mbgl/renderer/retained_underlay_cache.cpp`
- `src/mbgl/renderer/retained_underlay_runtime_state.hpp`
- `src/mbgl/renderer/render_segment_json.hpp`
- `packages/maplibre-particle-rn/src/types.ts`
Test App / tests:
- `platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/Step099ReadinessFixture.kt`
- `platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt`
- `test/renderer/retained_underlay_cache.test.cpp`
工具:
- `tools/particle_android_validation/build_validation_apk.sh`
- RDC 修复 `unzip | awk ... exit` 在 `pipefail` 下偶发 SIGPIPE/141;
- 仅移除 awk 的提前 exit,不改变 APK 验证语义。
文档:
- `docs/40_任务/STEP-100_SourceScoped_RenderSegment局部失效_Codex任务_v2.md`
- `docs/50_Step记录/STEP-100_SourceScoped_RenderSegment局部失效_v1.md`
- 本报告;
- `粒子开发记录.md`
## 4. 生产设计
### 4.1 Source-scoped revision
新增正式 source-scoped revision bookkeeping。
明确的 Source/Tile mutation:
- source diff;
- feature state;
- tile changed;
推进对应 sourceId revision。
现有 global `retainedUnderlayContentRevision` 继续增长,供:
- compatibility;
- diagnostics;
- Fast Path fail-closed。
RETAINED Segment cache 不再直接以 global Source/Tile revision 作为内容 revision。
### 4.2 Segment dependency
正式 renderer 与 diagnostics 共用 source→segment 依赖推导。
规则:
- layer sourceId 决定 source dependency;
- source-less background 不制造假 dependency;
- shared source 可影响多个 segment;
- symbol 的 glyph/image/placement 作为明确 global prerequisite;
- 真正无法证明的 dependency fail closed。
### 4.3 Per-segment readiness
未修改:
- `Source::isLoaded()`;
- `renderTreeParameters->loaded = styleLoaded && isLoaded()`。
full CPU retained path 增加 per-segment source readiness/stability:
- dependent source unloaded → 仅该 Segment fallback;
- dependent source fading → 仅该 Segment unstable;
- unrelated source unloaded/fading → 不影响该 Segment;
- style/image/sprite/glyph/placement/context 等 global prerequisite 仍 fail closed。
### 4.4 Mixed Build / Reuse / Fallback
移除 retained full-CPU path 的 all-or-none `useRetainedRanges` 依赖。
每个 RETAINED Segment独立决定:
- Build
- Reuse
- Fallback
主 buffer 按 Style Segment 顺序执行:
- RETAINED Build/Reuse → composite cache
- RETAINED Fallback → live render only that range
- CONTINUOUS → live render
因此允许:
```text
R0 = Reuse
C = Live
R1 = Fallback
```
不再因为 R1 fallback 把 R0 一起变成 live redraw。
Fast Path gate / `singleContinuousSegment` / multi-continuous Fast Path:**未修改**。
## 5. Android build
首次标准 validation script 的 Gradle 本体实际结果:
```text
BUILD SUCCESSFUL in 12s
86 actionable tasks: 6 executed, 80 up-to-date
```
但脚本在 Gradle 成功后以 141 退出。RDC 用 `bash -x` 精确定位到:
```bash
LIB_BYTES="$(unzip ... | awk '/.../ {print $1; exit}')"
```
在 `set -o pipefail` 下触发 SIGPIPE。
RDC 仅去掉 awk 的 `exit`,重跑标准脚本:
```text
RC=0
[validation-apk] BUILD SUCCESS
```
最终 APK:
- variant:OpenglDebug
- ABI:x86_64
- apk bytes:61,956,640
- libmaplibre.so bytes:39,636,472
- SHA-256:
`86e22bf081bf7d3196f0bf0e7c56af51bde01e9f0b01e0bab238cb794273315a`
证据:
`artifacts/maplibre-particle/android-validation/`
## 6. 设备
- serial:`192.168.200.139:5555`
- model:FZG1-2
- Android:13
- API:33
- ABI:x86_64
APK 覆盖安装成功,`ParticleCoreLayerValidationActivity` 冷启动成功。
STEP-100 log:
`artifacts/maplibre-particle/android-validation/step100-logcat.txt`
log SHA-256:
`e9d18ea987d12cb30e321d0d9c669b2588972aac89e5277920bf1fa9fee8146b`
## 7. T1 STATIC
运行约 20 秒。
aggregate:
```text
falseWindowCount=1
falseWindowAvgMs=203
falseWindowMaxMs=203
openFalseWindowMs=0
```
最终:
- R0 build=1
- R0 reuse=1217
- R0 decision=reuse
- R1 build=1
- R1 reuse=1187
- R1 decision=reuse
静态 R-C-R 正常。
## 8. T4 R-C-R 主验收
结构:
```text
R0 -> step099-static
C -> particle-fixture
R1 -> step099-dynamic (+ framework annotation source if present)
```
window marker:
```text
baseline tick=8 17:05:21.642
final tick=130 17:06:23.240
```
窗口约 61.6 秒。
dynamic update 总计:68 次。
global revision:
```text
128 -> 973
```
说明 global compatibility revision **仍正常增长**,没有通过停掉 global revision“作弊”。
### R0
baseline:
```text
build=1
reuse=132
invalidation=20
srcRev=5127273714767523329
decision=reuse
```
final:
```text
build=1
reuse=4042
invalidation=20
srcRev=5127273714767523329
decision=reuse
```
Delta:
```text
buildDelta = 0
invalidationDelta = 0
reuseDelta = +3910
sourceRevision = unchanged
```
**硬门槛 PASS。**
### R1
baseline:
```text
build=5
invalidation=26
```
final:
```text
build=135
invalidation=211
```
动态 Source mutation 正常驱动 R1。
mutation diagnostics 持续:
```text
sourceId=step099-dynamic
affected=[2]
scope=source-local-diagnostic
```
### loaded false windows
T4:
```text
falseWindowCount=5
falseWindowAvgMs=202
falseWindowMaxMs=203
openFalseWindowMs=0
```
即 transient global loaded=false 仍存在,但不再让 R0 build/invalidation 增长。
## 9. T2 双 GeoJSON readiness 回归
约 35 秒。
aggregate:
```text
falseWindowCount=6
falseWindowAvgMs=203
falseWindowMaxMs=205
openFalseWindowMs=0
```
R0 在动态窗口保持:
- build=1
- invalidation=18
- revision unchanged
- reuse 持续增长。
R1 随两个动态 GeoJSON 持续 build/invalidate。
结论:
**DYNAMIC_SOURCE_READINESS_THRASH 仍是 global loaded 事实,但不再误伤无关 retained cache。**
## 10. T3 permanently unready unrelated source
`step099-unready-raster-array`:
- no layer;
- enabled=false;
- isLoaded=false。
global:
```text
renderOrchestratorLoaded=false
openFalseWindowMs=21468
```
约 21.468 秒持续 false。
与此同时:
R0:
- build=1
- invalidation=19
- reuse:11 → 1200
- decision=reuse
R1:
- build=1
- invalidation=19
- reuse:11 → 1200
- decision=reuse
因此:
> global loaded 仍保持旧兼容语义,但 unrelated permanently-unready source 已不能把无关 RETAINED Segment 拖成 style-not-loaded fallback。
**硬门槛 PASS。**
## 11. Mixed fallback
T4 中 R1 `fallbackCount` 从约 21 增长到 76,而 R0 fallback 计数保持不变,同时 R0 reuse 持续增长。
这证明运行期间真实发生了:
```text
R0 = Reuse
R1 = transient Fallback / rebuild
```
而不是只有“两个段都 ready”的理想场景。
未观察到 crash / ANR。
## 12. Fast Path 边界
RDC diff 审计确认未修改:
- `tryRenderParticleFastFrame`
- `particleFastPathNonCameraInputsMatch`
- `singleContinuousSegment`
- multi-continuous fail-closed
- global Fast Path signature/fade/contentRevision/pending/placement 等 gate。
STEP-100 只修 full CPU retained locality。
## 13. Native host tests
Codex 已新增 `RenderSegmentLocality.*` C++ 测试源码。
RDC 尝试恢复 `macos-metal` test runner,但 host configure 需要 `libuv`,当前未安装。
尝试 `brew install libuv` 时发现 Intel Homebrew 会连带升级:
- CMake
- OpenSSL
- Python
- ca-certificates 等
且当前 Intel macOS 已无官方 bottle 支持。为避免污染开发环境,RDC 主动停止。
因此:
- Native host GTest:**ENV BLOCKED / NOT RUN**
- Android C++ full native build:**PASS**
- Android device runtime:**PASS**
## 14. Jest / iOS
- Jest:NOT RUN。
- iOS runtime:本 Step 未运行。
- V18 unified release:在 STEP-100 Git 收尾后单独执行。
## 15. 最终判定
STEP-100 的核心实现目标全部成立:
1. global revision 继续增长;
2. R1 GeoJSON mutation 不再 invalid/rebuild R0;
3. R0 buildDelta=0;
4. R0 invalidationDelta=0;
5. R0 reuseDelta>0;
6. transient unrelated unloaded 不影响 R0 reuse;
7. permanent unrelated unloaded 不影响 R0 reuse;
8. mixed Reuse/Fallback 实际发生;
9. Fast Path 语义未放宽。
因此本 Step 功能与 Android runtime 验收:
**PASS / CLOSED**。
下一步允许进入 V18 unified release。

View File

@@ -2,7 +2,9 @@
#include <cstdint>
#include <optional>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>
namespace mbgl {
@@ -38,6 +40,82 @@ struct StyleLayerDiagnostics {
bool operator==(const StyleLayerDiagnostics&) const = default;
};
// The style layer list is the single dependency source for diagnostics and rendering.
struct RenderSegmentDependencies {
std::vector<std::string> sourceIds;
std::vector<std::string> globalPrerequisites;
bool unknown = false;
};
inline RenderSegmentDependencies renderSegmentDependencies(const std::vector<StyleLayerDiagnostics>& layers,
int32_t begin,
int32_t endExclusive) {
RenderSegmentDependencies result;
if (begin < 0 || endExclusive < begin || endExclusive > static_cast<int32_t>(layers.size())) {
result.unknown = true;
return result;
}
std::set<std::string> sources;
std::set<std::string> prerequisites;
for (int32_t i = begin; i < endExclusive; ++i) {
const auto& layer = layers[static_cast<std::size_t>(i)];
if (layer.sourceId) sources.insert(*layer.sourceId);
if (layer.layerType == "symbol") prerequisites.insert("symbol/glyph/image/placement");
if (layer.layerType == "custom" || layer.layerType == "custom-drawable") result.unknown = true;
if (layer.layerType != "background" && layer.layerType != "custom" &&
layer.layerType != "custom-drawable" && !layer.sourceId) result.unknown = true;
}
result.sourceIds.assign(sources.begin(), sources.end());
result.globalPrerequisites.assign(prerequisites.begin(), prerequisites.end());
return result;
}
class RenderSegmentRevisionBook {
public:
void advanceGlobal() noexcept { ++globalEpoch; }
void advanceSource(const std::string& sourceId) { sourceRevisions[sourceId] = ++sequence; }
std::optional<uint64_t> revisionFor(const RenderSegmentDependencies& dependencies) const {
if (dependencies.unknown) return std::nullopt;
uint64_t revision = globalEpoch;
for (const auto& id : dependencies.sourceIds) {
const auto found = sourceRevisions.find(id);
if (found == sourceRevisions.end()) return std::nullopt;
for (const unsigned char ch : id) revision = (revision ^ ch) * 1099511628211ULL;
revision = (revision ^ found->second) * 1099511628211ULL;
}
return revision;
}
private:
uint64_t globalEpoch = 1;
uint64_t sequence = 1;
// Retain tombstones: remove + recreate of the same ID always receives a newer identity.
std::unordered_map<std::string, uint64_t> sourceRevisions;
};
struct RenderSegmentSourceStatus { bool loaded = false; bool fading = false; };
struct RenderSegmentSourceAssessment {
bool ready = false;
bool stable = false;
const char* reason = "unknown-dependency";
};
template <typename Lookup>
RenderSegmentSourceAssessment assessRenderSegmentSources(const RenderSegmentDependencies& dependencies,
Lookup lookup) {
RenderSegmentSourceAssessment result;
if (dependencies.unknown) return result;
for (const auto& id : dependencies.sourceIds) {
const std::optional<RenderSegmentSourceStatus> source = lookup(id);
if (!source) { result.reason = "missing-source"; return result; }
if (!source->loaded) { result.reason = "source-not-loaded"; return result; }
if (source->fading) { result.ready = true; result.reason = "source-fading"; return result; }
}
result.ready = true;
result.stable = true;
result.reason = "none";
return result;
}
struct RenderSourceReadinessDiagnostics {
std::string sourceId;
std::string sourceType;
@@ -88,6 +166,13 @@ struct RenderSegmentCacheDiagnostics {
uint64_t build = 0;
uint64_t reuse = 0;
uint64_t invalidation = 0;
uint64_t fallbackCount = 0;
std::string finalDecision{"disabled"};
std::string lastFallbackReason{"none"};
bool sourceReady = false;
bool sourceStable = false;
bool supported = false;
uint64_t sourceRevision = 0;
std::string lastInvalidationReason{"none"};
uint64_t textureCreate = 0;
uint64_t compositeCreate = 0;
@@ -163,4 +248,24 @@ struct ResolvedRenderSegmentPlan {
bool operator==(const ResolvedRenderSegmentPlan&) const = default;
};
// This is the main-buffer traversal used by the renderer. A failed retained range
// is drawn live exactly once; neighboring cached ranges remain composites.
template <typename Cached, typename Composite, typename Live>
void visitRenderSegmentExecution(const ResolvedRenderSegmentPlan& plan,
Cached cached,
Composite composite,
Live live) {
if (!plan.valid) {
live(0, plan.layerCount);
return;
}
for (const auto& segment : plan.segments) {
if (segment.strategy == RenderSegmentStrategy::Retained && cached(segment.segmentIndex)) {
composite(segment.segmentIndex);
} else {
live(segment.beginIndex, segment.endExclusive);
}
}
}
} // namespace mbgl

View File

@@ -97,6 +97,13 @@ export type RetainedRenderSegmentDiagnostics = Readonly<{
build: number;
reuse: number;
invalidation: number;
fallbackCount: number;
finalDecision: string;
lastFallbackReason: string;
sourceReady: boolean;
sourceStable: boolean;
supported: boolean;
sourceRevision: number;
lastInvalidationReason: string;
textureCreate: number;
compositeCreate: number;

View File

@@ -122,6 +122,10 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
if (step099Tick % 2L == 0L) {
updateStep099GeoJson(Step099ReadinessFixture.DYNAMIC_SOURCE, step099Tick)
refreshStep099Diagnostics(log = true)
if (step099Tick == 8L || step099Tick == 130L) {
Log.i(TAG, "STEP100_WINDOW_MARKER phase=${if (step099Tick == 8L) "baseline" else "final"} " +
"tick=$step099Tick sourceId=${Step099ReadinessFixture.DYNAMIC_SOURCE}")
}
}
}
else -> if (step099Tick % 2L == 0L) refreshStep099Diagnostics(log = true)
@@ -675,7 +679,12 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
} else {
"cache=${cache.optString("cacheState")}@${cache.optLong("contentRevision")}" +
" b=${cache.optLong("build")} r=${cache.optLong("reuse")}" +
" i=${cache.optLong("invalidation")}/${cache.optString("lastInvalidationReason")}"
" i=${cache.optLong("invalidation")}/${cache.optString("lastInvalidationReason")}" +
" srcRev=${cache.optLong("sourceRevision")}" +
" ready=${cache.optBoolean("sourceReady")}/${cache.optBoolean("sourceStable")}" +
" supported=${cache.optBoolean("supported")}" +
" decision=${cache.optString("finalDecision")}" +
" fallback=${cache.optLong("fallbackCount")}/${cache.optString("lastFallbackReason")}"
}
add("${segment.optInt("segmentIndex")}:${segment.optString("strategy")}" +
"[${segment.optInt("beginIndex")},${segment.optInt("endExclusive")})" +

View File

@@ -3,7 +3,7 @@ package org.maplibre.android.testapp.activity.particle
import org.json.JSONArray
import org.json.JSONObject
/** STEP-099 offline readiness/invalidation fixtures. Diagnostics only; no renderer semantics are changed. */
/** STEP-099/100 offline source readiness and retained segment fixtures. */
internal object Step099ReadinessFixture {
enum class Scenario(val label: String) {
T1_STATIC("T1 STATIC"),
@@ -14,6 +14,7 @@ internal object Step099ReadinessFixture {
const val PARTICLE_SOURCE = "particle-fixture"
const val STATIC_SOURCE = "step099-static"
const val UPPER_STATIC_SOURCE = "step099-upper-static"
const val DYNAMIC_SOURCE = "step099-dynamic"
const val DYNAMIC_SOURCE_B = "step099-dynamic-b"
const val UNREADY_SOURCE = "step099-unready-raster-array"
@@ -41,6 +42,11 @@ internal object Step099ReadinessFixture {
"""{"type":"Point","coordinates":[135.0,28.0]}"""
))
}
if (scenario == Scenario.T1_STATIC || scenario == Scenario.T3_PERMANENT_UNREADY) {
sources.put(UPPER_STATIC_SOURCE, geoJsonSource(
"""{"type":"Polygon","coordinates":[[[116,4],[154,4],[154,52],[116,52],[116,4]]]}"""
))
}
if (scenario == Scenario.T2_GEOJSON_1HZ) {
sources.put(DYNAMIC_SOURCE_B, geoJsonSource(
"""{"type":"Point","coordinates":[136.0,29.0]}"""
@@ -100,11 +106,13 @@ internal object Step099ReadinessFixture {
.put("circle-color", "#ff6600")
.put("circle-opacity", 0.90)))
}
if (scenario == Scenario.T1_STATIC || scenario == Scenario.T3_PERMANENT_UNREADY) {
layers.put(JSONObject()
.put("id", UPPER_STATIC_LAYER)
.put("type", "fill")
.put("source", STATIC_SOURCE)
.put("source", UPPER_STATIC_SOURCE)
.put("paint", JSONObject().put("fill-color", "#d3a640").put("fill-opacity", 0.38)))
}
return JSONObject()
.put("version", 8)

View File

@@ -227,11 +227,38 @@ void RenderOrchestrator::recordReadinessMutation(const char* eventType,
}
}
void RenderOrchestrator::advanceRetainedSourceRevision(RetainedUnderlayReason reason,
const std::string& sourceId) noexcept {
if (sourceId.empty()) {
std::lock_guard<std::mutex> lock(readinessDiagnosticsMutex);
retainedSegmentRevisions.advanceGlobal();
return;
}
try {
std::lock_guard<std::mutex> lock(readinessDiagnosticsMutex);
retainedSegmentRevisions.advanceSource(sourceId);
} catch (...) {
// Correctness state must fail closed if a source revision cannot be recorded.
std::lock_guard<std::mutex> lock(readinessDiagnosticsMutex);
retainedSegmentRevisions.advanceGlobal();
}
recordReadinessMutation(readinessEventType(reason), reason, sourceId);
}
void RenderOrchestrator::advanceRetainedUnderlayContentRevision(
RetainedUnderlayReason reason,
const std::optional<std::string>& sourceId) noexcept {
retainedUnderlayContentReason.store(reason, std::memory_order_release);
retainedUnderlayContentRevision.fetch_add(1, std::memory_order_acq_rel);
if (sourceId && (reason == RetainedUnderlayReason::SourceMutation ||
reason == RetainedUnderlayReason::TileMutation)) {
advanceRetainedSourceRevision(reason, *sourceId);
return;
}
{
std::lock_guard<std::mutex> lock(readinessDiagnosticsMutex);
retainedSegmentRevisions.advanceGlobal();
}
recordReadinessMutation(readinessEventType(reason), reason, sourceId);
}
@@ -484,15 +511,16 @@ std::unique_ptr<RenderTree> RenderOrchestrator::createRenderTree(
const SourceDifference sourceDiff = diffSources(sourceImpls, updateParameters->sources);
if (!sourceDiff.added.empty() || !sourceDiff.removed.empty() || !sourceDiff.changed.empty()) {
advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::SourceMutation);
retainedUnderlayContentReason.store(RetainedUnderlayReason::SourceMutation, std::memory_order_release);
retainedUnderlayContentRevision.fetch_add(1, std::memory_order_acq_rel);
for (const auto& entry : sourceDiff.added) {
recordReadinessMutation("SourceMutation", RetainedUnderlayReason::SourceMutation, entry.first);
advanceRetainedSourceRevision(RetainedUnderlayReason::SourceMutation, entry.first);
}
for (const auto& entry : sourceDiff.removed) {
recordReadinessMutation("SourceMutation", RetainedUnderlayReason::SourceMutation, entry.first);
advanceRetainedSourceRevision(RetainedUnderlayReason::SourceMutation, entry.first);
}
for (const auto& entry : sourceDiff.changed) {
recordReadinessMutation("SourceMutation", RetainedUnderlayReason::SourceMutation, entry.first);
advanceRetainedSourceRevision(RetainedUnderlayReason::SourceMutation, entry.first);
}
}
sourceImpls = updateParameters->sources;
@@ -582,11 +610,13 @@ std::unique_ptr<RenderTree> RenderOrchestrator::createRenderTree(
if (!impl.sourceLayer.empty()) value.sourceLayer = impl.sourceLayer;
styleLayerDiagnostics.push_back(std::move(value));
}
retainedStyleLayers = styleLayerDiagnostics;
updateParameters->retainedUnderlayState->recordRenderSegmentResolution(
renderSegmentPlan, std::move(styleLayerDiagnostics));
}
} else {
renderSegmentPlan = {};
retainedStyleLayers.clear();
}
// Update all sources and initialize renderItems.
@@ -1119,6 +1149,52 @@ bool RenderOrchestrator::retainedRangeIsStable(TimePoint timePoint, int32_t begi
return true;
}
RenderOrchestrator::RetainedSegmentSourceState RenderOrchestrator::retainedSegmentSourceState(
int32_t begin, int32_t endExclusive) const {
RetainedSegmentSourceState result;
const auto dependencies = renderSegmentDependencies(retainedStyleLayers, begin, endExclusive);
if (dependencies.unknown) return result;
// The global epoch contains only changes whose effect cannot be scoped to one source.
{
std::lock_guard<std::mutex> lock(readinessDiagnosticsMutex);
const auto value = retainedSegmentRevisions.revisionFor(dependencies);
if (!value) return result;
result.revision = *value;
}
const auto assessment = assessRenderSegmentSources(dependencies, [&](const std::string& sourceId)
-> std::optional<RenderSegmentSourceStatus> {
const auto source = renderSources.find(sourceId);
if (source == renderSources.end()) return std::nullopt;
return RenderSegmentSourceStatus{source->second->isLoaded(), source->second->hasFadingTiles()};
});
result.ready = assessment.ready;
result.stable = assessment.stable;
result.reason = assessment.reason;
return result;
}
bool RenderOrchestrator::retainedSegmentIsStable(TimePoint timePoint,
int32_t begin,
int32_t endExclusive) const {
if (renderLight.hasTransition()) return false;
if (retainedRangeUsesPlacement(begin, endExclusive) && placementController.hasTransitions(timePoint)) return false;
const auto first = std::clamp<int32_t>(begin, 0, static_cast<int32_t>(orderedLayers.size()));
const auto last = std::clamp<int32_t>(endExclusive, first, static_cast<int32_t>(orderedLayers.size()));
for (int32_t index = first; index < last; ++index) {
const auto& layer = orderedLayers[index].get();
if (layer.hasTransition() || layer.needsRepaint()) return false;
}
return true;
}
bool RenderOrchestrator::retainedGlobalResourcesReady() const {
return imageManager->isLoaded();
}
bool RenderOrchestrator::retainedGlobalLightTransition() const {
return renderLight.hasTransition();
}
bool RenderOrchestrator::retainedUnderlayHasUnsupportedLayer(int32_t boundary) const {
return retainedRangeHasUnsupportedLayer(0, boundary);
}

View File

@@ -128,6 +128,16 @@ public:
bool retainedUnderlayHasUnsupportedLayer(int32_t boundary) const;
bool retainedRangeUsesPlacement(int32_t begin, int32_t endExclusive) const;
bool retainedRangeIsStable(TimePoint, int32_t begin, int32_t endExclusive) const;
struct RetainedSegmentSourceState {
uint64_t revision = 0;
bool ready = false;
bool stable = false;
const char* reason = "unknown-dependency";
};
RetainedSegmentSourceState retainedSegmentSourceState(int32_t begin, int32_t endExclusive) const;
bool retainedSegmentIsStable(TimePoint, int32_t begin, int32_t endExclusive) const;
bool retainedGlobalResourcesReady() const;
bool retainedGlobalLightTransition() const;
bool retainedRangeHasUnsupportedLayer(int32_t begin, int32_t endExclusive) const;
bool continuousRangeSupportsParticleFastPath(int32_t begin, int32_t endExclusive) const;
uint64_t retainedRangeContentFingerprint(int32_t begin, int32_t endExclusive) const;
@@ -336,6 +346,7 @@ private:
void advanceRetainedUnderlayContentRevision(
RetainedUnderlayReason,
const std::optional<std::string>& sourceId = std::nullopt) noexcept;
void advanceRetainedSourceRevision(RetainedUnderlayReason, const std::string& sourceId) noexcept;
void recordReadinessMutation(const char* eventType,
RetainedUnderlayReason reason,
const std::optional<std::string>& sourceId = std::nullopt,
@@ -372,6 +383,7 @@ private:
std::string retainedGlyphURL;
const void* retainedFontFacesIdentity = nullptr;
std::atomic<uint64_t> retainedUnderlayContentRevision{1};
RenderSegmentRevisionBook retainedSegmentRevisions;
std::atomic<RetainedUnderlayReason> retainedUnderlayContentReason{RetainedUnderlayReason::None};
struct SourceReadinessEventState {
@@ -394,6 +406,7 @@ private:
// reallocation on each frame.
std::vector<Immutable<style::LayerProperties>> filteredLayersForSource;
RenderLayerReferences orderedLayers;
std::vector<StyleLayerDiagnostics> retainedStyleLayers;
ResolvedRenderSegmentPlan renderSegmentPlan;
bool renderSegmentStyleReady = false;
RenderLayerReferences layersNeedPlacement;

View File

@@ -198,6 +198,13 @@ inline std::string renderSegmentDiagnosticsToJSON(const RenderSegmentDiagnostics
writer.Key("build"); writer.Uint64(cache.build);
writer.Key("reuse"); writer.Uint64(cache.reuse);
writer.Key("invalidation"); writer.Uint64(cache.invalidation);
writer.Key("fallbackCount"); writer.Uint64(cache.fallbackCount);
writer.Key("finalDecision"); writer.String(cache.finalDecision.c_str());
writer.Key("lastFallbackReason"); writer.String(cache.lastFallbackReason.c_str());
writer.Key("sourceReady"); writer.Bool(cache.sourceReady);
writer.Key("sourceStable"); writer.Bool(cache.sourceStable);
writer.Key("supported"); writer.Bool(cache.supported);
writer.Key("sourceRevision"); writer.Uint64(cache.sourceRevision);
writer.Key("lastInvalidationReason"); writer.String(cache.lastInvalidationReason.c_str());
writer.Key("textureCreate"); writer.Uint64(cache.textureCreate);
writer.Key("compositeCreate"); writer.Uint64(cache.compositeCreate);

View File

@@ -551,17 +551,17 @@ void Renderer::Impl::render(const RenderTree& renderTree,
const auto retainedTopologyRevision = particleFastFrame ? retainedUnderlayTopologyTracker.current()
: retainedUnderlayTopologyTracker.observe(retainedTopology);
bool legacyInRetainedRange = false;
std::vector<bool> legacyInSegment(segmentPlan.segments.size(), false);
if (retainedMode) {
for (const auto& item : layerRenderItems) {
const auto index = static_cast<int32_t>(item.index);
for (const auto& segment : segmentPlan.segments) {
if (segment.strategy == RenderSegmentStrategy::Retained && index >= segment.beginIndex &&
index < segment.endExclusive && item.layer.get().usesLegacyRendering()) {
legacyInSegment[segment.segmentIndex] = true;
legacyInRetainedRange = true;
break;
}
}
if (legacyInRetainedRange) break;
}
}
bool retainedStaticUsesPlacement = false;
@@ -601,7 +601,7 @@ void Renderer::Impl::render(const RenderTree& renderTree,
.has3D = renderTreeParameters.has3D,
.unsupportedStaticLayer = retainedUnsupportedStaticLayer,
};
const auto retainedStaticRevision = retainedUnderlayRevisionTracker.observe(
retainedUnderlayRevisionTracker.observe(
retainedRevisionState, retainedUnderlayReasonText(retainedContentReason));
const bool retainedSupported = retainedMode &&
renderTreeParameters.loaded && !renderTreeParameters.has3D &&
@@ -636,7 +636,8 @@ void Renderer::Impl::render(const RenderTree& renderTree,
const auto wantsResources = [](RetainedUnderlayDecision decision) {
return decision == RetainedUnderlayDecision::Build || decision == RetainedUnderlayDecision::Reuse;
};
bool resourcesReady = true;
std::vector<RenderOrchestrator::RetainedSegmentSourceState> segmentSourceStates(segmentPlan.segments.size());
std::vector<bool> segmentSupportsCache(segmentPlan.segments.size(), false);
for (const auto& segment : segmentPlan.segments) {
if (segment.strategy != RenderSegmentStrategy::Retained) continue;
auto& cache = retainedSegmentCaches[segment.segmentIndex];
@@ -646,36 +647,40 @@ void Renderer::Impl::render(const RenderTree& renderTree,
const bool layerContentChanged = retainedSegmentFingerprints[segment.segmentIndex] != 0 &&
retainedSegmentFingerprints[segment.segmentIndex] != fingerprint;
retainedSegmentFingerprints[segment.segmentIndex] = fingerprint;
const auto segmentContentRevision = retainedStaticRevision ^
(fingerprint +
0x9e3779b97f4a7c15ULL + (retainedStaticRevision << 6U) + (retainedStaticRevision >> 2U));
const auto sourceState = orchestrator.retainedSegmentSourceState(
segment.beginIndex, segment.endExclusive);
segmentSourceStates[segment.segmentIndex] = sourceState;
const auto segmentContentRevision = sourceState.revision ^
(fingerprint + 0x9e3779b97f4a7c15ULL + (sourceState.revision << 6U) +
(sourceState.revision >> 2U)) ^
static_cast<uint64_t>(updateParameters->pixelRatio * 1000000.0f);
const bool segmentStable = sourceState.stable && orchestrator.retainedSegmentIsStable(
renderTreeParameters.timePoint, segment.beginIndex, segment.endExclusive);
const bool segmentSupported = retainedMode && updateParameters->styleLoaded &&
updateParameters->spriteLoaded && orchestrator.retainedGlobalResourcesReady() &&
updateParameters->mode == MapMode::Continuous && !renderTreeParameters.has3D &&
renderTreeParameters.debugOptions == MapDebugOptions::NoDebug && segmentStable &&
retainedPlacementStable && !legacyInSegment[segment.segmentIndex] &&
!orchestrator.retainedRangeHasUnsupportedLayer(segment.beginIndex, segment.endExclusive);
segmentSupportsCache[segment.segmentIndex] = segmentSupported;
const char* segmentFallbackReason = !sourceState.ready ? sourceState.reason
: !sourceState.stable ? sourceState.reason
: !updateParameters->styleLoaded || !updateParameters->spriteLoaded ||
!orchestrator.retainedGlobalResourcesReady() ? "style-not-loaded"
: !segmentStable ? "static-transition" : retainedFallbackReason;
decision = cache->evaluate(segmentKeys[segment.segmentIndex],
segmentContentRevision,
retainedSupported,
retainedFallbackReason,
segmentSupported,
segmentFallbackReason,
layerContentChanged ? "layer-mutation"
: retainedUnderlayRevisionTracker.reason().c_str());
if (resourcesReady && wantsResources(decision)) {
resourcesReady = staticData->shaders && cache->prepareResources(
context, *staticData->shaders, *segmentKeys[segment.segmentIndex]);
}
}
if (!resourcesReady) {
for (const auto& segment : segmentPlan.segments) {
if (segment.strategy != RenderSegmentStrategy::Retained) continue;
auto& decision = segmentDecisions[segment.segmentIndex];
if (wantsResources(decision)) {
retainedSegmentCaches[segment.segmentIndex]->markBuildFailed("resource-or-compositor");
: "source-or-global-mutation");
if (wantsResources(decision) &&
(!staticData->shaders || !cache->prepareResources(
context, *staticData->shaders, *segmentKeys[segment.segmentIndex]))) {
cache->markBuildFailed("resource-or-compositor");
decision = RetainedUnderlayDecision::Fallback;
}
}
}
bool useRetainedRanges = retainedSupported;
for (const auto& segment : segmentPlan.segments) {
if (segment.strategy == RenderSegmentStrategy::Retained) {
useRetainedRanges = useRetainedRanges && wantsResources(segmentDecisions[segment.segmentIndex]);
}
}
// Upload layer groups
{
@@ -726,9 +731,10 @@ void Renderer::Impl::render(const RenderTree& renderTree,
if (!particleFastFrame) {
orchestrator.visitDebugLayerGroups([&](LayerGroupBase& layerGroup) { layerGroup.upload(*uploadPass); });
}
if (useRetainedRanges) {
if (retainedMode) {
for (const auto& segment : segmentPlan.segments) {
if (segment.strategy == RenderSegmentStrategy::Retained) {
if (segment.strategy == RenderSegmentStrategy::Retained &&
wantsResources(segmentDecisions[segment.segmentIndex])) {
retainedSegmentCaches[segment.segmentIndex]->upload(*uploadPass);
}
}
@@ -797,10 +803,10 @@ void Renderer::Impl::render(const RenderTree& renderTree,
};
const auto buildRetainedRange = [&](RetainedUnderlayCache& cache,
RetainedUnderlayDecision decision,
RetainedUnderlayDecision& decision,
int32_t begin,
int32_t endExclusive) {
if (!useRetainedRanges || decision != RetainedUnderlayDecision::Build || begin >= endExclusive) return;
if (decision != RetainedUnderlayDecision::Build || begin >= endExclusive) return;
bool drawFailed = false;
bool globalUniformsBound = false;
try {
@@ -841,7 +847,7 @@ void Renderer::Impl::render(const RenderTree& renderTree,
}
if (drawFailed) {
cache.markBuildFailed("resource-or-compositor");
useRetainedRanges = false;
decision = RetainedUnderlayDecision::Fallback;
} else {
cache.markBuildSucceeded();
}
@@ -878,7 +884,7 @@ void Renderer::Impl::render(const RenderTree& renderTree,
PaintParameters::depthEpsilon;
// draw layer groups, opaque pass
if (useRetainedRanges && retainedMode) {
if (retainedMode) {
orchestrator.visitLayerGroupsInRangeReversed(
begin, endExclusive,
[&](LayerGroupBase& layerGroup, size_t ordinal) {
@@ -902,7 +908,7 @@ void Renderer::Impl::render(const RenderTree& renderTree,
PaintParameters::depthEpsilon;
// draw layer groups, translucent pass
if (useRetainedRanges && retainedMode) {
if (retainedMode) {
orchestrator.visitLayerGroupsInRange(
begin, endExclusive,
[&](LayerGroupBase& layerGroup, size_t ordinal) {
@@ -929,7 +935,7 @@ void Renderer::Impl::render(const RenderTree& renderTree,
parameters.currentLayer = i;
const LayerRenderItem& layerItem = *it;
const RenderItem& item = layerItem;
const bool itemInLiveRange = !useRetainedRanges || !retainedMode ||
const bool itemInLiveRange = !retainedMode ||
(static_cast<int32_t>(layerItem.index) >= begin &&
static_cast<int32_t>(layerItem.index) < endExclusive);
if (itemInLiveRange && item.hasRenderPass(parameters.pass)) {
@@ -964,20 +970,39 @@ void Renderer::Impl::render(const RenderTree& renderTree,
segment.endExclusive);
}
}
if (retainedRuntimeState) {
for (const auto& segment : segmentPlan.segments) {
if (segment.strategy != RenderSegmentStrategy::Retained) continue;
const auto index = segment.segmentIndex;
const auto decision = segmentDecisions[index];
const char* decisionText = decision == RetainedUnderlayDecision::Build ? "build" :
decision == RetainedUnderlayDecision::Reuse ? "reuse" :
decision == RetainedUnderlayDecision::Fallback ? "fallback" : "disabled";
const auto& source = segmentSourceStates[index];
retainedRuntimeState->recordSegmentExecution(index, source.ready, source.stable,
segmentSupportsCache[index], source.revision, decisionText,
decision == RetainedUnderlayDecision::Fallback
? retainedSegmentCaches[index]->getLastInvalidationReason().c_str() : nullptr);
}
}
commonClearPass();
context.bindGlobalUniformBuffers(*parameters.renderPass);
if (useRetainedRanges) {
for (const auto& segment : segmentPlan.segments) {
if (segment.strategy == RenderSegmentStrategy::Retained) {
retainedSegmentCaches[segment.segmentIndex]->composite(orchestrator, parameters);
} else {
drawableOpaquePass(segment.beginIndex, segment.endExclusive);
drawableTranslucentPass(segment.beginIndex, segment.endExclusive);
}
}
} else {
const bool globalLiveFallback = !retainedMode || renderTreeParameters.has3D ||
renderTreeParameters.debugOptions != MapDebugOptions::NoDebug ||
updateParameters->mode != MapMode::Continuous || !updateParameters->spriteLoaded ||
!orchestrator.retainedGlobalResourcesReady() || !retainedPlacementStable ||
orchestrator.retainedGlobalLightTransition();
if (globalLiveFallback) {
drawableOpaquePass(0, segmentPlan.layerCount);
drawableTranslucentPass(0, segmentPlan.layerCount);
} else {
visitRenderSegmentExecution(segmentPlan,
[&](uint32_t index) { return wantsResources(segmentDecisions[index]); },
[&](uint32_t index) { retainedSegmentCaches[index]->composite(orchestrator, parameters); },
[&](int32_t begin, int32_t endExclusive) {
drawableOpaquePass(begin, endExclusive);
drawableTranslucentPass(begin, endExclusive);
});
}
drawableDebugOverlays();

View File

@@ -42,6 +42,11 @@ RetainedUnderlayReason retainedReason(const char* reason) noexcept {
if (value == "style-mutation") return RetainedUnderlayReason::StyleMutation;
if (value == "layer-mutation") return RetainedUnderlayReason::LayerMutation;
if (value == "source-mutation") return RetainedUnderlayReason::SourceMutation;
if (value == "source-or-global-mutation") return RetainedUnderlayReason::SourceOrGlobalMutation;
if (value == "source-not-loaded") return RetainedUnderlayReason::SourceNotLoaded;
if (value == "source-fading") return RetainedUnderlayReason::SourceFading;
if (value == "missing-source") return RetainedUnderlayReason::MissingSource;
if (value == "unknown-dependency") return RetainedUnderlayReason::UnknownDependency;
if (value == "tile-mutation") return RetainedUnderlayReason::TileMutation;
if (value == "image-mutation") return RetainedUnderlayReason::ImageMutation;
if (value == "glyph-mutation") return RetainedUnderlayReason::GlyphMutation;
@@ -177,6 +182,7 @@ RetainedUnderlayDecision RetainedUnderlayCache::evaluate(const std::optional<Ret
return RetainedUnderlayDecision::Disabled;
}
if (runtimeState && !runtimeState->isEnabled()) {
lastInvalidationReason = "runtime-disabled";
++fallbackCount;
runtimeState->recordFallback();
particle::incrementRendererDebugCounter(particle::RendererDebugCounter::RetainedUnderlayFallback);

View File

@@ -9,6 +9,7 @@
#include <atomic>
#include <mutex>
#include <set>
#include <string_view>
#include <tuple>
#include <utility>
@@ -45,6 +46,11 @@ enum class RetainedUnderlayReason : uint8_t {
StyleMutation,
LayerMutation,
SourceMutation,
SourceOrGlobalMutation,
SourceNotLoaded,
SourceFading,
MissingSource,
UnknownDependency,
TileMutation,
ImageMutation,
GlyphMutation,
@@ -75,6 +81,11 @@ inline const char* retainedUnderlayReasonText(RetainedUnderlayReason reason) noe
case RetainedUnderlayReason::StyleMutation: return "style-mutation";
case RetainedUnderlayReason::LayerMutation: return "layer-mutation";
case RetainedUnderlayReason::SourceMutation: return "source-mutation";
case RetainedUnderlayReason::SourceOrGlobalMutation: return "source-or-global-mutation";
case RetainedUnderlayReason::SourceNotLoaded: return "source-not-loaded";
case RetainedUnderlayReason::SourceFading: return "source-fading";
case RetainedUnderlayReason::MissingSource: return "missing-source";
case RetainedUnderlayReason::UnknownDependency: return "unknown-dependency";
case RetainedUnderlayReason::TileMutation: return "tile-mutation";
case RetainedUnderlayReason::ImageMutation: return "image-mutation";
case RetainedUnderlayReason::GlyphMutation: return "glyph-mutation";
@@ -226,18 +237,11 @@ public:
entry.layers.assign(next.styleLayers.begin() + segment.beginIndex,
next.styleLayers.begin() + segment.endExclusive);
}
std::set<std::string> dependentSourceIds;
std::set<std::string> unknownDependencies;
for (const auto& layer : entry.layers) {
if (layer.sourceId) dependentSourceIds.insert(*layer.sourceId);
if (layer.layerType == "symbol") {
unknownDependencies.insert("symbol/glyph/image/placement");
} else if (layer.layerType == "custom" || layer.layerType == "custom-drawable") {
unknownDependencies.insert("custom");
}
}
entry.dependentSourceIds.assign(dependentSourceIds.begin(), dependentSourceIds.end());
entry.unknownDependencies.assign(unknownDependencies.begin(), unknownDependencies.end());
const auto dependencies = renderSegmentDependencies(next.styleLayers,
segment.beginIndex, segment.endExclusive);
entry.dependentSourceIds = dependencies.sourceIds;
entry.unknownDependencies = dependencies.globalPrerequisites;
if (dependencies.unknown) entry.unknownDependencies.push_back("unknown/source-or-custom");
if (segment.strategy == RenderSegmentStrategy::Retained) {
++next.retainedSegmentCount;
entry.cache.emplace();
@@ -308,6 +312,19 @@ public:
entry.cache->cacheState = retainedRangeCacheStateText(state);
entry.cache->contentRevision = contentRevision;
}
void recordSegmentExecution(uint32_t index, bool sourceReady, bool sourceStable,
bool supported, uint64_t sourceRevision,
const char* decision, const char* fallbackReason) {
updateSegmentCache(index, [&](auto& value) {
value.sourceReady = sourceReady;
value.sourceStable = sourceStable;
value.supported = supported;
value.sourceRevision = sourceRevision;
value.finalDecision = decision;
if (fallbackReason) value.lastFallbackReason = fallbackReason;
if (std::string_view(decision) == "fallback") ++value.fallbackCount;
});
}
void recordSegmentBuild(uint32_t index) { updateSegmentCache(index, [](auto& value) { ++value.build; }); }
void recordSegmentReuse(uint32_t index) { updateSegmentCache(index, [](auto& value) { ++value.reuse; }); }
void recordSegmentInvalidation(uint32_t index, RetainedUnderlayReason reason) {

View File

@@ -957,6 +957,7 @@ TEST(RenderSegmentDiagnostics, ReadinessSnapshotMapsSourcesToSegmentsWithoutSide
state.recordRenderSegmentResolution(resolved, layers);
state.recordSegmentBuild(0);
state.recordSegmentCacheState(0, RetainedRangeCacheState::Ready, 41);
state.recordSegmentExecution(0, true, true, true, 17, "reuse", nullptr);
RendererReadinessDiagnostics readiness;
readiness.styleLoaded = true;
@@ -1029,6 +1030,8 @@ TEST(RenderSegmentDiagnostics, ReadinessSnapshotMapsSourcesToSegmentsWithoutSide
ASSERT_TRUE(second.segments[0].cache);
EXPECT_EQ(second.segments[0].cache->build, cacheBuild);
EXPECT_EQ(second.segments[0].cache->contentRevision, 41u);
EXPECT_EQ(second.segments[0].cache->sourceRevision, 17u);
EXPECT_EQ(second.segments[0].cache->finalDecision, "reuse");
const auto third = state.renderSegmentSnapshot();
EXPECT_EQ(third.topologyRevision, second.topologyRevision);
EXPECT_EQ(third.contentRevision, second.contentRevision);
@@ -1041,6 +1044,109 @@ TEST(RenderSegmentDiagnostics, ReadinessSnapshotMapsSourcesToSegmentsWithoutSide
EXPECT_NE(json.find("\"incompleteTileCount\":1"), std::string::npos);
EXPECT_NE(json.find("\"dependentSourceIds\":[\"dynamic\"]"), std::string::npos);
EXPECT_NE(json.find("\"affectedSegmentIndices\":[1]"), std::string::npos);
EXPECT_NE(json.find("\"sourceRevision\":17"), std::string::npos);
}
TEST(RenderSegmentLocality, SourceRevisionsAreScopedAndRecreationUsesNewIdentity) {
const std::vector<StyleLayerDiagnostics> layers{
{0, "background", "background", true, std::nullopt, std::nullopt},
{1, "a0", "fill", true, "a", std::nullopt},
{2, "b", "circle", true, "b", std::nullopt},
{3, "a1", "line", true, "a", std::nullopt}};
const auto r0 = renderSegmentDependencies(layers, 0, 2);
const auto r1 = renderSegmentDependencies(layers, 2, 3);
const auto r2 = renderSegmentDependencies(layers, 3, 4);
const auto sourceLess = renderSegmentDependencies(layers, 0, 1);
EXPECT_EQ(r0.sourceIds, std::vector<std::string>({"a"}));
EXPECT_EQ(r2.sourceIds, r0.sourceIds);
EXPECT_TRUE(sourceLess.sourceIds.empty());
RenderSegmentRevisionBook book;
book.advanceSource("a");
book.advanceSource("b");
const auto beforeA = book.revisionFor(r0);
const auto beforeB = book.revisionFor(r1);
const auto beforeA2 = book.revisionFor(r2);
const auto beforeSourceLess = book.revisionFor(sourceLess);
book.advanceSource("a");
EXPECT_NE(book.revisionFor(r0), beforeA);
EXPECT_NE(book.revisionFor(r2), beforeA2);
EXPECT_EQ(book.revisionFor(r1), beforeB);
EXPECT_EQ(book.revisionFor(sourceLess), beforeSourceLess);
const auto beforeRecreate = book.revisionFor(r0);
book.advanceSource("a"); // removal
book.advanceSource("a"); // recreation with the same ID
EXPECT_NE(book.revisionFor(r0), beforeRecreate);
book.advanceGlobal(); // image, glyph, style, context or unknown mutation
EXPECT_NE(book.revisionFor(r1), beforeB);
EXPECT_NE(book.revisionFor(sourceLess), beforeSourceLess);
const auto unknown = renderSegmentDependencies(
{{0, "custom", "custom", true, std::nullopt, std::nullopt}}, 0, 1);
EXPECT_TRUE(unknown.unknown);
EXPECT_FALSE(book.revisionFor(unknown));
const auto symbol = renderSegmentDependencies(
{{0, "label", "symbol", true, "a", std::nullopt}}, 0, 1);
EXPECT_FALSE(symbol.unknown);
EXPECT_EQ(symbol.sourceIds, std::vector<std::string>({"a"}));
EXPECT_EQ(symbol.globalPrerequisites,
std::vector<std::string>({"symbol/glyph/image/placement"}));
}
TEST(RenderSegmentLocality, ReadinessAndFadeFollowOnlyDependencies) {
const RenderSegmentDependencies r0{{"static"}, {}, false};
const RenderSegmentDependencies r1{{"dynamic"}, {}, false};
const auto lookup = [](bool dynamicLoaded, bool dynamicFading) {
return [=](const std::string& id) -> std::optional<RenderSegmentSourceStatus> {
if (id == "static") return RenderSegmentSourceStatus{true, false};
if (id == "dynamic") return RenderSegmentSourceStatus{dynamicLoaded, dynamicFading};
return std::nullopt;
};
};
EXPECT_TRUE(assessRenderSegmentSources(r0, lookup(false, false)).stable);
EXPECT_FALSE(assessRenderSegmentSources(r1, lookup(false, false)).ready);
EXPECT_TRUE(assessRenderSegmentSources(r0, lookup(true, true)).stable);
const auto fading = assessRenderSegmentSources(r1, lookup(true, true));
EXPECT_TRUE(fading.ready);
EXPECT_FALSE(fading.stable);
EXPECT_STREQ(fading.reason, "source-fading");
EXPECT_FALSE(assessRenderSegmentSources({{"missing"}, {}, false}, lookup(true, false)).ready);
EXPECT_FALSE(assessRenderSegmentSources({{}, {}, true}, lookup(true, false)).ready);
}
TEST(RenderSegmentLocality, MixedExecutionDrawsEachLiveRangeOnceInStyleOrder) {
ResolvedRenderSegmentPlan plan;
plan.valid = true;
plan.layerCount = 5;
plan.segments = {{0, RenderSegmentStrategy::Retained, std::nullopt, 0, 1},
{1, RenderSegmentStrategy::Continuous, std::nullopt, 1, 3},
{2, RenderSegmentStrategy::Retained, std::nullopt, 3, 5}};
std::vector<std::string> events;
visitRenderSegmentExecution(plan,
[](uint32_t index) { return index == 0; },
[&](uint32_t index) { events.push_back("composite:" + std::to_string(index)); },
[&](int32_t begin, int32_t end) {
for (auto i = begin; i < end; ++i) events.push_back("live:" + std::to_string(i));
});
EXPECT_EQ(events, (std::vector<std::string>{"composite:0", "live:1", "live:2", "live:3", "live:4"}));
events.clear();
plan.valid = false;
visitRenderSegmentExecution(plan, [](uint32_t) { return true; },
[&](uint32_t) { ADD_FAILURE() << "invalid plan composite"; },
[&](int32_t begin, int32_t end) {
for (auto i = begin; i < end; ++i) events.push_back("live:" + std::to_string(i));
});
EXPECT_EQ(events.size(), 5u);
}
TEST(RenderSegmentLocality, FailedBuildCannotReuseOldTexture) {
RetainedUnderlayCache cache;
const auto viewportKey = key();
EXPECT_EQ(cache.evaluate(viewportKey, 7, true), RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
EXPECT_EQ(cache.evaluate(viewportKey, 7, true), RetainedUnderlayDecision::Reuse);
EXPECT_EQ(cache.evaluate(viewportKey, 8, true), RetainedUnderlayDecision::Build);
cache.markBuildFailed("resource-or-compositor");
EXPECT_FALSE(cache.isValid());
EXPECT_EQ(cache.evaluate(viewportKey, 8, true), RetainedUnderlayDecision::Build);
}
TEST(RenderSegmentPlan, StrictJSONRejectsUnknownFieldsStrategyAndInvalidFirstBoundary) {

View File

@@ -99,7 +99,7 @@ unzip -tq "$SOURCE_APK" >/dev/null || fail "APK ZIP integrity check failed."
ABI_LIST="$(unzip -Z1 "$SOURCE_APK" | sed -n 's#^lib/\([^/]*\)/.*#\1#p' | sort -u | tr '\n' ' ' | sed 's/ $//')"
[[ "$ABI_LIST" == "x86_64" ]] || fail "Unexpected APK ABI set: [$ABI_LIST]"
LIB_BYTES="$(unzip -l "$SOURCE_APK" 'lib/x86_64/libmaplibre.so' | awk '/lib\/x86_64\/libmaplibre\.so$/ {print $1; exit}')"
LIB_BYTES="$(unzip -l "$SOURCE_APK" 'lib/x86_64/libmaplibre.so' | awk '/lib\/x86_64\/libmaplibre\.so$/ {print $1}')"
[[ -n "$LIB_BYTES" ]] || fail "lib/x86_64/libmaplibre.so not found in APK."
APK_BYTES="$(wc -c < "$SOURCE_APK" | tr -d ' ')"

View File

@@ -1032,3 +1032,13 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2`
- T4 证实 dependency diagnostics 能正确给动态 source `affected=[2]`,但 global contentRevision 1760→2540,R0 虽只依赖 `step099-static`,cache build 仍 1→121、invalidation 20→200,确认 `GLOBAL_CONTENT_REVISION`。源码审计同时确认 global fade/update signature/pending/placement/repaint/annotation/singleContinuous 等 fast-path gate 仍存在。
- Q1~Q7 与 blocker 分类已闭合:runtime confirmed `PERMANENT_SOURCE_UNLOADED`、`DYNAMIC_SOURCE_READINESS_THRASH`、`GLOBAL_CONTENT_REVISION`;source confirmed `GLOBAL_FADE`、`GLOBAL_UPDATE_SIGNATURE`。
- 完成报告:`docs/报告/STEP-099_RenderSource就绪与RenderSegment失效诊断_Codex完成报告_v1.md`;Step:`docs/50_Step记录/STEP-099_RenderSource就绪与RenderSegment失效诊断_v1.md`;运行证据保存在忽略目录 `artifacts/maplibre-particle/android-validation/`。
### 2026-10-06 STEP-100 SourceScoped RenderSegment 局部失效(CLOSED / PASS)
- 基于 `c4b481fd2c5b5da20d31f6ae54011f0552d7dbd0`,Codex `gpt-6-sol / high` 实现 source-scoped revision、production/diagnostics 共用 dependency、per-segment loaded/fading/readiness 以及独立 Build/Reuse/Fallback;global loaded/content revision 与 Fast Path gate 保持原兼容语义。
- RDC 标准 Android x86_64 OpenGL validation build PASS,APK 61,956,640 bytes,`libmaplibre.so` 39,636,472 bytes,SHA-256 `86e22bf081bf7d3196f0bf0e7c56af51bde01e9f0b01e0bab238cb794273315a`。RDC 同时修复 validation script 在 `pipefail` 下的 `unzip | awk ... exit` SIGPIPE/141,小修不改变产物校验语义。
- FZG1-2 T4 R-C-R 约 61.6 秒主窗口:global revision 128→973;R0 build 1→1、invalidation 20→20、reuse 132→4042、source revision 不变;R1 build 5→135、invalidation 26→211。动态 source 持续 `affected=[2]`,核心 locality 硬门槛 PASS。
- T2 双 GeoJSON 约 35 秒仍产生 6 个约 203ms loaded false window,但 R0 build/invalidation 不变;T3 unrelated permanently-unready RasterArray 使 global loaded=false 持续 21.468s,但 R0/R1 build=1、invalidation=19 不变,reuse 11→1200,证明无关 source 不再拖垮 retained cache。
- T4 运行期间 R1 fallback count 增长而 R0 fallback 不变、reuse 持续增长,实际验证 mixed `R0 Reuse + C Live + R1 Fallback`。未发现 crash/ANR。Fast Path、`singleContinuousSegment`、multi-continuous 语义未改。
- Native host GTest 因当前 Intel Mac 缺 `libuv` 环境阻塞;尝试 Homebrew 安装时发现会连带升级 CMake/OpenSSL/Python 且 Intel 无 bottle 支持,已停止,未把环境阻塞虚报 PASS。Android full native build + device runtime 作为本 Step 功能验收事实。Jest 未运行。
- Step:`docs/50_Step记录/STEP-100_SourceScoped_RenderSegment局部失效_v1.md`;完成报告:`docs/报告/STEP-100_SourceScoped_RenderSegment局部失效_Codex完成报告_v1.md`;运行证据在忽略目录 `artifacts/maplibre-particle/android-validation/`。允许进入 V18 unified release。