Compare commits

...

44 Commits

Author SHA1 Message Date
tei
6b48f905d2 release: finalize unified particle.19
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-10-07 16:31:36 +09:00
tei
7c5ed27b0b release: prepare unified particle.19 2026-10-07 08:42:32 +09:00
tei
01863569fb docs: close STEP-101 teardown validation
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-10-07 08:42:09 +09:00
tei
64cf8f4435 fix: make render orchestrator teardown safe
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-10-07 08:41:08 +09:00
tei
d29e84c1a9 release: finalize unified particle.18
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-10-06 20:52:00 +09:00
tei
2780550b62 release: prepare unified particle.18 2026-10-06 17:20:34 +09:00
tei
524aaf4af5 fix: localize retained segment source invalidation
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-10-06 17:18:09 +09:00
tei
c4b481fd2c docs: close STEP-099 render source diagnostics
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-10-06 15:33:12 +09:00
tei
8a5214d92d diagnostics: add STEP-099 render source readiness audit 2026-10-06 15:15:12 +09:00
tei
e8b451fe70 release: finalize unified particle.17
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-29 09:44:09 +09:00
tei
95f0a45d6d release: prepare unified particle.17 2026-09-28 21:40:56 +09:00
tei
baf9374318 fix: reset V17 weather runtime across Android and iOS
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-28 21:37:16 +09:00
tei
1e7215e2a0 release: finalize unified particle.16
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-27 22:34:41 +09:00
tei
e9f84a8ec6 build(ios): include WeatherColor lifecycle header in Bazel core 2026-09-27 21:35:45 +09:00
tei
2e119961d9 release: prepare unified particle.16 2026-09-27 19:21:52 +09:00
tei
750c14f527 docs: 校正 V15 WeatherColor 阶段状态
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-27 18:44:50 +09:00
tei
4712e1752a docs: 记录 V15 WeatherColor RDC 验证与推送
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-27 18:43:14 +09:00
tei
5af91fe7a1 fix: 修复 V15 WeatherColor Metal 纹理生命周期
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-27 18:41:56 +09:00
tei
cd71640a84 release: finalize unified particle.15
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-27 11:10:40 +09:00
tei
1fdc5df625 docs: plan NavSea PBF tile codec across platforms 2026-09-27 10:56:25 +09:00
tei
2fca4a71a7 build(ios): declare particle GPU math header in Bazel core 2026-09-27 10:40:03 +09:00
tei
f081180bed release: prepare unified particle.15 2026-09-27 08:43:08 +09:00
tei
159b0ad5f1 feat(step098): single-frame display and keyed rolling cache 2026-09-27 08:42:00 +09:00
tei
f3a44c7d5f feat(step097): preserve B fixes for unified V15 source 2026-09-27 08:41:27 +09:00
tei
b1239ac3b1 docs(step097): close A2 Android Core validation
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-26 15:18:45 +09:00
tei
f49ffa68a1 fix(step097): complete A2 clock and GPU field correctness
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-26 15:17:43 +09:00
tei
5bb1a0e635 docs(step097): record A2 expanded regression and legacy test mismatch
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-26 11:54:20 +09:00
tei
bd1cfe95b7 docs(step097): record A2 partial RDC verification and resume gate
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-26 11:23:17 +09:00
tei
72d9369384 docs(step097): close A1 Android gate with iOS runtime waiver
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-26 10:58:57 +09:00
tei
8c006ac83d fix(step097): compile Metal retained attachment tests
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-26 10:47:09 +09:00
tei
0aab2bdc83 docs(step097): record A1 test entries and RDC checks
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-26 09:15:31 +09:00
tei
d0ced72351 test(step097): add A1 renderer regression entry points 2026-09-26 09:15:10 +09:00
tei
be5b9080ed docs: validate STEP-097 cache diagnostics on FZG1
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-26 08:21:42 +09:00
tei
237a3e6608 docs: verify STEP-097 A1 diagnostic GTest on Mac
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-26 08:07:20 +09:00
tei
0d13552bed docs: record STEP-097 A1 FZG1 APK smoke validation
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-25 23:55:57 +09:00
tei
91c2491b0e docs: record STEP-097 partial A1 handoff
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-25 22:28:23 +09:00
tei
e67e4961b5 fix: checkpoint STEP-097 A1 renderer correctness 2026-09-25 22:26:33 +09:00
tei
720d309364 docs: plan STEP-097 three-review V13 fixes
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-25 22:08:37 +09:00
tei
b5c1c4879a docs: deliver isolated blind V13 audit STEP-096
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-25 21:31:27 +09:00
tei
82e8a18de7 docs: define isolated V13 blind review STEP-096
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-25 21:06:43 +09:00
tei
4bc9d0ca56 docs: record STEP-095 delivery verification
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-25 20:48:48 +09:00
tei
ad5f92c48e docs: audit V13 source and performance for STEP-095
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-25 20:46:44 +09:00
tei
05878d6623 docs: define STEP-095 independent V13 code review
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-25 18:53:45 +09:00
tei
45c4dca849 docs: archive independent Particle V13 code review
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-25 18:52:06 +09:00
132 changed files with 10569 additions and 341 deletions

View File

@@ -667,8 +667,10 @@ MLN_CORE_HEADERS = [
"include/mbgl/particle/field/field_profile_contract_json.hpp",
"include/mbgl/particle/field/field_projection_materialization.hpp",
"include/mbgl/particle/field/field_runtime.hpp",
"include/mbgl/particle/field/particle_gpu_math.hpp",
"include/mbgl/particle/field/field_binding.hpp",
"include/mbgl/particle/renderer_debug_counters.hpp",
"include/mbgl/renderer/layers/weather_color_texture_lifecycle.hpp",
"include/mbgl/particle/ingress/qbin_v2_decoder.hpp",
"include/mbgl/particle/ingress/qbin_v3_decoder.hpp",
"include/mbgl/particle/ingress/encoded_field_transport.hpp",

View File

@@ -0,0 +1,104 @@
# Particle V13 严格代码审查报告
日期:2026-09-25(Asia/Tokyo)
审查性质:Mac 仓库只读代码审查、历史任务/报告交叉核对、定向原生测试复跑。未修改业务源码、未执行设备 GPU runtime 或 profiler。
## 1. 审查基线和证据分级
- Mac:`/Users/tei/dev/MapLibreParticleLab`;分支:`particle/main`。
- HEAD 与 `origin/particle/main` 一致:`ee0b89e711bf01335b3aa71d36def785cd97fec3`;工作树 clean。
- V13 功能提交:`016fd1fff6a33cf795c10d4f5c5413d308383677`。发布版本:`13.4.1-particle.13`。
- Integration 主线:`39ad8415fbdb76c1822460bb09f3a3f35fe141e0`。
- 参照:`docs/40_任务/STEP-092...`、`STEP-093...`、`STEP-094...` 任务;对应 `docs/报告/` 与 `docs/50_Step记录/`;STEP-088/089/090 性能历史报告。
- 本次独立复跑:Render Segment/Retained/Fast Path 43/43 PASS;Field/QBIN/Viewport Reseed 69/69 PASS;V13 RN package TypeScript PASS。
- 当前 RN Test Host `npm run typecheck` **失败(exit 2)**:TS2307 无法解析 `@navsea/maplibre-particle-rn`,伴随 TS7006。Host package.json 仍指向 `.10`。此前 Codex 使用临时工作树依赖通过,不等于当前默认安装可重复验证。
- STEP-094 报告记录 OpenGL、Metal 构建、Codegen、Android Validation APK 通过;**Android/iOS V13 runtime、GPU frame/visual、功耗与长时间运行仍 NOT RUN**。此报告不将历史 Android 性能测量冒充 V13 性能。
## 2. 审查结论
没有在已复跑的纯数据结构/协议测试中发现失败;但 V13 在高频渲染、Metal 多段重建、GPU 资源分配和真实运行态验收上存在明确风险。不能由 43 项纯 Native 测试和编译 PASS 推断任意 R/C 交错的 GPU 输出、跨平台性能已经验收。
### F01 — P1:完整 Diagnostics 深复制进入每帧热路径(源码确定)
证据:`src/mbgl/renderer/renderer_impl.cpp:206-208,500-505`;`src/mbgl/renderer/retained_underlay_runtime_state.hpp:243-254`。
`tryRenderParticleFastFrame()` 和 `render()` 调用 `renderSegmentSnapshot()`;该函数在互斥锁内复制完整 `RenderSegmentDiagnostics`(含 `styleLayers` 与每个 Segment 的重复 `layers`/字符串),再补几个状态字段。一次 fast frame 可能复制两次。与 pull-based 诊断的设计意图不一致。
修复:增加只读的轻量 hot-path 状态(enabled/configRevision/topologyRevision 等);完整快照只在外部 pull 时构造;快照构建/复制次数做计数,稳定 30/60 FPS 应接近零。
### F02 — P1:Metal Retained 构建逐段 GPU 同步等待(源码确定,运行影响待 profile)
证据:`renderer_impl.cpp:790-824`;`retained_underlay_cache.cpp:316-322`;`src/mbgl/mtl/command_encoder.cpp:29-31`;`src/mbgl/mtl/offscreen_texture.cpp:86-91`。
`finishBuild()->present(*offscreenTexture)->swap()` 内部 `commit()` 后 `waitUntilCompleted()`。一帧多个 Retained 段失效时,可能产生多次串行 CPU/GPU 同步。使用 Metal System Trace / GPU Capture 测量 `waitUntilCompleted` 时间和 command-buffer 数量,再设计保持依赖顺序的异步提交/跨 pass 协调;不可仅删除 wait 而不确保纹理可读。
### F03 — P1:缓存内容失效与 GPU 资源销毁耦合(源码确定)
证据:`retained_underlay_cache.cpp:203-209,241-255,364-374`。
`revisionChanged` 使 `invalidate()` 与 `releaseResources()` 一起发生,即使 viewport 未变、GPU context 有效,重建时仍需重新创建纹理和 composite group。普通 camera、source/tile、content revision 在多 Retained 段下会放大分配/销毁和相关同步。
修复:分离 `contentDirty` 与 `resourceInvalid`,同尺寸同 context 保留 allocation、重新 clear/build;context loss、尺寸不兼容、显式关闭/释放时才销毁。用 peak GPU bytes、textureCreate、resourceRelease、每帧 build/reuse 证明收益与正确性。
### F04 — P1:多缓存没有 GPU 内存预算(源码确定,OOM 影响待量化)
证据:`renderer_impl.cpp:506-524`;`retained_underlay_cache.cpp:241-251`;`mtl/offscreen_texture.cpp:20-55`;`gl/offscreen_texture.cpp:18-35`。
每个 Retained Segment 均拥有完整 viewport 的 RGBA + depth/stencil 离屏资源;总成本约随 retainedCount×width×height 增长。相邻 R-R 也各自独立,符合契约,但高像素 iPad 与多段场景缺乏实际 bytes/peak/budget 观测。先增加预算与失败时的安全 full-render fallback,切勿通过强制合并相邻 R-R 破坏 V13 契约。
### F05 — P1:V13 Test Host 默认安装不可复现(已复现失败)
证据:`integration/react-native/MapLibreParticleRNTestHost/package.json` 指向 `.10`;当前 `npm run typecheck` exit 2;报告也明确临时本地替换后已恢复旧依赖。
修复:将 Test Host 依赖和 lockfile 与已发布 `.13` 精确对应,重新安装并验证 V13 typecheck/codegen/iOS & Android bridge/真实运行;测试日志注明源包及 Native 精确版本。不得将临时链接环境结果写成默认环境 PASS。
### F06 — P1:多 Continuous 的 Particle Fast Path 固定 fallback(明确设计限制)
证据:`renderer_impl.cpp:45-57,198-287` 和 `particle_fast_path.hpp:110-140`,STEP-094 设计与报告。
N 个 Continuous 中只支持恰好一个的 Fast Path;R-C-R-C-R 场景总走 Full CPU。STEP-090 历史 A/B 场景 RenderThread 约为 fast ON 21.7% / OFF 97.0%,只是历史参考,不能直接视作 V13 数据。先采集 V13 多 C 的 CPU/GPU 帧时间与频率,再设计可组合的 multi-C fast path;绝不可为性能偷偷移动 Style Layer 或合并相邻段。
### F07 — P2:范围访问反复计算线性 ordinal(源码确定)
证据:`src/mbgl/renderer/render_orchestrator.hpp:156-180`,`std::distance(multimap.begin(), lower_bound(...))`。每个 Segment 的 opaque/translucent、build、upload/tweaker 路径可重复触发定位。
修复:在 LayerGroup topology 变化时预计算有序索引/ordinal;高频 render 只用稳定区间和 ordinal,避免多段场景重复 O(N) 前缀遍历。
### F08 — P2:多段失效粒度仍过宽(明确设计限制)
证据:`renderer_impl.cpp:565-646`;`render_orchestrator.cpp:156-158,1005-1018`。Layer Impl fingerprint 可局部失效;source/tile/image/glyph/placement 与聚合 `retainedSupported` 仍可能使全部 R fallback/build。应先用 `sourceId` 与 Segment 关联建立有证据的保守依赖关系,无法归属继续全局失效,不得牺牲安全。
### F09 — P2:Diagnostics 资源指标可能发生归属偏差(源码路径风险)
证据:`retained_underlay_runtime_state.hpp:173-240` 更新新 topology/entry;`renderer_impl.cpp:506-524` 随后 `clear()` 旧 caches;旧对象析构在 `retained_underlay_cache.cpp:364-374` 用旧 segmentIndex 报 release。不同 topology 的相同 index 可能指向新段,旧资源的释放计数可能归于新段或被 Continuous cache=null 丢弃。建议使用 cache generation/unique ID 归属,或在更换 diagnostics topology 前记录旧 generation 的 release;聚合 totals 与 per-generation 分离。
### F10 — P2:发布与协调状态资料漂移(核对事实)
Integration 的 `status/`、`compatibility/MATRIX.md` 仍描述 `.5` / iOS XCFramework 等旧状态,Mac 粒子仓库已发布 `.13`。应由粒子负责人维护正式兼容矩阵,区分“Particle 已发布”和“NavSea 已接入/验证”,不得仅根据旧矩阵断言 V13 不存在或已在 NavSea 生效。
## 3. 正确性与验证缺口
以下不是已证实的渲染 bug,而是必须在设备上验证的风险:
- V13 segmented pass 改变了 opaque/translucent、offscreen composite 的执行拆分;纯 resolver GTest 不覆盖 GPU blending/depth/stencil 与 Metal/OpenGL 的最终像素。
- 必须覆盖 R-C-R、C-R-R、R-R-C、R-C-R-C-R、相邻 R-R 独立 cache、全 R、全 C,特别是透明航标/半透明颜色场与动态粒子叠加。
- hidden boundary、invalid/recovery、Style layer insert/remove/reorder、Resize/Rotate/Pan/Zoom/Pitch、Context loss、Cache ON/OFF、Source/Tile mutation、切换 Plan 后的下一帧,均需要截图/帧采样和资源 counter 交叉验收。
- STEP-093/094 报告已明确运行态 NOT RUN,因此不以原生编译 PASS 代替此项。
## 4. 推荐执行顺序(分批,降低反复安装成本)
A. **无需设备的修复**:先处理 F01 快照深复制、F05 Test Host 精确版本与复现、F07 ordinal、F09 诊断归属;增加纯 C++ 定向回归。每阶段只做对应范围,不把源文件无关改动混入。
B. **Metal 资源修复**:合并 F02/F03/F04,统一处理 GPU 对象分配与同步、资源预算;OpenGL/Metal 静态编译和 Native regression 先行;再一次集中到 iPhone/iPad 采集 GPU Frame Capture。
C. **正式运行态验收**:集中执行 topology、alpha/depth、invalid/recovery、30/60 FPS、稳定/变更、内存峰值、30 分钟以上长稳态;记录相同设备、相同 Style、相同视口的 V12/V13 或 V13 ON/OFF 成对指标,不外推历史数字。
D. **后续性能架构**:在基础正确性通过后,再研究多 Continuous Fast Path 和 source-to-segment 精确依赖。保持 Style Z-order 唯一顺序源、不合并同策略相邻段、不引入业务频率分类。
## 5. 最终判定边界
- 纯 Native tests:43/43 + 69/69 PASS;RN V13 package TS PASS。
- Test Host 默认环境:FAIL(缺失模块 / `.10` 引用)。
- 源码性能问题:F01/F02/F03/F07 的触发调用链得到静态确认;真实节省量仍需设备测量。
- V13 Android/iOS GPU runtime、完整画面、内存和长时间性能:本次 NOT RUN;不声称生产级全量 PASS。
- Mac Git:HEAD 与 origin 一致,工作树 clean;未修改粒子源码。

View File

@@ -0,0 +1,285 @@
# STEP-095 V13 独立严格代码与性能调查 v1
日期:2026-09-25(Asia/Tokyo)。性质:独立静态审查;只写指定中文文档,不修复源码,不运行构建、测试、codegen、安装或设备操作。
## 1. 执行摘要与结论边界
V13 的通用分段解析、Style 顺序及保守 Fast Path 门控有源码依据,但不能据此批准 GPU 正确性或性能。本轮先检查 V12→V13 diff、真实调用链和生命周期,再阅读旧报告逐项挑战。新增优先问题为:离屏目标切换未重置 CPU stencil 掩码记录;Metal depth/stencil 清除契约未完整下传;LayerGroup 抛错后 retained build 仍可能标成功;V4 PLAY 缺少生产时钟推进调用。前三项的实际像素、故障注入和设备行为仍需验证。
旧 F01/F02/F03/F04/F07 的成本路径成立,但没有本轮 profiler 数据证明热点排序、OOM 或收益。F06 多 Continuous 退出静态 CPU Fast Path、F08 保守全局失效属于显式设计限制。F09 可给出计数跨 topology 错归的具体链,但不证明 stale texture 被复用。F05 的 Host `.10`/native `.13` 错配成立,历史 TS2307 不能仅归因于版本号;F10 只证明协调文档滞后。
新增成本还包括 V4 全网格物化、UI 线程解码、普通粒子 Metal 离屏等待及 drawable 重建。未找到足以定 P0 的证据;不宣称不存在其他缺陷。调查内容完成,Git 交付结果见阶段记录的收尾条目。**V13 产品运行态不判 PASS:本轮所有构建、测试、设备、性能验证均 NOT RUN。** RDC 仍须独立复核和验证。
## 2. 冻结基线、环境与范围
| 项目 | 核实结果 |
| --- | --- |
| 机器/目录 | `Mac-mini-tei.lan`,Darwin x86_64;`/Users/tei/dev/MapLibreParticleLab` |
| 分支/起始 HEAD | `particle/main`;`05878d662308b9a92c20c433c3cd2943d74ae718` |
| 起始状态 | 工作树 clean;子模块未见偏离记录提交的标记 |
| 远端 | cached origin 与实际 `git ls-remote` 都是上述 HEAD;origin 为 `https://git.eyaku.jp/tei/MapLibreParticleLab/` |
| 冻结源码 | `ee0b89e711bf01335b3aa71d36def785cd97fec3` |
| V13 功能/旧报告归档 | `016fd1fff6a33cf795c10d4f5c5413d308383677` / `45c4dca84928a57a2cf0226737c34cabd7fbfbfb` |
| V12 比较点 | tag 解引用提交 `7eaff6e12360e26591dceb5e011129eeab7ba4c7` |
| V13 tag | `.13` tag 对象 `bccbceddec70f7a2b6362bce653f9c577aa2db1d`,解引用为冻结源码,远端一致 |
| manifest sourceCommit | `ddf20dec9fe9cd05b77274827b6825829500dd5a`;至冻结提交为发布元数据收尾,生产源码一致 |
| 模型记录 | 用户在本次同一会话恢复指令中明确指定 `gpt-6-astra high`,与任务要求一致;这是当前会话的用户指定记录。未获得可独立核验的底层启动参数,不声称已独立验证实际模型/effort |
冻结源码至起始 HEAD 仅新增旧审查报告及 STEP-095 任务书;下文源码行号按冻结源码,与本轮起始工作树对应。V12→V13 整体 diff 为 45 文件、2393 行增加/203 行删除(含文档),主要新增 segment/parser/resolver、runtime diagnostics、renderer/orchestrator 调度与平台桥接,并非粒子数值 shader 的整体重写。
已读取 AGENTS、PARTICLE_BASELINE、项目规范、项目总则 README 所列当前有效规则及基线 v2,交叉阅读 STEP-088~094 的任务/设计/阶段/报告相关内容。总则仍含早期 `.3` 和 Linux 主线描述,本任务以 Mac `.13` 冻结点为准,不重写历史文档。官方 Core 基线 `18545ca10d296704b2cba675b182ff2c8da24588`,Android 官方 13.4.1、iOS 官方 6.28.0。
### 2.1 发布坐标(仅核对仓库记录)
来源:`release/maplibre-particle/release-manifest.json` 与 `release/maplibre-particle/ios/release-manifest.json`。
| 产物 | 坐标 | manifest 大小/SHA-256 |
| --- | --- | --- |
| RN | `@navsea/maplibre-particle-rn@13.4.1-particle.13`,MLRN 11.3.6 | 25,966 bytes;`3a726db06cb967fe56de77ca4a72feed98cd642a2d1b73fd51b1531d15ef7539` |
| Android OpenGL | `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.13` | 169,786,873 bytes;`ddf1deced00cc963d1d625c73c68c449232602113a1490349e69100473dda82b` |
| iOS Metal | `6.28.0-particle.13`,MapLibre XCFramework/SPM exactVersion | 8,418,198 bytes;`8d47d9f6d44479aff51299c213887bd9be204da320da4fd4fff15dd064930556` |
manifest 的 `PUBLISHED_PUBLIC_READBACK_PASS` 是 HISTORY。本轮没有下载、重算产物散列、重建或核实设备实际链接版本;文档提交不是新 V13 发布。
### 2.2 调用链和未覆盖范围
已沿 segment JSON/plan → runtime → orchestrator/renderer → cache → PaintParameters/GL/Metal target,及 canonical/QBIN/profile → Store/Clock/projection → RasterArraySource immutable Impl → WeatherColor/Particle RenderLayer → Field/State/Trail GPU 追踪;检查 Android Java/Kotlin/JNI、iOS ObjC++、RN typed API/JSON Spec、Host package/lock/plugin及 Integration 只读状态。阅读 resolver/cache/diagnostics/field/decoder/reseed 测试源码,不等同执行测试。
未逐行形式化验证全部官方 renderer/driver、Vulkan、业务 provider/HTTP/Route/ETA或所有并发交错;未使用设备、capture、sanitizer、fuzzer。此报告不是全仓无缺陷证明。
## 3. Finding 总表
D=源码可复核缺陷路径;C=源码确定成本;R=后果需运行验证;E=环境/文档偏差。置信度均指源码链而非事故发生概率。每项详细位置、修复与验收见后文。
| 编号 | 严重度/类别/置信度 | 触发与影响 | 旧报告关系 |
| --- | --- | --- | --- |
| N01 | P1/D+R/高 | 相同 tiles 跨 target,CPU stencil 映射复用,后段可能被裁掉 | 新增 |
| N02 | P1/D+R/高 | Metal retained depth/stencil clear 未完整转换,模拟器 attachment 缺口 | 新增 |
| N03 | P1/D+R/高 | group 绘制抛错被 catch,仍标 cache 成功,可能持续复用残图 | 新增 |
| N04 | P1/D/高 | V4 只 PLAY 不继续 set time,生产时钟无推进调用 | 新增 |
| N05 | P2/D/高 | JSON NUL/重复 strategy 被接受,严格解析失真 | 新增 |
| N06 | P2/D+R/高 | 结构合法但数值非法的场先入库,物化失败后可用投影被置空 | 新增 |
| N07 | P2/D/高 | 同 view 重复 attach,listener 和持有引用递增 | 新增 |
| C01 | P1/C/高 | full diagnostics 锁内深复制进入 fast/render | F01 |
| C02 | P1/C+R/高 | Metal retained build 与普通 State/Trail target 等待 | F02,新增扩大范围 |
| C03 | P1/C/高 | 内容 dirty 释放同尺寸可复用 GPU allocation | F03 |
| C04 | P1/C+R/高 | 多 R 全视口资源无总预算;Store 也缺 fieldKey 总预算 | F04,新增场预算 |
| C05 | P2/C/高 | ordinal 线性前缀遍历,fast update 仍扫全部 items | F07,新增扫描 |
| C06 | P1/C+R/高 | 同时间/mix-only 整网格物化;提交链可占用 UI | 新增 |
| C07 | P2/C/高 | tick 重建 drawable/attributes/tweaker,窗口切换复制场 | 新增 |
| O01 | P2/D/高 | 新 topology 先发布,旧 cache 析构向新 index 计数 | F09 |
| E01 | P1/E/高 | Host RN `.10` 与 Android 插件 `.13` 错配 | F05 |
| E02 | P3/E/高 | Integration 仍旧状态,实际接入 UNKNOWN | F10,降级 |
## 4. 新增正确性和生命周期发现
### N01:CPU stencil 记录跨 framebuffer 复用
证据:`src/mbgl/renderer/renderer_impl.cpp:798-847,938-945`;`src/mbgl/renderer/retained_underlay_cache.cpp:304-319`;`src/mbgl/renderer/paint_parameters.cpp:185-201,365-374`;`src/mbgl/gl/layer_group_gl.cpp:67-98`;`src/mbgl/mtl/tile_layer_group.cpp:118-119`。
最短链:同一个 PaintParameters → 第一个 R 清 stencil、写 mask 并记录 tileClippingMaskIDs → 第二个 R 新 target/清 stencil → tileIDsCovered 为真而跳过写 mask → draw 仍 Equal 非零 ID。CPU 记录属于前一 target,后一 GL target 实际为零。main buffer 清除后也未清 CPU 记录;updateStencilBufferAvailability 不是映射重置。
触发用例:同 vector source、同 tile 集合的两个 fill 分置相邻 R-R,首次都 build;或 R build 后同 tiles 的 C 绘入主目标。tile 集合变化可触发清记录,所以不是所有画面必坏。V13 任意 R-R 扩大暴露面,V12 多 target 也应回归,不能断言只由 V13 引入。
最小修复:target/pass 切换时明确失效 CPU mask/编号,或按 target generation 隔离,并保证 GPU clear 一致。验收:GL/Metal cache OFF 对照,同 source R-R/R-C-R 的首帧、rebuild、reuse 像素和 stencil capture。缺口源码已确认,实际像素 NEEDS_RUNTIME。
### N02:Metal depth/stencil 描述未完整下传
证据:`retained_underlay_cache.cpp:304-310` 请求 clearDepth=1、clearStencil=0;`src/mbgl/mtl/render_pass.cpp:13-38` 只转换 clearColor;`src/mbgl/mtl/offscreen_texture.cpp:32-83,139-148` 绑定附件但该路径未设置 depth/stencil clear/load/store。模拟器不创建 stencilTexture,也未将 combined depth/stencil texture 绑定 stencilAttachment;格式选择见 `src/mbgl/mtl/texture2d.cpp:99-104`。
触发:Metal retained tile/depth 绘制,尤其首次 target、simulator。源码确认契约缺口,但未测驱动默认内容/validation 行为,不宣称所有真机黑屏。isRenderable 仅验证 color,depth/stencil create 在 bind,早期就绪判断不能证明所有附件就绪。
最小修复:完整翻译 descriptor、正确绑定 combined attachment,并验证所有附件后允许 build;保持官方接入薄。验收:Metal API Validation、真机/模拟器 capture 核对 attachment/load/store/clear,故障注入验证安全 fallback。NEEDS_RUNTIME。
### N03:绘制异常未阻止 cache 发布为成功
证据:`src/mbgl/renderer/render_orchestrator.hpp:156-178` 的 visitor catch 只通知 observer;`renderer_impl.cpp:798-824` 随后无错误结果判断,finishBuild/markBuildSucceeded。
触发:R 的一个 layerGroup.render 抛错,observer 正常返回,其他 group 继续,残缺图像可变 valid 并在稳定帧 reuse。反例:资源准备阶段已有失败回退(`retained_underlay_cache.cpp:240-255`、`renderer_impl.cpp:662-677`),问题不等于所有资源失败都没有 fallback。
最小修复:build 汇总明确成功状态,失败 target 不发布/复用,可恢复错误进入完整渲染或约定安全失败。验收:第二个 group 故障注入,确认 successful build/reuse 不增长、无 stale composite、恢复和资源释放正确。NOT RUN。
### N04:V4 PLAY 没有生产时钟推进
证据:`src/mbgl/style/sources/raster_array_source.cpp:453-470`;`src/mbgl/particle/field/field_runtime.cpp:196-220`;`src/mbgl/renderer/layers/render_particle_validation_layer.cpp:1174-1189,1276-1278`。生产树 src/include/platform/packages 中 advanceForecastClock 仅见声明和定义,无调用。Android `platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java:225-242`、iOS `platform/ios/src/MLNParticleFieldRuntime.mm:147-165` 的 wake 只 repaint。
最短链:设一次 requested time → PLAY 改 clock state → repaint/particle cadence → renderer 读取不变的 immutable projection.mix。粒子运动不能证明预报时间在播放。Host 每次显式 set time 可绕过,但不代表 PLAY 本身有效。
最小修复:在既有 map/source 所属线程接入唯一单调时钟,明确速度、pause/resume、后台和销毁语义,不建立第二套时间真相。验收:仅设一次时间,PLAY 的 .5/1/2 倍 mix、PAUSE、跨窗口和恢复;`test/particle/field_runtime.test.cpp:272-310` 手动 advance 单测不覆盖生产接线。本轮 NOT RUN。
### N05:Segment JSON NUL 截断与重复键
证据:`src/mbgl/renderer/render_segment_json.hpp:16-49`。Parse 用 C 字符串,strategy/startLayerId 用 GetString 未带 GetStringLength;`"RETAINED\u0000x"` 截成 `RETAINED`,`"boundary\u0000x"` 可解析到另一真实 ID。segment 两个重复 strategy 满足 MemberCount<=2,白名单不检唯一性,读取首值。
触发可由 primitive JSON/native 入口构造,TS typed API 不替代校验。普通未知 strategy/schema、missing/reversed 仍会 invalid/fallback。最小修复:长度感知解析/构造,拒绝 NUL/重复键,保持 invalid 清旧 plan 语义。验收:上述 payload、raw NUL 后缀、正常 unicode、未知字段、invalid→valid及两桥接错误归一化。NOT RUN。
### N06:数值非法的场在入库后才被拒绝物化
证据:`src/mbgl/particle/ingress/qbin_v2_decoder.cpp:41-73` 校验结构、CRC、scale/offset但不扫描 Float32 样本;`src/mbgl/particle/field/field_profile_validator.cpp:77-166` 匹配契约;`field_projection_materialization.cpp:38-67,97-135` 才检查解码数值;`raster_array_source.cpp:422-440,496-522` 先 submit,再物化,失败 next 对应投影为空。
触发:已有绑定字段,同时间更高 revision、CRC 正确、valid=1、样本 NaN/Inf 的 QBV2。结构/契约可接受,替换旧帧后物化失败,但 submit 返回 AcceptedInserted/AcceptedReplacedRevision。有限 scale 乘法溢出也应纳入验证。此处不声称 NaN 上传 GPU——物化拒绝正是反例;缺陷是 accepted/可渲染语义及旧状态保留脱节。
最小修复:明确 ingress 数值约束,入库前校验或事务式验证,保留合法无效 cell/通用未绑定 plane 语义。验收:好旧帧→坏高 revision,检查返回值、Store/投影保持、noData/validity、极值及恢复。NOT RUN。
### N07:Android attach listener 积累
证据:`packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt:30-63`。每 attach 新增捕获 concrete map 的 listener;显式 detach 只 dispose runtime,view detach 回调也不移除 listener。
同一个存活 view 重复 attach/detach,listener/引用及回调为 O(k)。未证明 view 回收后永久泄漏或 UAF;每次重新解析 view、没有全局 reactTag registry 是正面约束。最小修复:view/map 单一 listener、attach 幂等、明确 remove 生命周期。验收:同 view 100 次、换 view、卸载/后台恢复,listener/dispose/存活对象回基线。NOT RUN。
## 5. 性能、资源与观测
### C01:完整 diagnostics 深复制进入热路径
证据:`renderer_impl.cpp:198-208,500-505` → `retained_underlay_runtime_state.hpp:243-254` 锁内复制 styleLayers、segments.layers、字符串。成功 fast frame 可两次,不是所有帧固定两次。成本 O(L+S),锁竞争程度待测。修复:一致的轻量热快照与完整外部 pull 分开,不能无同步拆字段。验收:稳定 30/60 FPS 的完整快照次数应随外部 pull,测 alloc/frame、锁等待、CPU;无收益百分比。
### C02:Metal 等待不限于 retained rebuild
证据:`retained_underlay_cache.cpp:316-319` → `src/mbgl/mtl/command_encoder.cpp:29-31` → `mtl/offscreen_texture.cpp:86-91` commit/waitUntilCompleted,失效 R 逐段同步;稳定 reuse 没有 retained build wait。
新增链:`src/mbgl/renderer/render_target.cpp:71-119` 普通 target 也 present;`render_particle_validation_layer.cpp:1990-2111` tick 注册 State/Trail 写目标,初始化可能更多。因此 retained 稳定也可能有多个普通粒子 wait,属继承成本。修复前先 capture buffers、wait 栈及依赖,再设计有序异步提交,不能直接删 wait。验收:State A/B 无别名,Trail/composite 顺序与轨迹一致,CPU wait/GPU frame time 成对比较。NEEDS_RUNTIME。
### C03:内容失效与 allocation 销毁耦合
证据:`retained_underlay_cache.cpp:161-237` revisionChanged 同时 invalidate/release;`:240-255,365-374` 重建/释放 texture/compositor。触发为内容/相机等 revision 改变,不是稳定帧无条件分配。修复:contentDirty 与尺寸/context/格式的 resourceInvalid 分开,同尺寸有效 context 保留 allocation。验收:mutation、camera、resize、context loss 分开统计 build/create/release/peak;先保证 N01/N02 clear 正确。耗时收益待测。
### C04:多段 GPU 与多字段 CPU 无总预算
证据:`renderer_impl.cpp:506-524`;`retained_underlay_cache.cpp:240-255`;`src/mbgl/gl/offscreen_texture.cpp:18-35`、`mtl/offscreen_texture.cpp:20-55`。每 R 全视口 color/depth/stencil;RGBA8 color 理论下界 R×W×H×4 bytes,另有附件、alignment、主 framebuffer、State/Trail及延迟释放,不能把逻辑 release 当物理显存回收。
新增:`src/mbgl/particle/field/field_runtime.cpp:223-230` 对新 fieldKey 建 series;`include/mbgl/particle/field/field_runtime.hpp:115` 每 series 默认容量并非总 key/总 bytes 上限。QBIN 单文件64 MiB/解码32 MiB 上限也不约束累计持有量。
修复:map 预算、分配前溢出安全估算、超限明确 full-render/拒绝策略,不合并相邻 R、不淘汰仍绑定窗口。验收:R×viewport×字段数阶梯、allocation failure/context loss、30分钟以上 peak/回收。OOM/泄漏未证实,NEEDS_RUNTIME。
### C05:ordinal 前缀遍历和全层扫描
证据:`render_orchestrator.hpp:156-178` 的 multimap std::distance 是线性,S 次范围定位可能 O(S×G);`render_orchestrator.cpp:1202-1226` fast updateParticleLayers 仍遍历完整 items,非粒子只跳过 update;`:973-975` 按 retained 稳定性还可能扫 source fading。
修复:topology 改变时缓存 global ordinal/粒子列表,保持 generation 和原始全局深度编号。验收:固定粒子层扩大 L/G/S,测 CPU/调用次数并回归 reorder/delete。实际热点占比待测。
### C06:同时间/mix-only 仍全网格物化,UI 成本遗漏
证据:`raster_array_source.cpp:453-455,496-522` 在 sameProjection 前分别物化 color/particle;`field_runtime.cpp:196-200` 相同非零时间也返回 true;`field_projection_materialization.cpp:69-75,97-211` 分配/遍历 validity、T0/T1 scalar/vector。Wind 双绑定可重复同向量物化。mix 更新产生新 Impl/onSourceChanged,`render_orchestrator.cpp:361-365` 全局 SourceMutation 可进一步失效所有 R。
桥接:Android withMap UI 调度经 `platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp:1501-1517` 同步到 Source;iOS `packages/maplibre-particle-rn/ios/WeatherRuntimeBridge.mm:105-120,235-240` 的 withMap 与 `platform/ios/src/MLNParticleFieldRuntime.mm:222-252` 也同步提交 Core。预读取文件不等于 CRC/解码/物化已经离开 UI。成本随格点/plane 增长,实际卡顿/ANR未测。
修复:相同时间早退,按端点 identity/binding 共享不可变物化、mix 单独更新;若 worker 解码,owner-thread 发布必须带 map/source generation,禁止 worker 直接改 Source。局部失效必须先有完整依赖。验收:同时间、mix-only、跨窗口、双绑定、最大网格的 CPU/alloc/UI阻塞/upload/rebuild计数。NEEDS_RUNTIME。
### C07:tick 对象重建及端点复制
证据:`render_particle_validation_layer.cpp:826-904` clearDrawables 后新 attributes/builder/tweaker;`:1668-1669,1990-2111` 更新 target 注册及 State/Trail/composite;`:657` createOrUpdate UBO 不证明跨新 drawable 复用;`:1494-1499,1538-1563` 场端点切换复制数组。
反例:State/Trail textures 和共享顶点不是每 tick 全部重建,field 复制主要在窗口边界,不能称 CPU 每帧模拟全部粒子或无条件上传场。修复:稳定 drawable 保留,仅更新 uniforms/texture,端点共享不可变存储。验收:tick/bootstrap/reseed/resize/端点轮换分别测对象、UBO、upload和峰值。NOT RUN。
### O01:旧 cache release 记入新 topology
证据:`retained_underlay_runtime_state.hpp:173-240` 先发布新 entries;`renderer_impl.cpp:500-524` 再 clear 旧 cache;`retained_underlay_cache.cpp:120-122,365-374` 析构按旧 segmentIndex 计数;runtime `:256-272` 寻找当前 entry。
旧 index 0 的 R 换成新 R,旧 release 可记入新 R;新 index 为 C/越界时 per-segment 计数丢失。不证明 GPU 未释放或旧图复用。另 runtime `:198` root fallback 只表示 resolution invalid,不能覆盖 cache OFF/unsupported/resource fallback;消费方不可读成 GPU 当前 retained 成功。
修复:计数带 cache identity/generation,聚合历史与当前段分开,明确两类 fallback。验收:真 cache R-R→C-R、相同 index 换边界、disable/restore析构计数,纯手动 counter 测试不足。NOT RUN。
## 6. 环境发现和旧 F01~F10 复核
E01 定位:`integration/react-native/MapLibreParticleRNTestHost/package.json:12`、`package-lock.json:12,1765-1766` 为 `.10`,`plugins/withParticleNativeAndroid.js:9` 注入 `.13`,当前 package/native 为 `.13`。默认干净 Host 不能证明 V13 全链。旧 typecheck exit2/TS2307/TS7006 为 HISTORY;缺模块和旧 API 是不同原因,本轮未复跑。修复方向:后续单独对齐 package/lock/native resolved version,干净安装、typecheck/codegen及设备调用;不能把临时工作树链接 PASS 当默认包 PASS。本轮不改依赖。
E02 定位:只读 `/Users/tei/dev/navsea-particle-integration` HEAD `39ad8415fbdb76c1822460bb09f3a3f35fe141e0`,`compatibility/MATRIX.md` 与 `status/` 仍含 Sept18 `.5`、iOS 等待、RN NOT_STARTED。它与本仓 `.13` 是不同时点,真实 NavSea 接入 UNKNOWN。降 P3 文档项,建议负责人以接入 commit/包/设备证据更新,不改 Core 协议;本轮不改 Integration。
| 旧项/原结论 | 当前意见 | 修正和依据 | 补证 |
| --- | --- | --- | --- |
| F01 P1 深复制 | CONFIRMED | C01,成功 fast 可两次而非每帧必两次 | alloc/锁/CPU NOT RUN |
| F02 P1 retained wait | CONFIRMED | C02,限定 build,补普通粒子 wait | 收益 NEEDS_RUNTIME |
| F03 P1 dirty 释放 | CONFIRMED | C03,稳定 reuse 不重建 | 内容/resize/context分测 |
| F04 P1 无预算 | CONFIRMED | C04,无预算不等于已 OOM | peak/压力 NEEDS_RUNTIME |
| F05 P1 Host 不可复现 | QUALIFIED | E01,错配确定,旧 TS2307非本轮且不可仅归版本 | 干净安装 NOT RUN |
| F06 P1 multi-C full CPU | QUALIFIED | 显式设计限制,建议 P2;不等于关闭 GPU retained reuse | 相同Style A/B NOT RUN |
| F07 P2 ordinal 线性 | CONFIRMED | C05,优化定位不能改全局编号 | S/G基准 NOT RUN |
| F08 P2 全局失效宽 | QUALIFIED | 保守设计,未证明 stale;未知依赖不能裁窄 | source/tile/glyph依赖 |
| F09 P2 错归风险 | CONFIRMED | O01具体先换entries后旧析构链;不证明stale texture | 真cache生命周期 NOT RUN |
| F10 P2 文档漂移 | QUALIFIED | E02降P3;不推定实际接入 | 负责人跨项目核对 |
没有为凑数将整个旧项标 DISPROVED;被否定的是“多C关闭所有GPU reuse”“计数错归证明旧纹理复用”“.10必然造成TS2307”“单测证明像素正确”等外推。旧报告未明确作这些断言之处,也不反向指控它作过。
## 7. 反例、边界和设计限制
1. `render_segment_plan.hpp:32-157` 采用完整 ordered IDs(含 hidden),边界存在且递增,全R/全C合法,相邻同策略不合并,V12经同模型映射;首段可省 startLayerId 是契约。普通 invalid 清 resolution、恢复重解析有依据。
2. reorder 不是“仅added/removed所以遗漏”:`src/mbgl/renderer/style_diff.cpp:9-42` 序列差异体现移动,`render_orchestrator.cpp:318-329` 重建 orderedLayers,未报告无可达链的 reorder bug。
3. `renderer.cpp:37-59` 在 full createRenderTree 前清 prepared tree,fast gate 检查 style/source 等输入;不能只见缓存引用便断言 UAF。context/destroy 极端交错仍需验证。
4. `renderer_impl.cpp:798-918` 保留 opaque reverse/translucent forward 与 global ordinal,compositor premultiplied alpha/depth disabled 有源码依据。但不能推导所有分段像素等价,N01/N02仍为 gate;不凭空宣称所有 alpha 顺序错误。
5. Cache OFF、不支持内容、资源准备失败已有普通路径;多 C 只退出 CPU fast,R可reuse。3D/debug/legacy/custom gate保守,不建议绕过求性能。
6. QBIN有文件/plane/cell/decoded上限、CRC/bounds/长度检查,Clock有速度/整数溢出检查;FieldSeries按时间/revision处理重复/旧版本并shared持有窗口。未找到可证实的任意写/越界链;N06是数值发布语义。旧canonical adapter已有有限性检查,不能一概否定。
7. State A/B、Trail、T0/T1和方向/速度各有路径;`render_particle_validation_layer.cpp:1671-1810` resize重建Trail、按zoom-out gesture决定reseed并保留其他状态,不代表orientation/zoom-out设备通过。nearest/linear采样及双端点validity需数值视觉基准,本轮未断言错误插值。
8. 共享海陆mask尚无验收证据:`field_projection_materialization.cpp:77-92` snapshot displayDomain为GLOBAL;`render_particle_validation_layer.cpp:278-293,1314-1324` 有synthetic coastal mask路径。不能据此证明Wave/Tide/Current共用正式Land/Ocean Mask;陆地覆盖显示不等于mask。列为架构/接入证据缺口,不擅改协议。
9. 桥接保持map-scoped,typed object经内部JSON,没有引入业务协议;普通invalid plan会关闭旧计划。符号接线不等于ABI/codegen或销毁竞态已测。
## 8. V12/V13 架构与成本比较
L=层数、G=LayerGroup数、S=段数、R=retained数、W/H=物理视口、P=格点。仅源码模型,不是测量。
| 维度 | V12 | V13 | 判断 |
| --- | --- | --- | --- |
| topology | 固定range,最多两个R | 任意独立R/C,相邻R不合并 | 扩大管理/资源上界,不自动提高FPS |
| GPU复用 | 固定retained部分 | 每R独立 | Layer fingerprint局部化,全局依赖仍保守 |
| CPU fast | 合格单连续区间 | 恰好一个C且gate满足 | 多C full CPU不等于GPU cache关闭 |
| diagnostics | 旧统计结构 | 完整Style和重复段层列表 | C01 O(L+S)复制 |
| 范围定位 | 固定数量 | S段前缀可能O(S×G) | 实际占比待测 |
| retained资源 | 至多两组 | O(R×W×H) | 物理峰值与OOM未测 |
| Metal等待 | 固定targets | 随失效R增加 | 普通State/Trail是继承成本 |
| 场/粒子更新 | 已有物化/drawable | 多数继承 | C06/C07不全归因V13 |
STEP-090 fast ON 21.7%/OFF97.0% RenderThread仅历史特定实验,不外推V13。历史一次退出及随后短时未复现,既不证明泄漏也不证明长稳。必须同设备、Style、源数据、视口、刷新率/热状态配对,先像素正确再比CPU/GPU。
## 9. 测试证据与 RDC 验证矩阵
| 项目 | 历史 | 本轮 |
| --- | --- | --- |
| STEP-094 GL构建、8 suites/43 tests | 报告PASS | HISTORY/NOT RUN;纯状态解析不能验证GPU |
| Field/QBIN/viewport69 tests、package TS | 旧审查PASS | HISTORY/NOT RUN |
| Metal-only iPhoneOS编译 | STEP-094 PASS | HISTORY/NOT RUN;不验证附件/像素 |
| Android APK、RN codegen | STEP-094 PASS,codegen用底层入口 | HISTORY/NOT RUN |
| Host工作树替换TS及旧默认失败 | 两种不同依赖环境 | HISTORY/NOT RUN,可同时成立 |
| 本轮静态/文档检查 | 不适用 | 已执行;Git结果见阶段收尾 |
以下所有运行项本轮均未执行。
| Gate | Android GL | iOS Metal真机/模拟器 | Vulkan/其他 | 证据要求 |
| --- | --- | --- | --- | --- |
| R/C/R-R/R-C-R/C-R-R/R-R-C/R-C-R-C-R | NOT RUN | NOT RUN | NOT RUN | cache OFF/ON截图、像素差、pass/ordinal |
| 同source stencil、半透明fill/line/symbol | NOT RUN | NOT RUN | NOT RUN | 首帧/rebuild/reuse、depth/stencil capture |
| hidden/reorder/delete/restore/invalid | NOT RUN | NOT RUN | NOT RUN | 下一帧无stale、revision与plan |
| OFF/资源失败/draw失败/context loss | NOT RUN | NOT RUN | NOT RUN | 失败不可reuse,资源配对与恢复 |
| PLAY/T0T1/坏数值/乱序/旧revision | NOT RUN | NOT RUN | NOT RUN | time/mix、返回码、旧窗口保持策略 |
| pan/zoom-out/pitch/resize/orientation | NOT RUN | NOT RUN | NOT RUN | state/trail/reseed及无额外field提交 |
| 30/60FPS,单C/多C、mutation | NOT RUN | NOT RUN | NOT RUN | CPU/GPU p50/p95/p99、miss/wait/alloc |
| 预算/峰值/30分钟以上长稳 | NOT RUN | NOT RUN | NOT RUN | R×视口×字段阶梯、bytes/后台/卸载 |
| attach/detach/双map/销毁中完成 | NOT RUN | NOT RUN | NOT RUN | owner thread、generation、listener |
| 干净包版本/ABI/codegen | NOT RUN | NOT RUN | 不适用 | resolved包/散列/生成接口/实际调用 |
RDC先记录HEAD/manifest/tag/构建配置;以下是既有精确命令,本轮未执行。新增缺陷尚未补测试,旧过滤集PASS不能关闭本报告。
```sh
cmake --build build-step088-opengl --target mbgl-test-runner -j3
build-step088-opengl/mbgl-test-runner --gtest_filter='RenderSegmentPlan.*:RenderSegmentDiagnostics.*:ContinuousLayerRange.*:RetainedDynamicBoundary.*:RetainedLayerGroupRange.*:RetainedUnderlayCache.*:RetainedUnderlayRevision.*:ParticleFastPath.*'
build-step088-opengl/mbgl-test-runner --gtest_filter='ParticleFieldRuntime.*'
cmake --build build-ios --target ios-sdk-static -- -quiet
```
Metal命令前确认构建目录MLN_WITH_METAL=ON、MLN_WITH_OPENGL=OFF;缺目录由RDC按规范配置,不复用未知缓存。RN先解决真实resolved `.13`前提,再按STEP-094/083现行入口typecheck/codegen。失败回传首个真实error、target、上下文和日志路径,成功仅exit/耗时/摘要。
## 10. 优先级与建议修复批次
只建议,不创建修复任务、不改源码。
| 批次 | 范围 | Gate/安全约束 |
| --- | --- | --- |
| A 正确性 | N01/N02/N03 target/Metal/失败发布,N04时钟另小提交 | 最小复现、故障注入、两平台OFF基线、PLAY端到端;不借机全面重构 |
| B 无设备可先回归 | N05/N06/N07/O01/E01 | 纯测试/桥接由RDC执行;版本可复现,不代替A的GPU gate |
| C CPU安全优化 | C01轻快照、C05ordinal/粒子列表、C06相同时间早退/端点复用 | 同步一致、Style唯一顺序、调用/分配次数证明 |
| D GPU资源/同步 | C02/C03/C04/C07 | 先profile,再预算和allocation/content分离;明确buffer依赖,不能直接删wait |
| E 较大架构 | multi-C fast、source依赖、异步大场、正式共享mask | 单独设计,未知依赖继续全局失效,不引入provider/HTTP业务调度 |
## 11. Git 与交付
仅四个允许路径:本文、`docs/50_Step记录/STEP-095_V13独立严格代码与性能审查_v1.md`、`docs/报告/STEP-095_V13独立严格代码与性能审查_Codex完成报告_v1.md`、根`粒子开发记录.md`末尾追加。创建前同主题输出不存在,使用v1,不覆盖旧报告。
起始HEAD `05878d662308b9a92c20c433c3cd2943d74ae718`。检查路径/引用/`git diff --check`后仅scoped staged/commit/push origin particle/main。实际SHA/message、push/remote/status及最终收尾提交自引用说明见阶段记录第4节;推送未完成前不写Git PASS。冻结源码至最终文档提交的差异只含既有两份文档及本轮四份文档,不含源码/shader/API/依赖/build/manifest/tag/产物。

View File

@@ -0,0 +1,231 @@
# STEP-096 V13 隔离盲审调查 v1
本报告仅使用 `/Users/tei/dev/MapLibreParticleV13Blind096` 内的当前规范、任务书与源码。发现由本轮独立阅读、追踪调用链及反例检查形成;未读取外部仓库、其他轮次审查、历史报告、会话日志、网页或既有审查结论。未构建、未执行测试、未运行设备、未修改源码。
## 1. 输入、结论边界与证据等级
- 合同:[STEP-096 任务书](../40_任务/STEP-096_V13隔离盲审_Codex任务_v1.md)。已读取根 AGENTS、PARTICLE_BASELINE、项目规范目录、总则 README 指定的当前有效文件。
- 唯一目录:`/Users/tei/dev/MapLibreParticleV13Blind096`。目标导出 SHA:`ee0b89e711bf01335b3aa71d36def785cd97fec3`,来源为任务书声明;本轮没有 Git 对象可供独立核验,未运行 Git。branch、当前 HEAD、Git clean/remote 状态均为 NOT AVAILABLE。
- [release manifest](../../release/maplibre-particle/release-manifest.json#L4) 声明 particleRevision=13、sourceCommit=`ddf20dec9fe9cd05b77274827b6825829500dd5a`,RN/Android 为 `13.4.1-particle.13`,iOS 为 `6.28.0-particle.13`。manifest 的 sourceCommit 是产物来源声明,不能替代本次快照导出 SHA,也不能据此断言导出错误。
- 当前基线总则仍包含 `.3` 发布坐标;本轮以快照源码及 `.13` manifest 描述实现。此为文档口径差异,未据此推断线上产物错误。manifest 的发布成功字样不是本轮公网回读证据。
- 当前工作机器观察值为 `Mac-mini-tei.lan`;工作日期为 2026-09-25。实际 TUI 启动命令、screen 归属、模型配置及 reasoning 设置的独立启动记录均 NOT AVAILABLE;不访问目录外补证,不声称核实了 RDC 启动要求。
“确定缺陷”指限定触发条件下源码控制流或数值关系已闭合,不表示已复现用户事故。“源码确定成本”指可证明的调用、遍历、分配或等待,不表示 profiler 排序。“设计限制”指当前能力或上界不足。所有 GPU 画面、耗时、卡顿、OOM、崩溃、性能收益均 NEEDS_RUNTIME;所有构建和测试均 NOT RUN。未发现可由本轮证据成立的 P0。
## 2. 独立架构地图
| 路径 | 实际责任和所有权 | 审查定位 |
|---|---|---|
| RN RendererRuntime → 平台 bridge → Map/Renderer → RenderOrchestrator → segment plan | Host 提交策略配置;Style 层顺序仍是绘制顺序来源,策略在该顺序上划分区间 | [RN 入口](../../packages/maplibre-particle-rn/src/RendererRuntime.ts#L22)、[分段计划](../../src/mbgl/renderer/render_segment_plan.hpp)、[Renderer](../../src/mbgl/renderer/renderer_impl.cpp#L583) |
| Renderer → RetainedUnderlayCache → gfx RenderPass/OffscreenTexture → GL/Metal | Renderer 持有各 retained 段缓存;缓存拥有离屏纹理/合成资源;同帧 PaintParameters 穿过多个目标 | [缓存生命周期](../../src/mbgl/renderer/retained_underlay_cache.cpp#L201)、[渲染调度](../../src/mbgl/renderer/renderer_impl.cpp#L798) |
| RN FieldRuntime → Android/iOS bridge → WeatherRuntime/MLNParticleFieldRuntime → RasterArraySource | bridge 定位地图并派发;Source 持有 encoded store、绑定、forecast clock;发布不可变 Impl/投影供 RenderLayer 读取 | [RN API](../../packages/maplibre-particle-rn/src/FieldRuntime.ts)、[Source 提交](../../src/mbgl/style/sources/raster_array_source.cpp#L422) |
| encoded ingress → QBIN V2/V3 → profile validator → EncodedFieldStore/FieldSeries → binding/window | magic 分派格式,校验结构和 profile;每个 fieldKey 保存时间序列;时间窗选取后物化数组 | [V3 解码](../../src/mbgl/particle/ingress/qbin_v3_decoder.cpp)、[profile](../../src/mbgl/particle/field/field_profile_validator.cpp)、[series](../../src/mbgl/particle/field/field_runtime.cpp#L144) |
| projection → WeatherColorLayer / RasterParticleLayer → GL/Metal shader | 色场与粒子分层;颜色消费标量或向量幅值;粒子持有场纹理、State A/B、Trail A/B 和 drawable/tweaker | [物化](../../src/mbgl/particle/field/field_projection_materialization.cpp#L201)、[粒子更新](../../src/mbgl/renderer/layers/render_particle_validation_layer.cpp#L1489)、[颜色更新](../../src/mbgl/renderer/layers/render_weather_color_layer.cpp#L205) |
正常路径存在实际保护:Style diff 处理增删/重排;无效分段与不支持场景可 fallback;稳定 retained 帧可复用;解码有结构、尺寸、CRC 等检查;同时间身份冲突和陈旧修订会拒绝;超出时间覆盖范围不会凭空外推。Source 使用共享不可变帧/投影,使读者可持有旧快照;粒子 tweaker 使用 weak_ptr,不能仅因资源与 drawable 互相引用的表象断言泄漏。
固定边界的实现情况:正式 Host 提交路径未要求 Native 承担 provider/manifest 下载;旧 fixture/local FileSource 路径应单独理解。RN 是控制入口,绘制仍在 Native Core。粒子模块有独立 field/ingress/render 层,但 Renderer/RenderOrchestrator 中也存在较多集成逻辑;没有上游对照输入,无法量化“薄接入”变更比例。Wave/Tide/Current 的共享地理掩码要求见 B09。GL 与 Metal 不能由共享 C++ 自动推出行为一致;Vulkan 完整粒子链未在本轮闭合。
## 3. 独立发现总表
编号按本轮源码证实顺序,排除的候选不占编号;优先级是建议处理顺序,不是实测事故等级。
| 编号 | 优先级 | 类型 | 置信度 | 发现及作用边界 |
|---|---|---|---|---|
| B01 | P1 | 确定缺陷 | 高 | 切换离屏/主目标未重置 tile stencil 的 CPU 覆盖记录 |
| B02 | P1 | 确定缺陷 | 高 | Metal RenderPass 没有落实 gfx 的 clearDepth/clearStencil 请求 |
| B03 | P2 | 源码确定成本 | 高 | Metal 每次离屏 present 显式等待 GPU 完成 |
| B04 | P2 | 源码确定成本 | 高 | retained 内容失效同时释放同尺寸 GPU 资源 |
| B05 | P2 | 源码确定成本 | 高 | 时间更新先全量物化场,再判断投影是否相同 |
| B06 | P1 | 确定缺陷 | 高 | encoded PLAY 无生产路径推进 forecast clock |
| B07 | P2 | 确定缺陷 | 高 | 8-bit 对称场编码不能表示零,产生有符号偏差 |
| B08 | P2 | 确定缺陷 | 高 | Mercator 东向 U 位移缺少纬度修正因子 |
| B09 | P2 | 设计限制 | 高 | 当前掩码为合成 fixture,未形成共享地理 Ocean Mask |
| B10 | P2 | 源码确定成本 | 高 | 平台 UI 派发链同步解码并物化 encoded 数据 |
| B11 | P2 | 设计限制 | 高 | encoded store 仅限制每序列帧数,无全局字节/key 上界 |
| B12 | P2 | 确定缺陷 | 高 | retained 段绘制抛异常后仍可能标记缓存构建成功 |
| B13 | P1 | 确定缺陷 | 高 | 粒子场更新门槛漏掉合法时间窗内的 V/有效性变化 |
### B01 — stencil 覆盖记录没有随渲染目标切换
**触发与调用链。** 多个 retained 段,或 retained 构建后主目标绘制,复用相同瓦片集合且需要 stencil 裁剪。[Renderer buildRetainedRange](../../src/mbgl/renderer/renderer_impl.cpp#L798) 共用 PaintParameters;[beginBuild](../../src/mbgl/renderer/retained_underlay_cache.cpp#L304) 创建清 stencil=0 的离屏 pass,GL 只更新 stencil 可用性;随后[主 pass](../../src/mbgl/renderer/renderer_impl.cpp#L839) 也清 stencil=0。[updateStencilBufferAvailability](../../src/mbgl/renderer/paint_parameters.cpp#L100) 不清覆盖表;[tileIDsCovered](../../src/mbgl/renderer/paint_parameters.cpp#L147) 和[早退](../../src/mbgl/renderer/paint_parameters.cpp#L185) 只看 tile ID,不看 attachment。下一目标仍会用[旧的 Equal stencil ID](../../src/mbgl/renderer/paint_parameters.cpp#L365)。[GL LayerGroup](../../src/mbgl/gl/layer_group_gl.cpp#L67) 正常调用掩码生成,也无法绕过这个早退。
**所有权、正常/错误/销毁。** 覆盖表属于同帧 PaintParameters,stencil 内容属于每个目标附件。下一帧重建 PaintParameters 不能修复同帧跨目标;[显式 clearStencil](../../src/mbgl/renderer/paint_parameters.cpp#L156) 会重置记录,但上述目标切换没有调用。资源创建失败有 fallback,与本问题不同;正常创建成功即可触发。在 GL 已清为 0 的新目标上,旧正 ID 与实际内容不一致的路径已证实。Metal 的清屏另受 B02 影响。
**反例与边界。** 同一 attachment 内复用覆盖记录是正确优化;瓦片集合改变可能重绘掩码;无裁剪图层、没有跨目标构建或全部退回主绘制的帧不满足条件。不能据此称所有 retained 帧都缺图,实际画面 NEEDS_RUNTIME。
**安全修复/最小验证。** 在目标/attachment 世代切换时失效掩码元数据及 ID 分配状态,保留同目标复用;不可在已绘制内容中途随意清 stencil。构造 R/C/R 和 R/R 共用瓦片的 fill/line 层,逐目标检查掩码写入及截图,与关闭 retained 对照;含稳定复用、相机变化、主目标后续 live 层。
### B02 — Metal 没有传递 depth/stencil 清屏语义
**触发与证据。** retained beginBuild 明确请求 depth=1、stencil=0;[Metal offscreen bind](../../src/mbgl/mtl/offscreen_texture.cpp#L62) 新建 descriptor 并挂接 color/depth/stencil 纹理。[Metal RenderPass 构造](../../src/mbgl/mtl/render_pass.cpp#L13) 只处理 clearColor,没有读取 clearDepth 或 clearStencil;[GL 对应构造](../../src/mbgl/gl/render_pass.cpp#L14) 把这些请求交给 clear。调用链为 Renderer → cache.beginBuild → gfx descriptor → Metal RenderPass → MTL descriptor,丢失发生在最后的转换。
**所有权与反例。** 附件属于 offscreen texture,pass/command encoder 管理本次命令。创建和销毁附件均不等于每次按请求清屏。主平台 descriptor 可自行配置清屏,不能证明新建离屏 descriptor 已正确配置;无 depth/stencil 的 pass 不受这两个字段影响。未使用外部 SDK 默认值推断确定黑屏,也未把未显式设置 StoreAction 直接定性为丢帧。模拟器 stencil attachment 分支另需设备验证。
**影响/修复/验证。** 确定的是 gfx 请求未落实,裁剪/深度表现 NEEDS_RUNTIME。统一转换各附件的 clear/load/store 语义,分别覆盖有无 clear 值、有无附件及模拟器组合格式。用非默认 depth/stencil clear 值做离屏后续比较测试,并核对 GPU capture;不得以 color 正常替代 depth/stencil 验证。
### B03 — Metal 离屏 present 中存在同步等待
**触发与链。** [RenderTarget::render](../../src/mbgl/renderer/render_target.cpp#L71) 在每个 target 绘制结束后 present;[Metal command encoder](../../src/mbgl/mtl/command_encoder.cpp#L29) 调 resource.swap;[OffscreenTexture::swap](../../src/mbgl/mtl/offscreen_texture.cpp#L86) commit 后执行 waitUntilCompleted,再重置命令缓冲。粒子稳态在[目标注册](../../src/mbgl/renderer/layers/render_particle_validation_layer.cpp#L2097) 中至少包含 StateWrite 和 TrailWrite,初始化还有额外读目标;retained 构建也有 present。
**所有权、边界和反例。** 等待保护命令/资源完成,是确定同步点,不是已测出的长时间阻塞。仅实际执行这些目标的帧计入;停绘、未激活层不计,retained 稳定复用不计新的 cache build 等待,GL 不经过此 Metal 函数。完成/销毁安全不能通过简单删 wait 保证。
**修复/最小验证。** 研究同队列命令依赖与完成回调持有资源,合并或延后提交,保留读写顺序及销毁保护。Metal capture 记录单粒子层每帧 command buffer、wait 次数和 CPU 等待时长,再测多粒子层、首帧、retained 重建及移除层;性能收益 NEEDS_RUNTIME。
### B04 — 内容失效与资源释放绑定
**触发与链。** 相机矩阵/内容 revision 改变而 viewport 尺寸不变。[Renderer revision 更新](../../src/mbgl/renderer/renderer_impl.cpp#L583) → [cache evaluate](../../src/mbgl/renderer/retained_underlay_cache.cpp#L201) → releaseResources → [prepareResources](../../src/mbgl/renderer/retained_underlay_cache.cpp#L244) 重新创建离屏资源。[releaseResources](../../src/mbgl/renderer/retained_underlay_cache.cpp#L365) 清掉纹理、合成组及资源键。
**正常/错误/销毁与反例。** 缓存对象持有资源,失效已实际释放所有权;稳定 valid 分支会 Reuse,不能称每帧都分配。viewport、格式、context 改变或最终销毁确需释放;资源创建异常已有 fallback。本 finding 针对同尺寸、同 context 的内容失效成本,不主张旧图继续有效。
**修复/验证。** 分开“内容有效性”和“附件可复用性”,保留匹配资源键的附件并重绘。固定 viewport 平移/缩放,对比 offscreen 创建次数、峰值显存、帧时间;再覆盖 resize/context 重建,防止错误跨 context 复用。成本路径已证实,分配耗时与收益 NEEDS_RUNTIME。
### B05 — 时间更新重复物化整个场
**触发与链。** Host 高频 setEncodedForecastTime,包括相同非零时间。[setRequestedForecastTime](../../src/mbgl/style/sources/raster_array_source.cpp#L453) → refreshV4RendererState;[clock setter](../../src/mbgl/particle/field/field_runtime.cpp#L196) 不因同值返回 false;[refresh](../../src/mbgl/style/sources/raster_array_source.cpp#L496) 先物化两类投影,再用 sameProjection 比较。[validity](../../src/mbgl/particle/field/field_projection_materialization.cpp#L69) 展开 bitset,[vectorSnapshot](../../src/mbgl/particle/field/field_projection_materialization.cpp#L105) 遍历向量;颜色 UV 与粒子各有自己的 T0/T1 物化。[sameProjection](../../src/mbgl/style/sources/raster_array_source.cpp#L65) 还会比较实际数组。
**所有权和反例。** 新数组在临时共享快照中,比较相同后可释放;发布新 Impl 时旧读者继续持有旧快照。无投影、无绑定、无法解出窗口会少做或跳过相关分支。相同内容可避免发布或 GPU 重传,故不等于每次都上传纹理;每个动画帧也不一定调用该时间 setter。完整性校验是必要成本,问题是同内容重复重建。
**成本/修复/验证。** N 个格点、两个时间面、颜色 UV 与粒子同时激活时,四份 vector+valid 基础数组约为 36N 字节的新逻辑存储,另有比较/校验遍历和旧快照重叠。缓存以完整不可变内容身份(含两分量、validity、grid、解释规则)为键,将 mix/time 与数组物化分离。统计同值时间、仅 mix 变化、跨窗、修订替换时的分配与上传次数;不得牺牲 B13 所需的完整身份。
### B06 — encoded PLAY 没有接入 forecast clock 推进
**触发与链。** 提交两帧、绑定、设置时间后,经公开 API 切 PLAY 并等待 repaint。[Source 播放设置及 advanceForecastClock](../../src/mbgl/style/sources/raster_array_source.cpp#L459) 中,播放设置只改 clock 状态,推进由独立方法完成。对 src/include/platform/RN 生产路径检索 advanceForecastClock 仅有[声明](../../include/mbgl/style/sources/raster_array_source.hpp#L240)和定义,没有调用方。Android [repaint timer](../../platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java#L225) 与 iOS [timer](../../platform/ios/src/MLNParticleFieldRuntime.mm#L147) 只触发重绘;粒子[读取 v4 mix/cadence](../../src/mbgl/renderer/layers/render_particle_validation_layer.cpp#L1262) 不推进该 clock。
**正常/错误/销毁与反例。** clock 由 Source 所有;平台 timer 的生命周期清理不提供时间增量。[advance 实现](../../src/mbgl/particle/field/field_runtime.cpp#L209) 本身存在且处理溢出;[测试](../../test/source/raster_array_v4_host_api.test.cpp#L297) 直接手动调用,所以不能证明公开 PLAY 可自行推进。Host 持续设置 forecast time、C++ 调用方自行 advance、旧 sourceTimeline 路径均是反例;粒子在固定场中移动也不代表预报时刻在播放。
**修复/验证。** 为 encoded clock 指定唯一推进所有者和单调时钟来源,处理暂停/恢复、rate、地图移除、颜色单层及多层,避免每层重复推进。仅调用一次 setTime 和 PLAY,观察 requestedForecastTime/mix 随 elapsed 改变;暂停保持、恢复不跳跃、不同 rate 成比例,窗口末端行为明确。公开路径缺口可静态确定,运行表现 NEEDS_RUNTIME。
### B07 — 零值在 8-bit 场纹理中变成负值
**触发与证据。** 有效向量为零或接近抵消。[粒子编码](../../src/mbgl/renderer/layers/render_particle_validation_layer.cpp#L174) 用 uint8 转换 `(x / scale * 0.5 + 0.5) * 255`,scale 至少为 1;[颜色编码](../../src/mbgl/renderer/layers/render_weather_color_layer.cpp#L45) 同类处理;[GL 解码](../../shaders/particle_gpu_validation.fragment.glsl#L76) 还原为 `(channel * 2 - 1) * scale`,Metal [解码](../../include/mbgl/shaders/mtl/particle_gpu_validation.hpp#L48) 同理。x=0 被截为 127,解码为 `-scale/255`,两个零分量都带偏差,最小 scale=1 时向量长度也高于 1e-5 的近零阈值。
**边界和反例。** 场快照的 CPU float 正确不代表上传表示正确;无效点被 valid 分支拒绝,不计入这个有效零场问题。不同数值/缩放下误差相对大小不同;方向归一化可放大近零偏差。理论截断误差小于 `2*scale/255`,这是假定合法编码区间的算术界,不是 GPU 实测。重用或销毁纹理不会改变表示误差。
**修复/验证。** 选取可精确表达零的带符号编码或 float/half 表示,并评估纹理兼容性;仅改四舍五入仍无法让奇数 255 的对称映射精确表示零。以有效零场、微小速度、正负对消、纯东/北场做 GPU 解码读回和粒子位移检查,GL/Metal 同测。实际漂移像素/速度 NEEDS_RUNTIME。
### B08 — Mercator 东向位移少一个 sec(latitude)
**触发与链。** formal profile 使用 eastward/northward、m/s 的 U/V 语义,见[profile 校验](../../src/mbgl/particle/field/field_profile_validator.cpp#L25)。上传缩放在 shader 中还原后,[GL formal 位移](../../shaders/particle_gpu_validation.fragment.glsl#L169) 取 `dx=U*dt/C`、`dy=-V*dt/(C*cos(latitude))`;[Metal](../../include/mbgl/shaders/mtl/particle_gpu_validation.hpp#L133) 同样处理。这里 C 为地球周长,输入链未见对 U 预乘 sec(latitude)。
**推导、反例和边界。** Mercator x=λ/(2π)+0.5,而局部东向速度满足 dλ/dt=U/(R*cosφ),故 dx 应为 U*dt/(C*cosφ)。赤道、U=0 时不受此项影响;60° 纬度当前东向投影位移为应有值的一半。统一 visual time scale 不修复东西/南北相对畸变。此为固定输入语义下的数学缺陷,实际视觉速度、极区处理 NEEDS_RUNTIME;与资源销毁和错误 fallback 无关。
**修复/验证。** 在一致纬度与极区夹紧策略下对两个分量使用正确度量转换,保留既定视觉时间倍率。0°/45°/60° 的等物理速度东西向和南北向场,比较 CPU 解析预测与单步 GPU 位移;含符号、反向、极区边界与跨日期变更线。
### B09 — Ocean Mask 尚未形成正式共享地理输入
**触发与证据。** Wave/Tide/Current 需要 OCEAN_ONLY,或启用当前 mask 模式。[makeCoastalMaskFixture](../../src/mbgl/renderer/layers/render_particle_validation_layer.cpp#L281) 用格点坐标表达式生成合成海岸,formal 资源初始化[仍安装该 fixture](../../src/mbgl/renderer/layers/render_particle_validation_layer.cpp#L1314)。物化[统一写 GLOBAL](../../src/mbgl/particle/field/field_projection_materialization.cpp#L77)。[顶点 shader](../../shaders/particle_gpu_validation.vertex.glsl#L56) 的 v_world 源自 quad 坐标,粒子合成[据此采样 mask](../../shaders/particle_gpu_validation.fragment.glsl#L262);[颜色分支](../../shaders/particle_gpu_validation.fragment.glsl#L113) 在该处理前返回。
**所有权、正常路径与反例。** mask 纹理由粒子资源拥有,随资源重建/销毁;缺少正式地理 mask 数据入口和两类层共享采样语义。默认关闭 mask 的 GLOBAL wind 不因此错误,Host 数据 nodata 也可能已有陆地缺测,但这不等价于独立共享 Land/Ocean Mask。不能把本限制扩展为所有风场不可用。
**修复/验证。** 由 Host 决定业务域策略,Core 接收明确地理坐标、网格和版本的共享掩码,两层统一采样;不把 provider/下载引入 Native。加载真实小型固定海岸 mask,平移、缩放、旋转后地理边界应固定;分别验证 Wind GLOBAL 与 Wave/Current OCEAN_ONLY,以及 nodata 叠加、删除与替换。当前设计缺口已证实,设备画面 NEEDS_RUNTIME。
### B10 — encoded 提交在 UI 派发链同步完成重工作
**触发与链。** RN 提交较大 QBIN。[Android bridge](../../packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt#L310) 经 runOnUiQueueThread 定位并调用 runtime;[Java submit](../../platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java#L97) → [JNI](../../platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp#L1501) 拷贝 bytes 并同步调用 Source。iOS [withMapForTag](../../packages/maplibre-particle-rn/ios/WeatherRuntimeBridge.mm#L105) 使用 UI block → [runtime submit](../../platform/ios/src/MLNParticleFieldRuntime.mm#L222) 拷贝并解码。V3 [CRC](../../src/mbgl/particle/ingress/qbin_v3_decoder.cpp#L66) 对字节做位循环,后续解码/校验/投影也在同步调用链内。
**边界、错误与反例。** Android 本地文件读取在 UI 派发之前,不能笼统写成全部文件 IO 都在 UI;小文件和 Duplicate 后的工作量不同,格式失败会提前返回。解码有大小上限,但允许的输入仍可较大。临时 bytes、decoded frame、Source 持久状态的所有权清楚;问题是所在线程及连续占用,并非已证明泄漏。未观测 ANR 或卡顿。
**修复/验证。** worker 只做不可变 bytes 解码/校验,带 map/style/source 世代令牌返回所有者线程提交;限制队列并处理取消、地图销毁和过时结果,不能把整个可变 Source 移到 worker。在中小/近上限输入、CRC 失败、连发及页面退出下记录 UI 区间耗时、峰值内存和错误回调。性能影响 NEEDS_RUNTIME。
### B11 — 每序列八帧不是全局内存预算
**触发与链。** 单 Source 连续提交不同有效 fieldKey。[EncodedFieldStore 成员](../../include/mbgl/particle/field/field_runtime.hpp#L151) 是 map 和 perSeriesCapacity=8;[submit](../../src/mbgl/particle/field/field_runtime.cpp#L223) 可为新 key 创建 series;[FieldSeries](../../src/mbgl/particle/field/field_runtime.cpp#L168) 只检查本序列容量。Source [提交](../../src/mbgl/style/sources/raster_array_source.cpp#L422) 不要求该 key 已绑定。V3 [输入上限](../../src/mbgl/particle/ingress/qbin_v3_decoder.cpp#L19) 限单帧 encoded/decoded bytes,不限 store 总量。
**所有权和反例。** store 持有共享帧,直到显式 clear/clearAll 或 Source 销毁;渲染读者可能延长旧快照寿命。单帧 decoded 上限 32 MiB 与八帧组合形成每序列最高 256 MiB 的逻辑上界,但不表示每个 profile 都能达到该值。key 数无总上界,不能据此保证 Source 总内存。Host 固定少量 key 并主动清理时可控;本轮没有 OOM 证据。
**修复/验证。** 在提交前计入全局 key/字节预算,包含共享去重与临时峰值;明确容量拒绝语义,保护当前绑定窗口,避免静默淘汰。循环提交多个合法 key,验证达到预算后稳定拒绝且现有窗口可用;clear 后可恢复,地图销毁后所有者释放,读者完成后最终释放。设计上界不足已证实,实际内存峰值 NEEDS_RUNTIME。
### B12 — 构建过程错误没有阻止 retained 缓存成功提交
**触发与链。** retained 范围内某 LayerGroup 在 render 时抛出被捕获的异常,observer 记录错误后正常返回。[RenderOrchestrator 遍历](../../src/mbgl/renderer/render_orchestrator.hpp#L156) 捕获异常并 onRenderError,不把失败状态返回调用方。[buildRetainedRange](../../src/mbgl/renderer/renderer_impl.cpp#L798) 在 opaque/translucent 遍历后继续 finishBuild、markBuildSucceeded;[成功标记](../../src/mbgl/renderer/retained_underlay_cache.cpp#L328) 使其可在后续稳定帧 Reuse。
**所有权、错误/销毁与反例。** 缓存持有可能只完成部分绘制的纹理,直到下次失效或销毁。资源准备阶段[有 catch/fallback](../../src/mbgl/renderer/retained_underlay_cache.cpp#L240),不属于本路径;无异常、observer 再抛出异常、随后 revision 立刻改变都不满足持续复用条件。此处只讨论同步可捕获 render 异常,不将异步 GPU 错误视作同一证据。
**修复/验证。** 让范围绘制返回成败或设置本次构建错误标记,失败不提交 valid 缓存,安全 fallback/下帧重试;保留 observer 通知,不引入每帧 GPU 等待作为错误检测。用一次性抛异常的假 LayerGroup 和正常返回的 observer,检查 BuildFailed、禁止 Reuse、下一帧成功恢复,再由 GL/Metal 场景验证画面。
### B13 — 粒子更新身份没有覆盖第二分量和有效性
**触发与可构造输入。** 同 fieldKey 的三个不同 validTime 帧使用相同 fieldID/generation/dataVersion,U 内容相同、V 依次变化(或 validity 变化),网格和 schema 兼容。此输入在[FieldSeries 新时间插入](../../src/mbgl/particle/field/field_runtime.cpp#L146) 合法:newerRevision 检查针对同一 timestamp,未要求不同时间递增 generation。窗口从 T0/T1 切到 T1/T2。
**完整调用链。** [materializeParticleProjection](../../src/mbgl/particle/field/field_projection_materialization.cpp#L201) 仅以 t0/t1FirstPlane 身份传给 baseSnapshot;[baseSnapshot](../../src/mbgl/particle/field/field_projection_materialization.cpp#L77) 用 fieldID+该身份组成 fieldID,保留 producer generation/dataVersion。[Source sameProjection](../../src/mbgl/style/sources/raster_array_source.cpp#L65) 比较实际数组,可正确发布新的 V/validity。但[粒子 sourceChanged](../../src/mbgl/renderer/layers/render_particle_validation_layer.cpp#L1489) 只调用[fieldIdentityChanged](../../include/mbgl/particle/field/field_data_adapter.hpp#L111),其只比 generation/fieldID/dataVersion。两个槽身份都未变时,不进入内部 sameFieldGpuContent/纹理更新分支,继续使用旧场纹理。
**所有权、反例和边界。** Source 新投影存在、Renderer 仍持有旧 resources.field/nextField 及纹理,直到其他身份变化、重建或销毁。每时次主动递增 generation、U 也变化、初次资源创建都能触发更新;纯 mix 变化且窗口内容确实不变应保留纹理。此 finding 不指向颜色层:颜色投影的实际内容比较是反例,已排除“颜色仅看 U 而漏掉 V”的候选。
**修复/最小验证。** 粒子更新门槛使用包含 U/V、validity、grid 与解释语义的完整不可变内容身份;窗口时间状态单独更新,保持相同内容可复用。采用上述三帧并让 V/validity 可见地不同,seek 跨窗,检查 CPU 选窗、GPU 上传/复用判定及读回;增加不同时间同 generation、仅 V 改变、仅有效性改变、完全相同内容四组。错误门槛可静态证明,旧场画面 NEEDS_RUNTIME。
## 4. 反例检查及未成案候选
- **颜色投影身份:排除。** Source sameProjection → sameSnapshot → sameFieldGpuContent 会比较两分量/validity 实际数组;不能仅因第一 plane 参与 fieldID 就断言颜色不更新。B13 另有粒子特定的前置门槛证据。
- **粒子资源强引用环:排除。** [ValidationDrawableTweaker](../../src/mbgl/renderer/layers/render_particle_validation_layer.cpp#L770) 持 weak_ptr;[layerRemoved](../../src/mbgl/renderer/layers/render_particle_validation_layer.cpp#L1140) 移除目标。本轮未形成泄漏闭环。
- **关闭缓存后辅助段状态残留:排除简单结论。** [synchronizeRetainedUnderlayState](../../src/mbgl/renderer/renderer_impl.cpp#L142) 覆盖段缓存同步;不能仅查主缓存 setter。
- **纯重排未被处理:排除。** [Style diff](../../src/mbgl/renderer/style_diff.cpp#L19) 与[orchestrator 更新](../../src/mbgl/renderer/render_orchestrator.cpp#L318) 处理移除/添加并重建有序层;分段不是独立 Z-order 数据源。
- **任意交错不受支持:排除笼统结论。** 单 continuous 段的 fast path 限制不等于普通 segmented path 不支持多段;[普通绘制](../../src/mbgl/renderer/renderer_impl.cpp#L938) 仍按计划遍历。透明混合、深度与遮挡整体等价性尚需运行验证。
- **异常输入必然越界:未成案。** QBIN 有 MaxCells、字节预算、偏移/结构/CRC 检查,FieldSeries 有冲突/陈旧/兼容性拒绝。未完成形式化证明或 fuzz,不能声称完全安全,也未凭大输入符号制造越界 finding。
## 5. 性能与资源模型(静态模型,非 benchmark)
设视口为 W×H,retained 段数 K,格点数 N,活跃粒子层数 L,每层有效粒子数 P,encoded 字节数 E,fieldKey 数 F。
| 路径 | 静态成本模型 | 稳定路径/限制 |
|---|---|---|
| retained | 每段至少一张 RGBA8 颜色纹理约 4WH 字节,另加实际 depth/stencil 附件、对齐及 drawable;总量随 K 线性增长 | 稳定内容复用;Build 时绘制该段并合成;B04 增加同尺寸分配抖动 |
| 粒子 | State A/B 随状态纹理面积增长,Trail A/B 随视口面积增长;积分和粒子绘制约随 P 增长,全屏衰减/合成随 WH 增长 | 初始化/重播种还有额外工作;不可只凭 particleCount 推断全部成本 |
| Metal 提交 | 实际执行的粒子层稳态至少两次离屏同步点/层,加当帧 retained Build 数量及其他 target | 这是显式 wait 次数下界模型,不是每次都发生长等待;需 capture 核对实际启用分支 |
| 时间 setter | 激活投影物化和数组相等比较为 O(N),颜色 UV+粒子约 36N 字节基础新数组 | 单纯 draw 并不必然调用 setter;不同 scalar/vector 绑定成本不同 |
| QBIN | CRC 和解码大体随 E/样本数增长;提交链还会临时复制并可能物化 | 上限约束单帧;失败可以提前结束,不可套用最坏值到所有输入 |
| encoded store | 每 key 最多八帧,但总量随 F 增长;另有 materialized snapshot、上传图像、在途旧读者 | 需要显式 clear;现有容量拒绝保护单序列,未构成全局预算 |
未取得 profiler、锁竞争、allocator、GPU capture 或设备温度数据。不提供热点排名、FPS、毫秒、节省比例或确定 OOM 阈值。查到同步等待和 UI 同步调用,不等于证明死锁;本轮未形成有闭环证据的锁死 finding。
## 6. 测试源码覆盖与缺口
阅读测试源码只表示理解其断言,以下全部 NOT RUN。
| 已阅读的代表性测试 | 能说明的设计意图 | 不能替代的验证 |
|---|---|---|
| [retained 状态测试](../../test/renderer/retained_underlay_cache.test.cpp#L116)、[失败测试](../../test/renderer/retained_underlay_cache.test.cpp#L479)、[segment 测试](../../test/renderer/retained_underlay_cache.test.cpp#L657) | Build/Reuse/Fallback、范围与交错计划 | 真实 attachment stencil、Metal 清屏、异常经 orchestrator 传递、透明/深度像素等价 |
| [clock 测试](../../test/particle/field_runtime.test.cpp#L272)、[Host API clock](../../test/source/raster_array_v4_host_api.test.cpp#L297) | 手动 advance 的时间算法 | 平台 PLAY 是否接入生产推进者 |
| [materialization 测试](../../test/source/raster_array_v4_host_api.test.cpp#L392)、[颜色幅值](../../test/source/raster_array_v4_host_api.test.cpp#L433) | 快照不变性、mix、重建计算 | GPU 8-bit 数值误差、B13 跨窗同 producer 身份、数组分配次数 |
| [binding 测试](../../test/particle/field_binding.test.cpp#L84)、[V3 解码测试](../../test/particle/qbin_v3_decoder.test.cpp#L346) | 内容身份/选面、格式失败和边界 | 全 renderer 资源切换、UI 延迟、全局 key 预算及竞态销毁 |
未覆盖或未闭合:Vulkan/WebGPU 完整粒子实现、所有 Style 图层组合、真实设备 Metal simulator/device 差异、GPU 厂商驱动行为、平台业务调用频率、RN 安装/codegen/ABI、所有 error string 和 promise 回调分支、跨线程竞争注入、完整 fuzz、产物字节与 manifest 的对应关系。当前审查是有边界的静态追踪,不是逐行穷尽全仓或安全证明。
## 7. 后续可执行验证矩阵(交给 RDC,全部 NOT RUN)
本轮禁止执行以下构建/测试。RDC 在独立获准的验证阶段准备测试 runner 与设备;新增回归用例/修复属于后续代码任务,不能把下表当作本轮已提供可运行补丁。
| 项目 | 准备与执行动作 | 最小通过条件 |
|---|---|---|
| B01 | GL、Metal 各加载同瓦片 fill/line,策略 R/C/R、R/R、C/R/C;首帧/重建/复用逐帧对照关闭缓存 | attachment 切换均生成对应掩码,像素裁剪等价 |
| B02 | 离屏附件预置非默认值,创建带 depth/stencil clear 的 pass,再以比较绘制检查;device/simulator 各一次 | clear 值与请求一致,无依赖未定义旧内容 |
| B03/B04 | 1/2 粒子层、1/3 retained 段;固定视口平移 300 帧,记录 capture/分配/CPU 等待 | 给出次数、时长分布;资源复用变化有证据且无画面回归 |
| B05 | 两时间面,固定时间重复 100 次、mix 小步变化 100 次,再跨窗和修订 | 输出物化/分配/上传次数;相同内容复用且变化可靠可见 |
| B06 | 仅颜色、仅粒子、两者共存;setTime 一次后 PLAY、PAUSE、不同 rate | forecast time/mix 正确推进,每地图只推进一次 |
| B07 | 有效零场、微值、正负对消、东西南北常量场,GPU 单步读回 | 零保持零,近零阈值一致,误差有明确预算 |
| B08 | 0°/45°/60°,相同 m/s、相同 dt,比较解析 Mercator 位移 | U/V 投影度量一致,符号正确,极区有限值 |
| B09 | 同一固定地理 mask 同时供颜色/粒子,平移缩放旋转,切 GLOBAL/OCEAN_ONLY | 两层海陆边界一致且固定在地理位置 |
| B10 | 合法中小/近上限 QBIN、坏 CRC、连发、加载中移除地图 | UI 耗时可量化;回调一次;无过时提交/释放后访问 |
| B11 | 多 key 连续提交直至预算,clear 后再提交 | 可预测拒绝、峰值有界、当前窗口不破坏、最终释放 |
| B12 | 假 LayerGroup 首次抛异常,observer 记录后返回,下一帧正常 | 首次不得缓存成功;下一帧重试并恢复 |
| B13 | 同 generation/dataVersion/U 的三时次,分别仅改 V、validity,再 seek 跨窗 | 新窗口 GPU 内容正确;完全相同内容仍可复用 |
| 兼容 | 增删/重排/隐藏边界层,透明层交错、3D/custom fallback、resize/context 重建 | Style 顺序一致,fallback 可恢复,无残留目标 |
已有 C++ 定向测试的执行入口需使用平台生成的实际 runner;[test/CMakeLists](../../test/CMakeLists.txt#L1) 的 mbgl-test 是库,[macOS 配置](../../platform/macos/macos.cmake#L84) 定义 mbgl-test-runner。RDC 已构建并确认路径后可执行下列命令(本轮未执行;路径必须由 RDC 填写):
```sh
RDC_TEST_RUNNER=/absolute/path/to/mbgl-test-runner
"$RDC_TEST_RUNNER" --gtest_filter='Retained*:ContinuousLayerRange.*:RenderSegment*:ParticleFastPath.*:ParticleField*:ParticleQbin*:ParticleEncoded*:Source.RasterArrayV4*'
```
这只运行现有匹配测试,不覆盖全部上述新增场景。RDC 应先确认 runner 的过滤输出确实命中测试,保存命令、exit code、测试数量和日志路径;设备场景记录设备/后端/版本、输入 fixture、截图或 capture。实际构建目录、签名、设备 ID、安装产物及历史日志均 NOT AVAILABLE,本轮不臆造构建命令或成功状态。
## 8. 分批建议与停止边界
1. **先补正确性闭环:** B01/B02 的目标附件状态、B06 的唯一时钟推进者、B13 的完整内容身份;加入最小回归后再做跨后端运行验证。B12 同批修复失败提交语义,避免错误缓存长期复用。
2. **再统一数值与域语义:** B07/B08 的数值表示/投影度量,以及 B09 的共享地理掩码;用解析输入和小型固定 fixture 定量验收。
3. **最后按测量做性能与预算:** B03/B04/B05/B10/B11,先记录同步、分配、线程耗时和峰值,再分别改命令提交、资源复用、物化缓存、工作线程和容量策略;避免多项同时修改而无法归因。
本轮仅交付三份中文盲审文档,不修改实现或测试。完成后停止,由 RDC 冻结三份原稿 SHA-256 和时间,再独立移交、追加开发记录并做正式仓库 scoped 提交及远端核验。本会话不参与解除盲态比较,不根据外部材料回写发现。
配套:[Step 记录](../50_Step记录/STEP-096_V13隔离盲审_v1.md) · [完成报告](../报告/STEP-096_V13隔离盲审_Codex完成报告_v1.md)

View File

@@ -0,0 +1,95 @@
# NavSea PBF 自动识别与轻量混淆跨平台方案设计 v1
日期:2026-09-27
状态:**规划 / DESIGN ONLY;未实现、未测试、未发布。**
适用范围:NavSea 自有矢量海图 PBF,MapLibre Native Android / iOS 与 MapLibre GL JS Web。
归属:MapLibre 资源加载适配层;**不是粒子、Weather Field 或 QBIN 格式变更**。
## 1. 背景与目标
NavSea 计划对自己分发的 PBF 进行低成本字节混淆,避免直接下载后被普通 PBF/MBTiles 工具读取;客户端收到瓦片后恢复标准 PBF,继续使用原有 MapLibre 解析和渲染链。Web 版本也要消费同一份服务器资源。
本方案明确只是增加直接复用数据的门槛,不承诺数字版权保护、防逆向或防止明文提取。客户端可被调试,解码后的 PBF 可在内存或缓存环节被截获;公开 Web 尤其如此。
**核心规则:检测到 NavSea 专用 Magic Header → 按版本解码;未检测到 → 维持 MapLibre 原有标准资源流程。** 不依据文件后缀决定是否解码,不修改标准 PBF 解析器,不建立第二套地图引擎。
## 2. 范围与不变项
- 只处理自有矢量瓦片资源;普通 PBF、第三方瓦片、Style JSON、Sprite、Glyph、图片以及 Weather/QBIN 不受影响。
- 原有 URL 路径、Source/Layer、Style Z-order、粒子绘制、GPU、地图交互与正常 HTTP 重试行为不变。
- 服务器可同时提供普通 PBF 与封装 PBF;三端遵守同一二进制契约,各自实现资源加载适配。
- 只在有效响应体上进行格式识别;404、500、304、取消、空响应等继续走既有错误/缓存语义,不把错误页当 PBF 解码。
- 本文是未来工作的设计草案;不指定当前源码已有某个具体 Hook,进入实施前须按当前 fork 做只读调查。
## 3. 封装结构草案
建议文件名仍为 `.pbf`,但编码后内容已不是标准 PBF,必须先解封装。以下 **16 字节头仅为待验证候选格式**,实施前冻结字节序、长度和兼容矩阵。
| 偏移 | 长度 | 字段 | 候选语义 |
| --- | ---: | --- | --- |
| 0 | 8 | Magic | ASCII `NSVTILE\0`,固定 8 字节 |
| 8 | 1 | Version | `1` |
| 9 | 1 | Codec | `1 = XOR-v1`;其他值拒绝 |
| 10 | 1 | Flags | bit0 表示内部 GZIP,其余保留位须校验 |
| 11 | 1 | HeaderLength | v1 固定 16,给后续扩展留入口 |
| 12 | 4 | CRC32 | 解封装且内部解压后,标准 PBF 明文的 CRC32;约定字节序 |
| 16… | 可变 | Payload | 经 XOR 混淆的原始 PBF 或内部 GZIP 字节 |
CRC32 只检测意外损坏或密钥不匹配的常见现象,**不是抗恶意篡改认证**。Magic 是类型标识,不是秘密,也不是加密强度来源。头部存在但格式、版本、长度、Flags、Codec 或校验不合法时,必须拒绝,不能退回普通 PBF。
## 4. 编码、识别、解码
服务器预处理一次:标准 PBF → 计算明文 CRC32 → 可选内部 GZIP → XOR → 添加 Header → 存储/CDN。对于已经是 GZIP 存储的原始瓦片,应先核对实际字节语义,再决定保留内部压缩还是重新封装,避免重复压缩。
客户端:收到已按 HTTP 传输层规则处理的响应体 → 检查 Magic → 读取并验证头部 → XOR 还原 Payload → 按 Flags 内部解压 → 校验长度/CRC32 → 把标准 PBF 交给现有解析器。普通资源无 Magic 时完全返回既有处理链,不新增一次盲目 GZIP 解压。
**HTTP `Content-Encoding` 与封装内部 GZIP 必须分开。** XOR 后的压缩 Payload 不得冒充 HTTP gzip,否则网络库可能在 XOR 前尝试解压。封装可用 `Content-Type: application/octet-stream`,自定义 `X-NavSea-Tile-Encoding` 仅作辅助诊断;格式识别以响应体 Header 为准。HTTP 层若另行压缩整个封装,须确认客户端收到的是解开传输编码后的封装字节。
已声明为“必须受保护”的自有 Source 可另设严格策略:缺少 NavSea Header 也报错(fail-closed),防止服务端误发明文;普通/混合 Source 则执行“无头=原路径”。Source 保护策略与字节自动识别是不同层面,不得混为一谈。
## 5. 三个平台的接入边界
| 平台 | 未来接入方向 | 重要约束 |
| --- | --- | --- |
| Android | 在当前 fork 的 MapLibre HTTP 资源响应交给解析器前解封装 | 核对 OkHttp/Native 缓存与 Response 回调实际位置,避免双重解压或额外整份复制 |
| iOS | 在当前 MapLibre Native 的对应资源响应路径解封装 | 以实际 iOS 网络资源实现为准,不假设 Android Java 修改会覆盖 iOS |
| Web | MapLibre GL JS `addProtocol()` 负责 fetch、校验、解封装并返回标准 PBF `ArrayBuffer` | 自有瓦片 URL 经自定义 scheme 或 `transformRequest` 导向注册的协议;后者本身不能修改响应体 |
Web 必须核对 GL JS 版本及协议回调契约、Style/TileJSON 的 URL 重写、Worker/主线程负载、CORS 和取消信号;服务端需暴露必要响应头。第三方请求不接入解码器。三端共享格式测试向量,不共享平台相关 HTTP 实现。
## 6. 缓存、错误与日志
- CDN/HTTP 缓存以**封装字节**为对象;ETag、Content-Length、缓存键、版本及更新失效策略要与实际封装内容一致。
- HTTP 304 没有新响应体;由现有缓存/重验证机制恢复对应实体,不能把空体交给解码器。按当前 Native/Web 路径调查 MapLibre 自有缓存究竟保存封装还是明文,不作默认安全保证。
- 若业务要求离线缓存也不可直接导出标准 PBF,需单独设计落盘边界;仅 HTTP 层 XOR 不保证 MapLibre 离线库/内存缓存保存密文。
- 解码前限制输入大小,解压后限制最大明文大小与压缩比,避免异常瓦片造成内存放大。数据损坏、版本不支持、密钥不匹配归类为独立 Codec 错误。
- 将前述 HTTP URL 诊断与 Codec 诊断分开:`INVALID_URL`、`HTTP_ERROR`、`NETWORK_ERROR`、`UNSUPPORTED_TILE_CODEC`、`TILE_DECODE_FAILED`;只记录必要的 URL(敏感查询参数脱敏)、状态码、版本与长度,不输出密钥或明文 PBF;正常 200 不逐张刷日志,可限频。
- 404/500、取消、超时、304、第三方瓦片和正常 200 行为均不因本功能改变。
## 7. 性能与安全预期
XOR 是 O(n) 的逐字节操作,优先服务器离线预编码、客户端原地处理,避免 React/JS Bridge 往返和多次完整 Buffer 拷贝。封装额外增加 Header 字节;“XOR 不改变 Payload 长度”不等于整体零额外字节。压缩应在 XOR 前进行,否则压缩收益可能明显恶化。
Web 先评估主线程处理时长与地图交互,再决定是否使用 Worker;Android/iOS 关注实际缓存策略、内存峰值与线程阻塞。性能影响只能经真实 PBF 样本、冷/热缓存及拖动缩放实测后定论。
固定或循环 XOR 密钥可以被分析,已知格式和客户端代码会进一步降低逆向门槛;把算法放到 C++/WASM 或换成 AES 也无法阻止客户端截获已解码的明文。本方案定位为**轻量混淆**。若未来目标提高到防批量下载,应另行规划 HTTPS、授权、短期访问凭据、配额/限速、异常下载监测与必要的数据分发范围;不要把 XOR 描述成加密认证。
## 8. 未来实施阶段(本次均不执行)
| 阶段 | 工作 | 产出/闸门 |
| --- | --- | --- |
| A 只读调查 | 当前 fork Android/iOS HTTP、资源与磁盘缓存;GL JS 版本;服务器 PBF/GZIP、CDN、TileJSON/Style 路径 | 确认真实接入点、压缩语义和数据所有权 |
| B 协议冻结 | 冻结 Header 字节序/CRC 覆盖、Codec、Flags、版本策略、普通/受保护 Source、Golden vectors | 三端可交叉解码同一 fixture |
| C 服务端 | 离线编码工具、标准/封装样本、响应头及缓存策略 | 普通与封装资源同时可分发 |
| D Android/iOS | 各自最小资源适配,保持解析器与业务接口不变 | 各平台原生样本验证;构建/实机由用户手工执行 |
| E Web | 注册自定义协议、URL 路由、解码与取消/CORS | 自有/第三方混合地图正常 |
| F 综合验收 | 性能、离线缓存、故障、日志、安全边界与回退 | 三端统一验收通过后再决定发布 |
验收矩阵至少覆盖:原始 PBF、标准 HTTP gzip、封装 RAW、封装内部 gzip、无头普通资源、受保护 Source 缺头、短头/错误 Magic/版本/Flags、错误 CRC/密钥、空瓦片、404/500/304、网络失败/取消、第三方瓦片、冷/热/离线缓存、跨平台 Golden Fixture、Web CORS 与长时间拖动缩放;不得以单张瓦片可显示作为最终通过。
## 9. 待实施前决策
1. 是否采用 XOR-v1 作为正式首版,还是只做试验;客户端密钥静态分发的风险是否接受。
2. Magic/Header 的精确二进制布局、CRC 字节序、最大明文长度、Codec ID 和压缩 Flags 是否按本草案冻结。
3. 哪些 NavSea Source 必须受保护;普通/混合资源的无头旁路范围。
4. 离线缓存是否必须保存封装字节,以及 MapLibre 内建缓存如何满足该要求。
5. Web URL 路由和跨域部署方案;第三方瓦片是否保持完全独立。
**本次结论:仅保留规划文档。没有新增 Codec、改动服务器资源、修改 MapLibre HTTP、引入密钥、触发构建/测试或宣布任何平台支持完成。**

View File

@@ -0,0 +1,76 @@
# STEP-097 V13 三轮问题去重与修复矩阵 v1
基线:Mac-mini-tei.lan;`particle/main`;起始 `720d309364628f85049cf9969d71c71e8dfacce9`,与查询时 `origin/particle/main` 一致;工作区干净。冻结 `.13` tag 指向 `ee0b89e71`。该 HEAD 相对任务书的 `b5c1c4879` 只新增 STEP-097 任务文档。Integration 本地 C-001/RFC-001 已只读;其远端是否最新尚未由 RDC 证实。所有构建、测试和运行验证:**NOT RUN**。
## 公共不变量
`fieldId/generation/validTime`、东向 U/北向 V、T0/T1/MIX、旧 T1→新 T0 复用、Style 全局 Z 顺序、每个 R 独立缓存、WeatherColor 与 Particle 分离、Host 拥有天气获取。无权改变 C-001 的公开生命周期、错误或数据所有权语义。
以下“入口”均为待执行回归,不能当作运行结果。源码状态随阶段更新;`WAITING_RDC_BUILD_AND_RUNTIME` 表示仅完成定位,尚未实现。
| # / 原编号 | 当前源码定位与最短触发 | 反例/安全约束 | 拟修文件与回归入口 | 状态 / commit / 平台待测 |
| --- | --- | --- | --- | --- |
| 01 N01/B01 | `paint_parameters.cpp:185` tile 集合相同跨 R-R target 复用 CPU mask | 同一未清 stencil target 可复用 | PaintParameters、Renderer、RenderTarget 已在新目标重置;同 tiles R-R/R-C-R 像素入口尚缺 | WAITING_RDC_BUILD_AND_RUNTIME / `e67e4961b` / GL、Metal |
| 02 N02/B02 | `mtl/render_pass.cpp:21` 仅 color clear;`offscreen_texture.cpp:67` 附件创建 | 无 depth/stencil 的 target 不可盲设附件 | Metal descriptor/combined 附件已修改;专用附件故障测试源码尚缺 | WAITING_RDC_BUILD_AND_RUNTIME / `e67e4961b` / Metal |
| 03 N03/B12 | `render_orchestrator.hpp:156` catch;`renderer_impl.cpp:824` 仍 mark success | 失败后 full fallback、下帧重试 | visitor 错误标志与 fallback 已接入;异常注入测试源码尚缺 | WAITING_RDC_BUILD_AND_RUNTIME / `e67e4961b` / GL、Metal |
| 04 N04/B06 | `Map::Impl::onUpdate()` 在 Continuous update 驱动每个 RasterArraySource;Source 使用 monotonic tick,并用 `forecastTickInProgress` 阻止同步 SourceObserver 重入二次推进 | 显式 setTime/PLAY/PAUSE/rate 由一个 Core clock owner 推进;平台 timer 只唤醒 | `map_impl.cpp`、RasterArraySource、Android/iOS WeatherRuntime;`RasterArrayV4ProductionClockTicksOnceForBothBindingsAndResetsOnCommands`、`RasterArrayV4ClockSourceNotificationReentryDoesNotAdvanceTwice` | CODE_IMPLEMENTED_STATIC_CHECKED / A2 源码提交 `f49ffa68a` / OpenGL 与 Metal host A2 定向 3/3 PASS;FZG1 V4 Golden PLAY/PAUSE/速率/clear/reload PASS;无 iOS runtime(用户豁免) |
| 05 B13 | `sameFieldGpuContent()` 保持 revision/version 无关的 payload 相等;renderer GPU 槽位更新使用严格 `sameFieldGpuEndpoint()`;Source mix-only cache 使用 projection identity、binding、immutable frame 和 plane/validity storage pointer identity | mix-only 复用同一 T0/T1 snapshot;frame/revision、grid、validity、U/V、encoding 或 binding 改变则重物化并失效;invalid/clear fail-closed | `field_data_adapter.hpp`、projection materialization、RasterArraySource 与 renderer;Source cache 测试 + `ParticleGpuField.*` | CODE_IMPLEMENTED_STATIC_CHECKED / A2 源码提交 `f49ffa68a` / broad OpenGL 与 Metal host 各 121/121 PASS,A2 定向各 3/3 PASS;FZG1 stable mix 时 upload/reset 计数稳定;clear/reload 后合法增加 |
| 06 B07 | 新增内部 signed RGBA8 编码,byte 128 明确表示零;GL/Metal shader 以 high precision 还原 byte | 不改公开 QBIN/FieldProfile encoding;量化误差与 sub-half-quantum 微速边界明确测试 | `particle_gpu_math.hpp`、GL/Metal shader 源及生成头、`particle_gpu_math.test.cpp` | CODE_IMPLEMENTED_STATIC_CHECKED / A2 源码提交 `f49ffa68a` / broad OpenGL 与 Metal host 各 121/121 PASS,含 signed zero、取消、NaN/invalid 与微速边界;两次 shader generator 幂等;无 iOS runtime |
| 07 B08 | 统一 helper 与 GL/Metal 粒子位移路径都为 Mercator 东向 U、北向 V 应用相同 cos(latitude) metric,并夹紧极区分母 | 保持 U/V canonical m/s、V 北向为正、既有视觉时间倍率 | `particle_gpu_math.hpp`、renderer 与 GL/Metal shader 源及生成头;0°/45°/60° 四方向和极区测试 | CODE_IMPLEMENTED_STATIC_CHECKED / A2 源码提交 `f49ffa68a` / broad OpenGL 与 Metal host 各 121/121 PASS,含数学定向测试;Android FZG1 场景闭环通过;无 iOS runtime |
| 08 N05 | 长度感知 JSON 解析,拒绝 NUL/重复键/非法 schema,invalid fail-closed | 清旧计划、合法恢复 | `render_segment_json.hpp`、双平台 bridge 与 GTest | CODE_IMPLEMENTED / B Core 定向 6/6、扩展 139/139 PASS;待 B Git,iOS runtime 未测 |
| 09 N06 | FieldProfileValidator 在 Store commit 前检验有效数值及 scale/offset | validity=0/noData 合法;坏高 revision 不覆盖好帧 | FieldProfileValidator/Source GTest,修正旧 16-bit fixture | CODE_IMPLEMENTED / B Core 定向 6/6、扩展 139/139 PASS;待 B Git |
| 10 N07 | View owner、generation guard、map destroy observer 源码已写 | 旧 map 回调不得影响新世代,监听须释放 | Kotlin/Java JUnit,真实 MapLibreMap.onDestroy | Android Native JUnit 2/2、Java 编译与 owner 隔离 JVM JUnit 4/4 PASS;BLOCKED_NATIVE_AAR_COMPATIBILITY:正式 `.13` 缺新 Map 方法,默认 RN consumer 不可配对编译,B 不判 PASS |
| 11 F01/C01 | `renderer_impl.cpp:505` full diagnostics 快照 | 外部 pull schema 不变 | runtime/renderer;快照计数 | WAITING_RDC_BUILD_AND_RUNTIME / 待提交 / Core |
| 12 F02/C02/B03 | Metal offscreen `swap()` 同步等待 | 不删不明依赖 wait | Metal;GPU capture | DEFERRED_DESIGN / 无 / Metal |
| 13 F03/C03/B04 | cache revision 变化释放 texture | target clear 必须先正确 | cache;同尺寸重绘/resize | WAITING_RDC_BUILD_AND_RUNTIME / 待提交 / GL、Metal |
| 14 F04/C04 | R 数 × viewport 独立附件内存 | 保留独立 R 与失败 fallback | cache;资源阶梯、失败注入 | DEFERRED_DESIGN / 无 / GL、Metal |
| 15 C04/B11 | encoded Store 无总 key/byte 预算 | 不静默驱逐绑定窗口 | Store;容量/旧读者 | DEFERRED_DESIGN / 无 / Core |
| 16 F07/C05 | `render_orchestrator.hpp:156` 每段前缀 distance | 全局 ordinal 与 Z 顺序不变 | orchestrator;重排/正反遍历 | WAITING_RDC_BUILD_AND_RUNTIME / 待提交 / Core |
| 17 C06/B05/B10 | A2 已处理相同 immutable projection 的 mix-only 重物化;其余 diagnostics pull、ordinal 与 UI owner 世代发布仍属后续范围 | A2 mix-only 不再复制/比较整张浮点快照;不据此宣称 C 阶段整体完成 | A2 部分:RasterArraySource cache 定向测试及 FZG1;C 部分仍按原任务另行实施 | A2 mix-only 子项已验证 / 当前工作树 `5bb1a0e` / OpenGL、Metal host 定向 3/3 PASS;C 其余项目未开始 |
| 18 C07 | drawable/tweaker tick 重建、端点复制 | 不保留 stale GPU 引用 | Particle renderer;tick/resize | DEFERRED_DESIGN / 无 / GL、Metal |
| 19 F09/O01 | `runtime_state.hpp:230` 新 topology;旧 cache 析构按 index 计数 | 总量与当前 topology 分离 | runtime/cache 按 revision 过滤;新增 `OldCacheReleaseCannotChargeReplacementAtSameIndex` GTest | CODE_IMPLEMENTED_STATIC_CHECKED / `e67e4961b` / 2026-09-26 Core GTest 1/1 PASS,诊断组 5/5 PASS;GPU 资源验收仍待测 |
| 20 F05/E01 | Test Host package/lock 已对齐正式 `.13`,SHA-256/SRI 核对一致 | RN/Native 必须精确同版,不改 `.13` 产物 | package/lock、release manifest、默认 RN 编译 | STATIC_VERSION_LOCK_PASS;B10 新 Native method 不在正式 `.13`,默认消费者仍阻塞 |
| 21 F06 | `renderer_impl.cpp` multi-C 走 full CPU | 保留安全 fallback | 后续 A/B 设计 | DEFERRED_DESIGN / 无 / GL、Metal |
| 22 F08 | source/tile 等全局失效 | 无依赖图前不缩窄 | 后续依赖设计 | DEFERRED_DESIGN / 无 / Core |
| 23 F10/E02 | Integration status/compatibility 仍写 `.5` | 发布≠NavSea 支持 | 负责人协调证据请求 | DEFERRED_DESIGN / 无 / 跨项目 |
| 24 STEP-095 §7/B09 | synthetic mask 非共享地理 mask | 不新建公开 mask API | C-001/RFC/ADR 协调 | DEFERRED_DESIGN / 无 / 跨项目 |
## 操作次数模型(静态,非性能实测)
- A1:跨 target 清除 CPU mask 为每次目标切换一次 O(1) 状态清理;当前同 tiles 可能零次重绘但产生错误像素。Metal clear 每个 pass 最多各一次附件 load 设置。异常后额外一次 full fallback,避免错误缓存跨帧复用。
- A2:PLAY 由唯一 Map/Source clock 每次 update 推进一次;V4 mix-only 的投影重物化计数为 0(50 次 Source tick 定向测试),decoded endpoint 变化时 color/particle projection 各物化一次。FZG1 日志在单次 stable mix-only epoch 内 upload/reset 计数不变;clear/reload 会合法增加计数。以上是源码测试和该次设备记录,不构成普遍 GPU 性能结论。
- B:JSON 严格校验 O(输入字节长度),有效数值验证 O(有效 cell × plane 数),View/map 每世代各至多一活跃监听;源码已实施,但正式 .13 Native ABI 不含新方法。无 GPU 性能实测。
- C:预期 full diagnostics 深复制从每 render 次降到每外部 pull 次;ordinal 前缀从每段 O(G) 降到 topology 更新 O(G);同时间物化从 O(cell) 降为 O(1)。尚未实施。
## 2026-09-26 第 19 项实测更新
- RDC 使用 `0d13552be` 重编译并执行 GTest,第 19 项 1/1 PASS、RenderSegmentDiagnostics 全组 5/5 PASS;详见 `docs/报告/STEP-097_A1_第19项GTest运行验证报告_v1.md`。其他问题状态不因此变化,A1 仍为 PARTIAL。
## 2026-09-26 第 19 项 FZG1 设备辅助证据
- 基于相同生产源码的 APK 在 FZG1-2 通过覆盖安装和设备 SHA-256 校验。两轮拓扑循环、重排、缓存 OFF/ON、缺失边界回退与恢复的 20 张截图及 UI 诊断已保存;有效场景的 B/T 粗粒度 textureCreate-resourceRelease 均为 0 或 1。此证据只支持 Android 运行态冒烟,不替代此前旧 revision 归属的 GTest 1/1 PASS,也不能证明 GPU 无泄漏。详见 `docs/报告/STEP-097_A1_第19项FZG1运行验证报告_v1.md`。A1 仍因 01/02/03 未闭合保持 PARTIAL。
## 2026-09-26 A1 专用入口补充(待 RDC 执行)
- 01:Android Validation Activity 的 STEP-093 菜单新增 STEP-097 same-source stencil 子菜单;`Step097StencilFixture` 用一个本地 GeoJSON source 与四个 fill layer 生成 R-R、R-C-R、C-R-R,菜单可重载与读 segment 诊断,既有 CACHE ON/OFF 可切换。RDC 仍需首帧/重建/reuse 的实际像素截图与 cache 计数;此处仅是可操作 fixture,未实测。
- 02:`mtl_retained_attachments.test.cpp` 检查实际离屏资源的 color/depth/stencil 绑定、simulator/x86 combined attachment、无 depth/stencil 时跳过 clear;生产 `RenderPass` 共用的 clear 辅助函数可检查 load/clear/store。设备资源创建失败与 encoder 失败的运行注入仍待 RDC 验证。
- 03:`render_orchestrator_error.test.cpp` 注入正/反向范围 visitor 异常,核对 observer/failed 标志,并用 cache 状态机核对失败后继续 Build、成功重试后 Reuse。真实 GPU 绘制故障与完整 renderer fallback 仍待 RDC 注入验证。
- 01/02/03 均保持 `WAITING_RDC_BUILD_AND_RUNTIME`;源码入口准备不等于编译或运行 PASS。19 的既有实测状态不变。
## 2026-09-26 A1 Android/Core 实测收口
- 01:same-source tiled fixture RR/RCR/CRR 两轮、rebuild/cache OFF/ON 共 18 点,屏幕中心与区域外采样两组像素均 18/18 一致;状态 valid=true/fallback=false,Android FZG1 实测通过。源码/测试提交 `d0ced7235`,编译修正 `8c006ac83`。
- 02:Metal host runner 已编译,GTest 两个测试均因没有 Metal device SKIP;用户决定不执行 iOS 运行测试,记 `USER_ACCEPTED_WITHOUT_IOS_RUNTIME_TEST`,不得称 Metal 实测 PASS。
- 03:`RenderOrchestratorError.*` 原生测试 2/2 PASS;真实 Android GPU fault injection 不在本次证据中。
- 19:前述 Core 1/1 PASS、诊断组 5/5 PASS、FZG1 辅助测试均保留。A1 在 Android/Core 验收及 iOS 用户豁免口径下进入 A2;详细事实见 `docs/报告/STEP-097_A1_Android专项验收报告_v1.md`。
## 2026-09-26 A2 RDC 最终验证
- 04–07 已在同一 dirty 源码工作树完成实现与 RDC 独立验证。旧 `sameFieldGpuContent()` 测试断言修复后原 58 项 58/58 PASS;更新后的 broad filter OpenGL 与 Metal host 各 121/121 PASS,三项 A2 Source 定向测试两平台各 3/3 PASS。
- 标准 Android Validation APK 为 OpenGL Debug/x86_64,61,753,803 bytes,SHA-256 `27bf4a6cc191bd86c4d1c10398478d81c2999edd61fe7242e40e5188516b0ca5`;安装到 FZG1-2 后设备 APK hash 相同。V4 Golden Wind test-only T0/T1 场景 17 张 PNG、24 个步骤、runner exit 0、ANR=0;PLAY 30 秒 UI 可响应,PAUSE 稳定,clear/reload 后再次 PLAY/PAUSE。
- 日志按资源窗口分别记录:clear/reload 前 mix-only epoch `fieldUploadCount=6/resetCount=2` 稳定;explicit clear/reload 后计数合法增加,达到 `fieldUploadCount=10/resetCount=4`,此后 replay 稳定。不可把 clear/reload 期间的上传与 reset 写成零。
- build source diff SHA-256 `18927a5f30a88f5c1c3a00e0017473dc33b04151c227e4baf52250dc4ad2e6a2` 与忽略目录中的 APK source snapshot 完全一致。未运行 iOS device runtime,按用户豁免记 `USER_ACCEPTED_WITHOUT_IOS_RUNTIME_TEST`;不得称 Metal host GTest 为 iOS PASS。A2 Android/Core 完成,B/C 未开始,整个 STEP-097 仍 PARTIAL。
## 2026-09-26 B 独立验证及阻塞
- RDC Core 定向 4/4、修正旧 16-bit fixture 后 broad 134/134 PASS;正式 .13 Android SDK 缺 B10 新 map destroy observer,默认 RN consumer 无法与该新源码配对。B PARTIAL,C 不启动;详见 B 独立报告。

View File

@@ -0,0 +1,76 @@
# STEP-098 QBIN V4 单帧与八帧滚动缓存设计
日期:2026-09-26
状态:**已按接受的设计契约实现源码;Native 构建、定向测试与平台运行验收待 RDC 执行**。
## 1. 权威契约
- RFC-004:`/Users/tei/dev/navsea-particle-integration/rfc/RFC-004_QBIN_V4单帧首显与八帧滚动缓存.md`
- ADR-001:`/Users/tei/dev/navsea-particle-integration/adr/ADR-001_QBIN_V4单帧与滚动缓存语义.md`
- Contract C-002:`/Users/tei/dev/navsea-particle-integration/contracts/C-002_QBIN_V4单帧与滚动缓存.md`
- COORD-005:`/Users/tei/dev/navsea-particle-integration/coordination/active/COORD-005/决议.md`
- 读取时 Integration `main` / `origin/main` 均为 `1dd576d3304e267e53ed64d31f9711d2350ce10a`。四份文件为 ACCEPTED / DESIGN ONLY 或 IMPLEMENTATION_PENDING,不代表实现、设备或 V15 发布通过。
- 原任务 v1 的旧 RFC-003 闸门和 `docs/报告/STEP-098_公共契约阻塞报告_v1.md` 属于此前状态。用户已确认旧 RFC-003/COORD-004 B 作废删除;本次不恢复、不改写任务书或旧报告。
八帧时间示例按 C-002 为 **13:00–20:00**。提交 21:00 后删除最早端 13:00。等距离时固定删除原最早端。
## 2. Core 存储与身份
`EncodedFieldStore` 按 `fieldKey` 保存独立 `FieldSeries`,默认每个序列容量 8。每个序列按 `validTimeMs` 严格升序存储;插入新帧使用 lower-bound 定位,不假定时间间隔。重复时间继续走 Duplicate、revision、stale、identity conflict 现有判定,不触发容量淘汰。
为同时支持新预报批次单帧首显和防止跨批次混插,分开处理两类兼容性:
- **同 key 共存兼容性**:grid、validity plane schema、所有 data plane 的数量与 schema 相同。不同 `fieldID`、QBIN `referenceTimeMs` 或解析 profile 的批次可以在序列中共存;否则有效的新日/新批次单帧可能无法进入序列。
- **时间插值兼容性**:除共存 schema 相同外,`fieldID`、`referenceTimeMs`、`profileID` 和 `profileVersion` 必须全部相同。resolve 只在相邻时间端点满足这些条件时返回双帧;不同批次之间的时间返回 unavailable。Ingress 会把已验证的 profile 身份保留到解码帧,避免校验后丢失身份。
满额插入先复制现有有序序列,在候选序列上执行淘汰和新增端点 schema 校验。候选有效后才 swap 到正式序列;grid/plane schema 不兼容时返回既有 `RejectedIncompatible` 路径,不淘汰现有帧。对容量 8 的正常有效插入返回既有 `AcceptedInserted`;`RejectedCapacity` 枚举与 RN 字符串继续保留兼容,不用于正常满额滚动。
对提交时间 `n` 和原序列最早/最晚端点 `e/l`,淘汰较远端:`|n-e| >= |l-n|` 时淘汰原最早端,否则淘汰原最晚端。距离计算避免无符号时间相减下溢。该规则不读取 forecast clock,所以仍符合 RN 先 submit、后 setTime 的顺序。
## 3. 单帧、双帧与投影
- 精确匹配某个 `validTimeMs` 时 resolver 返回同一个不可变 frame 作为 T0/T1,`mix=0`。颜色和有合法 U/V 或方向分量的粒子可从该帧物化;标量帧仍不能通过向量绑定校验。
- 非精确时间只在合法的相邻双帧 bracket 内插值,mix 按两端实际时间比例计算。落在窗口外、跨 batch/profile 边界或只有一帧但时间不匹配时返回 unavailable;不回退为最近帧。
- 后续帧到达只更新实际变化的投影。新帧位于当前 bracket 外时保留 Source 的不可变物化 projection 与样本时刻;当前端点被淘汰后重新 resolve,原投影不可用则清除。
- 单帧 materialization 只创建一个 `ParticleFieldSnapshot` 并让 T0/T1 共用它。Particle 和 WeatherColor 的首次资源安装也共享相同 endpoint 的 GPU texture/image/scale;GL 与 Metal 共用这段 renderer 代码及语义。设备资源实际行为仍须各平台独立验证。
- 淘汰仅释放 Store 对已移除 frame 的引用。已发布 projection 或 GPU 更新仍持有共享 snapshot/texture 引用时,按 shared ownership 生命周期释放。
## 4. 内存观测范围
新增 `DecodedFieldFrame::decodedByteSize()`、`FieldSeries::decodedByteSize()` 与 `EncodedFieldStore::decodedByteSize()`,返回 validity bitset 加所有非 validity 原始 plane storage 的字节总和;共享 validity plane storage 不重复计数。该计数**不包含** profile/容器元数据、物化 Float32 snapshot、图像、GL/Metal texture、粒子 state 或 trail。
公式为单帧 `ceil(cellCount / 8) + Σ(plane payload bytes)`;序列与 Store 分别对帧及 key 求和。Native 定向测试代码覆盖滚动时每 key 保持 8 帧及此原始字节计数回落/清空,但本轮没有运行测试,也没有 CPU/GPU 峰值、32 帧总量或长期泄漏实测。8 帧不构成全局内存保证;不新增固定 MiB 上限或公开容量错误码。
## 5. API、职责与平台
RN TypeScript、Android Kotlin/JNI、iOS Objective-C++/Facade 保持 `submitEncodedField(...)` 与 `setEncodedForecastTime(...)` 两步调用及原有方法名、参数和结果名称;Codex 静态核对现有传递路径,本轮没有改 bridge。submit 不修改目标时钟,setTime 不下载数据。两调用仍非原子,不承诺无闪烁。
`DecodedFieldFrame` 增加内部解析 profile 身份字段及 decoded-byte 计数方法,改变该 C++ 数据结构的二进制布局;不改变 RN/Android/iOS bridge 的公开方法签名或结果枚举。Core 与两个平台必须使用同一源码重编;不得把既有 `.13` AAR 或旧发布物当作新 ABI 消费证据。
NavSea、Server、下载/cache/manifest、日期 UI、QBIN 格式、Rice 解码、V3、Style Z-order、provider、公开 RN API 均未改。
## 6. 定向测试映射
以下是本轮新增或扩展的测试代码覆盖,不代表已执行:
| 项目 | 测试入口 | 本轮代码覆盖 |
| --- | --- | --- |
| T01 | `ParticleFieldBinding.SingleFrameProjectsAtItsExactTimeAndUpgradesToInterpolation`;`Source.RasterArrayV4SingleExactFrameMaterializesAndSecondFrameEnablesInterpolation` | 单帧精确颜色/向量 projection、T0=T1、mix=0、第二帧中点升级;标量-only 无向量绑定 |
| T02 | 同上 | 第二帧到达后返回双帧与 mix=0.5 |
| T03 | `ParticleFieldRuntime.UnorderedSubmissionsAreSortedAndReferenceCyclesStaySeparate` | 乱序排序;wind/wave 分 key;跨 reference/profile 只可单帧解析 |
| T04 | `ParticleFieldRuntime.FullWindowRollsBothDirectionsAndBreaksTiesAtEarliest`;`Source.RasterArrayV4RollingWindowPreservesAnUnchangedActivePair` | 满 8 后向未来提交、淘汰最早端并保持活动 pair |
| T05 | `ParticleFieldRuntime.FullWindowRollsBothDirectionsAndBreaksTiesAtEarliest`;Source rolling 测试 | 满额向过去提交并淘汰最晚端 |
| T06 | `ParticleFieldRuntime.FullWindowRollsBothDirectionsAndBreaksTiesAtEarliest` | 中部等距插入固定淘汰最早端、有序且 8 帧 |
| T07 | `Source.RasterArrayV4StoresNewReferenceAndProfileAsSingleFrameWithoutCrossBatchMix` | 新 reference/profile 批次精确首显、补帧后同批插值、回到早批次单帧 |
| T08 | `ParticleFieldRuntime.RevisionIdentityAndIndependentCadence`;既有 Source stale/revision cases | Duplicate、identity conflict、revision;既有 stale 路径保留。满额下 revision 不占槽由同一 Store 分支处理 |
| T09 | `Source.RasterArrayV4HostApiRejectsKeepPublicProjectionStable`、`...RejectsNumericOverflowBeforeReplacingAcceptedRevision`、`ParticleFieldRuntime.RejectedFullWindowInsertDoesNotEvictAndKeysRemainIndependent` | 解码/profile/数值和 grid schema 拒绝路径、有效帧/投影/clock 零副作用。数据帧入 Store 前仍由现有 ingress 完成解码与 profile 校验 |
| T10 | `ParticleFieldBinding.MixChangesSampleStateButNotContentIdentityOrStorage`、单帧升级 Source 测试 | 合法 bracket 的中间比例;精确端点以单帧语义表示 |
| T11 | 既有 binding `WindowUnavailable` assertions;新单帧测试 | 非覆盖时间 unavailable,不用最近帧顶替 |
| T12 | `Source.RasterArrayV4RollingWindowPreservesAnUnchangedActivePair` | 外围补帧/双向滚动不改变当前物化 projection 与 requested time |
| T13 | `Source.RasterArrayV4EvictedActiveFrameClearsItsMaterializedProjection` | 当前单帧端点淘汰后 fail-closed 清除物化 projection |
| T14 | `ParticleFieldRuntime.RejectedFullWindowInsertDoesNotEvictAndKeysRemainIndependent` | 两个 key 各自 8 帧,独立计数与字节 |
| T15 | 单帧 binding/materialization 测试;GL/Metal renderer 资源安装源码 | T0/T1 共用物化 snapshot;renderer 首装共用 endpoint texture/image。GPU/context-loss 运行验证未覆盖 |
| T16 | `ParticleFieldRuntime.RepeatedBidirectionalRollingAndClearStayBounded` | 120 次双向提交、每 key 8 帧边界、clear/clearAll 和 decoded bytes 归零 |
| T17 | 现有 `FieldRuntime.ts`、`NativeWeatherRuntimeBridge.ts`、Android/iOS bridge 源码静态核对 | 旧 API、类型、方法名和两步语义未变;RN 自动化/双平台 consumer build 未运行 |
所有测试代码的执行状态均为 **NOT RUN**。完整执行状态与 RDC 命令见 STEP-098 完成报告。

View File

@@ -0,0 +1,109 @@
# STEP-095 V13 独立严格代码与性能审查 — Codex 任务 v1
## 0. 工作性质与模型
- 本任务是独立、严格、全链路、以源码为最终证据的只读代码/性能调查,不是修复任务,也不是批准已有报告。
- 执行环境:Mac-mini-tei.lan,仓库 /Users/tei/dev/MapLibreParticleLab,分支 particle/main。
- 运行模型:gpt-6-astra,reasoning effort=high;由外部启动命令实际指定,报告记录实际模型。
- 最终交付物必须为中文,具体源码符号、文件路径、命令、证据可保留英文。
- 不以“编译通过”“定向单元测试通过”“已有 Codex 报告 PASS”推断 GPU 运行态、安全性、性能或跨平台一致性已经通过。
## 1. 源码冻结基线
- 被审查的 V13 代码提交:ee0b89e711bf01335b3aa71d36def785cd97fec3。
- V13 核心功能提交:016fd1fff6a33cf795c10d4f5c5413d308383677。
- V13 发布标识:RN 13.4.1-particle.13;iOS 6.28.0-particle.13;以实际 manifest/tag/API 核实,不假设已在 NavSea 接入。
- 前置独立审查归档提交:45c4dca84928a57a2cf0226737c34cabd7fbfbfb。
- 审查前读取 AGENTS.md、PARTICLE_BASELINE.md、docs/00_项目规范/、docs/00_项目总则/README.md 与其中标记的当前有效总则。
- 同步检查 git branch、HEAD、origin/particle/main、working tree、release manifest 和相关 tag;记录所有偏差。
- 本任务自身新增文档提交不改变上述被审查的源码 commit;核查源码时固定以该提交为事实基线。
## 2. 审查独立性:先源码,后已有报告
- 首先依据 Git V12→V13 改动、当前调用链及实际代码独立扫描,不从旧报告结论直接生成 findings。
- 然后阅读 docs/20_调查/Particle_V13_严格代码审查报告_v1.md,逐项复核 F01–F10:确认、修正、否定或标记需动态验证。
- 不能复制上一份报告来冒充独立 review。需要发现旧报告遗漏、误判、证据不足及其他模块的潜在风险。
- 交叉阅读 STEP-088~094 的任务、设计、Step 记录、Codex 报告及相关发布/验证事实。
- 当任务书与代码冲突,分别说明“设计意图”和“实际实现”;不能把历史数据冒充 V13 测量。
- 可以读取 Integration repo 的 contract/status/compatibility,但不得修改该仓库;发现跨平台契约问题只提升级建议。
## 3. 强制审查范围 A:Renderer 与 V13 Segment
- 通用 RenderSegment plan、JSON parser/resolver、配置切换、missing/reorder/hidden boundary、valid/invalid/recovery。
- Style 全局 Z-order、layerIndex、LayerGroup ordinal、opaque reverse、translucent forward、depth/stencil/blending、debug/legacy/3D 与 offscreen composite 的一致性。
- 任意 R/C 交错:全 R、全 C、R-C-R、C-R-R、R-R-C、R-C-R-C-R、相邻 R-R;缓存 OFF 和资源失败的 full fallback。
- Retained cache 独立生命周期、build/reuse/invalidate/release、topology generation、content fingerprint、diagnostics counters 的归属。
- V12 连续区间兼容映射、单 Continuous Fast Path、多个 Continuous 安全 fallback,注意 silent fallback 与正常渲染的真实成本。
- 特别核查 Segment pass 分割后的 alpha/depth/stencil 语义、source/layer mutation 与下一帧 stale GPU object 风险。
## 4. 强制审查范围 B:GPU、粒子和时间场
- OpenGL 与 Metal OffscreenTexture、Framebuffer、Texture、render pass、commit/present/wait、resource destroy/context loss;区分通用 Core 与平台实现。
- 是否存在 CPU/GPU 同步等待、重复 GPU allocation、每帧 upload/UBO/drawable 重建、纹理引用失效和资源峰值。
- Particle State A/B、Trail、Field T0/T1/MIX、spatial sampling、forecast/playback、zoom-out reseed、viewport bounds 和 resize/orientation。
- Wind/Wave/Current/Rain 等 canonical FieldProfile;V3/V4 encoded/decoded field、QBIN decoder、projection/materialization、binding/validity/missing data。
- 检查异常或损坏输入、极大网格/时间、浮点 NaN/Inf、整数溢出、文件/缓冲区生命周期、重复/乱序/过期 field、安全失败和内存占用。
- 需要从 Source→Runtime→RenderLayer→Renderer→GPU 按真实调用链追踪,不接受只搜索名称的浅层判断。
## 5. 强制审查范围 C:高频、线程和 Host API
- 每帧 CPU 分配/深复制/锁竞争/字符串或 JSON 构造、LayerGroup 遍历复杂度、静态 CPU Fast Path 命中与错过原因。
- style/source/tile/image/glyph/placement 失效范围是否正确,segment-local 与全局 revision 的边界是否一致。
- map-scoped lifetime、prepared RenderTree、Runtime/renderer/control state 的并发、线程归属、异步回调、销毁后引用和重复 attach/detach。
- Android Java/Kotlin/JNI 与 iOS ObjC++/Metal 的桥接接口、错误归一化、RN TurboModule typed API、ABI/codegen 与版本锁。
- Test Host 的依赖及 lockfile、构建结果的可复现性、release manifest/npm/AAR/XCFramework 的版本一致性。
- 不将业务 provider、HTTP、Route/ETA 带入 Shared Core;不建议建立新的隐藏私有桥接或第二套协议。
## 6. 强制审查问题清单
- 是否存在 V13 上线后能够导致画面层级错误、透明度/遮挡错误、丢帧、黑屏、闪烁、过期 cache composite、GPU/CPU 泄漏、崩溃或错误数据的实际源码路径?
- 非法配置是否真正 fail closed?同一 index 换不同 Segment 时旧 cache/diagnostics 归属是否错误?
- Retained contentDirty 是否被误当作 GPU allocation invalid?Metal 逐 cache wait 是否可证实?需要哪些 profiler 证据才能决定修改?
- 高频 Fast Path 是否复制 full diagnostics、扫描 Style 或反复线性搜索?有无更重的热点在既有报告中遗漏?
- 多段 retained 是否存在全 viewport depth/stencil 过度分配;是否有峰值预算、安全回退及 resource accounting?
- source/tile 局部更新是否引起不必要全 retained rebuild?若建议局部化,必须给出不破坏 correctness 的依赖证据。
- 单 Continuous 与多 Continuous 的 Frame 处理在相同真实 Style 下有什么可测的差异?不能外推 STEP-090 历史 A/B 数字。
- release、Test Host、RN/iOS/Android 版本与实际产物是否能被独立复现?尚未测试的必须标 NOT RUN。
## 7. 审查方法
1. 建立 V12→V13 变更与模块地图,阅读入口、类型定义、状态机、调用方和析构路径。
2. 针对每个怀疑的问题制作最短触发条件和源码调用链;尝试找反例,禁止只因为名称相似就报告漏洞。
3. 按已证实缺陷、源码确定的成本、真实运行影响待验证、显式设计限制、文档/环境偏差分类。
4. 为每条 finding 写严重度 P0/P1/P2/P3、置信度、源文件精确行范围、触发场景、影响、最小安全修复方向和验收方法。
5. 独立比较上一份 F01–F10,标为 CONFIRMED / QUALIFIED / DISPROVED / NEEDS_RUNTIME,说明依据和新增发现。
6. 对潜在收益只给复杂度/估算表达和建议基准测试,不提供无 profiler 支持的 FPS/CPU/GPU 节省百分比。
7. 所有未亲自执行的测试必须标 HISTORY / NOT RUN;已有报告的 PASS 只能列为历史证据,不替代当前独立执行。
## 8. 运行与修改边界
- 禁止修改任何生产源码、shader、公共 API、构建配置、package/lockfile、release manifest、tag、发布产物或 Integration 文件。
- 禁止运行耗时 CMake/Xcode/Gradle 编译、安装、设备操作和大型测试;由 RDC/用户另行验证。
- 允许只读 git show/log/diff、文件检索、源码静态分析和查看既有日志、测试源码、报告。
- 如判断必须借助 profiler、真机或编译才可定论,写精确验证方案而不是擅自实施。
- 仅允许新增本 STEP 中文调查/Step/完成报告,按规定追加根目录粒子开发记录;不要顺手修复发现的问题。
## 9. 完成报告格式(必须实际落盘)
- 执行摘要:哪些是确定缺陷、哪些是性能成本、哪些只能动态验证;不能只给结论口号。
- 基线与范围:审查 commit、分支、release manifest、审查的主要文件/代码路径、未覆盖范围。
- Finding 总表:编号、严重度、证据类别、问题、触发、影响、代码定位、修复方向、验证方法。
- 逐条完整分析:至少包含最短可复核调用链及确认或推翻依据;新问题与旧报告重复项区分。
- V13 与 V12 架构/性能成本比较:不确定的写待测;不要虚构性能数值。
- F01~F10 独立复核矩阵:原结论、当前意见、依据、是否需补充/修正。
- 缺失测试与运行态矩阵:GPU correctness、CPU/GPU frame time、allocation/peak memory、30/60 FPS、长时间稳定性、各平台状态。
- 优先级清单:分 correctness gate、无需设备的安全优化、GPU 资源/同步、较大架构改造。
- 具体建议的下一轮 Codex 修复批次(只建议,不创建修复任务,不改源码)。
- Git:起始/最终 HEAD、docs-only diff 清单、commit SHA/message、push 结果、remote HEAD、最终 status。
- 结论状态:调查 PASS/PARTIAL/BLOCKED 与 V13 产品运行态是否通过必须分开。
## 10. 必做输出和固定路径
- 调查主文档:docs/20_调查/STEP-095_V13独立严格代码与性能调查_v1.md。
- 阶段记录:docs/50_Step记录/STEP-095_V13独立严格代码与性能审查_v1.md。
- Codex 完成报告:docs/报告/STEP-095_V13独立严格代码与性能审查_Codex完成报告_v1.md。
- 追加:根目录 粒子开发记录.md,短而具体地记录本次调查变化、现状及下一步,不覆盖历史。
- 若同主题相同文件已经存在,改用递增版本号并在报告写明,不覆盖旧报告。
- 以上文件全部中文;任何报告必须明确区分事实、推断和待验证。
## 11. Git 收尾
- 先确保用户现有 dirty/untracked 不被覆盖;只有本任务新增/追加文档允许 staged。
- 不执行 git reset --hard、git clean、force push、tag 移动或向 upstream push。
- 对所有新增文档进行路径、目录、内容、交叉引用检查;git diff --check。
- 仅 git add 上述具体文档和本次追加的开发记录;scoped commit 后 push origin particle/main。
- push 失败或 HEAD 与远端不一致时,不得写 PASS;明确失败与恢复建议。
- 记录源代码相对于原 V13 源码提交没有改变,禁止把 docs-only commit 误写成新 V13 软件发布。
- 最终由 RDC 独立检查报告证据、Git 与任务边界,Codex 不自行宣布正式产品验收。
## 12. 停止条件
- 无法确认当前 commit、源代码状态与所审查发布版本对应时,停止并报告 BLOCKED。
- 遇到跨项目协议变更/需要修改业务源码时,只形成接口阻塞或修复建议,不扩大任务。
- 证据不足时明确 NEEDS_RUNTIME,不应为了凑数量虚构问题。
- 重大 P0 或明确破坏性问题优先直接写进报告,但本任务仍只读,不自动修复。

View File

@@ -0,0 +1,59 @@
# STEP-096 V13 隔离盲审 — Codex 任务 v1
## 0. 工作性质与隔离目标
这是针对已冻结 V13 源码的全新、只读、源码优先的代码与性能审查。发现必须来自当前隔离目录内的源代码和规范,不允许从任何既有审查结论、聊天记录或外部仓库导入问题清单。不得为凑数量制造 finding。
- 执行模型:全新 Codex TUI 会话,`gpt-6-astra`,reasoning effort=`high`;RDC 负责实际启动与记录,Codex 报告仅记录能核实的启动事实。
- 唯一工作目录:`/Users/tei/dev/MapLibreParticleV13Blind096`。
- 目标源码版本:`ee0b89e711bf01335b3aa71d36def785cd97fec3`;由 RDC 从该 Git 对象导出**无 .git 元数据**的源码快照。当前隔离目录不是主仓库、不是 Git worktree;不得在此运行 git,也不得用其他路径反查仓库历史。
- 唯一任务说明:本文件。用户和外部 RDC 将在审查开始前检查隔离树不含已有调查、阶段记录、完成报告、任务历史或开发日志。
- 禁止访问隔离目录之外的所有源码、文档、Git metadata、会话日志、终端回放、网页、连接器、用户历史或其他 AI 输出;不要尝试恢复缺失文档。对任何未在快照内的证据写 NOT AVAILABLE。
- 不得读取、搜索或比较其他轮次的审查材料;**本任务只有盲审,不包含解除盲态后的交叉比较。** 完成后的文件由 RDC 先冻结散列,再独立移交正式仓库。
## 1. 只读入口与事实层级
先读取隔离目录的 `AGENTS.md`、`PARTICLE_BASELINE.md`、`docs/00_项目规范/`、`docs/00_项目总则/README.md`、其中列出的当前有效总则和本任务。隔离快照故意不提供历史 Step、调查/完成报告;读取要求在盲审范围内以当前有效规范和实际源码为准,不得向目录外补读。规范包含历史发布坐标时,以本快照的 release manifest、源码和目标 SHA 为实现事实,区别说明规范意图与实际代码。
允许读取:隔离树内的 C++/GL/Metal/Android/iOS/RN 代码、头文件、shader、构建配置、测试源码、release manifest,以及当前有效规范/架构设计。历史测试输出和运行日志不可用;阅读测试源码不等于执行测试。不得浏览 `docs/20_调查`、`docs/50_Step记录`、`docs/报告`、既有历史任务或开发日志(这些输入在隔离树内也应不存在)。不引用主仓库的当前 HEAD 作为 V13 源码 SHA。
## 2. 审查目标与探索方法
从目录和公开 API 识别实际架构,自行建立模块和调用链地图;不要按照预设缺陷名称凑检索结果。对每个候选问题至少追踪入口、状态所有权、调用方、正常路径、错误路径、销毁路径,并寻找反例。分别审查:
1. Style 顺序和分段解析/更新/绘制语义;任意连续渲染策略交错、透明/深度/裁剪及缓存一致性。
2. Renderer→gfx→OpenGL/Metal 的目标、命令、资源和生命周期;稳定/变化帧、失败恢复、尺寸变化及上下文切换。
3. GPU 粒子、颜色场、时空插值与场数据链;输入合法性、数值边界、时间状态和显示状态。
4. Source/Runtime/Field/QBIN、数据快照、线程与内存所有权;重复、乱序、异常或极大输入与 fail-closed。
5. Android/iOS/RN 的公开 API、错误传播、生命周期和版本/产物引用;区分源码可证事实与安装环境需要的验证。
6. 高频执行成本:CPU、锁、分配、遍历、复制、GPU 同步/资源、UI 线程以及资源上界。查找实际热点路径,但没有 profiler 不得断言运行时热点排序或节省百分比。
7. 兼容与工程边界:既有行为、上游薄接入、Style Z-order 唯一事实来源;业务下载/HTTP/provider 属于 Host,不进入 Native。
如同一问题已有安全 fallback、缓存稳定复用或数据校验,必须写出反例和限制,而不是仅报告表面上可疑的符号。源代码路径和行号必须在隔离快照内真实存在。
## 3. 每条 finding 的证据契约
- 按发现顺序自行编号 `B01`、`B02`……;不要采用其他报告的任何编号。
- 列:P0/P1/P2/P3、类型(确定缺陷/源码确定成本/设计限制/环境或文档偏差/需运行验证)、置信度、触发条件、最短源码调用链与精确路径/行号、反例、影响边界、安全修复方向、最小验证方案。
- 区分“代码中的风险路径已经证实”和“用户画面、GPU、CPU、OOM、崩溃、性能收益已经实测”;后者本轮一概不可假定。
- 如果推断尚缺关键调用链,降为 HYPOTHESIS/NEEDS_RUNTIME,不以 P0/P1 明确事故冒充。
- 不要求 finding 数量、也不要求与任何报告相同;实际不存在证据就明确无此类 finding。
- 不复制包含商业/个人机密的数据;路径与定位信息足够即可。
## 4. 验证边界与停止条件
本任务只允许静态读取、轻量文件搜索、源码/语法/引用自检。禁止生产代码、测试代码、shader、manifest、package/lock、工具链改动;禁止 CMake/Xcode/Gradle、Codegen、Jest、设备、安装、性能测试、发布、Git 写入。历史构建/测试结果没有暴露,不得编造。所有未运行门槛标 `NOT RUN`,运行效果标 `NEEDS_RUNTIME`。如必须依赖平台设备、profiler 或竞态注入,写可执行验证矩阵,停止在证据边界。
只能写以下三份中文文件,写完确认三份存在、正文和相对链接有效、所引用路径/行号有效、无错误引用和行尾空格后退出:
1. `docs/20_调查/STEP-096_V13隔离盲审调查_v1.md`:架构地图、独立 finding 总表与证据、反例、风险、性能模型、未覆盖范围、运行验证矩阵、分批建议。开头明确声明仅使用隔离输入。
2. `docs/50_Step记录/STEP-096_V13隔离盲审_v1.md`:状态、输入版本、检查范围、只读工作过程、结果、三份文件清单、未运行验证、阻塞与下一步。
3. `docs/报告/STEP-096_V13隔离盲审_Codex完成报告_v1.md`:执行结果 PASS/PARTIAL/BLOCKED(仅表示盲审文档交付)、finding 索引、证据质量、输入限制、构建测试 NOT RUN、源码未变、交付/移交方案。
本隔离树无 Git metadata,**不要在报告内伪造 Git commit/push/remote/clean 状态**。正式仓库移交、`粒子开发记录.md` 追加、四个文件的 scoped commit/push、最终 SHA 和远端核对均由 RDC 在盲审文件冻结之后执行。Codex 不得自行打开正式仓库进行移交,避免在写稿前接触比较材料。
## 5. 盲审冻结与移交验收(由 RDC 执行,不是 Codex 自行执行)
Codex 停止后,RDC 验证隔离树的文件清单,计算三份原始报告 SHA-256 并记录时间;冻结后不得由同一会话基于其他资料重写 finding。随后独立把三份报告完整复制到正式 `particle/main` 同路径,追加根目录 `粒子开发记录.md`,执行引用、边界、git diff --check、scoped commit、push `origin particle/main`、远端 HEAD/clean 核查。禁止修改已冻结正文,若必须校正移交元数据,应写入独立移交记录,不得改写发现表。
文档交付 PASS 不等于 V13 Android/iOS GPU 运行态或性能 PASS。若 Codex 无法访问输入、丢失上下文或额度阻断,停止、保留已成品并标 PARTIAL/BLOCKED;不得使用其他会话已有审查结果补齐。

View File

@@ -0,0 +1,153 @@
# STEP-097 V13 三轮审查问题合并修复 — Codex 任务 v1
状态:**计划 / 待执行**。本文件只定义后续代码修复任务;创建本文件不代表任何缺陷已经修复、编译通过或 V13 运行验收通过。
## 0. 目标、基线及本轮决策
目标:对 V13 三轮源码调查的问题去重、复核、按安全依赖顺序修改;保留全部原始编号和证据归属,不以“多份报告都说有问题”取代源码验证。先消除渲染与天气数据的正确性风险,再处理输入安全、Host 生命周期和可证明的热路径成本。保留通用连续 Render Segment、Style Z-order 唯一顺序、每个 RETAINED 独立 cache;不得退回旧 Bottom/Continuous/Top 固定三明治模型。
本次用户决定:**暂不执行 V13 正式运行态确认**。本任务结束的最高状态只能是 \`IMPLEMENTED / WAITING_RDC_BUILD_AND_RUNTIME_VALIDATION\`;不能写 Android/iOS GPU、FPS、长期稳定性、正式发布或 NavSea 接入 PASS。本任务不要求 Codex 编译、安装、操控设备或执行性能测试。运行态集中验证清单由 Codex准备,之后单独授权执行,避免小变动引发多次 Android 编译/安装。
实际源码基线以执行时 \`particle/main\`、\`origin/particle/main\` 和 clean worktree 为准;编写任务书时 HEAD 为 \`b5c1c4879a9d36608242e788afab5012bd9388f2\`,V13 软件冻结源码为 \`ee0b89e711bf01335b3aa71d36def785cd97fec3\`,功能提交 \`016fd1fff6a33cf795c10d4f5c5413d308383677\`。当前 \`.13\` 软件已发布,所有修复在后续开发提交中完成,**不得原地覆盖、移动或重新发布 \`.13\` 的 tag/manifest/产物**。若起始 HEAD 已前进,先核实源代码差异并报告,不用旧行号机械覆盖。
审查输入,必须逐份读取:
1. \`docs/20_调查/Particle_V13_严格代码审查报告_v1.md\`(F01–F10,含历史实测)。
2. \`docs/20_调查/STEP-095_V13独立严格代码与性能调查_v1.md\`(N01–N07、C01–C07、O01、E01/E02)。
3. \`docs/20_调查/STEP-096_V13隔离盲审调查_v1.md\`(B01–B13;**已冻结的独立盲审原文,不得因修复结果修改其 findings**)。
4. STEP-094、095、096 的任务/Step/完成报告与 release manifest,按真实性区分历史 PASS 与本轮 NOT RUN。
开始前读根 \`AGENTS.md\`、\`PARTICLE_BASELINE.md\`、\`docs/00_项目规范/\`、\`docs/00_项目总则/README.md\` 指定的四份有效总则。通过 RDC 确认 \`navsea-particle-integration\` 已同步到最新 \`main\`,并只读检查 \`README.md\`、\`WORKFLOW.md\`、\`coordination/TEAM_MODEL.md\`、\`contracts/\`、\`rfc/\`、\`adr/\`、\`status/\`、\`compatibility/\`。创建任务时 Integration main 为 \`39ad8415fbdb76c1822460bb09f3a3f35fe141e0\`,C-001 / RFC-001 为既有合同,不把其中历史 \`.5\` 误认为当前软件发布 \`.13\`。本任务**不修改 Integration、NavSea 仓库**,新增跨项目公共语义须先有协调/RFC/ADR/Contract 决定。
## 1. 去重修复总表与执行分类
状态枚举:\`CODE_FIX\`=本任务可实施;\`CONDITIONAL\`=只有满足设计/证据门槛才动;\`DESIGN_ONLY\`=不作为既有缺陷盲改;\`COORDINATION\`=需跨项目负责人协调。Codex 应将表中每项逐条写入实施矩阵,落实文件、测试入口、commit、实际状态;确认某项不成立也必须提供反证,不能直接删除。
| 合并问题 | 三轮原编号 | 初始分类 | 目标与安全边界 |
| --- | --- | --- | --- |
| 01 跨 render target 的 stencil CPU/GPU 状态失配 | N01 / B01 | CODE_FIX | target/pass 世代一致;首帧、R-R/R-C-R、cache ON/OFF |
| 02 Metal depth/stencil clear 与附件不完整 | N02 / B02 | CODE_FIX | 全部 RenderPass descriptor/combined attachment;不得只改颜色 |
| 03 LayerGroup render 异常后缓存错误标成功 | N03 / B12 | CODE_FIX | 失败不得 \`markBuildSucceeded\` 或 reuse;允许安全恢复 |
| 04 encoded PLAY 缺失唯一 production clock 驱动 | N04 / B06 | CODE_FIX | map/source owner 单一单调推进;无双重 tick |
| 05 GPU 粒子场身份未覆盖 V/validity | B13 | CODE_FIX | 两时间端点完整内容身份;正确复用和局部上传 |
| 06 8-bit 零向量偏差 | B07 | CONDITIONAL | 修复零表达和编码/解码一致性,不引入未验证的格式破坏 |
| 07 Mercator 东向位移缺纬度系数 | B08 | CODE_FIX | U/V 相同物理单位下正确度量,保留既定时间倍率 |
| 08 JSON 嵌入 NUL/重复键被接受 | N05 | CODE_FIX | 长度感知解析;非法计划清旧状态、fail-closed |
| 09 坏数值场先入库、物化失败替换好投影 | N06 | CODE_FIX | 事务式校验/发布;允许合法 no-data/validity=0 |
| 10 Android 重复 attach listener 积累 | N07 | CODE_FIX | 幂等 attach/detach、移除监听、对象释放 |
| 11 热路径完整 Diagnostics 深复制 | F01 / C01 | CODE_FIX | 内部轻快照与对外完整 pull 分离,线程一致性 |
| 12 Metal retained/普通 State/Trail 同步等待 | F02 / C02 / B03 | CONDITIONAL | 先记录同步点/依赖;不可直接删除 wait |
| 13 内容 dirty 导致相同尺寸 GPU allocation 重建 | F03 / C03 / B04 | CODE_FIX | contentDirty 与 resourceInvalid 分离;清屏正确性先通过 |
| 14 retained GPU 资源无总预算 | F04 / C04 | CONDITIONAL | 安全估算、分配失败完整 fallback;独立 R 不合并 |
| 15 encoded store 缺总 key/byte 预算 | C04 / B11 | CONDITIONAL | 保留绑定窗口和旧读者;若拒绝结果需新公共语义则停止 |
| 16 ordinal 前缀 O(S×G)/fast 扫全层 | F07 / C05 | CODE_FIX | topology generation 缓存索引,不能改变全局顺序 |
| 17 相同 time/mix-only 重物化和 UI 同步处理 | C06 / B05 / B10 | CODE_FIX(异步部分 CONDITIONAL) | 先幂等和不可变端点复用;owner-thread 安全发布 |
| 18 drawable/tweaker 重建与窗口端点复制 | C07 | CONDITIONAL | 只改可证明稳定的对象生命周期,避免新 stale 引用 |
| 19 旧 cache 的 release 记入新 topology | F09 / O01 | CODE_FIX | identity/generation 归属、历史/当前计数分离 |
| 20 Test Host RN \`.10\` / native \`.13\` 错配 | F05 / E01 | CODE_FIX | 默认 package/lock 与插件精确锁定;不把旧 TS 报错误归因 |
| 21 多 Continuous 退出 CPU Fast Path | F06 | DESIGN_ONLY | 保留安全 full CPU fallback,后续真实 A/B 再立项 multi-C |
| 22 source/tile 等保守全局失效 | F08 | DESIGN_ONLY | 没有完整依赖证明不得缩窄,后续 profiling/依赖设计 |
| 23 Integration compatibility/status 历史口径 | F10 / E02 | COORDINATION | 向粒子负责人提交接入证据请求,不擅改或声称 NavSea 支持 |
| 24 正式共享地理 Ocean Mask 尚未形成 | STEP-095 §7 / B09 | COORDINATION | 跨 Host/Shared Core/双平台显示契约需单独 RFC/ADR,不用合成 fixture 冒充正式实现 |
注意:B13 与 N06 **不是同一缺陷**;GPU 内容身份遗漏与 encoded ingress 事务发布必须分别修复和验证。B07/B08 是粒子数值正确性,不等于 V13 分段回归。所有 P1/P2 是调查建议优先级,不是运行事故证明。
## 2. 固定顺序与不可跨越的检查点
严格按 \`基线复核 → A正确性 → B输入/生命周期 → C安全性能 → D交付\` 串行进行。前一阶段没有明确代码完成和静态检查结果,禁止推进下一阶段;失败、进程超时、状态未知不能当作成功。任一阶段发现需要更改冻结公共 API、QBIN/FieldProfile 语义或破坏跨平台 ABI,**停在该问题**,记录接口阻塞和建议,不自行加入 Adapter/Normalizer、隐藏通道或不兼容 fallback。不得为了凑齐总表自动进行大架构重构。
### 阶段 0:基线与定点反证
1. 确认 branch/HEAD/origin、dirty/untracked、submodule、manifest、V13 tag;对照三轮行号回读实际源码,建立 \`问题→当前源码→最短复现→反例→修复文件→目标回归\` 表。
2. 确认 GL/Metal/RN/Test Host 的实际入口和平台条件,核对是否已有后续修改解决某项;有明确反证者记 \`NOT_REPRODUCIBLE_ON_CURRENT_HEAD\` 并附定位,不重复修复。
3. 固定公开语义:\`fieldId/generation/validTime\`、U 东向/V 北向、T0/T1/MIX 与 OLD_T1→NEW_T0 reuse、Style Z-order、每个 R 独立 cache、颜色与粒子分离、天气获取由 Host 所有。必要的内部对象身份可以新增,禁止创造第二套公共场协议。
4. 在 \`docs/30_设计/STEP-097_V13三轮问题去重与修复矩阵_v1.md\` 先写入逐项状态和安全不变量;后续随源码实现持续更新。这不是重新撰写第四轮审查。
### 阶段 A1:Renderer 正确性(01/02/03/19)
- **01 stencil**:明确记录属于当前 render target/pass,切换附件时重置/分代 CPU mask/ID 记录;GPU clear 与 CPU 映射同世代。保留同一 target 中安全的 tile mask 复用,不能中途全局清掉仍需使用的 stencil。针对相同 tiles 的 R-R、R-C-R、C-R-R、首次 build/rebuild/reuse/cache OFF 加最小可执行回归入口。
- **02 Metal**:从 gfx RenderPass descriptor 到 MTL descriptor 完整传递 color/depth/stencil 的 load/clear/store;正确处理 combined depth-stencil 和 device/simulator 附件存在性,创建失败显式不发布可用缓存。不能只依赖默认 loadAction 或 color isRenderable。补 descriptor/附件状态与 fallback 可观察测试入口;不宣称 iOS runtime PASS。
- **03 错误缓存**:使 layer-group 绘制失败结果贯穿 opaque/translucent、observer、finishBuild;部分绘制不得发布为 valid,不得在下一帧 reuse。正常 fallback/retry 能恢复;保留已有资源准备失败回退,不吞异常或改成进程崩溃。
- **19 计数归属**:cache 创建/析构使用稳定 identity + topology generation,旧资源 release 不记入同 index 的新段;聚合统计与当前 topology 统计分离。连续模式切换、R→C、相同 index 换边界均有验证入口。诊断错误不夸大为纹理本体泄漏。
- 本阶段有任何 stencil/Metal/异常正确性未闭合时,**不得开始资源复用/异步 GPU 性能改造**。
### 阶段 A2:Field/粒子正确性(04/05/06/07)
- **04 PLAY**:只允许一个 map/source owner 推进 forecast clock,基于单调时间处理 PLAY/PAUSE、速度、恢复前后台、clear/destroy;平台 timer 仅调度/唤醒,不允许 WeatherColor 与 Particle 各推进一次。只 setTime 一次后 PLAY 自动移动 time/mix;保持 Host 显式 setTime 路径语义。
- **05 B13**:在两个端点的 GPU 更新门槛覆盖 U、V、validity、grid、encoding/解释语义及来源版本;generation、fieldID、dataVersion 不能代替完整内容身份。只变 mix 不应上传场,真正跨窗时旧 T1→新 T0 安全复用,只上传需要的新端点,不能每 tick 重建;无效/旧 revision 不可污染有效窗口。
- **06 B07**:先确认当前 GL/Metal 纹理格式、GPU 精度与 CPU/shader 编解码约束;以零、微风、正负抵消及 NaN/validity fixture 定量约束误差。选择可准确表达零的**内部**表示或明确等效方案,GL/Metal 统一;如果必须改变公共 encoding、需要不支持的图形格式或改变已发布输入 ABI,则不强行实施,记录 \`BLOCKED_FORMAT_DECISION\`。
- **07 B08**:按 eastward U / northward V m/s 推导 Web Mercator 度量,对 U/V 一致应用纬度修正、符号、极区夹紧和既有 visual time scale。建立 0°/45°/60°、东/西/南/北及极区有限值的解析对照;不得更改 Host 输入向量单位。
### 阶段 B:输入与平台生命周期(08/09/10/20)
- **08 N05**:JSON strategy/startLayerId 必须使用长度感知字符串;拒绝内嵌 NUL、重复键、未知 schema/strategy、缺失/逆序边界。无效配置 fail-closed、旧计划清除与恢复语义保持,Android/iOS/RN 错误归一化不分叉。
- **09 N06**:在已解码 frame 真正替换 Store 前,按既有 profile/validity/no-data 语义验证有效 cell 的有限 U/V 和 scale/offset 运算不溢出;事务式提交,失败返回明确错误并保留好旧帧、投影及 GPU 显示。不把合法 validity=0 或规范允许的 noData 当成错误;不同时间/高 revision 的合法行为不改变。
- **10 N07**:同 View 重复 attach/detach 幂等;map listener 有明确 remove/dispose owner 和 map/destroy 世代。测试入口覆盖 100 次复用、切 view、销毁中的异步回调,不产生悬挂访问或多重 dispose。
- **20 E01**:核实真实发布坐标、Host \`package.json\` 和 lockfile、plugin 注入的 Native 版本;将**默认** Test Host 消费组合对齐经核对的 \`.13\` 精确配对,不将临时 node_modules/本地软链当作成功。若需要 install/codegen/Gradle,则准备 RDC/用户命令并标 \`NOT RUN\`;不发布、不覆盖现有 \`.13\` 产物、不改 NavSea。
### 阶段 C:有边界的性能与资源(11–18)
严格先做只读热点/资源依赖记录,再实施不会改变正确性和公共契约的优化,优先把同一阶段兼容变更集中,避免每个小点都要求一次 Android APK。
1. **11 轻量 Diagnostics**:内部 fast/render 热路径只取同一 generation 的轻量一致状态;完整 Style/Segments 深复制仅在外部 pull 时构造。避免用若干无同步原子值组成不一致快照;低频 pull schema 兼容。
2. **16 ordinal/扫描**:预计算 global ordinal 和粒子/待更新 group 列表,topology/style reorder 后失效重建;保持 opaque reverse、translucent forward、隐藏层及全局 Z-order,不能用局部 ordinal 替代全局编号。
3. **17 物化与 UI**:相同 requestedTime 和相同不可变端点早退;mix-only 仅更新 mix,复用完整内容 identity(U/V/validity/grid/encoding),与 B13 修复共用身份。双 binding 共享安全的不可变端点,不能跨 generation 复用旧数据。Android/iOS 解码和 CRC 若转 worker,必须保存 map/source 世代、owner-thread 事务发布、有限队列、取消和 destroy race;若涉及跨平台公共异步返回语义或不能证明生命周期,先完成同步路径早退,异步部分标 \`DEFERRED_DESIGN\`。
4. **13 retained allocation**:仅在 A1 的 target clear/错误语义已经正确后,分离内容 dirty 与资源 key invalid;同尺寸、同 gfx context/format 可清除重绘;resize/context loss/关闭明确释放。相邻 R-R 仍是独立 cache。
5. **14/15 预算**:显式估算 retained color+depth/stencil+pending 与 fieldStore key/byte 上界,处理整数溢出和在途旧快照。无法分配必须走原设计的安全 fallback 或拒绝;不得静默丢弃仍绑定窗口。若新增拒绝状态无法用现有 public error contract 表达,保留安全上界/诊断并提交接口阻塞,禁止私改 RN/Native 双方错误语义。
6. **18 Drawable**:仅在能证明版本/target/UBO 生命周期正确时保留可复用 drawable/tweaker、减少 field endpoint 深复制;区分 tick、reseed、resize、context loss 与窗口轮换。未能证明不产生 stale resources 时不做大重构。
7. **12 Metal wait**:仅盘点 retained build、State/Trail 等实际同步点、命令和资源依赖,准备 Metal capture/性能基准。未经设备/renderer 验证**不得直接删 \`waitUntilCompleted\`、改成无法证明安全的异步 present**。可在现有顺序中去除明确重复且不改变依赖的等待,但须提供静态资源所有权证据;否则标 \`DEFERRED_PROFILE_AND_RUNTIME\`。
## 3. 本任务明确不进行的架构/协调事项
- **F06 multi-C**:保留既有安全 full CPU fallback 和 GPU retained reuse,不为达到指标改变 Style 顺序或暗中合并 C/R;新的可组合 multi-C fast path 单独设计、后续 A/B 验证。
- **F08 精细失效**:保留 source/tile/glyph/image/placement 的保守全局 invalidation,直到存在完整依赖与变更序列回归;不因性能冲动将其裁窄。
- **B09 Ocean Mask**:现有 synthetic fixture 不是正式共享地理 Mask。本任务只形成 Host→Shared Core→两层消费及跨平台一致性需求、最小数据边界和阻塞报告,交粒子负责人按 Integration C-001/RFC/ADR 决定;**不借任务顺便建立新的公开 mask API、导入 provider 或让 MapLibre 理解 NavSea 业务**。
- **F10/E02**:只输出“需 Integration 更新 Compatibility/Status”的事实与建议,并提供当前软件版本与真正验证状态;不修改 Integration 文档或宣称 NavSea 已支持。
- 不扩大为 renderer 全架构重写、QBIN 格式版本升级、RN 第二套桥、全量新主题、NavSea业务变动、新软件版本发布或复原旧三明治设计。
## 4. 验证准备、执行责任与验收矩阵
Codex 负责按实际代码添加**现有框架下必要的定向 C++/平台测试源码或最小 fixture**、静态核对、命令和期望结果;仅有调用说明但没有针对关键回归的测试入口,不能把相应问题标代码完成。可按现有项目测试结构写回归,但本任务**不包含 Jest,也不运行 Jest**。不修改旧断言来掩盖真实失败,不删除测试、不降低测试门槛。
| 验证集合 | 必备情形/最低证据 |
| --- | --- |
| Renderer A1 | 同 source/same tiles R-R/R-C-R、主/离屏 stencil;Metal clear/load/store、combined attachment;异常后禁止 reuse;R→C/拓扑更换计数 |
| Field A2 | 一次 setTime 后 PLAY/PAUSE/rate;T0/T1 boundary reuse;同 identity 仅 V 或 validity 变化;零/微速量化、0°/45°/60° Mercator |
| 输入 B | NUL/重复键/invalid→valid;好旧场→坏高 revision 且旧场保留;重复 attach/销毁;Host 默认版本与 lockfile |
| CPU C | pull-only diagnostics、reorder/ordinal、相同时间/仅 mix、窗口换端点、UI owner 世代安全 |
| 资源 C | mutation vs resize vs context loss、allocation fail、R×viewport×fieldKey 阶梯、清理恢复、GL/Metal resource ownership |
| 集中运行态(后续) | Android OpenGL + iOS Metal 各自像素/数值/时间/资源复现;30/60 FPS、p50/p95/p99、峰值/长稳、方向/缩放、状态恢复;按平台独立记录 |
**现在不执行** CMake、Xcode、Gradle、Codegen、Test Host install、APK/AAR、ADB、安装、真实设备、GPU capture、profiler、NavSea 集成或正式 V13 运行确认。Codex 结束时给出可复制的 RDC 验证命令(以本仓现有 runner/build 入口为准);Android Win11 手工 Gradle/ADB 和 iOS 设备验证依后续授权,不在本任务自动触发。不得把 STEP-094 历史 43/43、旧报告 69/69 或任何已发布 manifest PASS 写成本任务结果。
RDC 在每个代码批次之后独立审查文件/静态 diff;未来获授权再集中编译/运行,而不是在本轮分阶段频繁请求重装 APK。未来完整 Android 编译应遵守 \`tools/particle_android_validation/build_validation_apk.sh\` 当前权威流水线与用户手工运行安排。Metal 修改必须留给 Mac 同 commit 验证,Android 结果不得替 iOS PASS。
## 5. 允许修改与明确禁止
允许(仅与已复核问题直接相关,实际存在的路径以源码核对为准):
- \`src/mbgl/renderer/\`、\`src/mbgl/gl/\`、\`src/mbgl/mtl/\`、\`include/mbgl/\`、所需 gfx backend;
- \`src/mbgl/particle/\`、\`src/mbgl/style/sources/raster_array_source.cpp\`、\`src/mbgl/renderer/layers/\`、对应 GL/Metal shader及其标准生成源;
- Android Java/Kotlin/JNI、iOS ObjC++、RN 公共实现及 Test Host 本任务限域依赖文件;
- 对应定向测试源码/fixture、下述交付文档和开发记录。
涉及 shader generator 必须改其权威源而非只手改自动生成产物,并附原生成/校验命令由 RDC 后续执行。
禁止:修改 Integration、NavSea、项目总则、旧三份调查/任务/报告、已发布 manifest/tag/tgz/AAR/XCFramework、无关 package/lock、官方上游基线;禁止 \`git reset --hard\`、\`git clean\`、force push、push upstream;禁止把公开 API/FieldProfile/QBIN breaking 变更悄悄塞进修复。不能自行把 V13 版本重命名为下一发布版,下一版本号属于独立发布任务。
## 6. 必做输出与报告路径
执行时必须建立、更新且全部中文:
1. \`docs/30_设计/STEP-097_V13三轮问题去重与修复矩阵_v1.md\`:保留表中全部 24 行、旧编号、实际源码定位、修复/不修理由、源码状态、测试入口、commit、平台影响、运行待测项。分阶段附可量化的变更前/后操作次数模型,不虚构 FPS/内存收益。
2. \`docs/50_Step记录/STEP-097_V13三轮审查问题合并修复_v1.md\`:阶段 0/A1/A2/B/C/D 检查点、状态、实际改动、阻塞、下一步。每一阶段结束后追加短而具体的事实,未完成不得勾选。
3. \`docs/60_测试/STEP-097_V13修复后集中验证清单_v1.md\`:按 Android GL、iOS Metal、通用 Core/测试入口、后续 GPU 性能分层记录**尚未执行**的精确命令、fixtures、预期输出和复核责任。
4. \`docs/报告/STEP-097_V13三轮审查问题合并修复_Codex完成报告_v1.md\`:按规定详列开始/结束 HEAD、每批提交、文件清单、各问题状态、跨项目阻塞、静态检查实际命令和结果、所有构建/运行 \`NOT RUN\`、软件发布 \`NOT RUN\`、下一步集中 RDC 验证。
5. 在根目录 \`粒子开发记录.md\` **仅追加**实际进展;不得覆盖历史。
6. 若实际触发跨 Contract 阻塞,额外写 \`docs/报告/STEP-097_公共接口阻塞报告_v1.md\`,明确现有 C-001/RFC-001、拟议语义、为什么无法安全局部修、Android/iOS/NavSea 各自责任;本地只记录并提升级建议,不假装协调决定已被接受。
完成状态只能细分为 \`CODE_IMPLEMENTED_STATIC_CHECKED\`、\`DEFERRED_DESIGN\`、\`BLOCKED_CONTRACT\`、\`NOT_REPRODUCIBLE_WITH_EVIDENCE\`、\`WAITING_RDC_BUILD_AND_RUNTIME\`;没有执行的验证永远写 \`NOT RUN\`,产品级 \`PASS\` 禁用。
## 7. Git 收尾与停止条件
在当前 \`particle/main\` 上执行,先保护所有已有 dirty/untracked;只 stage 本任务变更。按 A1、A2、B、C 各自边界形成 scoped commit,文档随阶段更新;阶段失败不能越过到下一阶段,不可混入不相关工作。每个成功阶段允许 push \`origin particle/main\` 供 RDC 直接核对,失败必须确认进程/状态并修复,不能以 timeout 当作已失败后盲目重复 push。最终 \`git diff --check\`、提交内容、远端 HEAD、clean status 明确记录。push 失败不得宣称交付完成;提交并不代表运行验收。
触发以下任一条件则停止相应实现并生成阻塞事实:需要新增/改变公共契约而无 Integration 决定;必须升级工具链/MapLibre;不能证明跨 GL/Metal 正确或资源所有权;修复需要删除已有功能/测试或强制绕过安全 fallback;当前 HEAD 与所需源码不符且不能安全合并;既有用户改动会被覆盖;任一关键问题没有明确退出条件。必要时整任务停在已完成的 scoped checkpoint,报告 PARTIAL,不靠继续扩大代码量冒充完成。
交付后由 RDC 独立审查并按后续授权集中执行构建、回归和设备验收。**本任务不发布下一版软件,不提前关闭 V13 GPU 运行态。**

View File

@@ -0,0 +1,141 @@
# STEP-098:QBIN V4 单帧首显与按 fieldKey 独立的八帧滚动缓存 — Codex 任务 v1
状态:**任务书 / 待契约闸门通过后执行**。本任务书不代表代码已经修改、编译或双平台运行验收通过。
## 0. 目标与执行环境
本任务落实已讨论的目标模型:**单帧可独立显示;第二帧到达后支持正常时间插值;后续时间片在 Native 内部进入每个 fieldKey 独立的、按 validTime 升序排序的固定 8 帧滚动缓存。** 满 8 帧提交新的合法时间片时应自动淘汰,不应因正常满额返回 `REJECTED_CAPACITY`。
当前用户维护和修改的源码以 **Mac** 为准:`/Users/tei/dev/MapLibreParticleLab`,分支 `particle/main`。编写本任务书时 Mac HEAD / origin HEAD 均为 `0d13552bed84f427dd43cae9856391bd26622179`。执行前重新查询 Git 事实,不以该 SHA 或本文行号覆盖后续提交;不要拿 Linux 旧工作树代替 Mac 代码。任务 MD 的目标归档位置为 `docs/40_任务/STEP-098_QBIN_V4单帧首显与八帧滚动缓存_Codex任务_v1.md`;不要创建 `docs/任务/`。
本阶段以 **Shared Core 公共语义和现有 RN V4 公共接口**为实施目标,不在 NavSea/Weather Server 中实施下载策略;Android 与 iOS 使用同一共享语义,平台专项编译/实体机验收分别另行完成。不得因 Mac 为编辑机而建立 iOS 专属 Core 行为。
### 固定边界
- RN:负责获取预报目录、QBIN 文件、本地下载缓存、用户选择的预报时间、异步命令 latest-wins 和 UI。RN 已提供 `FieldRuntime.submitEncodedField(...)`、`FieldRuntime.setEncodedForecastTime(...)`,**先 await 前者成功,再 await 后者**;无需新增组合提交 API。
- Shared Core/Native:负责 QBIN 校验、存储、每 fieldKey 内排序/淘汰/复用、单帧/双帧解析、颜色与粒子投影、GPU 资源正确性。
- 不增加 Native→RN 索取数据回调;Native 不接入 provider、HTTP、manifest、下载、缓存文件管理或 NavSea 业务语义。
- 不修改 QBIN 文件格式、Rice 解码语义、现有公开方法签名、Style Z-order 或 V3 路径,不在 JS 伪造第二帧,也不在 Host 手动清空 8 帧来实现正常拖动。
## 1. 首要闸门:Integration 公共契约
1. 先经 RDC 检查并同步 `/Users/tei/dev/navsea-particle-integration` 的最新 `origin/main`(`git fetch origin main` + 确认可 fast-forward;尊重现有本地改动)。读取该仓库 `README.md`、`WORKFLOW.md`、`coordination/TEAM_MODEL.md`、`contracts/`、`rfc/`、`adr/`、`status/`、`compatibility/`、`coordination/active/COORD-004/` 和 `instructions/PARTICLE_PROJECT_INSTRUCTIONS.md`。
2. 编写本任务书时 Integration HEAD 为 `318f8ccf911ac3136c3086a1d14e61f83e2d4bce`;`RFC-003_编码场两帧首显与增量补帧语义.md` 仍处于 DRAFT,其第一条要求至少两帧、满额返回容量拒绝,与本任务新目标**明确冲突**。本任务书不是已经接受的跨平台 RFC。
3. **开始改公共源码前,必须由粒子负责人/Integration 按既定流程正式更新 RFC-003、COORD/Contract 或 ADR,接受下述单帧和滚动淘汰语义,明确 Android/iOS 共用。** Codex 不得自行把 DRAFT 当 ACCEPTED,也不得静默更改 Integration 文档替代负责人决定。
4. 如果执行时仍没有已接受且不冲突的正式依据:先在 `docs/报告/STEP-098_公共契约阻塞报告_v1.md` 写出当前 SHA、冲突条目、拟实施语义与所需协调决策,**停止功能修改及 push**,状态记为 `BLOCKED_CONTRACT`,交回粒子负责人。不得绕过闸门做 Android/iOS 两套实现。
5. 若 Integration 已更新且批准,记录精确文档路径、版本与 commit SHA,后续实现以正式版本为准;如正式结论与本文不同,应停止并回报,不自行择一。
## 2. 开始前必须核对的 Mac 实际基线
先读根项目规范、`PARTICLE_BASELINE.md`(如存在)、`docs/00_项目规范/`、`docs/00_项目总则/README.md` 指定总则、与 STEP-097 的正在实施范围。读取并建立文件/函数对照表:
- `include/mbgl/particle/field/field_runtime.hpp`、`src/mbgl/particle/field/field_runtime.cpp`:`FieldSeries`、`EncodedFieldStore`、`resolve`、容量及 revision/identity。
- `src/mbgl/particle/field/field_binding.cpp`、`src/mbgl/particle/field/field_projection_materialization.cpp`:单/双帧投影、独立绑定和物化。
- `src/mbgl/style/sources/raster_array_source.cpp` 及相关头文件:`submitEncodedField`、`setRequestedForecastTime`、`refreshV4RendererState`、现有 projection equality/notification。
- `src/mbgl/renderer/layers/render_weather_color_layer.cpp`、`render_particle_validation_layer.cpp` 及 shader/texture 资源:单帧/双帧下颜色、粒子、轨迹和 GPU 复用。
- `packages/maplibre-particle-rn/src/FieldRuntime.ts`、`NativeWeatherRuntimeBridge.ts`、Android Kotlin bridge/JNI、iOS `WeatherRuntimeBridge.mm`/`MLNParticleFieldRuntime.mm`:确认两个现有公开调用完整传递结果。
- `test/particle/field_runtime.test.cpp`、`test/particle/field_binding.test.cpp`、`test/source/raster_array_v4_host_api.test.cpp` 及现有 Renderer 定向验证入口。
编写任务书时源码事实:`FieldSeries::submit` 对第 9 个不同时间直接 `RejectedCapacity`;`resolve` 对 `frames.size()<2` 返回空;`submitEncodedField` 已会刷新投影但不修改 forecast time。以上仅供定位,若新 HEAD 有变化应以实际代码为准。若 STEP-097 仍在改相同的 runtime/projection/Renderer 文件,先确认依赖顺序,不得并行覆盖或回滚已完成的修复;尤其保留 STEP-097 的输入校验、forecast clock、GPU 身份、资源生命周期等新修正。
## 3. 正式目标语义
### 3.1 缓存所有权、容量与排序
- `EncodedFieldStore` 按 `fieldKey` 分别持有 `FieldSeries`。`wind`、`wave`、`rain`、`tide` 使用不同 key 时分别最多 8 帧;同 key 的多个 Layer/Binding 共享该序列。不能把四类天气合为“全局 8 帧”。
- 每帧按 `frameContentIdentity.validTimeMs` **升序**保存,提交顺序不影响排序;不得假设固定一小时/三小时间隔。
- 已有相同 `validTime`:先走现有 Duplicate、identity conflict、stale、合法 revision 替换语义;不额外占一个槽,不因为满额淘汰别的帧。
- 不同时间且不足 8 帧:先完整校验,按时间有序插入。
- 不同时间且已满 8 帧:在成功通过解码、profile、grid/plane、身份和相邻兼容性校验后,选择淘汰一帧并原子提交新有序集合。正常满额替换成功返回现有 `ACCEPTED_INSERTED`(或正式合同要求的**既有**成功语义),不返回 `REJECTED_CAPACITY`。为了 TypeScript/ABI 向后兼容可以保留旧结果枚举,但正常容量=8的有效插入路径不再产生它;不可偷偷更名公共结果。
- 淘汰只作用于正在提交的 `fieldKey`,不影响其他天气种类、Bindings、requestedForecastTime、V3 数据或 Style。
### 3.2 双向淘汰,按新提交帧时间取中心
由于既定调用顺序是先 `submitEncodedField()`、后 `setEncodedForecastTime()`,**提交阶段不能依赖尚未下发的新目标时间**。本版使用“新提交帧 validTime 为中心,淘汰时间距离最远的端点”的确定性规则;新帧本身必须留下。对于已升序的满额序列,只在最早端或最晚端候选之间选择;若距离相同,按正式合同规定稳定决胜,允许优先避免淘汰当前显示端点,但不可牺牲新提交帧。不得永远只删除最旧帧。
必须覆盖:
- 原有示意 `13:00 ... 21:00` 共 8 帧;提交 `22:00` → 删除最早端 `13:00`,插入 `22:00`,最终仍 8 帧。
- 已向后滚动后提交 `12:00` → 删除最远的最晚端,重新保留 `12:00`;向过去拖动同样成立。
- 已满时提交位于内部的新时刻 → 仍保持有序、8 帧和确定的端点淘汰。
- 今天跳到后天某一时刻 → 接收该时刻 QBIN,而不是要求 RN 先清空旧窗口或填齐 8 帧。之后逐帧提交,Native 自行滚动。
- 乱序提交、Duplicate、同时间新 revision 不应造成意外淘汰。
- 插入不兼容数据或解码失败时,**零淘汰、零 Store/Projection/Clock 修改**;不得先删除旧帧再验证。
### 3.3 单帧首显与双帧升级
- 当请求时间精确对应某个已验证 `validTime`,即使整个 Store 只有一帧,或它与其余 7 帧相隔很远无法形成合理双帧窗口,也应能够显示该帧:颜色为这一帧的值;有有效 U/V(或规范允许的方向向量)时粒子可持续运动,**天气时间不变不等于粒子停止**;仅标量帧不能伪装成可平流向量。
- 单帧在运行时具有真实单端点语义。可在内部使用同一 Snapshot 作为 T0/T1、`mix=0` 来复用现有双纹理 shader,但不得向 RN 要求伪造第二个 QBIN 或捏造第二个不同 validTime;优先共享解码结果、物化 Snapshot 和 GPU 纹理,避免同一单帧无意义地双份常驻。
- 第二帧补入后,如果目标时间被合法的相邻端点覆盖,则自动升级为双帧插值,保持既有 `T0/T1/MIX`,端点、mix 与实际预报时间对应;如果并未覆盖,保持合法单帧或 fail-closed,不能强行把不相关的旧帧插值进去。
- 目标时间落在两帧之间且有合法覆盖时执行正常时间插值;目标时间既不精确匹配可用单帧、也没有合法双帧覆盖时返回明确 unavailable,不使用“最近旧帧”冒充该时刻。RN 若选择最近预报帧首显,必须以该帧实际 `validTime` 作为已显示时刻;用户日历选中时间与实际显示时间不可混称。例如用户初始选择 13:30,只有最近的 12:00 QBIN 时,RN 可先提交该帧并调用现有 `setEncodedForecastTime(12:00)` 显示 12:00 静态预报,仍将 13:30 保留为业务层待完成目标;等 15:00 帧到达后再设置 `setEncodedForecastTime(13:30)` 进入插值。不得在 Native 悄悄修改 `requestedForecastTime`,也不得将临时 12:00 画面标成 13:30 的插值天气。
- 不能把跨 dataset/reference/Profile 的帧混合成一个时间序列;沿用已有权威身份契约。不凭固定时间间隔猜测有效帧,也不能用任意远离的旧时间片推断新的天气。若当前元数据不足以明确区分“同一批次的合法双端点”与“跨批次伪兼容”,作为契约阻塞项报告,不能自行新造隐含协议。
- 明确 `fieldKey` 之间可有不同 validTime 覆盖。颜色与粒子在同一全局请求时刻分别 resolve,各自可处于单帧、双帧或 unavailable;不得因一个 fieldKey 无数据清空其他 key 的合法投影。
### 3.4 两个现有 RN API 的行为不变
正常时间轴操作为:
1. RN 从本地缓存或服务器取得对应 QBIN,先 `await FieldRuntime.submitEncodedField(...)`。
2. 仅在 `ACCEPTED_INSERTED`、`ACCEPTED_REPLACED_REVISION` 或可确认的 `DUPLICATE` 等成功结果后,`await FieldRuntime.setEncodedForecastTime(reactTag, targetTimeMs)`。
3. Native 的 submit **不擅自修改目标时间**,setTime 不触发网络下载。已缓存时间直接 setTime 即可;无需每次重新 submit。
4. 两调用非原子,可能在两次调用之间短暂显示旧时间、单帧或暂时不可用,**不可对外保证无闪烁**。不得把旧时刻天气伪标为新目标。RN 侧 latest-wins、目标版本和展示状态属于后续 Host 接入范围;本任务不私改 NavSea。
5. 不新增 `submitAndSetTime`、Native→RN 数据请求事件、逐帧删除 RN API 或新的 provider 参数;保留现有 `clearEncodedField()` / `clearAllEncodedFields()` 作为显式重置能力,而不是正常滚动手段。
### 3.5 Renderer 与资源安全
- 正常向前/向后补帧且当前已选 pair 和 mix 未变化时,避免无条件 rebuild、重复 GPU 上传、清空 trail 或发布无意义 Source revision;复用 existing `sameProjection`/GPU content identity。
- 单帧→双帧、双帧窗口变化和 revision 更新必须正确刷新,shader 无非法 T1/null;若相同 Snapshot 对应 T0/T1,避免重复上传同一内容。支持 GL/Metal 共享语义,不能只修 Android Renderer。
- 淘汰旧帧不能销毁仍被当前 projection/GPU 更新流程持有的不可变 Snapshot;通过正常引用计数和资源所有权安全释放。切换不同窗口时遵守现有 trail/particle reset 规则,不错误承诺跨任意远跳跃保留轨迹。
- 坏帧不允许覆盖已验证的好投影;生命周期关闭、clear、style reload、map 销毁与异步提交有明确所有权,不使旧请求重新点亮已 OFF 的画面。
### 3.6 内存边界
- 8 帧是**每 fieldKey 的数量上限**,不是无限制内存保证。每个 decoded frame 由原始 Plane 字节、validity bitset 和元数据构成;参与显示的端点另有 Float32 物化数组、图像、纹理和粒子/trail 资源。不得错误地用压缩 QBIN 大小表示运行内存。
- 不在本任务未经批准添加任意固定全局 MiB 上限或第二套容量公开错误码。为风/浪/雨/潮各 8 帧、单端点/双端点和跳跃替换提供 CPU/GPU 常驻及峰值测量入口或现有计数;记录可计算的 decoded byte 占用、资源复用/释放及最坏边界风险。
- 评估单帧 T0=T1 情况下重复物化/重复上传;保证淘汰与资源 release 后无持续增长。若测量显示 32 帧和显示资源会触及移动端内存风险,报告下一步“全局字节预算/多 fieldKey 优先级”设计建议,**不可把内存申请失败伪装成正常 REJECTED_CAPACITY**。
## 4. 实施顺序与测试矩阵
阶段 A:只读复核与契约闸门 → 阶段 B:Core Store 滚动淘汰及单帧 resolver → 阶段 C:Binding、物化、Renderer 单/双帧和资源复用 → 阶段 D:已有 RN Bridge 端到端语义检查 → 阶段 E:文件、报告、Git 收尾。前一阶段未明确通过不进入后一阶段;命令 timeout/进程未知不能算通过。发生公共 ABI、QBIN、profile、dataset 身份或无法证实跨平台一致性的阻塞时停止扩大范围。
至少具备以下定向验证;优先扩展既有 C++ 源级测试和可复用 Test Host 场景,不新建无关测试框架:
| 编号 | 场景 | 必须得到的结果 |
|---|---|---|
| T01 | 只有一帧,target=validTime | 投影合法;色场可显示;有 U/V 时粒子可动;实际时刻正确 |
| T02 | 首帧后补入第二帧,target 在两者中间 | 单帧→双帧,MIX 正确;不用伪造第二帧 |
| T03 | 8 帧未满乱序提交 | 按 validTime 升序;各 key 相互隔离 |
| T04 | 满 8 帧,向未来提交新帧 | 淘汰最早端、保留新帧,计数仍 8,无正常容量拒绝 |
| T05 | 满 8 帧,向过去提交新帧 | 淘汰最晚端、保留新帧,计数仍 8 |
| T06 | 满 8 帧,提交中间时刻 | 确定性端点淘汰;排序/唯一性不变 |
| T07 | 今天→后天单帧→后续补帧→返回今天 | 新时刻单帧可用,后续升级双帧;旧数据需 RN 重交时可正常滚动,不越界、不混批 |
| T08 | Duplicate、同时间合法新 revision、stale、identity conflict | 保留现有判定;重复/替换不淘汰其他时刻 |
| T09 | 错 QBIN、Profile/Grid/Plane 不兼容、无效向量 | 拒绝且 Store、forecast clock、原投影不被破坏 |
| T10 | 目标时间位于已缓存相邻两帧之间 | 正常插值,mix 首/中/末精确 |
| T11 | 目标不被合法帧覆盖也不精确匹配 | unavailable,不把最近帧冒充目标时间 |
| T12 | 新帧在当前 pair 外围、当前投影不变 | requestedTime、当前 pair/mix 保持;避免无意义资源重建 |
| T13 | 新帧改变当前 bracket,或当前端点被淘汰 | 正确更新;不沿用失效旧 GPU 数据、错误轨迹 |
| T14 | `wind` 满额后提交 `wave`;同 key Color+Particle | 独立 8 帧;其他 key 不受影响;同 key 不复制 Store |
| T15 | 单/双端点 Materialization/GL/Metal 资源 | 单帧无需两份独立 QBIN/CPU/GPU 内容;复用、release 与上下文丢失恢复正确 |
| T16 | 连续 100+ 次双向滚动、反复 clear、切 Runtime | 帧上限不破、无持续内存增长和悬挂引用 |
| T17 | RN 两步调用成功与失败分支 | 既有 API/TypeScript/Android/iOS bridge 类型兼容,失败不继续 setTime;不新增公开 API |
注意:Native 静态/定向测试只能证明对应源码路径,不等于手机 GPU 首帧或 NavSea UI 通过。对尚未执行的 Android/iOS 真机、滚动拖动、跨天回退、内存峰值必须标 `NOT RUN / WAITING_USER_VALIDATION`。
## 5. 修改范围与明确禁止项
允许根据当前真实源码在上述 Shared Core/Source/Renderer 及对应现有测试文件做必要的最小修改;仅在证明是既有 API 传递障碍时修改 Android/iOS bridge,保持方法签名和结果语义。可在 `docs/30_设计/` 记录淘汰不变量与单帧/双帧选择表,在 `docs/50_Step记录/` 记录实施进度。
禁止:修改 NavSea、Server、天气 provider、下载缓存、Manifest、日历 UI;重新生成 PBF、修改 Style Z-order、强行更改 QBIN/QBV3 格式;新建第二套 FieldStore/Timeline、额外 JS Normalizer/Wrapper、私有 Native 通道、自动请求 RN 数据、提交单帧伪造第二份不同时间、给四类天气合用 8 帧、让正常满额仍返回容量拒绝。不允许顺带完成 STEP-097 的未授权项,也不允许覆盖现有发布 tag/manifest 或把源码通过称为正式发布通过。
## 6. 构建、交付与 Git
- 本次编译、Gradle/Pod/Xcode 构建、APK/IPA 安装、ADB/设备操作及实际 GPU 验收由用户人工执行。Codex 可做源码、`git diff --check`、格式/静态检查及环境许可的低成本 C++ 定向测试;**不得擅自启动完整 Android/iOS 编译或设备操作**,也不得把未运行项目写成 PASS。
- 准备一份 Windows 11 可直接执行的 Android 手动验证清单,ADB 命令如需涉及当前虚拟机必须显式 `adb -s 192.168.200.139 ...`;iOS 只列 Mac 用户手工构建/设备验收条件。合并一轮验证 T01–T17,不为单个按钮或日志要求反复重装。
- 必做输出:`docs/30_设计/STEP-098_QBIN_V4单帧与八帧滚动缓存设计_v1.md`、`docs/50_Step记录/STEP-098_QBIN_V4单帧与八帧滚动缓存_v1.md`、`docs/报告/STEP-098_QBIN_V4单帧与八帧滚动缓存_Codex完成报告_v1.md`。目录不存在先创建;同主题报告已存在则递增版本,不覆盖旧报告。全部中文。
- 报告明确记录:当前 HEAD、获接受的 Integration 协议版本/commit、真实改动列表、API/ABI 是否变化、每项 T01–T17 执行状态、`REJECTED_CAPACITY` 新语义、单帧显示实际范围、CPU/GPU 内存测量与未验证项、下一步 Android/iOS/Host 责任。最多只能报告 `IMPLEMENTED / WAITING_PLATFORM_RUNTIME_VALIDATION`;契约闸门未过则 `BLOCKED_CONTRACT`。
- 完成可实施阶段且检查通过后,只提交本次任务相关变更到 **Mac 当前项目 `particle/main`**,并 push 到公开 `origin/particle/main`。提交前检查未跟踪/用户既有文件,不得 `git add -A` 混入无关变更;先核验 `origin` 是否前进,冲突时停下处理而非强推。完成报告必须写最终 commit hash、commit message、push 结果和远端 branch/HEAD;push 失败不能声称完成。若被契约闸门阻塞,不提交或 push 功能代码。
## 7. 最终验收口径
用户能在 RN 维持两个现有 API 和顺序:第一帧即显示其真实有效时间;第二帧具备合法覆盖时升级插值;后续任意顺序提交,Native 每 key 自动保持最多 8 帧。向未来提交淘汰最早,向过去提交淘汰最晚;正常已满不会 `REJECTED_CAPACITY`;四类天气互不挤占。无效帧零副作用、时间语义不造假、Core 不掌握下载/日历。Renderer 不因正常补帧无谓清屏或持续泄漏。Android 和 iOS 分别通过用户实际设备验证后,才能由 Integration 更新兼容性及正式状态。

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,405 @@
# STEP-101 RenderOrchestrator teardown 生命周期修复 — Codex 任务 v1
## 0. 任务目的
修复 Particle V18 Android 在正常 Renderer teardown / MapView reset 时发生的 Native SIGABRT。
现场已在 NavSea Android x86_64 设备稳定复现:
```text
Fatal signal 6 (SIGABRT)
thread: RenderThread 11
FORTIFY:
pthread_mutex_lock called on a destroyed mutex
libmaplibre.so
std::__ndk1::mutex::lock()
...
mbgl::android::MapRenderer::resetRenderer()
```
V17 正常,V18 接入后稳定在启动约 9~11 秒触发。
本任务只修 Particle Native 生命周期;禁止在 NavSea 增加绕路、private wrapper、禁止阻止正常 MapView reset。
未经用户明确要求:不加入、不执行 Jest。
---
## 1. 冻结基线
工作树:
`/Users/tei/dev/MapLibreParticleLab-step101`
分支:
`step101-render-orchestrator-teardown`
起点必须是:
`origin/particle/main = d29e84c1a90daeea104b389d28b000c4f017d08d`
即正式 V18 Final HEAD。
V18 已发布,不得覆盖、重发或移动:
- `maplibre-particle/rn/13.4.1-particle.18`
- Android `13.4.1-particle.18`
- iOS `6.28.0-particle.18`
修复通过后由 RDC/ChatGPT 发布新不可变版本 V19。
---
## 2. 已知根因证据
V18 STEP-100 新增:
```cpp
RenderSegmentRevisionBook retainedSegmentRevisions;
mutable std::mutex readinessDiagnosticsMutex;
uint64_t readinessEventSequence;
std::unordered_map<std::string, SourceReadinessEventState> sourceReadinessEvents;
RenderMutationDiagnostics lastReadinessMutation;
```
多个 RenderSource / Tile observer callback 会进入:
```cpp
advanceRetainedSourceRevision(...)
recordReadinessMutation(...)
onTileChanged(...)
onTileAction(...)
buildReadinessDiagnostics(...)
retainedSegmentSourceState(...)
```
并 lock:
```cpp
readinessDiagnosticsMutex
```
V18 当前成员声明中:
```text
renderSources
renderLayers
...
retainedSegmentRevisions
...
readinessDiagnosticsMutex
sourceReadinessEvents
...
threadPool
```
C++ 成员按声明反序析构,因此 readiness state / mutex 会早于 renderSources 销毁。
而 RenderSource 在创建时:
```cpp
renderSource->setObserver(this);
```
observer 指向 RenderOrchestrator。
当前 `RenderOrchestrator::~RenderOrchestrator()` 只:
1. contextLost 时标记 layer;
2. `threadPool.waitForEmpty()`;
没有在 readiness state 仍有效时显式 detach RenderSource observer / 清理 renderSources。
因此 V18 存在明确生命周期危险:
```text
readinessDiagnosticsMutex destroyed
↓
renderSources still destroying/alive
↓
late tile/source callback
↓
RenderOrchestrator::onTile*
↓
lock destroyed mutex
↓
FORTIFY SIGABRT
```
---
## 3. 修复原则
不要只依赖调整成员声明顺序。
本任务应建立显式 teardown contract:
```text
RenderOrchestrator destructor begins
↓
stop / drain deferred scheduler work
↓
detach observer from every RenderSource
↓
destroy/clear renderSources while all orchestrator state is still alive
↓
only then continue member teardown
```
同时 `RenderSource::setObserver(nullptr)` 必须安全恢复到 NullObserver。
目标是即使未来成员声明顺序变化,Source callback 也不能在 owner teardown 后访问 RenderOrchestrator。
---
## 4. 必做实现
### 4.1 RenderSource nullptr observer 语义
当前:
```cpp
void RenderSource::setObserver(RenderSourceObserver* observer_) {
observer = observer_;
}
```
改成与 Style Source / Layer 一致的安全语义:
```cpp
observer = observer_ ? observer_ : &nullObserver;
```
要求:
- constructor 仍默认 NullObserver;
- `setObserver(nullptr)` 是合法 detach;
- onTileChanged / onTileError / onTileAction 不需要加 null 分支;
- 不吞掉正常有 observer 时的回调。
必须加非 Jest C++ 回归测试验证 nullptr detach 后 callback 不 crash、不调用旧 observer。
### 4.2 RenderOrchestrator 显式 teardown
在 `RenderOrchestrator::~RenderOrchestrator()` 中,在 readiness mutex/state 仍确定存活时:
1. 保留现有 contextLost layer handling;
2. 等待已有 deferred work 完成;
3. 对所有 `renderSources` 执行 observer detach;
4. 显式 `renderSources.clear()`;
5. 必要时对 glyph/image observer 也做已有 API 支持的 detach,但不要扩大范围;
6. 不允许 teardown callback 再进入 RenderOrchestrator source callback。
顺序必须结合当前 TaggedScheduler / RenderSource / TilePyramid 的真实生命周期检查,而不是机械照抄上述编号。
如果存在“clear renderSources 本身可能产生新的 scheduler work”,需要设计第二次 drain 或更安全顺序,并在报告解释。
### 4.3 生命周期状态可选保护
如果仅 detach + explicit clear 仍无法证明并发安全,允许增加最小 teardown flag,例如 atomic bool,但只作为二次防线。
禁止用 flag 代替 observer detach。
禁止 catch FORTIFY;destroyed mutex abort 不是 C++ exception。
---
## 5. 不得修改
- STEP-100 Source→Segment locality 语义;
- per-segment revision;
- per-segment readiness;
- mixed Build/Reuse/Fallback;
- Fast Path gate;
- singleContinuousSegment;
- Source::isLoaded;
- Style Z-order;
- provider/network;
- NavSea;
- MapRenderer::resetRenderer 正常生命周期;
- Android Activity 为避免 reset 而加 workaround。
---
## 6. 必做测试
### 6.1 Native 非 Jest
至少覆盖:
1. RenderSource 默认 NullObserver 可安全接 callback;
2. setObserver(real) 正常转发;
3. setObserver(nullptr) 后不再调用旧 observer;
4. detach 后 onTileChanged / onTileError / onTileAction 安全;
5. RenderOrchestrator teardown 有至少一个含 RenderSource 的场景;
6. source callback 与 teardown 边界不访问已销毁 readiness state;
7. STEP-100 locality 相关 focused tests 不回归。
如果现有测试设施不方便直接构造完整 RenderOrchestrator teardown,允许提取最小可测试 teardown helper,但禁止为了测试重构大范围生产结构。
### 6.2 Android Test App
在现有 ParticleCoreLayerValidationActivity / Validation App 增加专门 STEP-101 teardown 场景。
场景必须真实触发 Native Renderer teardown,而不是只 clear field。
至少提供:
```text
T1 RESET_ONCE
T2 RESET_LOOP_20
T3 RESET_WITH_DYNAMIC_GEOJSON
T4 RESET_WITH_DIAGNOSTICS
```
目标:
- recreate/destroy MapView 或等价真实 renderer.reset;
- 每轮等待足够让 source/tile work 进入正常运行;
- T3 同时保持动态 GeoJSON/source mutation;
- T4 在 reset 前调用一次 RendererRuntime diagnostics,模拟 NavSea probe 时序;
- 20 次循环无 SIGABRT / crash / ANR;
- reset 后新 renderer 能继续正常显示 / 获取 diagnostics。
不得通过延迟 reset、禁用 observer、关闭 GeoJSON 来逃避问题。
---
## 7. NavSea 现场等价验收
RDC 在 Codex 完成后独立执行。
Android 设备:
`192.168.200.139:5555`
必须显式 `adb -s`。
最终至少执行:
1. Validation App STEP-101 T1~T4;
2. crash buffer 检查;
3. 20 次 reset/recreate;
4. 安装 V19 进 NavSea 后做真实启动;
5. 保留 NavSea 当前 `key={map-style-generation-...}` 正常 reset 触发条件;
6. 保留/启用一次 RenderSegment diagnostics probe A/B;
7. 连续至少 5 次冷启动,每次观察 >30 秒;
8. 不允许出现:
- `pthread_mutex_lock called on a destroyed mutex`
- SIGABRT RenderThread
- `MapRenderer::resetRenderer` teardown crash。
---
## 8. STEP-100 回归验收
修复 teardown 后必须确认 V18 的核心 locality 没被破坏。
至少复测 T4 R-C-R:
无关 R0:
```text
buildDelta = 0
invalidationDelta = 0
reuseDelta > 0
```
动态 R1 正常 build/invalidation。
禁止通过删除 mutex/readiness/revision 功能来修 crash。
---
## 9. Codex 执行边界
Codex负责:
- 只读检查真实 V18 代码;
- 实现最小 Native 修复;
- 编写/更新非 Jest C++ tests;
- 更新 Android Validation STEP-101 场景;
- 静态检查;
- 生成报告。
Codex不负责:
- NavSea 修改;
- V19 release;
- tag;
- publish;
- 覆盖 V18;
- Jest。
Mac Native 开发/编译原则保持;不要使用 Linux Native 环境。
---
## 10. 完成报告
生成:
`docs/报告/STEP-101_RenderOrchestrator销毁生命周期修复_Codex完成报告_v1.md`
Step 记录:
`docs/50_Step记录/STEP-101_RenderOrchestrator销毁生命周期修复_v1.md`
报告必须写:
- root cause;
- start HEAD;
- 修改文件;
- destructor teardown 顺序;
- observer detach 语义;
- 为什么不依赖成员声明顺序;
- tests added;
- 实际运行 / NOT RUN;
- STEP-100 locality 是否保持;
- 风险;
- 未发布 V19(Codex 阶段)。
Codex阶段不 push,由 RDC 验收后统一 commit/push。
---
## 11. V19 发布门槛
仅当:
- Native compile PASS;
- focused C++ tests PASS;
- Android Validation teardown loop PASS;
- STEP-100 locality regression PASS;
- NavSea V19 真实 5 次冷启动 >30s PASS;
- crash buffer 无 destroyed mutex;
才允许发布:
- RN / Android:`13.4.1-particle.19`
- iOS:`6.28.0-particle.19`
- tag:`maplibre-particle/rn/13.4.1-particle.19`
V18 保持不可变,不覆盖、不删除。
---
## 12. 最终原则
正常:
```text
MapView unmount
Style generation replacement
renderer.reset()
Activity recreate
```
都是合法生命周期。
Particle Native 必须做到:
> Renderer teardown 永远不能因为 observer late callback 访问已销毁 mutex/state 而杀掉宿主 App。

View File

@@ -0,0 +1,510 @@
# STEP-101 RenderOrchestrator teardown 生命周期修复 — Codex 任务 v1
## 0. 任务目的
修复 Particle V18 Android 在正常 Renderer teardown / MapView reset 时发生的 Native SIGABRT。
现场已在 NavSea Android x86_64 设备稳定复现:
```text
Fatal signal 6 (SIGABRT)
thread: RenderThread 11
FORTIFY:
pthread_mutex_lock called on a destroyed mutex
libmaplibre.so
std::__ndk1::mutex::lock()
...
mbgl::android::MapRenderer::resetRenderer()
```
V17 正常,V18 接入后稳定在启动约 9~11 秒触发。
本任务只修 Particle Native 生命周期;禁止在 NavSea 增加绕路、private wrapper、禁止阻止正常 MapView reset。
未经用户明确要求:不加入、不执行 Jest。
---
## 1. 冻结基线
工作树:
`/Users/tei/dev/MapLibreParticleLab-step101`
分支:
`step101-render-orchestrator-teardown`
起点必须是:
`origin/particle/main = d29e84c1a90daeea104b389d28b000c4f017d08d`
即正式 V18 Final HEAD。
V18 已发布,不得覆盖、重发或移动:
- `maplibre-particle/rn/13.4.1-particle.18`
- Android `13.4.1-particle.18`
- iOS `6.28.0-particle.18`
修复通过后由 RDC/ChatGPT 发布新不可变版本 V19。
---
## 2. 已知根因证据
V18 STEP-100 新增:
```cpp
RenderSegmentRevisionBook retainedSegmentRevisions;
mutable std::mutex readinessDiagnosticsMutex;
uint64_t readinessEventSequence;
std::unordered_map<std::string, SourceReadinessEventState> sourceReadinessEvents;
RenderMutationDiagnostics lastReadinessMutation;
```
多个 RenderSource / Tile observer callback 会进入:
```cpp
advanceRetainedSourceRevision(...)
recordReadinessMutation(...)
onTileChanged(...)
onTileAction(...)
buildReadinessDiagnostics(...)
retainedSegmentSourceState(...)
```
并 lock:
```cpp
readinessDiagnosticsMutex
```
V18 当前成员声明中:
```text
renderSources
renderLayers
...
retainedSegmentRevisions
...
readinessDiagnosticsMutex
sourceReadinessEvents
...
threadPool
```
C++ 成员按声明反序析构,因此 readiness state / mutex 会早于 renderSources 销毁。
而 RenderSource 在创建时:
```cpp
renderSource->setObserver(this);
```
observer 指向 RenderOrchestrator。
当前 `RenderOrchestrator::~RenderOrchestrator()` 只:
1. contextLost 时标记 layer;
2. `threadPool.waitForEmpty()`;
没有在 readiness state 仍有效时显式 detach RenderSource observer / 清理 renderSources。
因此 V18 存在明确生命周期危险:
```text
readinessDiagnosticsMutex destroyed
↓
renderSources still destroying/alive
↓
late tile/source callback
↓
RenderOrchestrator::onTile*
↓
lock destroyed mutex
↓
FORTIFY SIGABRT
```
---
## 3. 修复原则
不要只依赖调整成员声明顺序。
本任务应建立显式 teardown contract:
```text
RenderOrchestrator destructor begins
↓
stop / drain deferred scheduler work
↓
detach observer from every RenderSource
↓
destroy/clear renderSources while all orchestrator state is still alive
↓
only then continue member teardown
```
同时 `RenderSource::setObserver(nullptr)` 必须安全恢复到 NullObserver。
目标是即使未来成员声明顺序变化,Source callback 也不能在 owner teardown 后访问 RenderOrchestrator。
---
## 4. 必做实现
### 4.1 RenderSource nullptr observer 语义
当前:
```cpp
void RenderSource::setObserver(RenderSourceObserver* observer_) {
observer = observer_;
}
```
改成与 Style Source / Layer 一致的安全语义:
```cpp
observer = observer_ ? observer_ : &nullObserver;
```
要求:
- constructor 仍默认 NullObserver;
- `setObserver(nullptr)` 是合法 detach;
- onTileChanged / onTileError / onTileAction 不需要加 null 分支;
- 不吞掉正常有 observer 时的回调。
必须加非 Jest C++ 回归测试验证 nullptr detach 后 callback 不 crash、不调用旧 observer。
### 4.2 RenderOrchestrator 显式 teardown
在 `RenderOrchestrator::~RenderOrchestrator()` 中,在 readiness mutex/state 仍确定存活时:
1. 保留现有 contextLost layer handling;
2. 等待已有 deferred work 完成;
3. 对所有 `renderSources` 执行 observer detach;
4. 显式 `renderSources.clear()`;
5. 必要时对 glyph/image observer 也做已有 API 支持的 detach,但不要扩大范围;
6. 不允许 teardown callback 再进入 RenderOrchestrator source callback。
顺序必须结合当前 TaggedScheduler / RenderSource / TilePyramid 的真实生命周期检查,而不是机械照抄上述编号。
如果存在“clear renderSources 本身可能产生新的 scheduler work”,需要设计第二次 drain 或更安全顺序,并在报告解释。
### 4.3 生命周期状态可选保护
如果仅 detach + explicit clear 仍无法证明并发安全,允许增加最小 teardown flag,例如 atomic bool,但只作为二次防线。
禁止用 flag 代替 observer detach。
禁止 catch FORTIFY;destroyed mutex abort 不是 C++ exception。
---
## 5. 不得修改
- STEP-100 Source→Segment locality 语义;
- per-segment revision;
- per-segment readiness;
- mixed Build/Reuse/Fallback;
- Fast Path gate;
- singleContinuousSegment;
- Source::isLoaded;
- Style Z-order;
- provider/network;
- NavSea;
- MapRenderer::resetRenderer 正常生命周期;
- Android Activity 为避免 reset 而加 workaround。
---
## 6. 必做测试
### 6.1 Native 非 Jest
至少覆盖:
1. RenderSource 默认 NullObserver 可安全接 callback;
2. setObserver(real) 正常转发;
3. setObserver(nullptr) 后不再调用旧 observer;
4. detach 后 onTileChanged / onTileError / onTileAction 安全;
5. RenderOrchestrator teardown 有至少一个含 RenderSource 的场景;
6. source callback 与 teardown 边界不访问已销毁 readiness state;
7. STEP-100 locality 相关 focused tests 不回归。
如果现有测试设施不方便直接构造完整 RenderOrchestrator teardown,允许提取最小可测试 teardown helper,但禁止为了测试重构大范围生产结构。
### 6.2 Android Test App
在现有 ParticleCoreLayerValidationActivity / Validation App 增加专门 STEP-101 teardown 场景。
场景必须真实触发 Native Renderer teardown,而不是只 clear field。
至少提供:
```text
T1 RESET_ONCE
T2 RESET_LOOP_20
T3 RESET_WITH_DYNAMIC_GEOJSON
T4 RESET_WITH_DIAGNOSTICS
```
目标:
- recreate/destroy MapView 或等价真实 renderer.reset;
- 每轮等待足够让 source/tile work 进入正常运行;
- T3 同时保持动态 GeoJSON/source mutation;
- T4 在 reset 前调用一次 RendererRuntime diagnostics,模拟 NavSea probe 时序;
- 20 次循环无 SIGABRT / crash / ANR;
- reset 后新 renderer 能继续正常显示 / 获取 diagnostics。
不得通过延迟 reset、禁用 observer、关闭 GeoJSON 来逃避问题。
---
## 7. NavSea 现场等价验收
RDC 在 Codex 完成后独立执行。
Android 设备:
`192.168.200.139:5555`
必须显式 `adb -s`。
最终至少执行:
1. Validation App STEP-101 T1~T4;
2. crash buffer 检查;
3. 20 次 reset/recreate;
4. 安装 V19 进 NavSea 后做真实启动;
5. 保留 NavSea 当前 `key={map-style-generation-...}` 正常 reset 触发条件;
6. 保留/启用一次 RenderSegment diagnostics probe A/B;
7. 连续至少 5 次冷启动,每次观察 >30 秒;
8. 不允许出现:
- `pthread_mutex_lock called on a destroyed mutex`
- SIGABRT RenderThread
- `MapRenderer::resetRenderer` teardown crash。
---
## 8. STEP-100 回归验收
修复 teardown 后必须确认 V18 的核心 locality 没被破坏。
至少复测 T4 R-C-R:
无关 R0:
```text
buildDelta = 0
invalidationDelta = 0
reuseDelta > 0
```
动态 R1 正常 build/invalidation。
禁止通过删除 mutex/readiness/revision 功能来修 crash。
---
## 9. Codex 执行边界
Codex负责:
- 只读检查真实 V18 代码;
- 实现最小 Native 修复;
- 编写/更新非 Jest C++ tests;
- 更新 Android Validation STEP-101 场景;
- 静态检查;
- 生成报告。
Codex不负责:
- NavSea 修改;
- V19 release;
- tag;
- publish;
- 覆盖 V18;
- Jest。
Mac Native 开发/编译原则保持;不要使用 Linux Native 环境。
---
## 10. 完成报告
生成:
`docs/报告/STEP-101_RenderOrchestrator销毁生命周期修复_Codex完成报告_v1.md`
Step 记录:
`docs/50_Step记录/STEP-101_RenderOrchestrator销毁生命周期修复_v1.md`
报告必须写:
- root cause;
- start HEAD;
- 修改文件;
- destructor teardown 顺序;
- observer detach 语义;
- 为什么不依赖成员声明顺序;
- tests added;
- 实际运行 / NOT RUN;
- STEP-100 locality 是否保持;
- 风险;
- 未发布 V19(Codex 阶段)。
Codex阶段不 push,由 RDC 验收后统一 commit/push。
---
## 11. V19 发布门槛
仅当:
- Native compile PASS;
- focused C++ tests PASS;
- Android Validation teardown loop PASS;
- STEP-100 locality regression PASS;
- NavSea V19 真实 5 次冷启动 >30s PASS;
- crash buffer 无 destroyed mutex;
才允许发布:
- RN / Android:`13.4.1-particle.19`
- iOS:`6.28.0-particle.19`
- tag:`maplibre-particle/rn/13.4.1-particle.19`
V18 保持不可变,不覆盖、不删除。
---
## 12. 最终原则
正常:
```text
MapView unmount
Style generation replacement
renderer.reset()
Activity recreate
```
都是合法生命周期。
Particle Native 必须做到:
> Renderer teardown 永远不能因为 observer late callback 访问已销毁 mutex/state 而杀掉宿主 App。
---
# 13. Codex 6-Sol High 架构评审修订(v2,优先级高于前文冲突表述)
只读评审结论:`APPROVE_WITH_CHANGES`。
## 13.1 根因表述修正
当前已经**代码层证实**存在生命周期漏洞:`readinessDiagnosticsMutex` / readiness state 的析构顺序早于 `renderSources`,而 `RenderSource` observer 仍指向 `RenderOrchestrator`;source/tile teardown 可同步或延迟回调进入 `onTileAction/onTileChanged` 并访问 mutex。
该漏洞与现场 `pthread_mutex_lock called on a destroyed mutex` 高度吻合;在没有完整符号化 crash stack 精确到具体 callback 前,不把“唯一现场根因”写成数学意义上的唯一证明。
## 13.2 teardown 必须双 drain
最低安全顺序调整为:
```text
contextLost layer handling
↓
第一次 threadPool.waitForEmpty()
↓
detach RenderSource observers
↓
detach GlyphManager / ImageManager observers
↓
renderSources.clear() // readiness mutex/state 此时仍存活
↓
第二次 threadPool.waitForEmpty()
↓
继续既有 backend/context render-job teardown
↓
成员析构
```
原因:source/tile destruction 本身可能投递新的 tagged cleanup/deferred work,第一次 drain 不能覆盖 clear 过程中产生的新任务。
同时必须审计并在报告说明:`TaggedScheduler::waitForEmpty()` 不等于清空 `runOnRenderThread` 队列;后者依赖现有 backend/context render-job 生命周期。不得声称双 drain 自动证明所有 render-thread job 已清空。
## 13.3 Observer detach 范围
除了所有 `RenderSource`:
- `glyphManager->setObserver(nullptr)`;
- `imageManager->setObserver(nullptr)`;
也应在 owner teardown 时显式 detach(前提是现有 API 的 nullptr 已安全回 NullObserver)。
`RenderSource::setObserver(nullptr)` 必须改为 NullObserver fallback。
不得直接给 `TilePyramid` / `Tile` observer 写 nullptr,除非其 API 本身有同等安全语义;本任务不扩展到这一步。
## 13.4 teardown flag
默认不增加 teardown atomic flag。
若实现审计发现真实并发 callback 不受 renderer thread / tagged scheduler 串行保证约束,必须先报告;atomic flag 只能二次防护,不能替代 observer detach、drain 和可等待取消。
## 13.5 Android Validation 证据升级
T1~T4 每一轮必须能证明发生了**真实 Native renderer reset**:
- 旧 MapView detach / SurfaceView detach;
- `nativeReset()`;
- `MapRenderer::resetRenderer()` 进入/完成;
- 新 renderer style ready;
- 新 renderer diagnostics 可用。
不能把 `setStyle()`、clear field、只读 diagnostics 当成 teardown 测试。
推荐按代次销毁并重建 MapView,或 Activity recreate;T2 至少 20 次已确认 reset。
T3:每轮 reset 前保持动态 GeoJSON/source mutation。
T4:每轮 reset 前调用一次 Native diagnostics,模拟 NavSea 8 秒 probe 的等价条件。
每轮应等待 source/tile 确实进入运行态,而不是只按固定 sleep 机械计数。
## 13.6 测试必须覆盖真实 source teardown
空 `RenderOrchestrator` 析构测试不够。
非 Jest C++ 测试至少需要一个含 RenderSource / tile callback 的 teardown 场景,使 source cancellation / callback 能在销毁边界发生,并验证旧 observer 不再收到回调。
## 13.7 正常 source erase / clearData 审计
必须只读检查 `clearData()` 和正常 source erase/replacement 路径,因为它们也会销毁 RenderSource。
本任务主要修 owner teardown;不得未经证明把 destructor 修复泛化为“所有 source replacement 异步问题均已解决”。若发现相同 observer 生命周期漏洞在正常 erase 路径存在,应做最小同源修复或明确 BLOCK,不得隐藏。
## 13.8 V19 候选验收顺序
V19 发布门槛修正为:
```text
STEP-101 source code PASS
→ 构建未公开的 V19 candidate artifacts
→ Validation App 实机 PASS
→ NavSea 使用 candidate 做 5 次冷启动/teardown 验收
→ 公开发布不可变 V19
→ public readback
→ finalize/tag
```
不得先公开 V19 再做 NavSea 验收。

View File

@@ -0,0 +1,50 @@
# STEP-095 V13 独立严格代码与性能审查 v1
日期:2026-09-25(Asia/Tokyo)。任务:[STEP-095 任务书](../40_任务/STEP-095_V13独立严格代码与性能审查_Codex任务_v1.md)。调查:[独立源码与性能调查](../20_调查/STEP-095_V13独立严格代码与性能调查_v1.md)。报告:[Codex 完成报告](../报告/STEP-095_V13独立严格代码与性能审查_Codex完成报告_v1.md)。
## 1. 合同、环境与基线
只读独立审查 V13,先源码后旧报告。仅新增三份指定中文文档并追加开发记录;不修复源码,不执行构建、测试、codegen、打包、安装、设备或 profiler。按 AGENTS 已读当前基线、项目规范、有效总则及 STEP-088~094 相关任务/设计/记录/报告。
机器 `Mac-mini-tei.lan`,仓库 `/Users/tei/dev/MapLibreParticleLab`,分支 `particle/main`;起始工作树 clean,HEAD 和实际 origin 均 `05878d662308b9a92c20c433c3cd2943d74ae718`。冻结源码 `ee0b89e711bf01335b3aa71d36def785cd97fec3`,功能提交 `016fd1fff6a33cf795c10d4f5c5413d308383677`。RN/Android `13.4.1-particle.13`、iOS `6.28.0-particle.13`;manifest 源码准备提交 `ddf20dec9fe9cd05b77274827b6825829500dd5a` 至冻结点无生产源码变化。冻结点至起始 HEAD 仅有两份文档。
用户在本次同一会话恢复指令中明确指定 `gpt-6-astra high`,与任务要求一致。记录的是用户指定;没有独立的底层模型/effort 启动参数证明,不声称完成模型独立核验。原 285 行调查稿保留,仅校正模型记录、一个源码路径及补充精确桥接引用;不重写调查、不扩展搜索范围。
## 2. 本阶段交付
- 沿 Segment→Runtime→Renderer→GPU、Source→Field Runtime→RenderLayer→GPU 追踪,检查平台桥接、版本锁及 Integration 只读状态。
- 新增 N01~N07:跨 target stencil、Metal depth/stencil 契约、失败 build 标成功、V4 PLAY 接线、JSON 严格性、数值入库语义、重复 attach listener。
- C01~C07 区分源码成本与运行影响:完整快照、Metal wait、dirty 重分配、预算、线性扫描、全网格物化/UI、drawable 与场复制。
- O01 给出旧 cache 计数污染新 topology 的具体链;E01/E02 区分 Host 错配与协调文档历史状态。
- F01~F10 独立复核:6 项 CONFIRMED(F01/02/03/04/07/09)、4 项 QUALIFIED(F05/06/08/10)。不把设计限制、历史 TS 失败或计数错归扩大为新实测事故。
- 给出 V12/V13 成本模型、缺失测试矩阵及后续修复批次;未创建修复任务,未修改 Integration。
## 3. 验证与未完成边界
本轮实际构建、测试、codegen、安装和设备验证全部 NOT RUN。STEP-094 构建/43 tests、旧报告 69 tests 与 TS 结果只列 HISTORY。源码查验不替代运行验证,缺陷触发的实际像素、故障注入、性能/峰值等保留 NEEDS_RUNTIME。
RDC 优先复核:同 source 同 tiles 的 R-R/R-C stencil;Metal attachment/load/clear;build 失败不得 reuse;只设一次时间后 PLAY 自动推进。其后验证完整 topology、invalid/recovery、30/60 FPS、峰值/长稳、销毁恢复。精确既有命令及证据要求见调查第 9 节;本轮未执行其中任何命令。
调查内容完成,产品运行态不判 PASS。外部模型参数、产物独立下载/重建、NavSea 实际接入、Vulkan、正式共享海陆 mask 均保留未核实范围,不作能力承诺。
## 4. Git 收尾
写稿时调查内容完成,Git 交付待提交/推送,整体暂记 PARTIAL;实际结果在本节追加,未完成 push 不预写 PASS。
仅四个允许路径:调查、本文、完成报告、根开发记录末尾追加。先验证引用/证据路径和 `git diff --check`,仅具体路径 staged,主提交 message 为 `docs: audit V13 source and performance for STEP-095`,push `origin particle/main`,不移动 tag、不 force push、不向 upstream push。
提交对象无法在自身正文中预先记录最终 SHA。采用主文档提交/推送后追加已知主提交证据,再提交收尾文档的方式;最后收尾提交 SHA、remote HEAD 和 clean status 在最终回复给出,可用 `git rev-parse HEAD` 与 `git ls-remote origin refs/heads/particle/main` 复核。
### 文档提交前检查
- 原调查仍为285行;新增Step/完成报告与开发记录追加齐备。9处相对文档链接、63处带行号源码引用的路径及行号范围检查通过;对校正引用回读源码,未执行产品测试。
- 开发记录原内容逐字节保留,仅末尾追加;工作树只含四个允许路径,原暂存区为空。`git diff --check` exit 0;新增文档亦检查无行尾空白,staged后再执行cached检查。
### 实际主提交与远端回读(收尾事实)
- 主提交:`ad5f92c48ea353c18548c79d223e9a06888330d3`,message:`docs: audit V13 source and performance for STEP-095`;仅四个允许文档,417行增加,无源码变化。
- 提交前 `git diff --cached --check` exit 0;主提交后 `git diff --check` exit 0。一次提交遇到瞬时index.lock,回读时锁已自行消失,重试成功;没有删锁或执行破坏性Git操作。
- `git push origin particle/main` exit 0,远端从 `05878d662` 前进至 `ad5f92c48`;随后 `git ls-remote origin refs/heads/particle/main` exit 0,返回完整主提交SHA,与本地HEAD及origin tracking一致。
- 主提交推送核对后的 `git status --porcelain=v1` 为空:工作树clean。冻结V13至主提交只含本轮四文档和既有审查/任务两文档,无生产源码、shader、API、依赖、build、manifest或产物变化。
- 调查内容及主交付:**PASS**;V13产品运行态仍 **NOT RUN / 未验收**。无构建、测试、安装或设备操作。
- 本条和完成报告的结果追加由后续scoped收尾提交保存,message:`docs: record STEP-095 delivery verification`。其自身最终SHA不能预写进自身内容;最终回复记录该收尾提交、再次push/remote核对和clean status。此说明不将尚未发生的第二次push冒充已完成。

View File

@@ -0,0 +1,56 @@
# STEP-096 V13 隔离盲审 v1
## 状态与范围
状态:**完成(仅隔离盲审文档交付)**。日期:2026-09-25。
本阶段按[当前任务书](../40_任务/STEP-096_V13隔离盲审_Codex任务_v1.md)独立读取隔离快照源码和当前规范,形成调查、Step 与完成报告。完成不表示 V13 构建、测试、GPU 画面或性能通过。未做代码修复,后续验证全部 NOT RUN。
## 输入与可核实环境
| 项目 | 事实及限制 |
|---|---|
| 唯一工作目录 | `/Users/tei/dev/MapLibreParticleV13Blind096` |
| 机器观察值 | `Mac-mini-tei.lan` |
| 目标导出 SHA | `ee0b89e711bf01335b3aa71d36def785cd97fec3`,来自任务书;未用 Git 对象独立核验 |
| 版本声明 | [manifest](../../release/maplibre-particle/release-manifest.json#L4):revision 13,RN/Android `13.4.1-particle.13`,iOS `6.28.0-particle.13` |
| 产物 sourceCommit 声明 | `ddf20dec9fe9cd05b77274827b6825829500dd5a`,不作为本快照 HEAD |
| 当前 branch / Git HEAD / clean / remote | NOT AVAILABLE;本轮未运行 Git |
| 实际 TUI、screen、模型和 effort 启动记录 | NOT AVAILABLE;未访问目录外配置、会话日志或终端回放补证 |
已读取 AGENTS.md、PARTICLE_BASELINE.md、`docs/00_项目规范/`、总则 README 及所列当前有效总则,包括当前基线与开发路线 v2,以及本任务。当前总则中的旧发布坐标与快照 `.13` 声明分开记录,不重写总则或历史。未打开外部路径、历史调查/Step/完成报告、既有审查材料或开发日志;本轮输出目录只用于写入和检查本轮三份新稿。
## 只读过程
1. 从公开 API 和目录建立 Style/分段、Renderer/gfx、场 ingress/runtime、平台 bridge 与 GPU 数据路径地图。
2. 追踪正常、拒绝、fallback、失效和销毁路径,区分 owner、共享不可变快照与 GPU 资源;对候选寻找反例,不按问题数量补齐。
3. 阅读 C++、GLSL、Metal、Android、iOS、RN、测试源码和配置;核对公开 PLAY 调用方、纹理更新条件、attachment 状态、CRC/容量、线程派发与同步点。
4. 排除颜色投影仅看第一分量、强引用环、关闭缓存同步遗漏、纯重排遗漏等未成立候选;保留粒子独立前置更新门槛的完整证据。
5. 写入三份中文报告;对本轮新稿进行文件存在、相对链接、引用行号和行尾空格的轻量静态检查。未启动构建、测试 runner、设备或 profiler。
## 结果
调查形成 **13 条独立 finding**:P1 四条(B01、B02、B06、B13),P2 九条;无 P0。类型为确定缺陷七条、源码确定成本四条、设计限制两条。这里的“确定”限定源码控制流/数值语义,不代表已发生用户事故;全部运行效果 NEEDS_RUNTIME。
主要正确性证据为跨渲染目标 stencil 记录、Metal depth/stencil clear 请求传递、encoded 播放推进、粒子完整内容身份、8-bit 零值和 Mercator 度量、retained 失败提交。成本和设计限制涉及 Metal 同步等待、资源重复分配、重复物化、UI 同步处理、共享地理 mask 与 store 总预算。每条的条件、精确路径/行号、反例、影响边界、安全修复与验证方案见调查报告。
## 三份交付文件
| 文件 | 内容 |
|---|---|
| [调查报告](../20_调查/STEP-096_V13隔离盲审调查_v1.md) | 架构地图、B01—B13 证据、反例、性能模型、覆盖缺口、验证矩阵、分批建议 |
| [本 Step 记录](STEP-096_V13隔离盲审_v1.md) | 范围、输入、只读过程、结果、未运行验证、后续责任 |
| [Codex 完成报告](../报告/STEP-096_V13隔离盲审_Codex完成报告_v1.md) | 交付状态、发现索引、证据质量、边界与移交方案 |
## 未运行验证与阻塞
- CMake/Ninja/Xcode/Gradle、编译、链接、codegen、打包:**NOT RUN**。
- C++/GL/Metal/Jest 测试、设备安装/启动、GPU capture、性能与内存测量:**NOT RUN**。
- Git 状态、commit、push、远端核对和产物公网回读:**NOT RUN**。
- 外部仓库、上游差异、真实安装环境、历史日志、启动配置:**NOT AVAILABLE**。
这些是任务约束和证据边界,不阻塞文档交付;它们阻止把本报告升级为运行态或发布验收。未确认 Vulkan 全链、全部图层组合及竞态行为,详见调查覆盖缺口。源码未写入;没有 Git diff 或导出对象逐文件比对证据,不声称已核验工作树 clean。
## 下一步与停止
Codex 在三份报告自检后停止,不继续修复、追加开发日志或解盲比较。RDC 随后冻结三份原稿的 SHA-256 与时间,独立移交正式仓库同路径、追加 `粒子开发记录.md`,执行四文件 scoped commit/push 和远端核查;这些动作本轮均未执行。新代码任务优先处理 B01/B02/B06/B13,并补 B12 失败恢复;其余数值/域语义和性能/容量工作按调查报告分批推进。验证场景和已有 runner 的定向执行命令已写入调查报告,交由后续 RDC 阶段执行。

View File

@@ -0,0 +1,81 @@
# STEP-097 V13 三轮审查问题合并修复 v1
状态:进行中。目标:按阶段修复三轮审查的当前源码问题,保护 C-001 与既有公开协议。基线:Mac-mini-tei.lan,`/Users/tei/dev/MapLibreParticleLab`,`particle/main`,起始 `720d309364628f85049cf9969d71c71e8dfacce9`,起始工作树 clean,子模块无偏离。当前 `.13` 发布 tag 指向 `ee0b89e71`;不修改发布坐标。
| 检查点 | 状态 | 实际事实、阻塞和下一步 |
| --- | --- | --- |
| 阶段 0 基线与反证 | 完成(静态) | 读取三轮审查、STEP-094~096 交付及 C-001/RFC-001 本地副本。当前 HEAD 比任务书基线只新增任务文档;远端 particle/main 经 `git ls-remote` 核对一致。建立 24 项矩阵。Integration 远端同步状态待 RDC 复核。 |
| A1 Renderer 正确性 | Android/Core 检查点完成,iOS runtime 用户豁免 | `e67e4961b` 记录 01/02/03/19 的部分代码;19 有 GTest。01/02/03 缺少任务要求的专用可执行像素/附件/故障注入测试入口;且未进行 GL/Metal 编译。故 A1 不判完成,严格停止于此,不进入 A2。 |
| A2 Field/粒子正确性 | Android/Core 验证完成;iOS runtime 用户豁免 | 04–07 在当前 GPT-6 Luna High 可见会话实现,RDC 独立验证完成;详见本记录末尾补记及 `docs/报告/STEP-097_A2_阶段验证记录_v3.md`。A2 达到 Android/Core 阶段门槛,不能据此关闭整个 STEP-097。 |
| B 输入/生命周期 | 计划 | 尚未开始。 |
| C 安全性能 | 计划 | 尚未开始;必须先完成 A1 正确性。 |
| D 交付 | 部分完成 | 已写 24 项矩阵、集中验证清单、Step、本轮完成报告和开发记录。所有构建、测试、运行、发布均 NOT RUN;后续 RDC 独立审查。 |
允许范围与禁止范围按任务书。未完成项目不勾选。RDC 后续执行构建及运行;本轮不运行构建或 Jest。
## 2026-09-25 RDC/FZG1 A1 APK 实际验证补记
- 用户授权在 FZG1 而非 Win11 虚拟机测试。Mac RDC 以 HEAD `91c2491b0` 的 clean 基线执行标准 OpenGL x86_64 Validation APK 脚本:Gradle `BUILD SUCCESSFUL in 15m 59s`,APK 60,295,184 bytes,SHA-256 `8677197c5f83a22747cb51f0c261edffc5db4678fc25b3f366474f6ecb94dc99`。
- 经 `adb -s 192.168.200.139:5555` 在 Panasonic FZG1-2 覆盖安装 `Success`;设备内安装 APK SHA-256 与产物一致。保存原包备份、20 张截图/UI XML、ADB 操作日志与实时 Logcat;设备实际验证见 `docs/报告/STEP-097_A1_FZG1测试APK与实机验证报告_v1.md`。
- R-C-R、R-C、C-R、C-only、single continuous、缓存 OFF/ON、Camera、缺失 START/END 后恢复、TOP_REORDER、跨连续区与 fail-closed 均已做**既有场景冒烟**,未发现本次测试窗口内 FATAL/SIGSEGV/SIGABRT。
- B/D 场景 top 不可见而诊断仍 topPresent=true 的现象已记录为待复核,不凭此断言确定像素错误;01 同 tile fixture、02 Metal、03 render exception 注入、19 GTest 仍未完成。因此 **A1 保持阻塞/PARTIAL,A2/B/C 未开始**。此补记更新了之前 Codex 报告中“全部测试 NOT RUN”的后续事实,不追溯修改旧报告。
## 2026-09-26 RDC 第 19 项 GTest 实跑补记
- Mac 同一 `particle/main` HEAD `0d13552bed84f427dd43cae9856391bd26622179` 将 `build-step088-opengl/mbgl-test-runner` 增量编译/链接完成:95/95 steps,exit 0,261.74 秒;构建警告记录于原始日志,不影响测试退出状态。
- 定向 `RenderSegmentDiagnostics.OldCacheReleaseCannotChargeReplacementAtSameIndex`:**1/1 PASS、exit 0**;相关 `RenderSegmentDiagnostics.*`:**5/5 PASS、exit 0**。测试证明旧 revision 释放不污染新 topology 相同 index 的当前段计数。详细报告:`docs/报告/STEP-097_A1_第19项GTest运行验证报告_v1.md`。
- 19 的**已存在 GTest 实测门槛通过**,但不等于 GPU 内存无泄漏或整项资源实机验收;01/02/03 专用测试入口仍缺,A1 依然 PARTIAL,不进入 A2/B/C。本补记覆盖此前 Codex 与 FZG1 报告中的历史 `GTest NOT RUN` 记录,不改写旧报告。
## 2026-09-26 FZG1 第 19 项设备辅助验收
- Mac RDC 确认 APK 源码 `91c2491b0` 到当前 `237a3e660` 只改变文档,复用原 OpenGL x86_64 APK;在 FZG1-2(ADB `192.168.200.139:5555`)执行 `install -r` 得 `Success`,设备内 APK SHA-256 `8677197c5f83a22747cb51f0c261edffc5db4678fc25b3f366474f6ecb94dc99` 与源文件相同。
- 两轮 A/B/C/D/E/A 拓扑循环及 TOP_REORDER、CROSS_CONTINUOUS、CACHE OFF/ON、缺失 START 后恢复完成:20 张 1920×1200 截图与 20 份 UI XML;18 个有效检查点 `valid=true/fallback=false`、预期无效检查点 fail-closed。B/T `textureCreate-resourceRelease` 在现有诊断的 19 个场景均为 0 或 1;实时日志无 FATAL/SIGSEGV/SIGABRT。
- 设备辅助冒烟 **PASS**,前述 19 GTest **1/1 PASS**;没有 on-device GTest、逐 revision GPU 内部观测或显存泄漏证明。B/D `topPresent=true` 与视觉不一致的既有疑点仍待单独核查;01/02/03 专项测试未闭合,A1 整体继续 PARTIAL。报告:`docs/报告/STEP-097_A1_第19项FZG1运行验证报告_v1.md`。
## 2026-09-26 Codex 继续 A1 测试入口
- 新增 Android 同源同 tile 的 R-R/R-C-R/C-R-R 本地 fixture 与诊断菜单;新增 Metal 附件/clear GTest 源码;新增 LayerGroup 正反范围异常、observer 与 cache 失败后重试 GTest 源码。生产 Metal clear 逻辑抽成仅内部调用的辅助函数,使测试检查同一路径,不调整公开协议。
- 本轮只做静态检查;构建、GTest、Jest、设备像素、Metal validation 均 **NOT RUN**。01/02/03 仍需 RDC 编译与设备验证;A1 保持 PARTIAL,A2/B/C 未启动。第 19 项原有 RDC 1/1 PASS 不受影响。
## 2026-09-26 A1 收口,允许进入 A2
- A1 新 Android APK 源码 `8c006ac83`,构建/安装/设备内 SHA-256 复核成功;FZG1 18 个同源同 tile RR/RCR/CRR 检查点、中心/区域外像素采样均一致,脚本 exit 0,实时日志无崩溃。03 原生异常恢复 GTest 2/2 PASS;19 已有 GTest 1/1 和诊断组 5/5 PASS。
- Metal host 可编译,但无 Metal device,两项测试均 SKIP;用户明确不做 iOS runtime,本轮验收按用户决定接受,不能记为 iOS 实测 PASS。B/D 的 topPresent 历史疑点保留;详见 `docs/报告/STEP-097_A1_Android专项验收报告_v1.md`。
- A1 Android/Core 阶段完成;下一步严格 A2→B→C,每阶段保留独立 RDC 构建及验证。STEP-098 未启动、未提交;`.13` 发布冻结不动。
## 2026-09-26 A2 RDC 中途验证,尚未进入 B
- Codex GPT-6-Sol medium 在 A2 四项范围内写入生产/测试源码但触及当期使用额度,未完成最终审查/提交。RDC OpenGL 原生编译 exit 0、16/16 定向 GTest PASS;Shader generator 双次 exit 0/幂等;Metal host 源码编译 exit 0、同样 16/16 Core GTest PASS,**没有 iOS runtime**。
- A2 仍 PARTIAL;完整差异和构建/测试日志位于 `artifacts/maplibre-particle/step097-a2-rdc/`,中文事实报告:`docs/报告/STEP-097_A2_阶段验证记录_v1.md`。B/C 均未启动;用户未跟踪 STEP-098 与已发布 `.13` 未动。已安排 Codex 额度恢复后严格串行继续。
## 2026-09-26 A2 Core/Android 最终验证收口(B/C 未启动)
- Codex 配置:GPT-6 Luna High,可见会话。仓库 `/Users/tei/dev/MapLibreParticleLab`,`particle/main`,验证基线 HEAD `5bb1a0e6353c5dfe8db8a61bbeba9cef447280ef`;A2 源码保持 dirty、未提交。Integration `main` 已有报告基线 `318f8ccf911ac3136c3086a1d14e61f83e2d4bce`,C-001 未改。
- 04–07 完成:唯一 Core forecast clock 加 synchronous notification reentry guard;严格 GPU endpoint identity 与 mix-only immutable snapshot reuse;内部 RGBA8 signed zero/byte 解码;Mercator U/V 同纬度度量。旧 `sameFieldGpuContent` 的语义未改变,修正了与其 version-agnostic payload 等价定义相冲突的旧测试断言。新增 source 回归覆盖 50 次 mix ticks 不重物化、endpoint/revision 改变触发 materialize、clear fail-closed 与重入单 tick。
- RDC 独立结果:原 58/58 完整回归 PASS;当前 broad filter OpenGL 121/121 与 Metal host 121/121 PASS;三项 A2 定向测试两平台各 3/3 PASS;shader generator 连跑两次幂等。Metal host 仅 Mac Core runner,不是 iOS 设备验证。
- 标准 Android OpenGL Debug x86_64 APK `61,753,803` bytes,SHA-256 `27bf4a6cc191bd86c4d1c10398478d81c2999edd61fe7242e40e5188516b0ca5`;已安装到 FZG1-2(`192.168.200.139:5555`),设备 APK hash 相同。V4 Golden Wind test-only fixture 覆盖 load、seek 25/75、speed 2x/1x、PLAY 30 秒、responsive UI、PAUSE、stable pause、replay、clear、reload、再次 PLAY/PAUSE;runner exit 0、17 PNG、24 steps、ANR=0。15 秒截图可见粒子。
- Mix-only 计数按两个稳定资源 epoch 记录:clear/reload 前 upload/reset=`6/2` 稳定;clear/reload 后合法增至 `10/4`,reload 后 playback 稳定。不能说跨 clear/reload 零上传或零 reset。APK source diff SHA-256 与 `mix-reuse-build-source.diff` 一致:`18927a5f30a88f5c1c3a00e0017473dc33b04151c227e4baf52250dc4ad2e6a2`。
- 历史异常保留:12:43 首次 FZG1 ANR 根因为 source notification 同步重入 forecast tick,当前 `forecastTickInProgress` 与 `RasterArrayV4ClockSourceNotificationReentryDoesNotAdvanceTwice` 覆盖此项;后续有过无 ANR 但主线程高负载、PLAY/TIME 无响应的失败试跑,栈指向 forecast tick 内重复投影物化。最终 mix reuse APK 通过本次闭环。历史 debuggerd、失败试跑和最终日志均保留在 `artifacts/maplibre-particle/step097-a2-final-validation/`。
- A1 与 A2 的 Android/Core 阶段检查完成;iOS runtime 明确按用户决定记 `USER_ACCEPTED_WITHOUT_IOS_RUNTIME_TEST`,不写成 iOS PASS。STEP-097 整体仍 PARTIAL:B/C 未开始,Jest 未运行,STEP-098 未运行,`.13` 未发布/覆盖。Git commit/push 留给 RDC 独立 QA 后执行。
### 2026-09-26 A2 源码独立 scoped commit 核验
- A2 的 26 个生产源码、shader、TestApp-only V4 Golden fixture 和定向测试文件已固定为 `f49ffa68a17e0a505199bdbeccf07f8543f3a05f` 并推送,`origin/particle/main` 回读一致;没有 stage 用户未跟踪 STEP-098。完整代码/设备证据仍见 `docs/报告/STEP-097_A2_阶段验证记录_v3.md`,不因 Git 提交改变原 APK 的 `git_dirty=true` 来源事实。STEP-097 整体 PARTIAL,B/C 待用户指令。
## 2026-09-26 B 阶段源码、Core 复核与 Native 版本阻塞(未收口)
- Codex 两次自动任务(Android 第 10 项销毁监听、旧数值夹具纠正)均使用 `codex exec --json` + GPT-6 Luna High,均有 `turn.completed` 和 exit 0,JSONL/status.json 已持久化于 `artifacts/maplibre-particle/step097-b10-codex/` 与 `step097-b-fixture-codex/`。
- 08 N05:长度感知 JSON/NUL/重复键/非法配置清除与恢复测试入口已实施;09 N06:提交前有限数值验证和旧有效修订保护已实施;10 N07:View owner、世代隔离与新 Map 销毁 observer 测试源码已实施;20 E01:Host package/lock 均指向正式 .13,正式 tarball SHA-256 与 SRI 已核对。
- RDC Mac Core 构建 PASS,四项 B 定向 GTest 4/4 PASS;broad 初次 132/134,真实原因是旧四单元 16-bit 测试借用了 4-byte UInt8 storage;仅修为合法 8-byte little-endian fixture 后复编与扩大回归 **134/134 PASS**,未放宽生产校验。
- 硬阻塞:B10 新 `MapLibreMap.addOnDestroyListener()` 不存在于已发布 .13 source commit `ddf20dec9`,现有 RN Gradle 默认正式 .13 AAR 无法提供该符号;本地新源码 Android 测试不能替代正式 RN consumer 精确配对构建。禁止借此修改 .13 发行产物。
- Linux Android 采用远端 `b1239ac3b` 新独立 worktree,并从 Mac 传入 B scoped patch/测试,原 Linux dirty 工作区未动。首次 Gradle 仅因工作树缺少 ANDROID_HOME 失败,按既有 SDK 路径重试;结果见 B 专项报告的后续记录。
- 当前 B = PARTIAL / BLOCKED_NATIVE_AAR_COMPATIBILITY,尚未 scoped commit/push;C 与 STEP-098 均不启动,iOS runtime 保持用户豁免。详见 `docs/报告/STEP-097_B_独立验收与版本阻塞报告_v1.md`。
### 2026-09-26 B 阶段 Mac RDC 最新核验(仍阻塞)
- B10 `codex exec --json` 终态 `turn.completed`、exit 0,完整 JSONL/status.json 持久化;旧 16-bit fixture 纠正任务亦同。RDC 复编后 B 定向 6/6、扩展 139/139 PASS。
- Android `MapLibreMapDestroyListenerTest` 2/2 PASS,Native Java 编译 exit 0;隔离 Kotlin owner 4/4 PASS,均非默认 RN consumer 集成验收。日志和 XML 见 `artifacts/maplibre-particle/step097-b-rdc/`。
- .13 release source 无新 `MapLibreMap.addOnDestroyListener()`,新 RN Kotlin 源码默认依赖 .13 AAR;B 保持 `PARTIAL / BLOCKED_NATIVE_AAR_COMPATIBILITY`。Linux 隔离 Gradle 仍待最终状态;未启动 C、STEP-098,也未修改 .13 发布资产。
### 2026-09-26 B scoped Git 阻塞检查点
- Mac RDC 在独立 worktree 对 21 个 B 文件逐项 SHA-256 一致检查后,提交并推送 `step097-b-blocked-20260926`:`e463ba04ce956a54eed02b77f981eeeedd9a5534`,远端回读一致;`particle/main` 仍为 `b1239ac3b`,原 dirty/untracked(含 STEP-098)未动。
- 该提交只冻结 PARTIAL/BLOCKED 的源码及证据,不是 B PASS;C 与 STEP-098 仍禁止启动,.13 发布资产不变。

View File

@@ -0,0 +1,137 @@
# STEP-098 QBIN V4 单帧与八帧滚动缓存
状态:**进行中(Codex 源码交付;等待 RDC 独立构建/定向测试及平台验收)**
日期:2026-09-26(JST)
## 目标与基线
- 目标:单帧真实时刻首显、合法同批双帧插值、每 `fieldKey` 最多八帧有序滚动,保留现有 RN API 两步调用。
- 仓库:Mac `/Users/tei/dev/MapLibreParticleLab`;branch `particle/main`;开工 HEAD `b1239ac3b1554943200e6fbf14321459ccc86527`,结束时 HEAD 未变。
- Integration:`main` / `origin/main`=`1dd576d3304e267e53ed64d31f9711d2350ce10a`;RFC-004、ADR-001、C-002、COORD-005 均已读。旧 RFC-003 阻塞由本轮明确的新接受契约解除;旧阻塞报告保留不改。
- 当前 Android Native/RN 坐标仍为 `13.4.1-particle.13`;本次未改版本或发布资产。iOS 基础版本 `6.28.0`。
- 工作树开工已有 STEP-097 B 修改/未跟踪文件、STEP-098 原任务与旧阻塞报告;保留原样。`git submodule status` 全部与记录 SHA 一致。
## 本轮完成
- `FieldSeries` 在 exact validTime 返回真实单帧窗口;非精确时间只解析本地相邻 pair。
- 按 key 区分共存 schema 与插值批次身份。新批次可进入滚动序列并按精确时间单帧显示;插值要求 fieldID、reference time、resolved profile ID/version 与 grid/plane schema 全部相同。Ingress 保存已验证 profile 身份。
- 满额候选集合先按新帧时间选择远端点,等距删除原最早端;通过 schema 邻接校验后原子 swap。拒绝帧不先淘汰。八帧以上正常插入使用既有成功结果。
- 新增 decoded raw-plane + validity 字节计数;单帧 T0/T1 共享一份 materialized Snapshot,WeatherColor/Particle 首次 GPU 安装共享相同 endpoint 资源。
- 扩展 Runtime、Binding、Ingress、RasterArraySource 定向测试代码,覆盖单帧/双帧/滚动/跨批次/原子拒绝/内存计数/重复清理场景;详见设计和完成报告矩阵。
- RN TypeScript、Android Kotlin/JNI、iOS bridge 静态核对:旧 API 名称、参数、返回类型和两步顺序不变;未修改 bridge。
- 新增中文设计文档与本报告;开发记录仅追加本轮事实。
## 修改范围
STEP-098 本轮源代码与测试:
- `include/mbgl/particle/field/field_runtime.hpp`
- `src/mbgl/particle/field/field_runtime.cpp`
- `src/mbgl/particle/field/field_projection_materialization.cpp`
- `src/mbgl/particle/ingress/qbin_v2_decoder.cpp`
- `src/mbgl/renderer/layers/render_particle_validation_layer.cpp`
- `src/mbgl/renderer/layers/render_weather_color_layer.cpp`
- `test/particle/field_binding.test.cpp`
- `test/particle/field_runtime.test.cpp`
- `test/particle/qbin_v2_decoder.test.cpp`
- `test/source/raster_array_v4_host_api.test.cpp`
文档/记录:
- `docs/30_设计/STEP-098_QBIN_V4单帧与八帧滚动缓存设计_v1.md`
- `docs/50_Step记录/STEP-098_QBIN_V4单帧与八帧滚动缓存_v1.md`
- `docs/报告/STEP-098_QBIN_V4单帧与八帧滚动缓存_Codex完成报告_v1.md`
- 根目录 `粒子开发记录.md`(追加)
未修改用户原任务书、STEP-097 的矩阵/记录/清单/桥接文件、NavSea/Server、QBIN 格式、版本锁、tag、manifest、制品。`test/particle/field_runtime.test.cpp` 与 `test/source/raster_array_v4_host_api.test.cpp` 开工前已有 B 阶段 dirty 内容;原 B 的 fixture 修正保留,本轮只叠加 STEP-098 测试,并为 Source 测试 QBIN helper 增加 forecast reference 参数。
## API / ABI 与限制
- RN 和平台 bridge 公开方法签名/枚举名称:未变。
- `DecodedFieldFrame` 增加了解析 profile 身份字段,C++ 结构二进制布局变化;Native/RN 配套版本必须同源码重编。没有用 `.13` 旧 AAR 验证该结构。
- `decodedByteSize()` 只计原始 plane storage 与 validity bitset,不含 profile 元数据、CPU 物化数组、图像、GPU texture、粒子/trail。没有执行 CPU/GPU 峰值或长时间泄漏测量。
- 单帧 Particle 的实际 GPU 运动、GL/Metal texture 复用、context loss 和设备视觉尚待平台实测。
## RDC 分阶段执行命令(本轮未运行)
### B:Shared Core Store / Ingress / Binding
```bash
ninja -C build-step088-opengl mbgl-test-runner
build-step088-opengl/mbgl-test-runner --gtest_filter='ParticleFieldRuntime.*:ParticleFieldBinding.*:ParticleEncodedFieldIngress.*'
```
### C:Projection / Source / Renderer 与 OpenGL、Metal host 回归
```bash
ninja -C build-step088-opengl mbgl-test-runner
build-step088-opengl/mbgl-test-runner --gtest_filter='Particle*.*:Source.RasterArray*'
ninja -C build-step097-metal-host mbgl-test-runner
build-step097-metal-host/mbgl-test-runner --gtest_filter='Particle*.*:Source.RasterArray*'
```
先完成 B 并检查结果,再执行 C;Metal host GTest 不等于 iOS 设备 Metal 通过。未通过/退出未知时停在原阶段。Codex 本轮只运行 `git diff --check`,未运行上述命令、Android Gradle、iOS/Xcode、Jest、设备或发布。
### D:RN / 平台验证前置条件
RN 端确认现有顺序为成功 await `submitEncodedField(...)` 后才 await `setEncodedForecastTime(...)`;失败提交不得调用 setTime。T17 使用 V15 同源码 Native/RN 配套构建和 Host 手工验证,不能用现有 `.13` AAR 替代。
Windows 11 用户手工验收(待相同 V15 Test Host APK、RN 包与 Native AAR 可用后):
```powershell
cd D:\React\MapLibreParticleRNTestHost-V4
npm install <RDC提供的同版本V15 RN package tgz路径>
npx expo prebuild --platform android
cd android
$env:ANDROID_HOME='C:\Users\taibe\AppData\Local\Android\Sdk'
$env:ANDROID_SDK_ROOT=$env:ANDROID_HOME
.\gradlew.bat :app:assembleDebug
adb -s 192.168.200.139 install -r <同源码V15 Test Host APK路径>
adb -s 192.168.200.139 shell am force-stop org.maplibre.particle.rntesthost
adb -s 192.168.200.139 shell am start -n org.maplibre.particle.rntesthost/.MainActivity
```
在 Host 可复用的本地 QBIN 场景中一次完成:首帧单帧显示/粒子持续运动、标量无粒子、第二帧中点 mix、乱序 8 帧、未来/过去/内部插入与淘汰、跨 reference/profile 日切首帧及回退、duplicate/revision/stale/损坏帧、wind/wave key 隔离、当前 endpoint 淘汰清场、100+ 次滚动及 clear。逐项记录真实请求时间与实际显示 validTime、T0/T1/mix、提交结果、异常日志、进程内存趋势。ADB 全部显式指定 `192.168.200.139`;不得将 Test Host 冒充 NavSea 验收。
iOS 由 Mac 用户使用同一冻结源码和配套产物手工构建;在 Metal 设备验证单帧首显、单帧向量运动、双帧 mix、跨批次 unavailable、双向淘汰、key 隔离、坏帧不覆盖好投影、clear/context 恢复及长时间内存趋势。Metal host runner 结果不能替代该设备证据。
## T01–T17 状态
测试覆盖映射见设计文档 §6。以下“代码覆盖”仅说明测试源码中存在场景;**实际运行均为 NOT RUN**。
| 项 | 代码覆盖状态 | 实际运行 |
| --- | --- | --- |
| T01 | 已覆盖 | NOT RUN |
| T02 | 已覆盖 | NOT RUN |
| T03 | 已覆盖 | NOT RUN |
| T04 | 已覆盖 | NOT RUN |
| T05 | 已覆盖 | NOT RUN |
| T06 | 已覆盖 | NOT RUN |
| T07 | 已覆盖 | NOT RUN |
| T08 | 部分覆盖;现有 revision/stale 入口 + 满额分支,尚无单一 T08 全覆盖测试 | NOT RUN |
| T09 | 部分覆盖;profile/decode/numeric/grid 拒绝及原子性,错误向量仍由既有 binding 测试覆盖 | NOT RUN |
| T10 | 部分覆盖;pair mix 0.25/0.5/0.99 已覆盖,精确 T1 以单帧表示 | NOT RUN |
| T11 | 已覆盖 | NOT RUN |
| T12 | 已覆盖 | NOT RUN |
| T13 | 已覆盖 | NOT RUN |
| T14 | 已覆盖 | NOT RUN |
| T15 | 部分覆盖;materialization/renderer 源码有覆盖,GPU/context loss 未运行 | NOT RUN |
| T16 | 部分覆盖;120 次 Core Store 双向滚动/clear 及 byte count 已覆盖,Runtime 切换/GPU 泄漏未运行 | NOT RUN |
| T17 | 部分覆盖;bridge 静态检查,自动化成功/失败序列与配套消费构建未运行 | NOT RUN |
## 当前结果与下一步
- 当前结果:**源码已实施,等待 RDC 阶段 B 独立构建和测试;没有 PASS 结论。**
- `git diff --check`:通过(只代表空白/patch 格式检查)。
- 未完成:所有新增 C++ 测试执行、OpenGL/Metal host 构建、Android/RN 同版本配套构建、iOS/Metal 设备验证、窗口拖动/跨天/内存实测、V15 pipeline、commit/push。
- 剩余阻塞:当前仅为 RDC 尚未运行本轮实现;`.13` 不能与新增 C++ Frame layout 配对,需后续同源码新版本构建。
- 下一责任:RDC 按 B→C 顺序执行并反馈真实日志;Android、iOS 设备与 Test Host 验收在后续配套阶段完成。当前停止于 Codex 源码交付,不进入 V15 pipeline。
## 2026-09-27 RDC 独立构建与定向测试补充(覆盖“本轮 NOT RUN”的后续事实)
- Codex `turn.completed` / exit 0 后,RDC 先执行 B;初次 runner 无 ninja PATH、Source 测试访问 private store、两处 fixture Grid 不合法,均已按首个真实错误做定向修复,记录见 `docs/报告/STEP-098_RDC定向验收报告_v1.md`。
- B OpenGL Core/Binding/Ingress 编译 PASS,19/19 GTest PASS。
- C1 OpenGL Host 扩展 `Particle*.*:Source.RasterArray*` 119/119 PASS。
- C2 Metal Host 编译 PASS,同 filter 119/119 PASS。
- 源级测试不等于 Android RN consumer/iOS 真机 GPU、内存/交互通过;V15 Pipeline 此时尚未启动。下一关必须为同源码发布构建和分端实测。

View File

@@ -0,0 +1,175 @@
# STEP-099 RenderSource 就绪与 RenderSegment 失效诊断
状态:**CLOSED / PASS**
日期:2026-10-06(JST)
## 基线
- 仓库:`/Users/tei/dev/MapLibreParticleLab-step099`
- 分支:`step099-render-source-diagnostics`
- frozen release:`maplibre-particle/rn/13.4.1-particle.17`
- frozen release commit:`e8b451fe70054107773e82484c80e90df3889147`
- implementation commit:`8a5214d92dd2f8899db76d2194da42999fa0c87d`
- Integration/NavSea:未修改。
## 本 Step 完成内容
- Renderer readiness snapshot additive 暴露 style/image manager/render orchestrator/sprite readiness、fading、global content revision、per-source 状态和最近 mutation。
- per-source diagnostics 暴露 sourceId/type/enabled/isLoaded;tile-backed source 提供 tileCount/incomplete/loaded/fading。
- RenderSegment diagnostics 只读推导 `dependentSourceIds` / `unknownDependencies`;source-less layer 不制造 source dependency,共享 source 可出现在多个 segment。
- mutation sequence/source/tile action/reason 只消费既有 renderer 事件;正式 revision 增长逻辑不变。
- 正式 loaded 判定保持:
`renderTreeParameters->loaded = updateParameters->styleLoaded && isLoaded();`
- diagnostics snapshot 不进入正式 loaded/cache/fast path/segment execution。
- RN 仅增加 diagnostics 类型字段;没有 NavSea 私有 API,没有改变业务 API。
- Android Test App 提供 T1 STATIC、T2 GEOJSON_1HZ、T3 PERMANENT_UNREADY、T4 R-C-R。
## Native 验证
Metal host `RenderSegmentDiagnostics.*`:
- 6/6 PASS;
- 新增 `ReadinessSnapshotMapsSourcesToSegmentsWithoutSideEffects` PASS;
- 覆盖 source readiness、tile stats、source→segment、shared source、source-less 与重复 query 无副作用。
最终源码再次尝试 Metal configure 时当前 Mac 缺 `libuv` pkg-config,因此第二次 host rerun 未进入 build;不虚报 PASS。最终代码已经被 Android 全量 native build 成功编译并由 T1~T4 运行覆盖。
Vulkan:因当前 Mac 缺 `libuv`,NOT RUN tests。
Jest:NOT RUN,用户未要求。
## Android 构建
首轮 Android NDK 暴露:
```text
error: missing field 'tileCount' initializer
[-Werror,-Wmissing-designated-field-initializers]
```
只补全 `RenderSourceReadinessStats` 默认空字段显式初始化。
随后 native build 通过,但 strip 阶段因 Mac 磁盘仅剩约 2 GiB:
```text
No space left on device
```
仅清理可再生 Gradle 9.5.1 cache / 失败中间产物后恢复约 12 GiB,再次增量构建:
- `BUILD SUCCESSFUL in 4m 54s`
- 86 actionable tasks:7 executed / 79 up-to-date
- OpenGL Debug / x86_64
- APK:61,923,024 bytes
- libmaplibre.so:39,602,856 bytes
- SHA-256:
`5de73f0358b835a78d3ae7e2ac22f4efdb2a66775b8c63654a8bb7313957d90a`
设备:
- `192.168.200.139:5555`
- FZG1-2
- Android 13 / API 33 / x86_64
## T1~T4 结果
### T1 STATIC
- 1 个启动 false window;
- avg/max = 204 ms;
- 随后 global loaded 稳定为 true;
- retained cache 可持续 reuse;
- fast path 可进入 active。
### T2 GEOJSON_1HZ
- 两个 GeoJSON source 交错更新,总 update 122 次,约 61 次/源;
- 10 个 loaded=false window;
- avg 203 ms;
- max 205 ms;
- open 0;
- 约 60 s 中 false 总时长约 2.03 s,约 3.4%。
结论:存在 readiness thrash,但不是“几乎永远 false”。
### T3 PERMANENT_UNREADY
`step099-unready-raster-array`:
- 无 layer;
- enabled=false;
- isLoaded=false;
- 其他主要 source loaded。
结果:
- global renderOrchestratorLoaded=false 持续;
- 观察结束 open false window = 21,459 ms;
- retained cache 持续 style-not-loaded fallback。
结论:单个与 segment 无关的 permanently unready source 足以拉低 V17 全局 loaded。
### T4 R-C-R
- R0 deps=[step099-static]
- C deps=[particle-fixture]
- R1 deps=[org.maplibre.annotations, step099-dynamic, step099-static]
- dynamic update:61 次
结果:
- loaded false windows:4
- avg 203 ms
- max 207 ms
- contentRevision:1760 → 2540
- diagnostics 对 dynamic source 正确给出 affected=[2]
- R0 不依赖 dynamic source,但:
- build 1 → 121
- invalidation 20 → 200
结论:dependency diagnostics 已能表达 source-local 影响,但 V17 正式 content revision/cache invalidation 仍为 global。
## Q1~Q7
1. Q1:**YES**。permanently unloaded source 会造成全局 style-not-loaded。
2. Q2:**YES**。1 Hz GeoJSON 会产生约 200 ms readiness false window;T2 avg 203 / max 205 ms。
3. Q3:**NO(本次观测)**。双 1 Hz source 并未让 global loaded 几乎始终 false。
4. Q4:**YES**。GeoJSON tile mutation 推进 global content revision,并使 retained cache 重建/失效。
5. Q5:**YES**。不引用动态 GeoJSON 的 R0 仍受 global revision 影响;源码还确认 `retainedRangeIsStable` 全局检查所有 source fading。
6. Q6:fast path 仍有 global update signature(sources/layers/images 等)、global content revision、global fade、pendingChanges、placement/light/non-particle transition/repaint、annotation mutation、retained revision/cache/topology/boundary,以及 singleContinuous gate。
7. Q7 blocker:
- runtime confirmed:`PERMANENT_SOURCE_UNLOADED`、`DYNAMIC_SOURCE_READINESS_THRASH`、`GLOBAL_CONTENT_REVISION`
- source confirmed:`GLOBAL_FADE`、`GLOBAL_UPDATE_SIGNATURE`
- OTHER:pending/placement/light/repaint/annotation/topology/boundary/singleContinuous。
## 证据
忽略目录:
`artifacts/maplibre-particle/android-validation/`
- `build-info.txt`
- `build.log`
- `MapLibreAndroidTestApp-validation-opengl-debug-x86_64.apk`
- `step099-logcat.txt`
- `step099-device-info.txt`
- `step099-metal-gtest.log`
STEP-099 log SHA-256:
`0f15d75dde77e7dd2f4d98c46a4c1501a628205e8aae62130b0cb96db6b8482f`
## 边界结论
本 Step 未修改:
- retained cache 算法;
- fast path gate;
- singleContinuous / multi-continuous;
- segment-local invalidation 正式语义;
- RenderSegment execution;
- NavSea;
- Source isLoaded;
- RN 业务 API。
下一阶段应拆分处理 global source readiness、source/segment-local content revision/invalidation,再独立处理 fast-path global gates。

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,54 @@
# STEP-101 RenderOrchestrator 销毁生命周期修复记录 v1
状态:**CLOSED / PASS(Native + Android runtime)**
日期:2026-10-07
## 目标与基线
- 工作区:`/Users/tei/dev/MapLibreParticleLab-step101`
- 分支:`step101-render-orchestrator-teardown`
- start HEAD:`d29e84c1a90daeea104b389d28b000c4f017d08d`
- implementation commit:`64cf8f44350eb8ec4b45360db4d3f9505ac4ac0b`(`fix: make render orchestrator teardown safe`),已 push 到 `origin/particle/main` 并回读一致。
- 工作合同:`docs/40_任务/STEP-101_RenderOrchestrator销毁生命周期修复_Codex任务_v2.md`,第 13 节优先于前文冲突项。
- V18 为不可变已发布版本;本阶段未创建、发布或标记 V19。
- 目标:修复 RenderSource/Tile 销毁时 observer 可能回调已析构的 RenderOrchestrator readiness mutex/state 的生命周期漏洞。
V18 成员声明使 readiness mutex/state 早于 `renderSources` 自动析构。Tile 销毁时同步发出 `Cancelled` 回调的路径可达,且与现场 Android destroyed mutex SIGABRT 高度吻合;没有完整符号化栈与修复后等价实机结果,暂不把它写成唯一确认的现场根因。
## 必做项进度
- [x] `RenderSource::setObserver(nullptr)` 恢复 NullObserver,保留正常 observer 转发。
- [x] Orchestrator 析构:保留 contextLost layer handling;第一次 tagged drain;detach RenderSource、GlyphManager、ImageManager observer;在 readiness state 存活时清理 sources;第二次 tagged drain。
- [x] 静态审计独立 render-job 队列:`waitForEmpty()` 不排空该队列;OpenGL context 析构的 `runRenderJobs(true)` 属于后续 backend 清理,测试中也单独调用 `runRenderJobs()`。不能以双 drain 代替 render-job 验收。
- [x] 新增非 Jest C++ 测试源码:默认/真实/detach observer、三种 tile callback、Cancelled、source 清理后第二次 drain、render-job 单独处理和重复 teardown。helper 像生产代码一样注册 Orchestrator observer,并断言 source 销毁前旧 observer 已被替换。
- [x] Android Validation 增加 T1 RESET_ONCE、T2 RESET_LOOP_20、T3 RESET_WITH_DYNAMIC_GEOJSON、T4 RESET_WITH_DIAGNOSTICS;通过 Activity recreate 触发 MapView 销毁/新建,并输出 SurfaceView detach、nativeReset、resetRenderer、style ready 与 diagnostics 日志供逐次配对。
- [x] 只读审计正常 `renderSources.erase()` / `clearData()`:两者在 Orchestrator 存活期间执行;TileCache 析构等待其延迟释放任务结束。本阶段没有扩大其行为修改,运行态异步边界仍待 RDC 验证。
- [x] 静态核对 STEP-100 locality、Fast Path、`singleContinuousSegment`、`Source::isLoaded()` 等代码未被本次 diff 修改。
- [x] Native:Android full native build PASS;focused Android C++ TestRunner 6/6 PASS。Mac host 因 libuv 环境阻塞,未虚报。
- [x] Android Validation APK build PASS;T1~T4 实机 PASS;T2 完整 20 次真实 reset/recreate,crash buffer 为空。
- [x] STEP-100 T4 R-C-R locality 回归 PASS:R0 buildDelta=0、invalidationDelta=0、reuseDelta=+3878。
- [ ] V19 candidate、NavSea 五次冷启动与正式 V19 发布:属于后续发行 Gate,尚未执行。
## 允许与禁止范围
实际变更限于 Orchestrator/RenderSource 生命周期、对应 C++ 测试、Android Validation 场景及 Java/JNI reset 观测日志。未修改 NavSea、provider/network、STEP-100 locality 实现、发布坐标或 V18 产物。未运行 Jest。
## 检查与当前结果
本次验证仅包含源码 diff 静态审计与 `git diff --check`。构建、链接、GTest、Gradle/APK、ADB、设备、NavSea、发布均 **NOT RUN**;源码交付不能记为运行态 PASS。完成报告:`docs/报告/STEP-101_RenderOrchestrator销毁生命周期修复_Codex完成报告_v1.md`。
## 阻塞与下一步
当前没有已观测编译失败;实际编译与场景行为仍未知。由 RDC 按完成报告的命令独立编译、运行 Native 测试、Android T1~T4、STEP-100 locality,并核对每次 `RESET_REQUEST` 对应 SurfaceView detach、`nativeReset`、`resetRenderer` 和新 renderer ready。随后才进入 V19 candidate 与 NavSea 等价验收;全部门槛满足后才可考虑正式发布。
## RDC 最终证据
- Android Validation APK:61,957,678 bytes;`libmaplibre.so` 39,637,512 bytes;SHA-256 `14cbd35ed0bc1366389ddea69925901013e3b209ed3575462421a7920c73c721`。
- T1~T4 teardown:全部 PASS;T2 20 次 reset;动态 GeoJSON / diagnostics 条件均无 crash。
- STEP-100 locality:global revision 62→894;R0 build 1→1、invalidation 19→19、reuse 12→3890;PASS。
- Android C++ focused TestRunner:4 个 `RenderSegmentLocality` + 2 个 `RenderOrchestratorTeardown` 全部 PASS;status=0。
- Mac host GTest:ENV BLOCKED(本机缺可用 libuv pkg-config),不影响 Android full native/device 闭环结论。
- Jest:NOT RUN。
**STEP-101 CLOSED / PASS。** 下一阶段进入 V19 candidate → NavSea 等价冷启动 Gate → 不可变公开发行。

View File

@@ -0,0 +1,73 @@
# V15 iOS WeatherColor 纹理生命周期修复
状态:**进行中(RDC Mac 定向验证 PASS;等待 iPad 人工验收)**
日期:2026-09-27(JST)
## 目标与基线
- 仅修复 V15 WeatherColor 的 Metal 纹理绑定生命周期,不涉及 V12/V13、JavaScript、NavSea API、Android 行为或发行版本/tag。
- 仓库:`/Users/tei/dev/MapLibreParticleLab`;机器:Mac mini、Darwin 24.6.0、x86_64;分支:`particle/main`。
- 开工 HEAD:`cd71640a84901212ce4681e296f027befdff4ee0`;`maplibre-particle/rn/13.4.1-particle.15` peeled commit 与 HEAD 相同;`origin/particle/main` 同步于 HEAD。
- manifest 的 V15 `sourceCommit` 为 `1fdc5df625d405233703136eb2dc12032d932da2`;后续 finalize release 生成的 V15 tag 指向 HEAD `cd71640a84901212ce4681e296f027befdff4ee0`。这两个坐标分别表示 manifest 源码提交与后续 tag HEAD,不构成冲突;本任务相关已跟踪源码在两提交间相同,tag 与发行内容未改。
- 开工时仅有用户提供的 V15 任务书为未跟踪文件;始终保留。现有 submodule 均已初始化,本轮未修改。
- 执行模型/推理档位:GPT-6 Luna High。
## 根因与修改
Metal LayerGroup 在 drawable 的 `upload()` 后才执行 Tweaker。旧实现于 Tweaker 的 `execute()` 才向既有 drawable 调用 `setTexture()`;当 endpoint 纹理已在 WeatherColor `update()` 中更换、wrap 范围不变时,drawable 的上传阶段仍持有旧纹理,随后 draw 才首次绑定新纹理,可能触发 `assert(!textureDirty)`。
本轮修改:
- 在 `update()` 进入资源更新时保存 drawable 原有 T0/T1 实际纹理指针;资源 endpoint 安装完成后比较新旧指针。
- 把 wrap 区间变化或任一纹理 endpoint 指针变化统一作为重建条件;重建在 WeatherColor update 阶段执行,builder 在 drawable 上传前安装正确纹理。
- 移除 WeatherColor Tweaker 的三处 `setTexture()`;Tweaker 只写 UBO 中的 mix、opacity、color stops、scale/bounds 等参数。
- 新增 `WeatherColorTextureLifecycle` 定向测试,覆盖无变化、单端/双端替换、T0/T1 交叉复用、T0=T1 合并/保持、wrap 改变、隐藏期间 endpoint 替换后恢复、空资源首次创建与释放。
动态 mix/opacity/stops 不参与 endpoint 比较,因此这些值单独变化不会触发 drawable 重建或纹理上传。Resources 清空后下次更新会重新创建纹理和 drawables;隐藏期间不更新时,恢复后的 endpoint 比较仍在下一次 update、drawable upload 之前发生。
## 修改文件
- `include/mbgl/renderer/layers/weather_color_texture_lifecycle.hpp`(新增)
- `src/mbgl/renderer/layers/render_weather_color_layer.cpp`
- `test/renderer/weather_color_texture_lifecycle.test.cpp`(新增)
- `test/CMakeLists.txt`
- `docs/50_Step记录/V15_iOS_WeatherColor纹理生命周期修复_v1.md`(新增)
- `docs/报告/V15_iOS_WeatherColor纹理生命周期修复_Codex完成报告_v1.md`(新增)
- `粒子开发记录.md`(追加)
未修改任务书、用户其他文件、公共 API/ABI、Style Z-order、shader slot、版本锁、release tag、ZIP/manifest、Android、RN/JS、NavSea、V12/V13 或粒子 advection/State/Trail/Retained 代码。
## 本阶段检查与状态
- 已完成只读源码调用链复核和 diff 静态核对准备。
- `git diff --check`:由 Codex 执行,结果记录于完成报告。
- RDC 首次 OpenGL runner build:**FAIL**,`weather_color_texture_lifecycle.test.cpp:60:17` 因 `nullptr` 参数无法推导模板类型;RDC 反馈 production renderer object 已编译。Codex 仅为该断言显式指定 `<int>`。RDC 随后 OpenGL build retry exit 0、定向 GTest 6/6 PASS;Metal host build exit 0、定向 GTest 6/6 PASS。原始日志位于 `/tmp/navsea_v15_weathercolor_validation/`(`opengl_build.log`、`opengl_build_retry.log`、`opengl_test.log`、`metal_host_build.log`、`metal_host_test.log`)。Codex 未运行构建或测试。
- iOS App/Xcode 编译、签名、安装、Metal 真机渲染和 iPad 事故场景:**等待用户手工验收**。
- 现有仓库内未找到 `STEP_RECORD.md`;按根 `AGENTS.md` 追加 `粒子开发记录.md`,没有新建或猜造该文件。
- RDC host 验证已完成,用户授权的 scoped commit/push 已完成;当前只等待用户手工 iPad 验收。
## RDC Mac 定向验证命令
先按 OpenGL,再按 Metal host 顺序执行:
```bash
cmake --build build-step088-opengl --target mbgl-test-runner --parallel 3
build-step088-opengl/mbgl-test-runner --gtest_filter='WeatherColorTextureLifecycle.*'
cmake --build build-step097-metal-host --target mbgl-test-runner --parallel 3
build-step097-metal-host/mbgl-test-runner --gtest_filter='WeatherColorTextureLifecycle.*'
```
RDC 已按顺序完成 OpenGL 与 Metal host 验证,结果均 PASS(各 6/6)。Metal host 测试仅验证 host 可执行代码,不等于 iOS 设备 Metal PASS。当前等待用户手工 iPad 验收;本阶段不再构建。
## Git 收尾
- scoped 实现提交:`5af91fe7a105f0ac047e18db1dc063c9ffd69d44`(`fix: 修复 V15 WeatherColor Metal 纹理生命周期`),仅纳入本任务 7 个文件。
- 已推送 `origin/particle/main`;`git ls-remote` 回读的远端 HEAD 为 `5af91fe7a105f0ac047e18db1dc063c9ffd69d44`。
- 验证结果补录提交:`4712e1752af2a8238def5839c8754270de4195c9`(`docs: 记录 V15 WeatherColor RDC 验证与推送`),包含 Step、完成报告、开发记录;推送后远端 HEAD 已回读为该提交。
- 未跟踪 V15 任务书未提交;V15 release tag 和发行内容未改。当前仍 **WAITING FOR MANUAL IPAD VALIDATION**。
## 2026-09-27 v16 发行构建兼容补充
- 统一发行 v16 的 Android 四 ABI 与 Maven Local 构建通过;首次 iOS Bazel 编译在 render_weather_color_layer.cpp 报找不到新增的 weather_color_texture_lifecycle.hpp。
- 根因:bazel/core.bzl 的 MLN_CORE_HEADERS 为显式清单,新增头文件未列入;仅追加该头文件路径,不改 WeatherColor 算法或公开 API。
- iOS Bazel 构建需由 RDC 在同一 v16 独立 worktree 重新验证;未通过前不得宣称 v16 发布或 iPad 修复通过。

View File

@@ -0,0 +1,130 @@
# STEP-097 V13 修复后集中验证清单 v1
状态:**STEP-097 整体 PARTIAL;A1 与 A2 的 Android/Core 检查已完成,B/C 未开始。** 下方较早段落按当时事实保留;后续 RDC 结果以本文末尾带日期的更新为准。Metal host Core 测试不等于 iOS runtime,iOS runtime 按用户豁免记 `USER_ACCEPTED_WITHOUT_IOS_RUNTIME_TEST`。
## 通用 Core 与现有测试入口(RDC)
在本仓 Mac 当前 checkout,使用已配置的现有 build 目录;若目录不存在,先按当前构建规范配置。此处不自动运行:
```sh
cmake --build build-step088-opengl --target mbgl-test-runner -j3
build-step088-opengl/mbgl-test-runner --gtest_filter='RenderSegmentPlan.*:RenderSegmentDiagnostics.*:RetainedUnderlayCache.*:RetainedUnderlayRevision.*:ParticleFastPath.*'
```
新入口 `RenderSegmentDiagnostics.OldCacheReleaseCannotChargeReplacementAtSameIndex` 预期同 index 的旧 revision release 不计入新 topology,当前 revision release 计 1。`RetainedUnderlayCache.UnsupportedAndResourceFailureFallBackSafely` 为旧入口,用于资源失败后的基础状态;尚不能替代真实 layer render 异常注入。失败保留完整日志,回传首个错误和日志路径。
## Android OpenGL(Particle-Android / RDC,NOT RUN)
```sh
./tools/particle_android_validation/build_validation_apk.sh
```
RDC 应使用同一 commit 生成 APK,再由后续授权的 Win11/模拟器操作按标准流水线安装。现有 Test Host 仅有 C-R 控制,尚缺同一 vector source/same tiles 的 R-R、R-C-R、C-R-R fixture,故该 APK 即使构建成功也不能关闭 01/03。需比较 cache ON/OFF 的首帧、重建、reuse、主目标与离屏目标像素;失败后 diagnostics 不得 Ready,下帧可恢复。记录 Renderer、ABI、SHA-256、截图与日志。不得引用 STEP-094 历史 43/43 作为本轮结果。
## iOS Metal(Particle-iOS / RDC,NOT RUN)
```sh
cmake --build build-ios --target ios-sdk-static -- -quiet
```
同 commit 的 iOS simulator 与真机需开启 Metal API Validation,捕获 retained R-R/R-C-R 的 color/depth/stencil texture、load/clear/store,特别确认 simulator 的 Depth32Float_Stencil8 同时绑定 depth 和 stencil。故障注入覆盖附件创建失败、encoder 创建失败及 layer render 异常;预期不发布 Ready cache、后续能 fallback/retry。专用自动测试入口尚缺,不能因静态修改或 `ios-sdk-static` 成功就宣称 Metal runtime PASS。
## 后续 GPU 性能(粒子负责人协调,NOT RUN)
完成 A1/A2/B/C 代码与平台测试入口后,以相同设备、commit、Style、视口和场数据采样 cache ON/OFF、R×viewport×fieldKey 阶梯,记录 30/60 FPS、p50/p95/p99、GPU/CPU frame time、纹理创建/释放、峰值/长稳内存、方向/缩放/状态恢复。Metal capture 记录 `waitUntilCompleted` 的实际同步点及资源依赖。未经运行不填写数字;F06 multi-C 与 F08 精细失效另行设计。
## Test Host 依赖(B 阶段后,NOT RUN)
目前默认 RN `.10` 与 Native `.13` 错配,阶段 B 未开始,不得运行安装并声称配对成功。B 修正 package/lock/SPM 后,RDC 再在 Test Host 目录执行:
```sh
npm ci
npm run typecheck
```
必须同时检查实际已安装 RN tgz、Android Gradle substitution、iOS SPM exactVersion 与 lockfile;不得以 node_modules 临时软链代替默认安装结果。本清单不含 Jest 命令。
## 2026-09-25 FZG1 A1 部分执行结果(覆盖上方 Codex 交付时的 NOT RUN 状态)
- 标准构建脚本已由 Mac RDC 实际执行;HEAD `91c2491b0`,x86_64 OpenGL Debug APK `BUILD SUCCESSFUL`,SHA-256 `8677197c5f83a22747cb51f0c261edffc5db4678fc25b3f366474f6ecb94dc99`。冻结于 `artifacts/maplibre-particle/step097-fzg1/`。
- ADB `192.168.200.139:5555` 对 FZG1-2 实际执行覆盖安装、20 组截图/UI XML、既有 STEP-093 topology、CACHE OFF/ON、相机、非法范围恢复与 mutation/fail-closed,日志见 `docs/报告/STEP-097_A1_FZG1测试APK与实机验证报告_v1.md`。
- 此处只是设备冒烟;A1 的同 tile stencil fixture、Metal 附件验证、真正 LayerGroup exception 注入与 `OldCacheReleaseCannotChargeReplacementAtSameIndex` GTest **仍 NOT RUN/缺专用入口**;A2/B/C 和软件发布亦未执行。
## 2026-09-26 第 19 项原生 GTest 已执行
- RDC 使用当前源码 HEAD `0d13552bed84f427dd43cae9856391bd26622179` 增量重编译 `mbgl-test-runner`:95/95 steps,exit 0;第 19 项 `OldCacheReleaseCannotChargeReplacementAtSameIndex` **1/1 PASS**;`RenderSegmentDiagnostics.*` **5/5 PASS**,两次退出码均为 0。原始日志和分析见 `docs/报告/STEP-097_A1_第19项GTest运行验证报告_v1.md`。
- 上方第 19 项“NOT RUN”是 2026-09-25 FZG1 冒烟时点的历史事实,现已由本条更新。01 同 tile、02 Metal、03 render exception 测试仍未闭合;A1 不得整体标 PASS。
## 2026-09-26 第 19 项 FZG1 设备辅助测试结果
- 经 RDC 在 FZG1-2 重新覆盖安装与 hash 复核,执行两轮 topology 轮换和资源诊断场景,20 个截图检查点;18 个有效配置均 valid=true/fallback=false,缺失 START fail-closed 后恢复。B/T 的粗粒度 create-release 差额 0/1,实时日志中无应用崩溃记录。详见 `docs/报告/STEP-097_A1_第19项FZG1运行验证报告_v1.md` 与 Mac 忽略目录 `artifacts/maplibre-particle/step097-fzg1-gtest19-device/`。
- 这次是 Android Runtime 冒烟,前述第 19 项精确计数归属 GTest 由 Mac 原生测试覆盖;01 同 source/tile stencil、02 Metal、03 绘制异常注入仍缺,A1 整体 PARTIAL,A2/B/C 未实施。
## 2026-09-26 A1 新入口执行清单(RDC,NOT RUN)
通用 Core(当前 OpenGL 构建目录):
```sh
cmake --build build-step088-opengl --target mbgl-test-runner -j3
build-step088-opengl/mbgl-test-runner --gtest_filter='RenderOrchestratorError.*:RenderSegmentDiagnostics.OldCacheReleaseCannotChargeReplacementAtSameIndex'
```
预期异常 observer/failed 与 cache 不复用、重试后 Reuse;第 19 项保持 1/1 PASS。Metal GTest `MetalRetainedAttachments.*` 需由 RDC 使用可在 Mac 主机执行且配置 `MLN_WITH_METAL=ON` 的 `mbgl-test-runner` 构建目录执行;先检查所用目录的 CMakeCache、目标和 Metal device,再编译、执行并记录命令与日志。不能用 iOS 静态库构建替代主机 GTest。真机/simulator 的创建失败、encoder 失败与 API Validation 仍单独执行。
Android 同源 stencil:以本提交标准脚本构建 Validation APK,打开 Particle Core Layer Validation → STEP-093 → STEP-097 SAME SOURCE STENCIL;依次选择 RR、RCR、CRR。每种拓扑在 CACHE ON 首帧、稳定 reuse、REBUILD CURRENT 后,再切 CACHE OFF 与 ON;保存每个检查点截图、`STEP097_STENCIL_PLAN/READY/DIAGNOSTICS` 日志及 `READ SNAPSHOT`。预期 `accepted=true`、`valid=true`、`fallback=false`,多个 retained 段分别 build/reuse,四层同一 source 的轮廓与填充在缓存切换后无残影/漏裁剪。再以相机移动强制重建;记录主目标与离屏结果。此清单是待执行判据,不能据此宣称像素 PASS。真正 GPU 绘制异常及下一帧 full fallback/recovery 需 RDC 专项注入;GTest 状态机不能代替它。
## 2026-09-26 A1 实测结果(覆盖上方待运行记录)
- Android/FZG1:新 APK 源码 `8c006ac83`,SHA-256 `48df19cf5a4c4250934efa2596423d3ad9571fe49a65b9b704a81cdb41f58671`;同 source/tile RR/RCR/CRR、重建、缓存 OFF/ON 18/18 状态与像素采样检查通过,无本次捕获的 FATAL。
- Core:03 `RenderOrchestratorError.*` 2/2 PASS;19 `OldCacheReleaseCannotChargeReplacementAtSameIndex` 1/1 PASS,诊断组 5/5 PASS。
- Metal:编译通过,2/2 SKIP(无 Metal device);用户豁免 iOS runtime,标 `USER_ACCEPTED_WITHOUT_IOS_RUNTIME_TEST`,不是 iOS 运行测试 PASS。详见 `docs/报告/STEP-097_A1_Android专项验收报告_v1.md`。后续 A2/B/C 未执行。
## 2026-09-26 A2 中途静态/Core 结果(不是 A2 关闭)
- OpenGL `mbgl-test-runner` 编译 exit 0;定向 16/16 PASS;两次 Shader generator 均 exit 0 且输出一致。Metal host 可编译并执行同一跨 Core GTest 16/16 PASS,未运行 iOS 设备。
- 上述结果仅覆盖现有原生测试入口。Codex A2 代码尚未审查/提交,Android 设备/APK 仍 NOT RUN,不能推进 B/C。证据与完整边界见 `docs/报告/STEP-097_A2_阶段验证记录_v1.md`。
## 2026-09-26 A2 最终验证结果(RDC 独立执行)
### Core / Shader
- 原先因旧测试断言与既有语义不一致而失败的 58 项完整集合,在修复断言后 **58/58 PASS**。`sameFieldGpuContent()` 仍表达忽略 producer generation/dataVersion 的解码 payload 等价;`sameFieldGpuEndpoint()` 单独负责严格 GPU endpoint identity。
- 当前 broad filter 在 OpenGL 与 Metal host `mbgl-test-runner` 各 **121/121 PASS**。命令:
```sh
build-step088-opengl/mbgl-test-runner --gtest_filter='Particle*.*:Source.RasterArray*:Source.HostField*:Source.ParticleField*:Source.CanonicalField*'
build-step097-metal-host/mbgl-test-runner --gtest_filter='Particle*.*:Source.RasterArray*:Source.HostField*:Source.ParticleField*:Source.CanonicalField*'
```
- 三项 A2 Source 定向回归在 OpenGL 与 Metal host 各 **3/3 PASS**:mix-only 50 ticks 不重物化、真 endpoint 与 revision 变化触发物化、同步通知重入不双推进、clear fail-closed。
- `node shaders/generate_shader_code.mjs` 两次执行成功且输出幂等。Metal host 是 Mac 上 Core 测试,不是 iOS device runtime。
### Android / FZG1-2
- RDC 使用 `./tools/particle_android_validation/build_validation_apk.sh` 构建标准 OpenGL Debug x86_64 APK;61,753,803 bytes,SHA-256 `27bf4a6cc191bd86c4d1c10398478d81c2999edd61fe7242e40e5188516b0ca5`。FZG1-2 `192.168.200.139:5555` 安装成功,设备 APK SHA-256 相同。
- Test-App-only V4 Golden Wind T0/T1:load、seek 25/75、speed 2x/1x、PLAY 30 秒、PLAY 时 UI/TIME 可响应、PAUSE/stable pause、replay、CLEAR、reload、再次 PLAY/PAUSE;脚本 exit 0、17 PNG、24 steps、ANR=0。`05_play_15s.png` 可见粒子。30 秒场景闭环不是长期稳定性或性能测试。
- stable mix-only epoch 中 renderer 日志的 `fieldUploadCount=6/resetCount=2` 保持不变;explicit CLEAR/reload 后 upload 合法增至 10,reset 增至 4;reload 后 playback 稳定在该新计数。不能宣称跨 CLEAR/reload 零上传/零 reset。
- APK 工作树源 diff 与 `mix-reuse-build-source.diff` SHA-256 均为 `18927a5f30a88f5c1c3a00e0017473dc33b04151c227e4baf52250dc4ad2e6a2`,源码快照一致。原始 build/device/test 日志、steps 与截图位于 `artifacts/maplibre-particle/step097-a2-final-validation/`。
### 历史失败与范围
- 12:43 首次 ANR 为 synchronous Source notification 重入 forecast tick;`forecastTickInProgress` guard 与重入 GTest 已修复并覆盖。
- 后续无 ANR 但主线程仍高负载、PLAY/TIME 无响应的试跑由 debuggerd 定位到每 tick 重物化大场和深比较;mix-only immutable endpoint reuse 后通过本次 FZG1 闭环。旧失败日志和 debuggerd 保留,不从报告中删除。
- 本次不测 iOS runtime;标 `USER_ACCEPTED_WITHOUT_IOS_RUNTIME_TEST`。没有完成 GPU 性能基准、FPS/pXX 或长期运行测试。STEP-097 整体仍 PARTIAL,B/C 未开始;不包含 Jest、STEP-098、发布 `.13`。
### 2026-09-26 A2 源码独立 scoped commit 核验
- A2 的 26 个生产源码、shader、TestApp-only V4 Golden fixture 和定向测试文件已固定为 `f49ffa68a17e0a505199bdbeccf07f8543f3a05f` 并推送,`origin/particle/main` 回读一致;没有 stage 用户未跟踪 STEP-098。完整代码/设备证据仍见 `docs/报告/STEP-097_A2_阶段验证记录_v3.md`,不因 Git 提交改变原 APK 的 `git_dirty=true` 来源事实。STEP-097 整体 PARTIAL,B/C 待用户指令。
## 2026-09-26 B 阶段 RDC 实测与待闭合
- Mac Core 增量构建 PASS;B 的 JSON 严格校验、非法配置清除与恢复、数值溢出拒绝和合法 validity=0 四项 GTest:**4/4 PASS**。
- 扩大回归初次 132/134,旧 16-bit fixture 误用 4-byte UInt8 storage;修正为合法四样本 8-byte 小端之后重新编译,broad **134/134 PASS**,没有放宽生产校验。
- Test Host package.json/package-lock.json 的 RN tarball 均为正式 `13.4.1-particle.13`,实包 SHA-256/SRI 一致。
- Android 要分别验证本仓新源码的 MapLibreMap destroy listener JUnit 与 RN module 对正式 `.13` AAR 的 compile;前者不能替代后者。正式 .13 source commit 无 `addOnDestroyListener`,因此当前 bridge 存在 ABI 阻塞,不能标 Android consumer PASS。
- C、STEP-098、Jest 与正式发布不执行;完整证据见 `docs/报告/STEP-097_B_独立验收与版本阻塞报告_v1.md`。
## 2026-09-26 B 阶段 RDC 验证更新
- Mac 最新 GTest runner:定向 6/6、扩展 139/139 PASS,旧 broad 2 项失败经纠正测试 fixture 后消除;完整日志 `artifacts/maplibre-particle/step097-b-rdc/validation-*.log`。
- Android Native OpenGL Debug:`MapLibreMapDestroyListenerTest` 2/2 PASS;`compileOpenglDebugJavaWithJavac` exit 0;owner Kotlin 独立 JVM JUnit 4/4 PASS(源码 SHA 与当前 B10 一致)。隔离单测不能替代 React Native 模块与发布 AAR 精确配对编译。
- B10 当前默认 RN Gradle 对已发布 .13 Native AAR 依赖新缺失符号 `addOnDestroyListener`;RN consumer 校验标 `BLOCKED_NATIVE_AAR_COMPATIBILITY`,禁止记录 PASS 或覆盖 .13。Linux 独立 Gradle 状态另记,C 与 STEP-098 均不得越过此门槛。

View File

@@ -0,0 +1,90 @@
# STEP-095 V13 独立严格代码与性能审查 — Codex 完成报告 v1
日期:2026-09-25(Asia/Tokyo)。本轮为独立静态调查与中文文档交付,不是修复或产品验收。
## 1. 执行摘要与状态
已完成 V13 冻结源码审查、旧 F01~F10 独立复核、V12/V13 成本比较和 RDC 验证方案。新增最高优先问题为 target 切换后 stencil 记录未失效、Metal depth/stencil 契约未完整下传、绘制抛错后 cache 仍可能标成功、V4 PLAY 未接入生产时钟推进。另有 JSON、数值入库、listener、物化/UI 成本等明确源码链。
没有运行构建、测试、设备或 profiler;不宣称已发生黑屏、OOM、崩溃或达到某个性能收益。调查内容完成,Git 状态以[阶段记录第 4 节](../50_Step记录/STEP-095_V13独立严格代码与性能审查_v1.md)追加结果为准。**调查交付 PASS 与 V13 产品运行态 PASS 分离;后者本轮不成立,运行验证均 NOT RUN/NEEDS_RUNTIME。**
## 2. 基线与审查范围
环境为 `Mac-mini-tei.lan`、`/Users/tei/dev/MapLibreParticleLab`、`particle/main`。起始 HEAD/实际 origin 为 `05878d662308b9a92c20c433c3cd2943d74ae718`,工作树 clean。冻结源码 `ee0b89e711bf01335b3aa71d36def785cd97fec3`,功能提交 `016fd1fff6a33cf795c10d4f5c5413d308383677`。RN/Android `13.4.1-particle.13`、iOS `6.28.0-particle.13`;manifest/tag 已核对,manifest sourceCommit `ddf20dec…` 至冻结点无生产源码变化。
本次恢复指令由用户在同一会话明确指定 `gpt-6-astra high`,符合任务要求。该记录不冒充底层启动参数的独立核验。保留已有调查正文,仅做模型来源澄清及引用校正。
已读取 AGENTS、当前规范/总则/基线,先源码后旧报告,交叉检查 STEP-088~094;范围包括 Segment JSON/resolver、全局排序、runtime/cache、GL/Metal offscreen、State/Trail/时间场/QBIN、Android/iOS/RN、Host 版本锁及 Integration 只读状态。Vulkan、全部驱动/并发交错、业务 provider/HTTP、产物独立重建下载、真实 NavSea 接入未验证。
完整 manifest 坐标/散列、源码路径图及范围限制见[调查第 2 节](../20_调查/STEP-095_V13独立严格代码与性能调查_v1.md)。发布回读仅 HISTORY,文档提交不发布新软件。
## 3. Finding 总表与详细证据入口
以下为交付索引;每项的精确文件/行范围、最短可复核调用链、反例、修复方向和验收方案完整保存在[调查第 3~6 节](../20_调查/STEP-095_V13独立严格代码与性能调查_v1.md),作为本报告证据明细,不另复制一套可能漂移的行号。D=缺陷路径,C=确定成本,R=后果待运行,E=环境偏差;源码链置信度均高。
| 编号/级别/类别 | 问题及最短触发/影响 | 最小修复方向 | 验收 |
| --- | --- | --- | --- |
| N01 P1 D+R | 同 tiles 的 R-R/R-C 跨 target 复用 CPU stencil 映射 | target generation 隔离/重置 | OFF基线、stencil capture |
| N02 P1 D+R | Metal clear/attachment 契约缺口 | 完整翻译 depth/stencil/combined attachment | 真机/模拟器 validation/capture |
| N03 P1 D+R | group 抛错被 catch,残图仍 markBuildSucceeded | build 成功状态传递,失败不可发布 | 故障注入/恢复/no reuse |
| N04 P1 D | 一次 set time 后 PLAY 无生产 advance | owner-thread 唯一时钟接线 | PLAY/PAUSE/速度/跨窗口 |
| N05 P2 D | NUL 截断/重复 strategy 绕过严格语义 | 长度感知与唯一键校验 | JSON/两桥接错误回归 |
| N06 P2 D+R | 坏数值先入库,物化失败丢可用投影 | 入库前约束/事务发布 | 好旧帧→坏高revision |
| N07 P2 D | 同 view attach/detach listener递增 | 单listener/幂等/移除 | 100次/换view/销毁 |
| C01 P1 C | full diagnostics 锁内深复制进热路 | 轻量一致快照,完整pull隔离 | alloc/锁/复制次数 |
| C02 P1 C+R | retained及普通粒子Metal wait | 先profile,再有序提交 | wait/GPU/State-Trail顺序 |
| C03 P1 C | dirty销毁同尺寸allocation | content/resource失效分离 | mutation/resize/context分别计数 |
| C04 P1 C+R | GPU多R和CPU多field无总预算 | map预算/安全fallback | 阶梯压力/peak/长稳 |
| C05 P2 C | ordinal线性与fast全items扫描 | generation索引/粒子列表 | S/G扩展和reorder回归 |
| C06 P1 C+R | 同时间/mix也整网格物化、UI提交 | 早退/端点复用/安全异步发布 | 大网格/UI/alloc/rebuild |
| C07 P2 C | tick drawable重建、端点复制 | 保留对象/不可变共享 | tick与窗口边界分测 |
| O01 P2 D | 旧cache析构计数落新topology index | identity/generation计数 | 真cache切换/析构 |
| E01 P1 E | Host `.10`/native `.13` | 对齐依赖和lock,独立后续任务 | 干净包/typecheck/codegen/设备 |
| E02 P3 E | Integration旧协调状态 | 负责人按接入事实更新 | commit/包/设备交叉核对 |
## 4. 旧 F01~F10 独立复核
| 旧项 | 意见 | 修正依据 |
| --- | --- | --- |
| F01 | CONFIRMED | 深复制链成立,成功fast可两次而非所有帧必两次 |
| F02 | CONFIRMED | retained仅build等待,补普通State/Trail等待 |
| F03 | CONFIRMED | dirty释放成立,稳定reuse不分配 |
| F04 | CONFIRMED | 无总预算成立,OOM/泄漏未测 |
| F05 | QUALIFIED | 错配成立,旧TS2307为历史环境失败,不能仅归`.10` |
| F06 | QUALIFIED | 显式设计限制建议P2;full CPU不等于关闭GPU reuse |
| F07 | CONFIRMED | ordinal线性,但全局编号对正确性必要 |
| F08 | QUALIFIED | 保守全局失效,裁窄前须证明依赖完整 |
| F09 | CONFIRMED | 可具体复核先换entries后析构;不证明stale纹理 |
| F10 | QUALIFIED | 文档偏差降P3;不能推定NavSea实际接入 |
不为凑数标 DISPROVED;没有证据的运行后果均留 NEEDS_RUNTIME。旧报告未作出的断言不反向归咎于旧作者。
## 5. V12/V13 与缺失验证
V12 固定最多两个 R,V13 任意独立 R/C,增加管理/资源上界;单 C fast 保留,多 C 回 full CPU。成本模型为完整快照 O(L+S)、多段 ordinal 最坏前缀 O(S×G)、retained 资源 O(R×W×H)、场物化 O(P)。普通粒子 wait、drawable/物化多为继承成本,不全归 V13。无 profiler 不给收益百分比,不外推 STEP-090 21.7%/97.0%历史值。
| 验证 | 本轮状态 | 不能替代它的历史证据 |
| --- | --- | --- |
| GL/Metal编译、Native/TS/codegen | NOT RUN | STEP-094构建/43 tests、旧审查69 tests/TS仅HISTORY |
| 任意R/C像素、depth/stencil/alpha | NOT RUN/NEEDS_RUNTIME | resolver/diagnostics单测不覆盖GPU |
| CPU/GPU时间、30/60FPS、单C/多C | NOT RUN/NEEDS_RUNTIME | 历史特定Style CPU不可外推 |
| allocation/peak、context loss、长稳 | NOT RUN/NEEDS_RUNTIME | 逻辑release计数不等于物理显存 |
| PLAY/数值/attach/destroy端到端 | NOT RUN | 手动clock advance不等于生产接线 |
| 默认Host版本/ABI与设备调用 | NOT RUN | 临时工作树TS/codegen不等于默认安装 |
| Android GL / iOS Metal / Vulkan运行 | 全部NOT RUN | 编译成功不证明运行支持 |
完整 topology/异常/生命周期矩阵、精确既有验证命令及日志要求见调查第 9 节。新增缺陷尚需增加对应测试;仅复跑现有过滤集不能关闭。所有运行由 RDC 后续独立执行。
## 6. 修复建议、交付边界与 Git
建议分批:A 正确性 gate N01~N04;B 输入/生命周期/观测/Host N05~N07/O01/E01;C 轻快照/ordinal/相同时间早退;D GPU预算、allocation/content分离和同步;E multi-C fast、source精确依赖、异步大场与正式mask独立设计。不得为性能绕过保守gate、合并相邻R或引入provider/HTTP/业务调度。只建议,不创建修复任务。
仅新增本报告、[调查](../20_调查/STEP-095_V13独立严格代码与性能调查_v1.md)、[阶段记录](../50_Step记录/STEP-095_V13独立严格代码与性能审查_v1.md),追加根[粒子开发记录](../../粒子开发记录.md)。没有生产源码、shader、API、package/lock、build、manifest、tag、产物或Integration变更。
起始SHA见第2节;实际主提交SHA/message、push、remote HEAD/status和最终收尾提交的自引用说明统一记录在阶段第4节。推送前交付暂记PARTIAL;完成并核对后以追加结果为准。最终由RDC独立审计本文证据和任务边界,Codex不宣布正式产品验收。
### 已完成的交付核对
主提交 `ad5f92c48ea353c18548c79d223e9a06888330d3`(`docs: audit V13 source and performance for STEP-095`)已push origin particle/main,exit 0;实际远端HEAD回读与本地一致,核对时工作树clean。四份文档、9处相对链接、63处带行号源码引用范围及cached diff空白检查通过,开发记录仅追加。调查主稿仍285行;无生产源码变化。
据此调查主交付为 **PASS**,产品运行态仍 **NOT RUN/未验收**。本追加结果由独立docs-only收尾提交保存,其最终SHA及二次push/clean核对按阶段记录自引用规则在最终回复提供;不虚构自身哈希或未执行测试。

View File

@@ -0,0 +1,55 @@
# STEP-096 V13 隔离盲审 Codex 完成报告 v1
执行结果:**PASS(仅盲审文档交付)**。日期:2026-09-25。
本轮只使用隔离快照及其当前有效规范和[任务书](../40_任务/STEP-096_V13隔离盲审_Codex任务_v1.md),独立读取源码形成结论,未导入任何既有审查发现。PASS 不表示 V13 Android/iOS GPU 运行态、性能、构建或发布通过。
## 交付
1. [独立调查报告](../20_调查/STEP-096_V13隔离盲审调查_v1.md):架构、完整发现证据、反例、成本模型、覆盖缺口、后续验证矩阵及分批建议。
2. [Step 记录](../50_Step记录/STEP-096_V13隔离盲审_v1.md):输入、只读过程、范围、结果和未运行事项。
3. [本完成报告](STEP-096_V13隔离盲审_Codex完成报告_v1.md):交付结果、索引、证据边界与移交责任。
三份正文与相对链接、引用文件及行号、行尾空格已作轻量静态自检;这不属于构建或测试执行。
## Finding 索引
完整路径、行号、触发条件、反例和验证动作集中在调查报告对应 B 编号中。编号来自本轮源码证实顺序。
| 编号 | 优先级 / 类型 | 独立发现 |
|---|---|---|
| B01 | P1 / 确定缺陷 | 跨目标 tile stencil 覆盖记录未失效 |
| B02 | P1 / 确定缺陷 | Metal 未落实 gfx depth/stencil 清屏请求 |
| B03 | P2 / 源码确定成本 | Metal 离屏 present 同步等待 GPU |
| B04 | P2 / 源码确定成本 | retained 内容失效释放同尺寸 GPU 资源 |
| B05 | P2 / 源码确定成本 | 时间更新先全量物化后比较 |
| B06 | P1 / 确定缺陷 | encoded PLAY 无生产时钟推进调用方 |
| B07 | P2 / 确定缺陷 | 8-bit 场纹理零值编码偏差 |
| B08 | P2 / 确定缺陷 | Mercator 东向速度未作纬度修正 |
| B09 | P2 / 设计限制 | 缺正式共享地理 Ocean Mask |
| B10 | P2 / 源码确定成本 | UI 派发链同步解码/物化 |
| B11 | P2 / 设计限制 | store 无全局 key/字节预算 |
| B12 | P2 / 确定缺陷 | retained 绘制异常后仍可提交成功缓存 |
| B13 | P1 / 确定缺陷 | 粒子更新门槛遗漏合法窗口 V/有效性变化 |
共 13 条,P1 四条、P2 九条,无证据成立的 P0。源码确定缺陷七条、确定成本四条、设计限制两条。颜色投影候选因实际数组比较而排除,粒子强引用环、纯重排遗漏等候选也未成案;没有为凑数量将其写成问题。
## 证据质量和输入限制
- 每条保留隔离树内真实源码定位及可构造条件,区分源代码事实与运行后果。同步点、分配/遍历、缺失调用方、身份门槛、数值公式可静态分析;设备画面、CPU/GPU 时长、OOM、崩溃和收益全部 **NEEDS_RUNTIME**。
- 目标 SHA `ee0b89e711bf01335b3aa71d36def785cd97fec3` 为任务书中的导出来源;隔离树不提供 Git 对象核验。本轮未运行 Git,未声称 branch、HEAD、commit、push、remote 或 clean。
- 快照 manifest 声明 RN/Android `13.4.1-particle.13`、iOS `6.28.0-particle.13` 和产物 sourceCommit `ddf20dec9fe9cd05b77274827b6825829500dd5a`;未外部回读,不能当作安装环境或产物字节核验证据。
- 实际模型/TUI/screen/effort 启动记录、历史构建日志、主仓库内容和其他审查材料均 **NOT AVAILABLE**;未向目录外寻找。当前规范中旧坐标仅作为设计/流程背景,不覆盖快照实现事实。
- 这是选择性静态追踪,未穷尽所有源码、后端和竞态。测试源码被阅读,不等于通过测试。Vulkan 全链、ABI/codegen、透明/深度全组合、平台销毁竞态和设备表现仍待后续验证。
## 验证与改动边界
构建、链接、codegen、打包、C++/GL/Metal/Jest 测试、设备安装/运行、profiler 和 GPU capture:**NOT RUN**。发布、公网回读、正式仓库操作和 Git:**NOT RUN**。
本轮只写任务规定的三份中文 Markdown 报告;未改生产源码、测试、shader、manifest、package/lock 或工具链,未追加开发日志。这里的“源码未变”指本轮没有源码写操作,不代表已借助 Git diff 或导出对象对比核验整个快照。
## 停止与移交
Codex 文档交付后停止。RDC 负责随后核对隔离输出清单、计算三份原稿 SHA-256 并记录冻结时间,再独立移交正式仓库、追加开发记录、做四文件 scoped commit/push 及远端核对。本轮没有执行散列冻结或移交,没有最终提交 SHA。
冻结后不由本会话依据外部资料改写发现。后续修复、测试和解盲比较应有独立任务与记录;所需最小验证矩阵已在调查报告给出。

View File

@@ -0,0 +1,38 @@
# STEP-097 A1 Android 专项验收报告 v1
## 结论及范围
**A1 Android/Core 已完成本轮可执行检查点;iOS/Metal 运行测试按用户决定豁免。** 不将豁免写成实际 Metal PASS。STEP-097 总体仍进行中,A2/B/C 尚待实现和验证;无新软件发布。
- 仓库:`/Users/tei/dev/MapLibreParticleLab`,`particle/main`,源码提交 `8c006ac83d16c2683037f90484fd86fb917a9d78`。Integration `main` 核对 `318f8ccf911ac3136c3086a1d14e61f83e2d4bce`。
- Android/FZG1-2:直接 ADB `192.168.200.139:5555`,非 Win11 虚拟机。APK OpenGL Debug/x86_64,60,311,571 bytes,SHA-256 `48df19cf5a4c4250934efa2596423d3ad9571fe49a65b9b704a81cdb41f58671`。标准脚本编译成功、ZIP/ABI 校验通过;`install -r` 返回 Success,设备安装文件哈希一致。
- 构建记录 `git_dirty=true` 的原因是用户另行放在 `docs/40_任务/` 的未跟踪 STEP-098 MD;本次构建起始及源码提交无 tracked 修改,未将 STEP-098 纳入编译或提交。冻结 `.13` 未修改。
## 01:同源同 tile Stencil — Android 实测
新入口为 `Step097StencilFixture`,单一 `step097-shared-polygon` GeoJSON tiled source 的四层同一 Polygon,运行中仅改变 R/C 连续 Segment 排列,不改来源、几何及图层视觉参数。
- 实测 RR、RCR、CRR 各两轮,稳定帧、REBUILD CURRENT、CACHE OFF/ON 与再次拓扑切换,共 **18 个检查点**,18 张 1920×1200 PNG 与 18 份 UI XML。
- 所有检查点均 `valid=true`、`fallback=false` 且实际策略序列匹配当前选项;缓存 OFF 时各 R 报告 fallback,ON 后重新构建,拓扑有效不变。代码回归脚本退出码 0;实时日志含 10 条 `STEP097_STENCIL_READY`,没有本次采集范围内的 FATAL/SIGSEGV/SIGABRT。
- 使用 AppKit 对原始 PNG 采样:18 张中心像素全部为 RGB `[193,176,119]`,区域外像素全部为 `[27,49,75]`,中心与区域外有明显差异。两种采样均一致,不以截图文字代替像素核验。
- 以上证明这组固定 same-source scene 在指定采样点和可见完整画面未出现异常;它不是对所有 tile/camera/pitch/zoom 组合的数学证明。
证据在忽略目录 `artifacts/maplibre-particle/step097-a1-fzg1/`:`run-stencil.log`、`pixel-check.log`、`after/*.png`、`after/*.xml`、`after/live-logcat.txt`、`after/step097-events.txt`、`after/fatal-events.txt`、`after/observations.json`、`install.log`、`installed.sha256`。
## 02:Metal 附件 — 源码与编译,不执行 iOS runtime
Metal 宿主测试二进制中包含 `MetalRetainedAttachments.*` 两个 GTest,Metal host CMake 增量编译退出码 0。当前 Mac 没有可用 `MTL::CreateSystemDefaultDevice()`,两个测试均按守卫 SKIP;没有将 0/2 误记为 PASS。针对 `metal-cpp` 对象指针解引用的最小编译修正已提交 `8c006ac83`。
用户于 2026-09-26 明确决定:本轮不执行 iOS 测试,Android 验收通过后 iOS 按其决定视作接受。记录为 `USER_ACCEPTED_WITHOUT_IOS_RUNTIME_TEST`,不宣称 iOS GPU/Metal 实际验证。
## 03:绘制异常恢复 — 原生测试
`RenderOrchestratorError.*` 两个新增原生测试实际执行 **2/2 PASS,exit 0**,覆盖 opaque/translucent 绘制异常、错误上报、失败 cache 不发布/不复用及干净重试恢复。日志:`artifacts/maplibre-particle/step097-a1-fzg1/render-exception.log`。本次不将 GTest 扩写为 Android GPU 真实异常注入 PASS。
## 19:资源释放归属 — 已有结果
`OldCacheReleaseCannotChargeReplacementAtSameIndex` 已在此前重新编译的 Mac Core 原生 runner 实测 **1/1 PASS**;诊断组 **5/5 PASS**。FZG1 资源辅助冒烟 20 个检查点已经单独报告。精确 revision 归属由原生 GTest 负责,不据设备摘要断言不存在 GPU 内存泄漏。
## A1 交接与限制
A1 在 Android/Core 验证和用户 iOS 豁免口径下可进入 A2;保留 B/D `topPresent=true` 的旧诊断疑点,不将其冒充已修复。下一阶段按原任务书 A2→B→C 严格串行;每阶段 Codex 实现/测试入口,RDC 负责实际构建、执行与独立审查;不加入 Jest、不覆盖 `.13`。

View File

@@ -0,0 +1,53 @@
# STEP-097 A1 FZG1 测试 APK 与实机验证报告 v1
**结论:Android FZG1 既有场景冒烟完成;STEP-097 A1 仍为 PARTIAL / BLOCKED。** 本次确实重新构建、安装并通过 ADB 操作 FZG1-2,获得 20 张截图与设备诊断。它不替代 01 stencil 同瓦片双目标注入、02 iOS Metal 附件验证、03 LayerGroup 绘制异常注入及 19 C++ GTest 的正式检查点;A2/B/C 未执行。
## 1. 源码、设备与边界
- 验证日期:2026-09-25;开发/构建/ADB 控制:Mac-mini-tei.lan;仓库:`/Users/tei/dev/MapLibreParticleLab`,`particle/main`,代码 HEAD `91c2491b0ef73fb9e01d7861b9c4ed7b55498197`,构建开始 `git_dirty=false`。
- Integration 已通过 RDC 同步 `main` 至 `318f8ccf911ac3136c3086a1d14e61f83e2d4bce`;本轮未改公共协议、Integration 或 NavSea。
- 设备:Panasonic `FZG1-2`,ADB `192.168.200.139:5555`,Android-x86/Bliss x86_64,1920×1200,ABI 列表以 `device-abi.txt` 为准。ADB 直接从 Mac 连接设备;**没有使用 Win11 虚拟机或模拟器**。
- 包名:`org.maplibre.android.testapp`;Activity:`.activity.particle.ParticleCoreLayerValidationActivity`;测试时始终使用 `adb -s 192.168.200.139:5555`。
## 2. APK 构建和安装的实际结果
- 命令:`./tools/particle_android_validation/build_validation_apk.sh`;OpenGL Debug、x86_64、strip native debug symbols;Gradle `BUILD SUCCESSFUL in 15m 59s`,86 tasks(29 executed / 57 up-to-date)。
- APK:`artifacts/maplibre-particle/step097-fzg1/MapLibreAndroidTestApp-STEP097-A1-91c2491b0-FZG1-x86_64.apk`,**60,295,184 bytes**;内含 `lib/x86_64/libmaplibre.so` 39,422,632 bytes;ZIP/ABI/标准 Pipeline 检查通过。
- APK SHA-256:`8677197c5f83a22747cb51f0c261edffc5db4678fc25b3f366474f6ecb94dc99`;`build-info.txt` 记录同 commit、ABI、时间和 hash。冻结副本与标准 Pipeline 产物字节一致。
- 已备份旧安装包至 `artifacts/maplibre-particle/step097-fzg1/before/original-installed-base.apk`。新包使用 `adb -s 192.168.200.139:5555 install -r <APK>` 覆盖安装,输出 `Success`;设备内 `base.apk` 的 SHA-256 与新包完全相同。未清理设备应用数据、未改 NavSea App。
## 3. FZG1 ADB 操作与截图证据
脚本:`artifacts/maplibre-particle/step097-fzg1/run_smoke.py`(本地验证资料,不属于产品源码)。`smoke-run.log` 与 `mutation-run.log` 两次执行 RC=0;另有针对事件的 `live-capture-run.log` RC=0。原始截图、UI XML、日志统一保存在同目录的 `after/`。以下只表述真实场景和屏幕/诊断结果:
| 截图 | 场景 | 设备观察 |
| --- | --- | --- |
| 00–01 | 普通启动,A:R-C-R | Activity 可启动;A 范围 valid=true、fallback=false,B/T 各一缓存并有 build/reuse;蓝底、粉/灰图形及白线可见。 |
| 02–05 | B:R-C、C:C-R、D:C only、E:single continuous | 场景可切换,截图显示对应图形可见/隐藏;状态均 valid=true、fallback=false。 |
| 06–08 | 恢复 A,CACHE OFF→ON | OFF:`runtime-disabled`、fallback、Fast inactive;ON:`first-frame` 重新构建并恢复 reuse。 |
| 09 | CAMERA NEXT | 相机变化后仍有有效范围与重建/复用,未观察到崩溃或整幅永久清空。 |
| 10–11 | 缺失 START→RESTORE VALID | `valid=false/fallback=true/caches=0`,恢复后 `valid=true/fallback=false`、重建。 |
| 12–15 | TOP REORDER、CROSS CONTINUOUS、FAST FAIL-CLOSED、RESET A | 层序变体可切换;带额外 Continuous 层时 Fast 关闭并保留全路径绘制,重置后恢复 Fast。 |
| 16–19 | 实时抓日志时重置 A、相机、缺失 END、恢复 | 日志有 `STEP093_SCENE_READY` / `STEP093_RANGE`,缺失 END 后设备诊断为 `end-layer-missing`,恢复后有效。 |
- 设备全局 Logcat 缓冲区较小,单次事后 dump 可能覆盖早期 STEP093 事件,故补充**实时过滤采集** `after/live-particle-logcat.txt`。该文件记录 A 场景 READY、缺失 END 和恢复的实际事件;`after/logcat-final.txt` 保留事后日志。
- 在本次保存的设备日志与实时采集内未发现 `FATAL EXCEPTION`、`SIGSEGV` 或 `SIGABRT`;最后确认该测试 Activity 仍为 topResumedActivity。这不是长稳/全设备无故障的证明。
## 4. 异常观察及未关闭的检查点
**待复核诊断现象(不直接判定绘制缺陷):** B「BOTTOM+CONTINUOUS」与 D「CONTINUOUS ONLY」截图中没有上段粉色/白线对象,但诊断仍分别报告 `topPresent=true`、`caches=2/1`,且 T reuse 计数继续增长。可能涉及空段、旧 topology 统计或诊断刷新/对象身份;需用独立布局期望与源码追踪证实,不能仅凭截图认定旧上段污染像素。截图:`02_B_R-C.png`、`04_D_C.png`。
| 原 A1 问题 | 本次验证覆盖 | 状态 |
| --- | --- | --- |
| 01 N01/B01 stencil CPU/GPU 切换 | 只做 R-C-R 等既有不同 GeoJSON source 的视觉冒烟、相机/缓存切换;**没有同 vector source/same tiles 在 R-R/R-C-R 切换的像素 fixture** | 未闭合 |
| 02 N02/B02 Metal depth/stencil | 本次 APK 是 Android OpenGL;不可能代替 Metal 附件、clear/load/store 和 combined attachment 验证 | 未执行 |
| 03 N03/B12 render 抛异常后错误缓存成功 | 做了配置无效 fallback→恢复及 Fast fail-closed;**没有真正 LayerGroup render 抛异常注入与下帧恢复** | 未闭合 |
| 19 F09/O01 旧 cache release 归属 | 设备保留了 topology/资源计数截图;新增 `OldCacheReleaseCannotChargeReplacementAtSameIndex` GTest **没有执行** | 未闭合 |
本次不是完整 STEP-097,也不意味着 24 项均修复;A2、B、C、正式 FZG1 同瓦片遮罩回归、iOS Metal、C++ GTest、性能 benchmark、发布与 NavSea 产品 Gate 均未完成。不存在新正式软件发布,原 `.13` tag/manifest/产物未动。
## 5. 后续准确步骤
1. 补齐 01 同 tile 的 R-R/R-C-R/C-R-R 设备 fixture,02 Metal 附件测试入口,03 render exception 注入及 recovery;解决/解释 B、D 的 topPresent 诊断现象。
2. RDC 在同 commit 执行 19 定向 GTest,补齐上述源码测试;Android 再集中构建/安装一次而不是逐按钮重装,iOS Metal 由 Mac 单独做对应设备验收。
3. 满足 A1 代码/测试入口检查点后,才进入 STEP-097 A2/B/C;本次不得修改旧三轮调查或将部分冒烟标成 A1 PASS。
证据目录与 APK 目前位于 Mac 仓库忽略的 `artifacts/maplibre-particle/step097-fzg1/`。已安装 APK 和冻存 APK SHA 相同,相关文本记录、截图、日志可复核。

View File

@@ -0,0 +1,34 @@
# STEP-097 A1 第 19 项 FZG1 运行验证报告 v1
## 1. 结论及验证边界
**FZG1-2 Android OpenGL 运行态冒烟 PASS;第 19 项原生 GTest 已于此前单独 1/1 PASS;A1 整体仍 PARTIAL。** 本次通过 Mac RDC 直接连接 Panasonic FZG1-2,不使用 Win11 虚拟机。20 个设备截图检查点、拓扑切换、缓存开关、无效范围回退及恢复完成;设备粗粒度资源计数符合被检查的不变量,未观察到本次采样范围内的应用崩溃。
本次 APK 未内嵌 Mac 原生 GTest,也不能从设备截图直接验证旧 topology revision 的内部归属。旧 revision 不计入新段 index 的精确断言由此前 `OldCacheReleaseCannotChargeReplacementAtSameIndex` C++ GTest(1/1 PASS)承担。设备测试仅对实际 Renderer 生命周期、画面与诊断提供辅助证据。
## 2. 基线、APK 和设备安装
- 时间:2026-09-26 08:10–08:19 JST;Mac `Mac-mini-tei.lan`;源码分支 `particle/main`,起始 HEAD `237a3e66083ede03f56b5e719086d3a1719b4790`,工作树 clean;Integration `main` HEAD `318f8ccf911ac3136c3086a1d14e61f83e2d4bce` 已核对同步。
- APK 源码提交 `91c2491b0ef73fb9e01d7861b9c4ed7b55498197`。与当前 HEAD 比较只有文档、Step、测试记录变化,生产代码和现有测试源码没有变化,因此直接复用经标准构建的 `OpenGL Debug / x86_64` APK,不为文档提交重复 Gradle 编译。
- 文件:`artifacts/maplibre-particle/step097-fzg1/MapLibreAndroidTestApp-STEP097-A1-91c2491b0-FZG1-x86_64.apk`,60,295,184 bytes;SHA-256 `8677197c5f83a22747cb51f0c261edffc5db4678fc25b3f366474f6ecb94dc99`。
- 目标:`FZG1-2`,ADB 序列号 `192.168.200.139:5555`,1920×1200,x86_64;包名 `org.maplibre.android.testapp`。
- 已执行 `adb -s 192.168.200.139:5555 install -r <APK>`,返回 `Success`;设备上 `base.apk` 的 SHA-256 与原始 APK 一致。覆盖安装未清 App 数据。
## 3. ADB 场景、诊断和截图
- 从 `ParticleCoreLayerValidationActivity` 开始,连续执行两轮 A(R-C-R) → B(R-C) → C(C-R) → D(C only) → E(single continuous) → A 恢复;然后 TOP_REORDER、CROSS_CONTINUOUS、恢复默认、CACHE OFF/ON、MISSING START 无效配置和 RESTORE VALID。全程按界面真实控件通过 ADB 点击,不直接伪造内部计数。
- 输出 20 张 1920×1200 PNG、20 份 UI XML,以及每次 UI STATUS/DIAG 的 `observations.json` 和实时 Logcat。除了启动首页和预期无效场景,另外 **18 个检查点均报告 `valid=true/fallback=false`**。
- 缺失 START 后 `valid=false/fallback=true`、`reason=start-layer-missing`、`caches=0`;恢复后 `valid=true/fallback=false`、`reason=first-frame`、`caches=2`。
- 对带 STEP-093 诊断的 19 个检查点,解析 B/T 两组 `res=textureCreate/compositeCreate/resourceRelease`;两组均满足 `textureCreate-resourceRelease ∈ {0,1}`。无效配置 B/T 差额均为 0,恢复后均为 1。**这是设备现有摘要计数检查,不能推论底层 GPU 没有泄漏,也不等同逐 revision 归属断言。**
- 实时日志 `live-logcat.txt` 记录 34 条 STEP093 日志事件,筛选 `step093-events.txt` 共 38 行;`fatal-events.txt` 为空。本次脚本正常结束 `FZG1_DEVICE_SMOKE_OK`,退出码 0;最后确认测试 Activity 仍在前台。
- 已查看 A、无效 START、恢复有效三张截图:主要图形、粒子与诊断区域仍可见,未观察到整幅永久清空。B/D 不显示上段对象但诊断仍为 `topPresent=true` 的历史待核现象仍存在;不据此直接判定图形错误,另需源码/拓扑语义核查。
## 4. 初次脚本重试与真实状态
第一次本地 ADB 测试脚本在普通首页截图后,因校验器错误地要求首页已有 STEP-093 专项诊断而触发 `Missing range diagnostic: 00_boot`;该失败属于测试脚本的启动页断言,不是 Renderer 测试失败。保留 `device-test-run-attempt1.log`;限定修正为启动首页允许 `n/a` 后完整重新执行,最终脚本退出码 0。没有跳过后续场景或修改 App。
## 5. 证据、未完成项与下一步
证据根目录(Mac 本地忽略目录):`artifacts/maplibre-particle/step097-fzg1-gtest19-device/`。其中 `install.log`、`before/` 和 `after/installed_sha256.txt` 对应安装;`after/*.png` 与 `after/*.xml` 对应 20 个检查点;`after/observations.json`、`after/status-summary.txt`、`after/live-logcat.txt`、`after/step093-events.txt`、`after/fatal-events.txt` 保留原始诊断。单独压缩包:`STEP097_A1_第19项_FZG1设备证据.zip`,APK 不包含在证据 zip 内,仍使用上述冻结 APK。
第 19 项可以记录为**原生 GTest 已通过 + Android 设备辅助冒烟通过**。本次没有验证逐次 GPU 纹理实际释放/峰值内存,没有 iOS Metal 验证,也未补齐 A1 中 01 同源同 tile stencil、02 Metal depth/stencil 附件、03 LayerGroup 真正 render 异常注入专用入口;因此 STEP-097 A1 仍为 PARTIAL,不能进入 A2/B/C 或宣称 V13 发布/产品 Gate PASS。
本次只新增报告和追加阶段记录,未修改生产源码、旧调查、正式发布产物、公共契约或 NavSea。

View File

@@ -0,0 +1,48 @@
# STEP-097 A1 第 19 项 GTest 运行验证报告 v1
## 1. 执行结论
**第 19 项 F09/O01 的现有原生单元测试:PASS。** 专项 `RenderSegmentDiagnostics.OldCacheReleaseCannotChargeReplacementAtSameIndex` 实测 **1/1 通过、退出码 0**;相关 `RenderSegmentDiagnostics.*` **5/5 通过、退出码 0**。这是 Core 诊断计数验证,并非 GPU 内存泄漏测试或 A1 整体验收。
- 执行机器:`Mac-mini-tei.lan`,日期 2026-09-26(日本时间);仓库 `/Users/tei/dev/MapLibreParticleLab`;分支 `particle/main`。
- 实测源码 HEAD:`0d13552bed84f427dd43cae9856391bd26622179`;其中第 19 项生产/测试源码提交 `e67e4961b5b1a1ff8fa7c1e3263799d63f89cadc`;开始时工作树 clean。
- Integration `main` 已由 RDC 同步核实至 `318f8ccf911ac3136c3086a1d14e61f83e2d4bce`。本次无公共契约或生产源码修改。
## 2. 构建和运行
在已有、可追踪源码的 `build-step088-opengl` CMake/Ninja 配置中,针对 `mbgl-test-runner` 增量重编译:
```sh
cmake --build build-step088-opengl --target mbgl-test-runner --parallel 3
build-step088-opengl/mbgl-test-runner --gtest_filter=RenderSegmentDiagnostics.OldCacheReleaseCannotChargeReplacementAtSameIndex --gtest_color=no
build-step088-opengl/mbgl-test-runner '--gtest_filter=RenderSegmentDiagnostics.*' --gtest_color=no
```
- 构建:**exit 0**,95/95 Ninja steps,实际耗时 **261.74 秒**;测试程序确实重新链接且新增测试列在 `--gtest_list_tests` 输出中,未复用旧二进制代替新测试。
- 编译有 Boost header 未使用形参与 vendor 静态库 macOS deployment target 链接警告;无构建失败,本次未更改工具链/警告规则。
- 产物:`build-step088-opengl/mbgl-test-runner`;SHA-256 `9fbbc08c44d8445114040ef3a127f5efdad29d0e6d0d1437617421c8a392de52`。
## 3. 测试和断言证据
专项用例源码:`test/renderer/retained_underlay_cache.test.cpp`,`RenderSegmentDiagnostics.OldCacheReleaseCannotChargeReplacementAtSameIndex`。
测试先配置旧 Segment 拓扑并取得 `oldRevision`,再将 Continuous 边界由 `weather` 改为 `land`,要求 `newRevision != oldRevision`。对相同 index=0 分别发送两种资源释放:
1. `recordSegmentResourceRelease(0, oldRevision)`:新 topology 第 0 段的 `resourceRelease` 必须仍为 **0**。
2. `recordSegmentResourceRelease(0, newRevision)`:同一当前段的 `resourceRelease` 必须变为 **1**。
本轮真实输出:
```text
[ RUN ] RenderSegmentDiagnostics.OldCacheReleaseCannotChargeReplacementAtSameIndex
[ OK ] RenderSegmentDiagnostics.OldCacheReleaseCannotChargeReplacementAtSameIndex (0 ms)
[==========] 1 test from 1 test suite ran.
[ PASSED ] 1 test.
```
相邻诊断组的 5 个用例亦全部通过:可见性元数据、旧缓存释放、缓存关闭保持拓扑、非法计划的 Style 顺序、快照序列化。全部无失败和跳过。
## 4. 事实边界、证据与后续
- 该测试仅验证**当前 topology 的按段释放计数不被旧 revision 污染**;不证明底层 GPU 纹理的实际释放、总分配平衡、没有显存泄漏或 FZG1/iOS 运行态表现。Aggregate release 仍由源码语义保证,本用例未单独断言 aggregate 数值。
- 第 19 项的**已有 GTest 执行检查点可标为 PASS**;STEP-097 A1 **仍 PARTIAL**:01 同 vector source/same tiles stencil、02 Metal 附件、03 真正 LayerGroup 绘制异常注入均没有被此测试覆盖。
- 保存日志:忽略目录 `artifacts/maplibre-particle/step097-gtest19/build.log`、`gtest19.log`、`diagnostics-suite.log`、`build-metadata.txt`。本轮没有执行 Jest、APK 构建/重装、性能或新版本发布。

View File

@@ -0,0 +1,38 @@
# STEP-097 A2 阶段验证记录 v1
状态:**PARTIAL / 代码待完成与审查**。本记录是 2026-09-26 Mac RDC 当前检查点,不是 A2 交付完成报告,不允许据此进入 B/C。
## 基线与执行事实
- 仓库:`/Users/tei/dev/MapLibreParticleLab`;分支 `particle/main`;起点 HEAD `72d93693842d0f98b1f26b4ec38d5b7389656184`。Integration `main` 同步 HEAD `318f8ccf911ac3136c3086a1d14e61f83e2d4bce`。
- A1 的 Android/FZG1 检查点已在 `docs/报告/STEP-097_A1_Android专项验收报告_v1.md` 记载;iOS runtime 按用户决定豁免,不能把未运行写为 PASS。
- GPT-6-Sol medium 可见 Codex 会话 `6215.7445.codex` 在 A2 修改 17 个已跟踪文件,并新增内部 GPU 数学头和定向测试;触及当期用量上限后停下,**源码和测试仍未形成 A2 完整提交**。未启动 B/C;不切换低能力模型冒充完成。
- 用户另有未跟踪 `docs/40_任务/STEP-098_QBIN_V4单帧首显与八帧滚动缓存_Codex任务_v1.md`,原样保留;与本轮 A2 无关。
## 已进行的独立检查
| 检查 | 真实结果 |
| --- | --- |
| `git diff --check` | exit 0 |
| OpenGL `cmake --build build-step088-opengl --target mbgl-test-runner --parallel 3` | exit 0,28 构建步骤,含新增测试目标 |
| OpenGL 定向 `ParticleGpuField.*:Source.RasterArrayV4*:ParticleFieldRuntime.ForecastClock*` | **16/16 PASS,exit 0** |
| `node shaders/generate_shader_code.mjs` 连续两次 | 两次 exit 0、输出一致,未产生额外工作树路径 |
| Metal host `cmake --build build-step097-metal-host --target mbgl-test-runner --parallel 3` | exit 0,224 步,耗时约 519.65 秒;没有运行 iOS 设备 |
| Metal host 同样的跨 Core 定向测试 | **16/16 PASS,exit 0** |
上述编译器提示 Boost vendor 未使用参数、macOS deployment target 链接警告,实际构建 exit 0。测试仅证明已有入口的行为,不等于整项 A2 运行态 PASS。
## 改动内容尚需复核
- 04 单一 map/source owner 基于 monotonic tick 推进 ForecastClock,Android/iOS Bridge 只作为唤醒调度;
- 05 GPU 双端点身份包含 U/V、validity、空间网格、解释/版本信息;只变 mix 不应触发新上传;
- 06 内部 RGBA8 signed zero 从 byte 128 精确重建,零/量化误差有数学测试;未改变公开 QBIN 格式;
- 07 Mercator 东向 U 与北向 V 采用相同纬度度量及极区夹紧,GL/Metal 代码准备中。
这些是**在工作树内可见的实现方向,不是已批准的最终行为**。待 Codex 恢复后完成剩余审查、针对性修正和阶段文档,再由 RDC 独立编译/测试/设备验证,明确代码完成后才能进入 B。
## 原始证据及后续
忽略目录:`artifacts/maplibre-particle/step097-a2-rdc/`。包含未提交差异备份、新增测试文件备份、`build-opengl.log`、`a2-focused-tests.log`、`shader-codegen1.log`、`shader-codegen2.log`、`build-metal-host.log`、`a2-metalhost-core-tests.log`。本次没有 Android APK/ADB 验收、iOS runtime、Jest 或发布。
当前 Codex 模型用量预计 2026-09-26 13:54(日本时间)恢复;已安排 13:55 从现有会话与工作树核实后继续执行。继续时不得重复启动原有进程、不得先进入 B/C、不得覆盖未跟踪 STEP-098 或旧 `.13` 发布资产。

View File

@@ -0,0 +1,44 @@
# STEP-097 A2 阶段验证记录 v2
**状态:PARTIAL / 独立扩展回归发现既有断言与既有函数语义冲突。** 本报告接续 v1,不覆盖其原始验证事实;A2 生产代码仍为未提交的工作树改动,不可据此进入 B/C。
## 1. 本次实际动作
- 2026-09-26(JST),在 Mac `/Users/tei/dev/MapLibreParticleLab`,`particle/main` 当前远端与本地同为 `bd1cfe95b7595a95314b9083754823ae170c5c8e`;Integration `main` 已核对 `318f8ccf911ac3136c3086a1d14e61f83e2d4bce`。
- 现有 A2 工作树修改仍保留,未修改生产文件、未提交 A2 源码;用户的未跟踪 STEP-098 任务 MD 原样保留。本次只扩大原生回归并核对失败的旧源码/测试。
- v1 已记录:OpenGL 和 Metal host 原生测试程序各自编译 exit 0;A2 聚焦 16/16 PASS,两次 Shader 生成一致;Metal 仅主机源码/原生测试,不是 iOS 实机测试。
## 2. 扩大回归
使用 OpenGL `build-step088-opengl/mbgl-test-runner`,运行:
```sh
--gtest_filter='ParticleFieldRuntime.*:ParticleFieldProfileValidator.*:ParticleGpuField.*:ParticleFieldBinding.*:Source.RasterArray*:Source.HostField*:Source.ParticleField*:Source.CanonicalField*'
```
结果 **58 项中 57 PASS、1 FAIL,退出码 1**。失败项 `Source.RasterArrayGpuContentIdentityExcludesRuntimeGeneration` 位于 `test/style/source.test.cpp:288`,预期 `sameFieldGpuContent(east, changed)` 为 false,实测 true。
核查本次未修改的 HEAD 定义:`sameFieldGpuContent()` 明确只比较兼容性、解码 scalar/vector/valid,不比较 `generation`、`dataVersion` 等生产者身份;旧测试却在仅增加 `dataVersion` 时要求 false。两者存在**原有测试期望与既有函数契约不一致**。本轮未另建旧 HEAD 二进制作基线重演,因此只称静态可证的原有矛盾,不声称已做旧版运行复现。
A2 新增 `sameFieldGpuEndpoint()` 负责严格 GPU 端点身份,明确比较 generation/dataVersion/validTime、U/V、validity、grid、encoding 等;对应新 `ParticleGpuField.*` 四项已通过。旧内容等价与新端点等价不应被混为一谈。
排除上述旧测试后,同一组其余 **57/57 PASS,退出码 0**;这只是隔离验证,不是宣布全部回归 PASS。
## 3. 后续修复门槛
- Codex 恢复 GPT-6-Sol medium 后,先复核旧内容比较与新端点比较各自用途;修正旧测试断言,使其与实际函数语义一致,或发现更深的契约问题时写阻塞报告。禁止仅为让旧断言通过而更改新端点身份约束或放宽安全机制。
- 修改后重新编译原生 runner,原 58 项完整回归必须退出 0,并复跑 A2 聚焦用例。随后核对 Android 端 A2 APK 与当前最终代码一致。
- **Android Gradle 编译由用户手工执行。** 标准 Mac 仓库命令为 `cd /Users/tei/dev/MapLibreParticleLab && ./tools/particle_android_validation/build_validation_apk.sh`。在 A2 代码完成前执行只能算中途 smoke,不是最终 Android 验收。用户提供编译结果后,再通过 RDC 对 FZG1-2 `192.168.200.139:5555` 安装、Logcat、截图验证。
- iOS runtime 按用户明确决定豁免,记 `USER_ACCEPTED_WITHOUT_IOS_RUNTIME_TEST`;不虚报 iOS 实测。
- 未进入 B/C,未运行 STEP-098,未改冻结的 `.13` 正式发布。
## 4. 原始证据
Mac 忽略目录 `artifacts/maplibre-particle/step097-a2-rdc/`:
- `a2-broader-core.log`:58 项、1 FAIL 原始记录;
- `a2-broader-excluding-legacy-test.log`:其余 57/57 PASS;
- `a2-broader-run-metadata.txt` / `a2-broader-exclude-metadata.txt`:过滤器;
- v1 中已有 `build-opengl.log`、`build-metal-host.log`、`a2-focused-tests.log`、`a2-metalhost-core-tests.log`、Shader 生成日志。
本报告不覆盖测试失败,也不将尚未提交的代码描述为正式完成。

View File

@@ -0,0 +1,96 @@
# STEP-097 A2 阶段验证记录 v3
## 1. 结论
**A2 Android/Core 验证完成;STEP-097 整体仍 PARTIAL。** A2 的 04–07 在 Core、OpenGL/Metal host 回归和 FZG1-2 设备闭环达到本阶段检查点。没有开始 B/C,没有执行 Jest、STEP-098 或 `.13` 发布。iOS runtime 依用户决定豁免,记为 `USER_ACCEPTED_WITHOUT_IOS_RUNTIME_TEST`,不是 iOS PASS。
Codex 会话配置:**GPT-6 Luna High**,用户可见会话。RDC 独立执行了 CMake/GTest、shader generator、标准 Android APK 构建、FZG1-2 安装和设备场景。Codex 本轮未执行这些命令。
## 2. Git 与版本基线
- 仓库:Mac `Mac-mini-tei.lan`,`/Users/tei/dev/MapLibreParticleLab`;分支 `particle/main`。
- 验证源码基线 HEAD:`5bb1a0e6353c5dfe8db8a61bbeba9cef447280ef`;A2 源码工作树 dirty,尚未提交。结束 HEAD 未变化。
- 本次记录前的 A2 source diff SHA-256:`18927a5f30a88f5c1c3a00e0017473dc33b04151c227e4baf52250dc4ad2e6a2`;与 `artifacts/maplibre-particle/step097-a2-final-validation/mix-reuse-build-source.diff` SHA-256 完全一致。标准 APK 的 build-info 也记录相同 commit/branch 和 `git_dirty=true`,证明设备验证所用 source snapshot 与本轮代码一致。
- Integration `main`:`318f8ccf911ac3136c3086a1d14e61f83e2d4bce`;C-001 未修改。
- 冻结的 `.13` 发布坐标、tag、manifest 与产物未修改;本轮不发布。
- 用户 untracked `docs/40_任务/STEP-098_QBIN_V4单帧首显与八帧滚动缓存_Codex任务_v1.md` 保持原样、未 stage。
## 3. A2 实施内容
| 项 | 实施与不变量 |
| --- | --- |
| 04 N04/B06 Forecast PLAY | Map Continuous update 驱动 Source-owned monotonic forecast clock;Android/iOS timer 只负责唤醒。同步 SourceObserver reentry 由 `forecastTickInProgress` 防止重复推进。显式 setTime、PLAY/PAUSE、rate、clear 保持 Core clock 所有权。 |
| 05 B13 GPU endpoint / mix-only reuse | `sameFieldGpuContent()` 继续只表示 version-agnostic decoded payload 等价;新的 `sameFieldGpuEndpoint()` 严格比较 revision/time、U/V、validity、grid、encoding 等 renderer endpoint 身份。Source 对相同 content identity、binding、immutable decoded frame 与 plane/validity storage pointer 复用已有 materialized T0/T1,只更新 `sampleState`。frame/revision 或任一输入 storage 改变则重新物化;invalid/clear fail-closed。移除每 tick 深比较完整 snapshot vectors。 |
| 06 B07 RGBA8 signed zero | 增加内部 `particle_gpu_math.hpp` signed RGBA8 编码,byte 128 明确表示 0;shader 以 high precision 还原 byte。未改公开 QBIN/FieldProfile encoding。数学测试覆盖正负取消、零、NaN/invalid 与量化下限。 |
| 07 B08 Mercator U/V | helper 与 GL/Metal shader 的东向 U、北向 V 都使用相同纬度度量,极区分母夹紧;保持 canonical m/s 和现有视觉时间倍率。测试覆盖 0°/45°/60° 四方向及极区有限值。 |
三项 A2 Source 测试还覆盖 50 次 mix-only clock tick 不重物化、真实 endpoint 和相同 valid-time revision 替换触发重物化、clear 后 projection 清空,以及 source notification 嵌套 tick 不双推进。Host-facing 新 API、C-001、QBIN 格式未改变。
## 4. 修改文件
### 源码、shader 与 A2 测试
- `include/mbgl/particle/field/field_data_adapter.hpp`
- `include/mbgl/particle/field/particle_gpu_math.hpp`(新增)
- `include/mbgl/shaders/gl/particle_gpu_validation.hpp`
- `include/mbgl/shaders/mtl/particle_gpu_validation.hpp`
- `include/mbgl/style/sources/raster_array_source.hpp`
- `platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java`
- `platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt`
- `platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/Step097V4GoldenFixture.kt`(新增)
- `platform/android/MapLibreAndroidTestApp/src/main/assets/integration/step097-v4/wind/T0.qbin`、`T1.qbin`、`contract.json`(新增 test-only fixture)
- `platform/ios/src/MLNParticleFieldRuntime.mm`
- `shaders/particle_gpu_validation.fragment.glsl`、`shaders/particle_gpu_validation.vertex.glsl`
- `src/mbgl/map/map_impl.cpp`
- `src/mbgl/particle/field/field_data_adapter.cpp`
- `src/mbgl/particle/field/field_projection_materialization.cpp`
- `src/mbgl/particle/field/field_runtime.cpp`
- `src/mbgl/renderer/layers/render_particle_validation_layer.cpp`
- `src/mbgl/style/sources/raster_array_source.cpp`、`src/mbgl/style/sources/raster_array_source_impl.hpp`
- `test/CMakeLists.txt`
- `test/particle/field_runtime.test.cpp`
- `test/particle/particle_gpu_math.test.cpp`(新增)
- `test/source/raster_array_v4_host_api.test.cpp`
- `test/style/source.test.cpp`(仅修正旧断言对 `sameFieldGpuContent()` 的预期;严格 endpoint identity 由新断言覆盖)
本报告之后的文档变更为 `docs/30_设计/STEP-097_V13三轮问题去重与修复矩阵_v1.md`、本报告、`docs/报告/STEP-097_V13三轮审查问题合并修复_Codex完成报告_v2.md`、`docs/50_Step记录/STEP-097_V13三轮审查问题合并修复_v1.md`、`docs/60_测试/STEP-097_V13修复后集中验证清单_v1.md` 及根目录 `粒子开发记录.md`。无其他步骤范围代码。
## 5. RDC 构建与测试
| 验证 | RDC 结果 |
| --- | --- |
| 原有完整 58 项回归(修正旧断言后) | OpenGL **58/58 PASS**。先前 57/58 的失败根因是旧断言把 `sameFieldGpuContent()` 当成 revision-sensitive;函数原有契约明确忽略 generation/dataVersion。测试现在分别验证 payload 等价与 strict endpoint identity,没有放宽 endpoint 比较。 |
| 当前 broad Core filter `Particle*.*:Source.RasterArray*:Source.HostField*:Source.ParticleField*:Source.CanonicalField*` | OpenGL **121/121 PASS**;Metal host **121/121 PASS**。 |
| A2 定向 Source 三项 | OpenGL **3/3 PASS**;Metal host **3/3 PASS**,包括 mix-only 50 ticks、endpoint/revision invalidation 与同步通知重入。 |
| shader generator | `node shaders/generate_shader_code.mjs` 连续两次成功,输出幂等。 |
构建/测试日志:`artifacts/maplibre-particle/step097-a2-final-validation/mix-reuse-broad-opengl.log`、`mix-reuse-broad-metal-host.log`、`mix-reuse-focused-opengl.log`、`mix-reuse-focused-metal-host.log`、`build-mix-reuse-opengl.log`、`build-mix-reuse-metal-host.log`。Metal host 测试是 Mac Core runner,不是 iOS runtime 或设备 Metal 验证。
## 6. Android APK 与 FZG1-2
- 标准命令由 RDC 执行:`./tools/particle_android_validation/build_validation_apk.sh`。
- 产物:`artifacts/maplibre-particle/android-validation/MapLibreAndroidTestApp-validation-opengl-debug-x86_64.apk`;OpenGL Debug、x86_64、61,753,803 bytes;SHA-256 `27bf4a6cc191bd86c4d1c10398478d81c2999edd61fe7242e40e5188516b0ca5`。步骤归档副本:`artifacts/maplibre-particle/step097-a2-final-validation/MapLibreAndroidTestApp-A2-mix-reuse-x86_64.apk`。
- RDC 安装至 Panasonic FZG1-2(ADB `192.168.200.139:5555`)成功;设备内 APK SHA-256 与构建产物相同。
- Test-App-only Golden Wind T0/T1 场景脚本 exit 0,17 PNG、24 steps、ANR=0:load、seek 25/75、speed 2x/1x、PLAY 30 秒与 PLAY 时 UI/TIME 响应、PAUSE/stable pause、replay、CLEAR、reload、再次 PLAY/PAUSE。截图 `after-v4-mix-reuse/05_play_15s.png` 可见粒子。
- 上传/reset 计数按资源 epoch 记录:clear/reload 前 mix-only playback 稳定于 `fieldUploadCount=6/resetCount=2`;CLEAR/reload 后计数合法增加到 `fieldUploadCount=10/resetCount=4`,reload 后 playback 稳定。没有宣称跨 CLEAR/reload 零上传/零 reset。
- APK 输入源码 diff 与保存快照 `mix-reuse-build-source.diff` 的 SHA-256 都为 `18927a5f30a88f5c1c3a00e0017473dc33b04151c227e4baf52250dc4ad2e6a2`。
## 7. 历史失败、性能与平台边界
- 2026-09-26 12:43 首次 FZG1 ANR 的 main stack 是 Forecast clock tick 的同步 SourceObserver reentry;`forecastTickInProgress` guard 与回归测试针对该缺陷。后续有一轮没有 ANR 但 UI/TIME 仍不可用、主线程高负载;debuggerd 定位到每 tick 对大 QBIN projection 重复物化 validity/vector 并深比较 snapshot。mix-only immutable snapshot reuse 后,最终 FZG1 闭环通过。
- 历史 debuggerd、首次 ANR、第二次无响应试跑及最终验证证据均保存在 `artifacts/maplibre-particle/step097-a2-final-validation/`;报告保留失败经过,不把中间失败覆盖成 PASS。
- PLAY 30 秒只是交互闭环,不是 long-run 稳定性、FPS、frame time、内存或 GPU 性能基准;本轮没有这类数字。
- 用户豁免 iOS runtime:`USER_ACCEPTED_WITHOUT_IOS_RUNTIME_TEST`。Android 与 Metal host Core 结果不能替代 iOS device PASS。
- Integration C-001 未改,Host 公开 API/QBIN schema 未改;没有进入 B/C、没有运行 STEP-098/Jest、没有更改 `.13` 发布物。
## 8. 阶段与 Git 收尾
- A2 状态:Android/Core 代码、原生回归和 FZG1 验证完成。
- STEP-097 整体:**PARTIAL**;B/C 未开始。其余 24 项逐项状态见更新后的去重矩阵。
- 本报告无 commit/push。RDC 后续做独立 QA 与 scoped Git 操作;用户未跟踪 STEP-098 不得纳入。
## 9. RDC Git 源码提交核验(报告编写后更新)
- A2 源码、标准 TestApp 内部 fixture 与定向测试已以 **`f49ffa68a17e0a505199bdbeccf07f8543f3a05f`** 独立 scoped commit 提交并推送 `origin/particle/main`;26 个代码/fixture/test 路径,未包含任何文档、STEP-098、Integration、NavSea 或发布资产。远端 HEAD 回读相同。
- 该提交只是把已经通过 RDC 验证的源码固定进 Git;构建时仍如实为 `5bb1a0e` + dirty source diff。源码部分保存快照的 SHA-256 对应提交内容,不把后续文档更新误算进构建输入。
- 文档与 Step/矩阵/测试清单将作为独立 scoped commit 收尾。

View File

@@ -0,0 +1,36 @@
# STEP-097 B 阶段独立验收与版本阻塞报告 v1
状态:**PARTIAL / BLOCKED_NATIVE_AAR_COMPATIBILITY**。B 不判 PASS,C 不启动。本报告按实际验证结果更新,不以 Codex 终态代替验收。
## 基线与执行
- 日期:2026-09-26;Mac 仓库 `particle/main` HEAD `b1239ac3b1554943200e6fbf14321459ccc86527`;实施工作树 dirty,尚未 commit/push。
- Integration `main` 已由 RDC fetch 核验,本地及远端均为 `318f8ccf911ac3136c3086a1d14e61f83e2d4bce`;Contract C-001 未改。
- Codex 两次均使用 GPT-6 Luna High,`codex exec --json`;分别收到 `turn.completed`、退出码 0;完整 JSONL/status.json 位于 `artifacts/maplibre-particle/step097-b10-codex/` 和 `step097-b-fixture-codex/`。
- 工作范围:08 JSON、09 数值 ingress、10 Android owner/map destroy、20 Test Host 版本配对;用户未跟踪 STEP-098 及正式 .13 发布资产未触碰。
## 代码和独立证据
- 08:按真实字节长度解析,拒绝 NUL、重复键、非法 strategy/schema,失败清旧计划;已存在定向 GTest。
- 09:有效 cell 数值及 scale/offset 有限性验证在提交 Store 前执行;合法 validity=0 保留,坏高 revision 不覆盖旧场。
- 10:新 View attachment owner、generation guard 与 map destroy observer;附 Android owner/JUnit 和 MapLibreMap 真正 onDestroy 测试。
- 20:Test Host package.json 与 lock 均精确为 RN `13.4.1-particle.13`;正式 tarball SHA-256 `3a726db06cb967fe56de77ca4a72feed98cd642a2d1b73fd51b1531d15ef7539` 与 release manifest 一致,lock integrity 的 SHA-512 与实包一致。
- Mac Core 增量构建 PASS;B 四项定向 GTest **4/4 PASS**;原 broad 134 项中 2 项旧 UInt8→Int16/UInt16 4-byte fixture 不合法,修正为合法 8-byte 小端四样本后重新构建,broad **134/134 PASS**。未放宽生产数值校验或既有断言。
- 原始日志:`artifacts/maplibre-particle/step097-b-rdc/core-*.log`。
## 阻塞门槛
- B10 新增 `MapLibreMap.addOnDestroyListener()` 为 Android SDK 新方法;正式 .13 release source commit `ddf20dec9fe9cd05b77274827b6825829500dd5a` **没有此方法**。
- Test Host 默认及 RN 模块 Gradle 仍消费已发布原生 AAR `13.4.1-particle.13`。所以新 RN Kotlin bridge 直接调用该方法时,现有默认正式消费组合不具备此符号;不能用本工作树 Native 源码 JUnit 冒充 .13 RN consumer 编译通过。
- 需要选择不增加已发布 ABI 依赖的完整生命周期方案,或者另走正式新 Native/RN 同版发行与配对验证。未经上游决定不得改写 .13 tag、manifest/AAR,也不得以反射或私有绕道掩盖版本问题。
## 2026-09-26 RDC 补充独立实测(Mac,覆盖上方旧测试数字)
- B10 Codex 的持久化 `status.json`:`gpt-6-luna/high`,75 条 JSONL 事件,`turn.completed`,exit 0;旧 fixture 修正任务同样 `turn.completed` / exit 0。两次交付本身不代表验收。
- 本轮 Mac 原生测试 runner 增量编译 exit 0;六项 B 定向 GTest **6/6 PASS**,完整扩展 filter **139/139 PASS**;旧 fixture 仅修正 16-bit 四样本合法长度,未放宽生产校验或原断言。证据:`artifacts/maplibre-particle/step097-b-rdc/validation-{build-core,focused-gtest,broad-gtest}.log` 与 `validation-status.json`。
- Mac 使用现有 OpenJDK 17 和 Android SDK:`:MapLibreAndroid:testOpenglDebugUnitTest --tests org.maplibre.android.maps.MapLibreMapDestroyListenerTest` **2/2 PASS**;`:MapLibreAndroid:compileOpenglDebugJavaWithJavac` exit 0。JUnit XML 位于 `platform/android/MapLibreAndroid/build/test-results/testOpenglDebugUnitTest/`;证据在 `android-validation-status.json`、`validation-native-{destroy-junit,compile}.log`。
- Owner Kotlin 单测使用独立临时 JVM 项目,源码及测试副本 SHA-256 与本工作树对应文件分别一致:生产 `36b437a0bc1573bafcf1a99ee37d4c438b72ea9ad5a9847fb0021f8093a3296a`,测试 `4c27cc3e82c0570b78b27aec46d565359eff11e18c2ff292e4188c158e8a4407`;JUnit **4/4 PASS**,0 fail/skip。第一次 offline 插件 marker 缺失,第二次正常解析并通过;详见 `owner-jvm-status.json` 和 `validation-owner-jvm-retry.log`。这是 owner helper 的 JVM 回归,不冒充 RN Host 集成。
- `git diff --check` exit 0,正式 .13 release commit `ddf20dec9` 的 `MapLibreMap.java` 无 `addOnDestroyListener()`,而当前 RN Bridge 调用该方法且 Gradle 精确锁定旧 .13;因此 **默认 RN consumer 编译仍未通过/不可宣称 PASS**,此阻塞不因上述单元测试通过而解除。
- Linux 独立工作树的 Gradle 仍有原进程,状态未确认完成,不能以 Mac 测试代替 Linux 结果;须等待并单独记录最终退出状态。
- 当前结论维持 `PARTIAL / BLOCKED_NATIVE_AAR_COMPATIBILITY`。STEP-097 整体未关闭;C、STEP-098、正式发布均 NOT RUN。未授权改写 .13 tag/manifest/产物,不把当前不兼容代码推入正式消费基线。
## Git 阻塞检查点(2026-09-26,后续事实)
- 为避免将当前无法和正式 .13 AAR 配对编译的新 RN bridge 提交进 `particle/main`,RDC 以 `b1239ac3b1554943200e6fbf14321459ccc86527` 创建独立 worktree,逐文件 SHA-256 比对 21 个 B 源码/测试/文档,并 scoped commit/push 到 `step097-b-blocked-20260926`。
- 首个 checkpoint commit:`e463ba04ce956a54eed02b77f981eeeedd9a5534`;独立远端回读相符。主线 `origin/particle/main=b1239ac3b1554943200e6fbf14321459ccc86527` 不变。原 Mac dirty B 工作树及用户 STEP-098 不变;发布资产无差异。
- Git 保存成果不等于 B 验收通过;B 仍 `PARTIAL / BLOCKED_NATIVE_AAR_COMPATIBILITY`,待 Integration / 发行流程明确新 Native/RN 同版配对或实现不依赖新 ABI 的可靠替代方案。不得启动 C、STEP-098 或覆盖 .13。

View File

@@ -0,0 +1,57 @@
# STEP-097 V13 三轮审查问题合并修复 — Codex 完成报告 v1
## 1. 执行结论
**PARTIAL,停在 A1。** 阶段 0 已静态复核并建 24 项矩阵;A1 的 01/02/03/19 已做部分源码修改。01/02/03 缺少任务要求的专用可执行像素、Metal 附件及异常注入测试入口,不能标 `CODE_IMPLEMENTED_STATIC_CHECKED`。19 有 GTest 源码且静态核对完成,测试 **NOT RUN**。按照任务书“前一阶段没有明确代码完成和静态检查结果,不推进下一阶段”,A2/B/C 未开始。此次提交不是 V13 运行态验收,也不是软件发布。
## 2. Git、机器与版本基线
- 机器:`Mac-mini-tei.lan`;目录:`/Users/tei/dev/MapLibreParticleLab`;分支:`particle/main`。
- 起始 HEAD 与远端查询值:`720d309364628f85049cf9969d71c71e8dfacce9`;起始工作树 clean,子模块未见偏离。
- A1 部分代码检查点:`e67e4961b5b1a1ff8fa7c1e3263799d63f89cadc`。本报告成稿时的代码 HEAD 为该提交;本报告及最终文档另作 scoped 交付提交,最终 HEAD 以交付后的 Git 记录为准。
- `.13` tag 解引用:`ee0b89e711bf01335b3aa71d36def785cd97fec3`;manifest 记录 RN/Android `13.4.1-particle.13`、iOS `6.28.0-particle.13`。没有更改 tag、manifest 或已发布产物。
- Integration 本地只读 HEAD `39ad8415fbdb76c1822460bb09f3a3f35fe141e0`,C-001/RFC-001 为既有合同;没有由 RDC 独立确认其远端同步。
## 3. 实际修改与新增文件
| 范围 | 文件与内容 |
| --- | --- |
| A1 代码 | `src/mbgl/renderer/paint_parameters.{hpp,cpp}`、`render_target.cpp`、`renderer_impl.cpp`、`render_orchestrator.hpp`、`retained_underlay_cache.{hpp,cpp}`、`retained_underlay_runtime_state.hpp`;`src/mbgl/mtl/offscreen_texture.cpp`、`render_pass.cpp`。 |
| A1 测试源码 | `test/renderer/retained_underlay_cache.test.cpp` 增加旧 topology release 不计入新同 index 的 GTest。 |
| 新增文档 | `docs/30_设计/STEP-097_V13三轮问题去重与修复矩阵_v1.md`、`docs/50_Step记录/STEP-097_V13三轮审查问题合并修复_v1.md`、`docs/60_测试/STEP-097_V13修复后集中验证清单_v1.md`、本报告。 |
| 仅追加 | 根目录 `粒子开发记录.md`。 |
没有修改 Integration、NavSea、项目总则、旧调查、release manifest、公开 Field/QBIN 协议或已发布坐标。
## 4. 各问题状态
具体旧编号、源码定位、反例、测试入口和平台待测项见[24 项矩阵](../30_设计/STEP-097_V13三轮问题去重与修复矩阵_v1.md)。编号 01/02/03:源码已局部修改,仍为 `WAITING_RDC_BUILD_AND_RUNTIME`,且专用测试源码缺口阻止 A1 完成。19:`CODE_IMPLEMENTED_STATIC_CHECKED`,GTest **NOT RUN**。04/05/07/08/09/10/11/13/16/17/20:`WAITING_RDC_BUILD_AND_RUNTIME` 在矩阵中表示待实施,不表示已有代码。06/12/14/15/18/21/22/23/24:`DEFERRED_DESIGN`;其中 23/24 是 Integration 协调项。无项目被判产品级 PASS,无项因三轮重复编号被删除。
## 5. 已实施事实、差异与停止原因
1. 01:新 render target 绑定后重置 `nextStencilID` 与 tile clipping map;同一 target 内未清 stencil 时仍可复用。接入 retained、普通 offscreen 与主目标三处目标切换。
2. 02:Metal 离屏 `isRenderable()` 检查 color/depth/stencil,simulator/x86 combined depth-stencil 同时绑定两个附件;RenderPass 转换 color/depth/stencil clear/load/store,创建失败显式抛出供 retained fallback。实际 Metal validation **NOT RUN**。
3. 03:范围 visitor 可回报绘制异常;retained build 收到错误后标失败并转 full fallback,不发布成功缓存。真实 layer 故障注入 **NOT RUN**。
4. 19:旧 cache 的 release 仅在同 topology revision 更新当前 segment 计数,aggregate release 仍记录。新增同 index 换边界的定向 GTest 源码。
与任务书差异:未完成 A1 关键测试入口,故没有进入 A2/B/C,未形成各阶段成功提交。停止依据是任务书第 2、4、7 节的不可跨越检查点,而非将未运行的构建当作失败。当前代码检查点仍需 RDC 独立审查,特别核对 Metal API/资源所有权和异常后的 GL/Metal fallback。
## 6. 静态检查、构建与测试记录
- 已执行:`git diff --check` 与 `git diff --cached --check`,均无输出、exit 0;`rg -n '[[:blank:]]+$'` 核对新文档,无匹配;`rg` 核对 `beginRenderTarget` 三处调用和 `recordSegmentResourceRelease` 的新签名及测试调用。已只读核对 metal-cpp 的 `StoreActionStore`、`setClearStencil` 声明。
- CMake/Ninja/Xcode/Gradle、codegen、链接、APK/AAR/XCFramework、npm install/typecheck、GTest、Jest、GPU capture、设备与 NavSea:**NOT RUN**。未有本轮 build log、测试数、耗时或性能数。
- 生成产物:无。大小/SHA-256/Renderer/Build Type/ABI:不适用。软件发布:**NOT RUN**。
- 后续精确命令、fixtures 缺口、预期与责任见[集中验证清单](../60_测试/STEP-097_V13修复后集中验证清单_v1.md)。
## 7. 已知问题、未完成事项与下一步
- 先为 01 增加同 vector source/same tiles 的 R-R/R-C-R/C-R-R 可执行像素 fixture;为 02 增加 Metal descriptor/combined attachment 与失败注入入口;为 03 增加 layer render 异常注入及下一帧恢复入口。之后静态审查 A1,再由 RDC 在授权下集中编译和运行。
- A2 的 PLAY/endpoint/零量化/Mercator,B 的 JSON/坏帧/Android listener/Host 版本,C 的 diagnostics/ordinal/物化/资源优化均未实施。不得将本报告用于关闭这些问题。
- Integration C-001/RFC-001 未更新;23 的 Compatibility/Status 及 24 的共享地理 Ocean Mask 需粒子负责人协调。此次未触发需要改变公共合同的实现,因此没有伪造“公共接口阻塞报告”或声称决定已被接受。
- 建议继续 STEP-097 A1 的测试入口与独立审查,在完成当前检查点后再按原任务书进入 A2;不自行创建下一 Step。
## 8. 2026-09-26 A1 测试入口续作(补充,历史结论保留)
从 `be5b9080ed926a2632c5126c3522e242a2a85967` 的 clean `particle/main` 继续,只处理 A1 01/02/03 测试入口。新增 `Step097StencilFixture.kt` 与 Validation Activity 菜单,提供一个 GeoJSON source 上的 R-R/R-C-R/C-R-R;新增 `render_orchestrator_error.test.cpp` 正反范围异常/observer/cache 重试 GTest;新增 `mtl_retained_attachments.test.cpp` 离屏附件和 clear/load/store GTest。`render_pass.{hpp,cpp}` 将生产 clear 逻辑抽为内部辅助函数供测试共用;`test/CMakeLists.txt` 登记两个测试源。更新矩阵、Step、集中验证清单和开发记录。
代码 scoped commit:`d0ced7235`。此续作的 `git diff --check` 静态检查无输出;构建、GTest、Jest、APK、Metal/iOS、设备截图及软件发布均 **NOT RUN**。RDC 已于本续作之前执行第 19 项 GTest 1/1 PASS 和 FZG1 设备辅助冒烟;不能把那些旧结果归到本次新测试源码。01/02/03 仍需 RDC 编译与 runtime 验证,特别是 Metal 失败注入和 GPU 层异常 fallback,A1 仍 PARTIAL;A2/B/C 未开始。文档 scoped commit 与远端状态以最终 Git 记录为准。

View File

@@ -0,0 +1,96 @@
# STEP-097 V13 三轮审查问题合并修复 — Codex 完成报告 v2
## 1. 执行结论
**PARTIAL:A1 与 A2 的 Android/Core 检查完成;B/C 未开始。** A2 04–07 已由 Codex GPT-6 Luna High 在用户可见会话实现,由 RDC 独立构建、测试和执行 FZG1-2 运行闭环。iOS runtime 依据用户决定豁免,状态为 `USER_ACCEPTED_WITHOUT_IOS_RUNTIME_TEST`,不是 iOS PASS。没有运行 Jest、STEP-098 或发布 `.13`。
## 2. Git、环境与发布基线
- 机器:`Mac-mini-tei.lan`;仓库:`/Users/tei/dev/MapLibreParticleLab`;branch:`particle/main`。
- 源码验证基线 HEAD:`5bb1a0e6353c5dfe8db8a61bbeba9cef447280ef`;验证时 worktree dirty,未提交。报告编写只增加/更新本报告所列 A2 文档。
- A2 source diff SHA-256 `18927a5f30a88f5c1c3a00e0017473dc33b04151c227e4baf52250dc4ad2e6a2`,与 APK build source snapshot 完全相同。
- Integration `main`=`318f8ccf911ac3136c3086a1d14e61f83e2d4bce`;C-001 未修改。
- 冻结 `.13` 坐标/tag/manifest/发布产物未改。没有提交或 push。
- 用户 untracked STEP-098 任务文件保持未跟踪、未修改、未 stage。
## 3. 修改文件
源码、shader、测试及 Test-App-only V4 Golden fixture 的完整路径列于 [A2 阶段验证记录 v3](STEP-097_A2_阶段验证记录_v3.md) 第 4 节。核心范围包括 `RasterArraySource` 与 `Map::Impl` Forecast clock、严格 GPU endpoint identity 和 mix-only snapshot reuse、signed RGBA8 helper、GL/Metal shader 与 Mercator 数学、Android/iOS timer wake bridge、A2 Core 回归和 test-only Wind T0/T1 fixture。
本轮交付文档:
- `docs/报告/STEP-097_A2_阶段验证记录_v3.md`
- 本文件 `docs/报告/STEP-097_V13三轮审查问题合并修复_Codex完成报告_v2.md`
- 更新 `docs/30_设计/STEP-097_V13三轮问题去重与修复矩阵_v1.md`、`docs/50_Step记录/STEP-097_V13三轮审查问题合并修复_v1.md`、`docs/60_测试/STEP-097_V13修复后集中验证清单_v1.md`
- 仅追加根目录 `粒子开发记录.md`
未修改 Integration、NavSea、项目总则、C-001、QBIN schema、`.13` 发布物、用户 STEP-098 或 Host public API。
## 4. 24 项状态
| # | 状态与实际结论 |
| --- | --- |
| 01 N01/B01 | A1 Android/Core 检查完成;同源 stencil fixture FZG1 检查已在 A1 报告记录。A1 iOS runtime 按用户决定豁免。 |
| 02 N02/B02 | A1 Android/Core 阶段收口;Metal host 不是设备验证。无 iOS runtime,按用户豁免记录。 |
| 03 N03/B12 | A1 Android/Core 阶段收口;异常恢复 GTest 已在 A1 报告记录。 |
| 04 N04/B06 | A2 完成;唯一 Source forecast clock、wake-only timer 与同步回调重入保护;OpenGL/Metal host 定向测试和 FZG1 PLAY/PAUSE 实测通过。 |
| 05 B13 | A2 完成;`sameFieldGpuContent` 仍为 payload 等价,strict endpoint identity 独立比较;mix-only snapshot reuse 与 revision invalidation 通过 Core/设备验证。 |
| 06 B07 | A2 完成;内部 signed zero encoding/shader 路径和数学边界测试通过 OpenGL 与 Metal host broad 回归。 |
| 07 B08 | A2 完成;U/V 纬度 metric 和极区夹紧,数学回归与 Android 场景通过;不等于 iOS runtime。 |
| 08 N05 | `WAITING_RDC_BUILD_AND_RUNTIME`;B 未开始。 |
| 09 N06 | `WAITING_RDC_BUILD_AND_RUNTIME`;B 未开始。 |
| 10 N07 | `WAITING_RDC_BUILD_AND_RUNTIME`;B 未开始。 |
| 11 F01/C01 | `WAITING_RDC_BUILD_AND_RUNTIME`;C 未开始。 |
| 12 F02/C02/B03 | `DEFERRED_DESIGN`;同步等待依赖未在本阶段处理。 |
| 13 F03/C03/B04 | `WAITING_RDC_BUILD_AND_RUNTIME`;C 未开始。 |
| 14 F04/C04 | `DEFERRED_DESIGN`;无资源预算改动。 |
| 15 C04/B11 | `DEFERRED_DESIGN`;无 store 总容量语义改动。 |
| 16 F07/C05 | `WAITING_RDC_BUILD_AND_RUNTIME`;C 未开始。 |
| 17 C06/B05/B10 | mix-only immutable materialization 子项随 A2 完成;其他 diagnostics、ordinal、UI owner 工作仍待后续,整体不得解读为 C 完成。 |
| 18 C07 | `DEFERRED_DESIGN`;drawable/tweaker 生命周期未改。 |
| 19 F09/O01 | A1 已有 GTest 与 FZG1 证据保留;不把资源诊断夸大为 GPU 无泄漏证明。 |
| 20 F05/E01 | `WAITING_RDC_BUILD_AND_RUNTIME`;B/Test Host package lock 未改、未验证。 |
| 21 F06 | `DEFERRED_DESIGN`;保留 multi-C 安全 fallback。 |
| 22 F08 | `DEFERRED_DESIGN`;保留保守失效策略。 |
| 23 F10/E02 | `DEFERRED_DESIGN`;需要跨项目协调,Integration status 未改。 |
| 24 STEP-095 §7/B09 | `DEFERRED_DESIGN`;共享地理 Ocean Mask 需要后续跨项目契约。 |
矩阵保留全部 24 项、旧编号及平台边界。
## 5. 实现摘要与偏差
1. Forecast clock 每次由 Map Continuous update 调用一次;同步 SourceObserver 重入不再触发第二次 tick。Android/iOS timers 负责 triggerRepaint 唤醒,不独立推进时间。
2. Strict endpoint identity 涵盖 frame/revision、grid、U/V、validity 与 encoding。mix-only 时复用当前 immutable materialized snapshots;新 frame/revision 或 storage identity 改变时重新 materialize;clear/invalid 仍 fail-closed。没有使用 generation/dataVersion 偷换 `sameFieldGpuContent()` 的已有语义。
3. RGBA8 byte 128 表示 signed zero,GL/Metal shader 使用高精度 byte reconstruction。东/北向 Mercator 位移应用一致纬度 metric。
4. 新增 A2 定向 source 测试、GPU math 测试及 test-only Golden Wind 两帧 fixture;没有新建 Host API、QBIN 格式或 C-001 语义。
5. 首次 FZG1 ANR 是同步 clock notification 重入;guard 修复后仍有一轮因每 tick 重物化大场、深比较而 UI 不响应。最终 mix reuse 版本完成设备闭环。原始失败日志与 debuggerd 均保留。
## 6. RDC 构建、测试、设备及产物
- 原 58 项回归:旧断言修复后 **58/58 PASS**。失败断言与旧函数定义不符,修复测试语义;没有放宽新 strict endpoint 条件。
- OpenGL broad Core filter:**121/121 PASS**。
- Metal host 相同 broad Core filter:**121/121 PASS**。
- 三项 A2 Source 定向测试:OpenGL 和 Metal host 各 **3/3 PASS**。
- Shader generator 连续两次成功、输出幂等。
- 标准 APK `./tools/particle_android_validation/build_validation_apk.sh`:OpenGL Debug、x86_64、61,753,803 bytes;SHA-256 `27bf4a6cc191bd86c4d1c10398478d81c2999edd61fe7242e40e5188516b0ca5`。FZG1-2 安装成功、设备 hash 相同。
- Golden 闭环:17 PNG、24 steps、exit 0、ANR=0;PLAY 30 秒并确认 TIME/UI 操作响应,随后 PAUSE/stable pause/replay/CLEAR/reload/replay/PAUSE。15 秒截图有可见粒子。
- 日志资源 epoch:首次 stable mix-only `upload=6/reset=2` 保持不变;CLEAR/reload 后合法到 `upload=10/reset=4`,随后 playback 稳定。没有宣称跨 CLEAR/reload 零上传或 reset。
- Android 产物、测试日志、设备日志、截图、步骤清单、debuggerd 与历史失败证据在 `artifacts/maplibre-particle/step097-a2-final-validation/`。
- 本轮无 FPS、p50/p95/p99、frame time、内存或 long-run 性能结论。iOS runtime 未运行,用户豁免。
## 7. 未完成与限制
- STEP-097 总体仍 `PARTIAL`:B/C 未进入。04–07 完成不代表 08–24 全完成;第 17 项只有 mix-only 子项完成。
- iOS runtime:`USER_ACCEPTED_WITHOUT_IOS_RUNTIME_TEST`。Metal host GTest 只证明共享 Core host 路径,不能替代 iOS Metal device PASS。
- FZG1 Golden 是 Test-App-only fixture,不是 NavSea、正式产品数据链或全量天气验收。
- 30 秒 PLAY 交互不构成长时稳定性或正式 GPU 性能验收。
- 无提交/推送;用户 STEP-098 未触碰;`.13` 未发布。
## 8. 后续交接
RDC 对当前 dirty A2 代码与本报告做独立 QA,按用户后续授权执行 scoped Git 操作。若后续继续 STEP-097,只能由用户明确允许进入下一个阶段后启动 B;本报告不自动开启 B/C。
## 9. RDC 源码 Git 后续事实(本报告编写后)
- 源码 scoped commit **`f49ffa68a17e0a505199bdbeccf07f8543f3a05f`** 已推送 `origin/particle/main`,远端核对一致。此事实覆盖前文“未提交/未推送”的报告编写时状态,保留原始验证 HEAD 以便追溯 dirty APK 的实际构建来源。
- 文档另行提交;不将源代码 Git commit 当作额外编译/设备测试。用户 STEP-098 仍不提交,B/C 不启动。

View File

@@ -0,0 +1,129 @@
# STEP-098 QBIN V4 单帧与八帧滚动缓存 Codex 完成报告 v1
日期:2026-09-26(JST)
结论:**IMPLEMENTED / WAITING_RDC_VALIDATION_AND_PLATFORM_RUNTIME**。Codex 源码阶段已交付;未构建、未运行 T01–T17,不能标记功能 PASS。
## 1. 执行身份与契约
- 执行器指定实际模型:**GPT-6 Luna High**。
- 仓库:Mac 原始正式源码目录 `/Users/tei/dev/MapLibreParticleLab`;branch `particle/main`。
- 起始/结束 HEAD:`b1239ac3b1554943200e6fbf14321459ccc86527`(本轮无 commit)。
- 起始时 `origin/particle/main` 同为 `b1239ac3b1554943200e6fbf14321459ccc86527`。
- Integration `main`/`origin/main`:`1dd576d3304e267e53ed64d31f9711d2350ce10a`,工作树干净。
- 本轮依据 RFC-004、ADR-001、C-002、COORD-005。新契约状态为已接受的设计决定,非代码/设备/V15 发布验收。旧 RFC-003/COORD-004 B 已按用户指示作废删除;此事实解除 `STEP-098_公共契约阻塞报告_v1.md` 的历史阻塞,本轮未修改旧报告或原任务 v1。
- 本机 Android Native/RN 版本文件仍为 `13.4.1-particle.13`,iOS VERSION 为 `6.28.0`。未更改版本锁、`.13` release/tag/manifest/AAR、独立 checkpoint worktree 或远端。
- `git submodule status` 与记录 SHA 一致。开工时工作树已有 STEP-097 B tracked/untracked 改动及 STEP-098 原任务等,共 29 条 status 项;均保留。没有执行 reset、clean、stage、commit 或 push。
## 2. 实施结果
### Core、Ingress、Resolver
- Store 按每个 `fieldKey` 最多 8 个帧升序存储。精确 validTime 返回真实单帧 T0=T1、mix=0;只有非精确请求且存在合法相邻 pair 时才做插值,未覆盖时间 fail-closed。
- 分离可共存 schema 与插值身份:相同 grid/plane schema 的新 reference/profile/fieldID 批次可进入窗口并按精确时刻单帧首显;双帧插值必须匹配 `fieldID`、QBIN `referenceTimeMs`、resolved `profileID/version` 和 grid/plane schema。Ingress 验证后保留 profile 身份,避免同 schema 不同 profile 被混插。
- 满额新时间插入使用临时候选有序集合:以新帧 validTime 为中心比较旧最早/最晚端点距离,远端先在候选集删除;等距按 C-002 删除原最早端。候选邻接 schema 验证成功后才 `swap`;失败不改 Store。满额正常有效插入仍返回既有 `AcceptedInserted`,`RejectedCapacity` 名称保留兼容。
- Duplicate、同时间 revision/stale/identity-conflict 仍走既有分支,不触发其他时间淘汰;submit 不触碰 forecast clock。每 key 可独立达到 8 帧。
- 增加原始 decoded 字节计数:每帧为有效性 bitset + 非 validity plane payload,Store/Series 汇总;不计 profile 元数据、Float32 物化快照、图像、GPU texture、particle/trail。
### Projection、Renderer、Bridge
- Binding 和 materialization 支持精确单帧,T0/T1 共享同一个物化 Snapshot。标量-only 帧仍不能成为向量 Particle projection。
- Particle 与 WeatherColor 首次安装同一 endpoint 时复用 image/texture/scale;当前 bracket 不变时 Source projection 继续沿用 existing equality/materialization reuse;被淘汰的活动端点重新 resolve 后失效清除。
- 实现位于 Shared Core/common Renderer,GL/Metal 共用语义;没有声明 Android 或 iOS runtime 通过。
- `FieldRuntime.ts`、`NativeWeatherRuntimeBridge.ts`、Android Kotlin/JNI 与 iOS Objective-C++/Facade 静态核对:旧 `submitEncodedField(...)`、`setEncodedForecastTime(...)` 名称、签名、返回名称和调用顺序未修改;bridge 没有本轮改动。
- **ABI 说明**:RN/平台 bridge 方法 ABI 未变;`DecodedFieldFrame` 新增内部 profile 字段,C++ 数据结构布局发生变化,需同源码重新构建 Native 消费端。未使用 `.13` AAR 冒充该 ABI 验证。
## 3. 本轮修改文件
### STEP-098 源码与测试
- `include/mbgl/particle/field/field_runtime.hpp`
- `src/mbgl/particle/field/field_runtime.cpp`
- `src/mbgl/particle/field/field_projection_materialization.cpp`
- `src/mbgl/particle/ingress/qbin_v2_decoder.cpp`
- `src/mbgl/renderer/layers/render_particle_validation_layer.cpp`
- `src/mbgl/renderer/layers/render_weather_color_layer.cpp`
- `test/particle/field_binding.test.cpp`
- `test/particle/field_runtime.test.cpp`
- `test/particle/qbin_v2_decoder.test.cpp`
- `test/source/raster_array_v4_host_api.test.cpp`
### 新增/追加文档
- 新增 `docs/30_设计/STEP-098_QBIN_V4单帧与八帧滚动缓存设计_v1.md`
- 新增 `docs/50_Step记录/STEP-098_QBIN_V4单帧与八帧滚动缓存_v1.md`
- 新增本报告 `docs/报告/STEP-098_QBIN_V4单帧与八帧滚动缓存_Codex完成报告_v1.md`
- 仅追加根目录 `粒子开发记录.md`
现存 dirty B 文件、STEP-098 原任务、旧阻塞报告与 B untracked helper/test 均保留。`test/particle/field_runtime.test.cpp` 和 `test/source/raster_array_v4_host_api.test.cpp` 开工前已有 B dirty 内容;其既有 fixture 修正未覆盖,本轮在其上增加 STEP-098 测试,并给 Source QBIN helper 加入 reference-time 参数。本轮没有新增 RN、Android/iOS bridge、Host/NavSea/Server 文件。
## 4. T01–T17 测试代码覆盖与真实运行状态
“代码覆盖”指对应源码测试场景已新增或由既有定向测试覆盖;所有行的**实际运行状态均为 NOT RUN**。没有将未运行测试写成 PASS。
| 项 | 代码覆盖 | 实际运行 |
| --- | --- | --- |
| T01 | 单帧精确 projection/materialization、标量-only 不产生向量、Source 色场/粒子单帧 | NOT RUN |
| T02 | 第二帧加入后中点进入双帧插值 | NOT RUN |
| T03 | 乱序升序;wind/wave 独立 key;批次边界不混插 | NOT RUN |
| T04 | 满 8 后未来帧保留、最早端淘汰 | NOT RUN |
| T05 | 满 8 后过去帧保留、最晚端淘汰 | NOT RUN |
| T06 | 内部时间等距 tie 删除原最早端并维持 8 帧有序 | NOT RUN |
| T07 | 新 reference/profile 批次精确首显、补帧升级、回到旧批次精确单帧 | NOT RUN |
| T08 | Duplicate、identity conflict、revision、stale 有既有 Core/Source 用例;无单个覆盖所有分支的新满额综合用例 | NOT RUN |
| T09 | 损坏/合同/数值/Grid schema 拒绝及旧投影/字节计数保持;既有 Binding 覆盖错误向量 | NOT RUN |
| T10 | pair mix 0.25/0.5/0.99 与真实 validTime;精确端点使用单帧表示 | NOT RUN |
| T11 | 非覆盖时间返回 WindowUnavailable | NOT RUN |
| T12 | 外围滚动时活动 pair 与 requestedTime 保持 | NOT RUN |
| T13 | 活动 endpoint 被淘汰后清除物化 projection | NOT RUN |
| T14 | wind 与 wave 各持 8 帧、各自字节计数 | NOT RUN |
| T15 | 同帧共用 Snapshot/首次 GPU endpoint 资源的源码路径;context loss/真实 GPU 未覆盖 | NOT RUN |
| T16 | Core 120 次双向滚动、8 帧上限、clear/clearAll 与 decoded byte 回落;Runtime/GPU 长期泄漏未覆盖 | NOT RUN |
| T17 | 现有 API/类型/方法顺序静态核对;自动化两步成功/失败 consumer 测试未运行 | NOT RUN |
## 5. 已执行与未执行验证
| 项目 | 实际结果 |
| --- | --- |
| `git diff --check` | **通过**,仅为空白与 patch 格式检查 |
| 新增 C++ 编译 | **NOT RUN** |
| Core/OpenGL GTest | **NOT RUN** |
| Metal host GTest / iOS 编译 | **NOT RUN** |
| Android Gradle、RN consumer、Test Host | **NOT RUN** |
| iOS/Xcode、Metal 设备 | **NOT RUN** |
| Jest/npm CI/ADB/模拟器/实体机 | **NOT RUN** |
| release pipeline、artifact、commit/push | **NOT RUN** |
RDC 按顺序执行以下命令,阶段 B 通过后才进入 C:
### 阶段 B:Store / Ingress / Binding
```bash
ninja -C build-step088-opengl mbgl-test-runner
build-step088-opengl/mbgl-test-runner --gtest_filter='ParticleFieldRuntime.*:ParticleFieldBinding.*:ParticleEncodedFieldIngress.*'
```
### 阶段 C:Source / Projection / Renderer(OpenGL 与 Metal host)
```bash
ninja -C build-step088-opengl mbgl-test-runner
build-step088-opengl/mbgl-test-runner --gtest_filter='Particle*.*:Source.RasterArray*'
ninja -C build-step097-metal-host mbgl-test-runner
build-step097-metal-host/mbgl-test-runner --gtest_filter='Particle*.*:Source.RasterArray*'
```
OpenGL/Metal host 命令均未在本轮执行。Metal host 结果也不能替代 iOS device runtime。
## 6. 手工验收、内存与剩余事项
Windows 11 Android 手工命令和 T01–T17 一轮场景清单见 [STEP-098 记录 §D](../50_Step记录/STEP-098_QBIN_V4单帧与八帧滚动缓存_v1.md)。只可使用后续相同源码生成且 Native/RN 版本匹配的 V15 Host APK、RN 包和 Native AAR;命令显式使用 `adb -s 192.168.200.139`。本轮未启动 V15 pipeline。iOS 需由 Mac 用户在同一冻结源码手工构建并用 Metal 设备检查单帧、双帧、批次边界、双向滚动、clear/context 恢复和长时内存。
CPU decoded 原始存储字节测量 API 已加入,测试源码含 120 次循环和 clear 后归零断言,但断言未运行。Float32 物化占用、GL/Metal 纹理、trail/GPU 峰值、32 帧风险和长时间内存趋势均未测量;不能据此报告内存安全 PASS。当前没有证据显示必须增加全局 MiB 上限,后续由 RDC/设备数据判断是否另行设计预算。
## 7. 工作树、阻塞与停止点
- 旧 `BLOCKED_CONTRACT` 不再适用,RFC-004 等新契约已正式接受。
- 本轮剩余门槛是 RDC 尚未构建/测试。测试失败、编译失败或状态未知时,停留该阶段并保留现场;不得跳过到平台/V15。
- STEP-097 B 的既有源码仍在本地工作树,原 Mac 工作树与 checkpoint worktree 均未重置或清理。本轮没有将 B 改动归入 STEP-098 报告修改集。
- 当前版本锁仍是 Native/RN `.13`;新增 `DecodedFieldFrame` C++ layout 不兼容证据尚未通过同版本新构建验证。不能用旧 `.13` AAR 替代。
- 未完成:RDC Core/Renderer 构建与 GTest、Android/iOS 同源码配套编译与真实 runtime、T01–T17 逐项实际执行、长时间内存/资源验证、V15 pipeline、版本制品与 Git 收尾。
- 本次停止点:**Codex 源码交付**。无 commit、push、release,也没有宣称 STEP-098 全部验收完成。

View File

@@ -0,0 +1,33 @@
# STEP-098 RDC 定向验收报告 v1
日期:2026-09-27(JST)
状态:**CORE / OPENGL_HOST / METAL_HOST_SOURCE_VALIDATION_PASS;PLATFORM_AND_RELEASE_PENDING**
仓库:Mac `/Users/tei/dev/MapLibreParticleLab`,执行前 `particle/main` HEAD `b1239ac3b1554943200e6fbf14321459ccc86527`,以此工作树叠加 STEP-097 B 及 STEP-098 实际源代码测试。
## 实际顺序和证据
1. Codex `GPT-6 Luna High` 源码运行 `step098-codex-20260926-224510` 的 JSONL 已有 `turn.completed`、退出码 0。设计/Step/完成报告存在;它自身没有编译,也不作为独立功能 PASS。
2. RDC B 初次运行 `step098-b-core-20260927-0833` 因 launchd PATH 不含 ninja 退出 127;核实原进程已结束才补 PATH 重新运行。
3. B 第一次真实编译 `step098-b-core-retry1-20260927-0834` 在 Source 测试引用不存在的 `Impl.encodedFieldStore` 处失败。按 public Source projection/seek 改测试断言,不改生产 API。下一次 `step098-b-core-retry2-20260927-0836` 编译 PASS,但 19 个定向测试中 2 个失败:测试 fixture 把 grid east=2 设成了小于 west=130 的非法 Grid,实际正确返回 `RejectedInvalidFrame`,而用例期待 `RejectedIncompatibleFrame`。将两处 east 改成 132(有效但与原 east=131 不兼容),没有降低生产校验。
4. B 再次执行 `step098-b-core-retry3-20260927-0838`:OpenGL Host Native C++ 编译退出 0,Core/Binding/Ingress 19/19 PASS;原始日志 `artifacts/maplibre-particle/step098-rdc/b-core-build.log`、`b-core-gtest.log`。
5. C1 `step098-c-opengl-20260927-0840`:Source/Projection/Renderer 扩展 OpenGL Host 119/119 PASS,日志 `c-opengl-gtest.log`。
6. C2 `step098-c-metal-host-20260927-0841`:Metal Host C++ 编译退出 0,同 filter 119/119 PASS,日志 `c-metal-host-build.log`、`c-metal-host-gtest.log`。现有 maplibre-tile-spec 个别对象的 macOS deployment target 警告非失败。
7. 全部新旧文件 `git diff --check` PASS;本记录时尚未执行正式 Android AAR、RN Test Host、iOS XCFramework、V15 Pipeline 或任何实体机验证。
## 未满足的门槛与边界
- `T01~T17` 已有对应源码/测试覆盖;上列 19/119/119 是实际运行的 Host GTest,不可将 T15 GPU/context loss、T16 长时 CPU/GPU 内存或 T17 RN 两步调用/真实 bridge 集成全部标 PASS。
- `DecodedFieldFrame` 新增 profile 身份,需用**同一冻结源提交**构建 Android/RN/iOS;现有正式 `.13` AAR 与旧 `.14-dev` 不能替代。
- V15 正式构建/发布需要先取得干净且确定的 release Git commit、检验本地空间、版本锁、脚本 prepare/build/publish/finalize 各自证据;公开版本不可覆盖。
- STEP-097 B 用户原工作内容必须保留,禁止 reset/clean、混入无关 untracked。按已授权的统一 V15 发行目标进行 scoped Git 收尾,不能将 Host 源级 PASS 冒称设备验收或正式 RELEASE_PASS。
## 真实统计
| 阶段 | 构建 | GTest | 结论 |
| --- | --- | --- | --- |
| B OpenGL Core/Binding/Ingress | PASS | 19/19 | PASS |
| C1 OpenGL Host | 已由 B 更新编译 | 119/119 | PASS |
| C2 Metal Host | PASS | 119/119 | PASS |
| Android/RN 同源配套 | NOT RUN | NOT RUN | PENDING |
| iOS XCFramework/设备 | NOT RUN | NOT RUN | PENDING |
| V15 Pipeline | NOT RUN | NOT RUN | PENDING |

View File

@@ -0,0 +1,47 @@
# STEP-098:公共契约闸门阻塞报告 v1
日期:2026-09-26(日本时间)
状态:**BLOCKED_CONTRACT / 未进入功能实施和发行**。
## 1. 当前真实基线
- 用户本轮要求:RDC 严格串行执行 STEP-098,并使用统一 Pipeline 发布 V15;未要求将历史 .14-dev AAR 当作 V15 产物。
- 源码仓库:Mac `/Users/tei/dev/MapLibreParticleLab`,分支 `particle/main`,本轮开工 HEAD 与公开 `origin/particle/main` 均为 `b1239ac3b1554943200e6fbf14321459ccc86527`。
- 该正式工作树已有 22 项修改/未跟踪内容(含 STEP-097 B 和本任务 MD);这些既有内容全部受保护,不得 reset、clean、覆盖或整体暂存。
- 本任务原始文件:`docs/40_任务/STEP-098_QBIN_V4单帧首显与八帧滚动缓存_Codex任务_v1.md`;其状态明确为“待契约闸门通过后执行”。
- Integration:Mac `/Users/tei/dev/navsea-particle-integration` 已执行 `git fetch origin main`;本地 `main` 和 `origin/main` 一致,均为 `318f8ccf911ac3136c3086a1d14e61f83e2d4bce`,工作树干净。
- 当前已安装正式版本:Android/RN `13.4.1-particle.13`,iOS 基础 VERSION `6.28.0`。先前 Native `13.4.1-particle.14-dev-d00d2ddc-bf4539890c6f` 仅为本地开发 AAR,不能替代 RN 联编、双平台测试或 V15 正式发行证据。
## 2. 契约冲突与停止原因
- `rfc/RFC-003_编码场两帧首显与增量补帧语义.md` 仍标为 `DRAFT/Shared Core 与 Android/iOS 评审待完成`,其目标语义第 1 项是至少两帧覆盖后才合法显示,第 4 项是超容量明确拒绝。
- STEP-098 的目标不同:一个有效时间的单帧可按真实 validTime 首显并驱动合法向量粒子;后续每 fieldKey 维持按时间升序的最多 8 帧,合法第 9 帧按新帧时间选择远端淘汰,正常满额提交不返回容量拒绝。
- `coordination/active/COORD-004/状态.md` 将 B 列为 `B_RFC_DRAFT`;同目录 `决议.md` 仅明确 A 的 React Style 方向,B 仍待调查。未发现正式接受 STEP-098 新语义的 RFC/ADR/COORD 决定或合同更新。现有 C-001 不可被当成这项新行为已经批准。
- 按 STEP-098 任务 §1 第 3~4 项,必须先由粒子负责人/Integration 正式接受且消除上述冲突,记录准确文件、版本、commit;本执行窗口不能把聊天目标、自身判断或 DRAFT 冒充正式跨平台决定。
## 3. 需要的正式协调决定
请由粒子负责人在 Integration 的 COORD-004/RFC-003 或新 ADR/Contract 中明确并正式接受:
1. 单帧真实时刻的首显和向量粒子行为;合法相邻两帧插值;不覆盖目标的旧帧必须 fail-closed,不能伪造数据或时间。
2. 每 fieldKey 独立的 8 帧排序/容量;正常已满时先验证、再原子淘汰并保留新帧;无效帧零副作用、不同 key 不相互挤占。
3. 未来/过去/内部插入的确定性端点淘汰,距离相同时的 tie-break,以及当前显示端点能否参与稳定优先级;禁止依赖尚未下发的新目标时间决定淘汰。
4. 同 validTime duplicate/revision/stale/conflict、dataset/profile/grid 身份、请求时间不被 submit 静默修改、现有 RN 两步调用、Renderer/GPU 资源复用与释放、内存边界及 Android/iOS 同一 Shared Core 契约。
5. 核对任务书中“13:00 ... 21:00 共 8 帧”的计数错误(若逐小时含两端实际为 9 个时间点),在正式合同及实施示例中写为确实 8 帧的区间。
6. 指明是否因生命周期、错误/数据所有权语义变化需要升级 C-001,以及适用的正式版本与跨平台责任。
以上正式结果若与 STEP-098 MD 的行为不一致,应先修订任务书,不得由 Android/iOS 各自选择不同语义。
## 4. Git 和 V15 Pipeline 独立门槛
- 用户提出 V15:统一脚本 `tools/particle_release_pipeline/release.sh 15` 的目标将是 Android/RN `13.4.1-particle.15` 与 iOS `6.28.0-particle.15`。这是待发行目标,不是已经构建/发布的事实。
- Pipeline 的 `prepare`、`build` 均要求工作树干净且版本锁正确;当前主工作树 dirty,不能直接运行。STEP-097 B 的未提交工作及正式版本锁需先按范围审计、受控收尾,不能 `git add -A` 或把 .14-dev 当正式 .15。
- Pipeline 的 public target 不得覆盖现有版本;正式发布必须在同一冻结 source commit 上完成 Android Native 四 ABI、RN、iOS Metal/XCFramework,分别验证,再检查 SHA、manifest、SwiftPM tag 与公网实际回读。
- Mac 只余约 11 GiB 可用空间,是完整构建前需要核实的容量风险;不能据此虚报构建失败或 PASS。
## 5. 本轮实际执行与未执行
- 已执行:RDC 只读 Git/任务/发布脚本审计、Integration fetch 和 SHA 对齐、RFC-003/COORD-004/Contract/职责核查。
- 未执行:Codex 功能实现、Core/Bridge/Renderer 修改、T01~T17 测试、Android/iOS 编译或设备验收、V15 Pipeline 的 prepare/build/publish/finalize、Git commit/push。
- 本报告是独立新增文件,不修改 STEP-098 MD、STEP-097 既有改动、Integration 文档、VERSION、历史制品或任何公网发行目录。
**当前结论:BLOCKED_CONTRACT。第一闸门未正式通过,不启动第二阶段,也不进行 V15 发行。**

View File

@@ -0,0 +1,536 @@
# STEP-099 RenderSource 就绪与 RenderSegment 失效诊断 — Codex 完成报告 v1
## 1. 执行结论
**CLOSED / PASS(诊断阶段完成)**。
STEP-099 按任务书保持“纯诊断、fail-closed、不优化正式语义”的范围完成。Renderer/Source/RenderSegment diagnostics、Android Test App T1~T4 场景、定向 native 测试和 Android 实机运行证据均已取得,Q1~Q7 可以据实回答。
本 Step **没有**修改 retained cache 算法、fast path gate、`singleContinuousSegment`、multi-continuous 行为、segment-local invalidation、RenderSegment 执行语义、NavSea、Source `isLoaded()` 定义或 RN 业务 API 语义。
## 2. 基线与 Git
- 仓库:`/Users/tei/dev/MapLibreParticleLab-step099`
- branch:`step099-render-source-diagnostics`
- frozen release tag:`maplibre-particle/rn/13.4.1-particle.17`
- frozen release commit / start base:`e8b451fe70054107773e82484c80e90df3889147`
- implementation commit:`8a5214d92dd2f8899db76d2194da42999fa0c87d`
- implementation message:`diagnostics: add STEP-099 render source readiness audit`
- Integration/NavSea:未修改。
- 文档 closeout commit、push result、`origin/particle/main` Final HEAD:Git 提交无法在自身内容中自指其最终 SHA,按项目既有规则由最终交付回读记录。
## 3. 实际修改范围
生产源码 / diagnostics:
- `include/mbgl/renderer/render_segment.hpp`
- `src/mbgl/renderer/render_orchestrator.cpp`
- `src/mbgl/renderer/render_orchestrator.hpp`
- `src/mbgl/renderer/render_segment_json.hpp`
- `src/mbgl/renderer/render_source.hpp`
- `src/mbgl/renderer/retained_underlay_runtime_state.hpp`
- `src/mbgl/renderer/sources/render_tile_source.cpp`
- `src/mbgl/renderer/sources/render_tile_source.hpp`
- `packages/maplibre-particle-rn/src/index.ts`
- `packages/maplibre-particle-rn/src/types.ts`
测试 / Test App:
- `test/renderer/retained_underlay_cache.test.cpp`
- `platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt`
- `platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/Step099ReadinessFixture.kt`
项目记录:
- `docs/50_Step记录/STEP-099_RenderSource就绪与RenderSegment失效诊断_v1.md`
- `docs/报告/STEP-099_RenderSource就绪与RenderSegment失效诊断_Codex完成报告_v1.md`
- `粒子开发记录.md`
## 4. 诊断能力
### 4.1 全局 readiness
新增只读 snapshot:
- `styleLoaded`
- `imageManagerLoaded`
- `renderOrchestratorLoaded`
- `spriteLoaded`
- `hasFadingTiles`
- global retained `contentRevision`
- RenderSegment plan/config/topology 信息
- 最近 mutation sequence / reason / source / affected segment diagnostics
正式 loaded 路径保持原式:
```cpp
renderTreeParameters->loaded = updateParameters->styleLoaded && isLoaded();
```
diagnostics snapshot 在旁路采样,失败被 best-effort 隔离,不决定正式 loaded。
### 4.2 per-source readiness
每个 RenderSource additive 暴露:
- sourceId / sourceType
- enabled / isLoaded
- tileBacked
- tileCount / incompleteTileCount / loadedTileCount
- hasFadingTiles
- last source/tile mutation sequence
- last tile action / reason
默认非 tile source 返回完整显式初始化的空统计,Android NDK `-Werror` 下已验证通过;未改变任何 Source `isLoaded()` 实现。
### 4.3 RenderSegment 只读依赖
每个 segment additive 暴露:
- segmentIndex / strategy / beginIndex / endExclusive
- `dependentSourceIds`
- `unknownDependencies`
规则:
- 从实际 ordered layers 的 sourceId 推导;
- shared source 可出现在多个 segment;
- source-less background 不制造假依赖;
- symbol/custom 等无法仅靠 sourceId 完整表达的依赖标记为 unknown/global;
- 依赖图只用于诊断,不参与 cache/loaded/fast path/execution。
### 4.4 mutation 定位
Source/tile mutation 复用已有回调记录 sequence/source/reason/action。原 `retainedUnderlayContentRevision` 的增长次数与正式调用路径不变;diagnostics 不增加额外 revision。
query 路径不清理或修改 event history,重复 snapshot/JSON 查询保持无副作用。
## 5. Native 定向测试
### 5.1 Metal host 定向 GTest
实际执行:
```bash
cmake --preset macos-metal
cmake --build build-macos-metal --target mbgl-test-runner --parallel 3
build-macos-metal/mbgl-test-runner --gtest_filter='RenderSegmentDiagnostics.*'
```
结果:
- `RenderSegmentDiagnostics.*`:**6/6 PASS**
- 新增 `ReadinessSnapshotMapsSourcesToSegmentsWithoutSideEffects`:PASS
- 覆盖 source loaded/unloaded、tile stats、source→segment、shared source、source-less、重复 query、topology/cache/content revision 无副作用。
证据:
`artifacts/maplibre-particle/android-validation/step099-metal-gtest.log`
SHA-256:
`f0618750ead03eae1b251126dd49488a9269a120373a66e3453fa9aaa1dc0f10`
说明:该 6/6 发生在 Android NDK 首轮暴露“默认 readiness struct designated initializer 未显式列完 optional 字段”之前;随后仅补全默认空字段初始化。最终源码由 Android 全量 native build 成功覆盖。为了在最终源码上重跑 Metal,重新 configure 时当前 Mac 缺 `libuv` pkg-config,configure fail;没有安装系统依赖来制造 PASS。
### 5.2 Vulkan
`macos-vulkan` configure:**BLOCKED / NOT RUN TESTS**。
原因:
```text
The following required packages were not found:
- libuv
```
这是当前 Mac host 环境依赖,不是 STEP-099 源码编译错误。
### 5.3 Jest
**NOT RUN**。用户未要求 Jest,任务未加入 Jest。
## 6. Android 构建与设备
### 6.1 首轮 Android 编译问题
首轮 x86_64 OpenGL build 在 Android NDK `-Werror` 下发现:
```text
render_source.hpp:84:51:
error: missing field 'tileCount' initializer
[-Werror,-Wmissing-designated-field-initializers]
```
修复仅补全 `RenderSourceReadinessStats` 默认空字段初始化,不改变运行语义。
随后 native C++ 已越过原失败点并完整编译。
### 6.2 磁盘阻塞与恢复
第一次完整 native build 后,在:
`MapLibreAndroidTestApp:stripOpenglDebugDebugSymbols`
因 Mac 仅剩约 2 GiB 出现:
```text
No space left on device
```
RDC 只清理可再生 Gradle 9.5.1 cache / 本轮失败中间产物,保留 Android native object;可用空间恢复到约 12 GiB 后增量重跑。
### 6.3 最终 APK
命令:
```bash
./tools/particle_android_validation/build_validation_apk.sh
```
最终结果:
- Gradle:**BUILD SUCCESSFUL in 4m 54s**
- actionable tasks:86(7 executed / 79 up-to-date)
- variant:OpenGL Debug
- ABI:x86_64
- APK:61,923,024 bytes
- `libmaplibre.so`:39,602,856 bytes
- APK SHA-256:
`5de73f0358b835a78d3ae7e2ac22f4efdb2a66775b8c63654a8bb7313957d90a`
构建时 metadata:
- base commit:`e8b451fe70054107773e82484c80e90df3889147`
- `git_dirty=true`
- 构建内容与随后形成的 implementation commit `8a5214d92` 源码一致;当前 source tree 对 implementation commit clean,剩余仅 docs closeout。
### 6.4 Android 设备
Mac ADB 直接连接:
- serial:`192.168.200.139:5555`
- model:`FZG1-2`
- Android:13
- API:33
- ABI:x86_64
APK 覆盖安装成功,`ParticleCoreLayerValidationActivity` 冷启动成功。
运行 log:
`artifacts/maplibre-particle/android-validation/step099-logcat.txt`
log SHA-256:
`0f15d75dde77e7dd2f4d98c46a4c1501a628205e8aae62130b0cb96db6b8482f`
## 7. T1~T4 实测
### T1 STATIC
场景:
- R0:静态 GeoJSON retained
- C:本地静态 RasterArray field 驱动 WeatherColor + Particle
- R1:静态 retained
- camera fixed
结果:
- 启动阶段 1 个 false window:204 ms;
- 随后 `renderOrchestratorLoaded=true` 稳定;
- `particle-fixture` 与 `step099-static` 均 loaded;
- retained cache 建立后持续 reuse;
- fast path 进入 `active=true / eligible=true / reason=none`;
- 没有持续 style-not-loaded。
aggregate:
```text
falseWindowCount=1
falseWindowAvgMs=204
falseWindowMaxMs=204
openFalseWindowMs=0
```
结论:静态基线可稳定进入 loaded/cache reuse/fast path。
### T2 GEOJSON_1HZ
两个 GeoJSON source 每 500 ms 交错更新,因此每个约 1 Hz。
实际:
- 总 source update:122 次,约 61 次/源;
- 60 秒量级观察中,出现 10 个 loaded=false window;
- 平均 203 ms;
- 最大 205 ms;
- 最终 open window = 0;
- 总 false 时间约 2.03 s,约占 60 s 的 3.4%,不是“几乎始终 false”。
aggregate:
```text
falseWindowCount=10
falseWindowAvgMs=203
falseWindowMaxMs=205
openFalseWindowMs=0
```
同时 final sample:
- global contentRevision 已到 1679;
- R0 仅依赖 `step099-static`,但 cache build=241、invalidation=362;
- R1 依赖动态 GeoJSON,也同样 build=241;
- mutation diagnostics 能正确指出动态 source 只 `affected=[2]`,但正式 cache invalidation 仍非局部。
### T3 PERMANENT_UNREADY
额外加入:
`step099-unready-raster-array`
它:
- 没有 layer;
- 因此 `enabled=false`;
- host-submission 无 field;
- `isLoaded=false`。
其他主要 source 均 loaded。
实测:
- `renderOrchestratorLoaded=false` 持续;
- retained segment cache 持续 fallback;
- `lastInvalidationReason=style-not-loaded`;
- 观察结束仍没有恢复。
aggregate:
```text
falseWindowCount=0
openFalseWindowMs=21459
```
结论:即使 source 与当前 layer/segment 无关且 disabled,只要仍在 `renderSources` 且 `isLoaded=false`,V17 全局 `RenderOrchestrator::isLoaded()` 就保持 false。
### T4 R-C-R
结构:
- R0:`deps=[step099-static]`
- C:`deps=[particle-fixture]`,WeatherColor + Particle
- R1:`deps=[org.maplibre.annotations, step099-dynamic, step099-static]`
- `step099-dynamic` 约 1 Hz 更新
实际:
- dynamic update:61 次;
- loaded false window:4;
- 平均 203 ms;
- 最大 207 ms;
- global contentRevision:1760 → 2540;
- mutation diagnostics:`step099-dynamic` 对应 `affected=[2]`;
- R0 并不依赖 `step099-dynamic`,但:
- build:1 → 121
- invalidation:20 → 200
- R1 同步得到相同级别 build/invalidation 增长;
- fast path 在稳定窗口可恢复 active,但 global revision / transient readiness 会反复制造 full CPU work。
aggregate:
```text
falseWindowCount=4
falseWindowAvgMs=203
falseWindowMaxMs=207
openFalseWindowMs=0
```
结论:依赖诊断已经能准确表达“R1 source-local”,但 V17 正式 content revision/cache invalidation 仍是 global。
## 8. Q1~Q7
### Q1:单个 permanently unloaded source 会造成 V17 style-not-loaded 吗?
**YES / CONFIRMED。**
T3 中 `step099-unready-raster-array` 无 layer、`enabled=false`、`isLoaded=false`;其他主要 source loaded,但整体 `renderOrchestratorLoaded=false` 持续 21.459 s 未恢复,retained cache 持续 `style-not-loaded` fallback。
源码事实与运行事实一致:`RenderOrchestrator::isLoaded()` 遍历全部 `renderSources`,不按 enabled/segment dependency 排除 source。
### Q2:1 Hz GeoJSON 会周期性让 isLoaded=false 吗?平均/最大窗口?
**YES / CONFIRMED。**
T2 双 source 交错各约 1 Hz:
- 10 次 false window;
- avg 203 ms;
- max 205 ms。
T4 单动态 source 约 1 Hz:
- 4 次 false window;
- avg 203 ms;
- max 207 ms。
### Q3:错开的多个 1 Hz GeoJSON 会导致 global loaded 几乎无法稳定为 true 吗?
**NO / 本次观测不支持。**
T2 约 60 s 中总 false 时间约 2.03 s,约 3.4%;绝大多数时间 global loaded=true。它确实产生 readiness thrash,但不是“几乎永远 false”。
### Q4:GeoJSON tile mutation 会推进 global retainedUnderlayContentRevision 并影响 retained cache 吗?
**YES / CONFIRMED。**
T4 global contentRevision 1760 → 2540。仅依赖静态 source 的 R0 也从 build 1 → 121、invalidation 20 → 200。
### Q5:不引用 GeoJSON 的 retained segment 仍会受它的 loaded/fading/tile mutation 影响吗?
**YES。**
运行证据:
- T4 diagnostics 正确报告动态 source `affected=[2]`;
- 但 R0 `deps=[step099-static]` 仍随动态 source mutation 重建/失效。
源码证据:
- `retainedRangeIsStable(begin,end)` 虽为 range API,但最后遍历 **全部 `renderSources`**,任一 source `hasFadingTiles()` 都让任意 retained range unstable。
本次 T4 稳态 `fade=false`,因此 R0 的实际反复失效主要直接证明 global content revision/tile mutation;GLOBAL_FADE 属源码确认、未在本轮场景主动触发的全局 gate。
### Q6:`tryRenderParticleFastFrame` 仍有哪些 global gate 会阻挡未来 localized cache CPU fast path?
**确认仍存在:**
1. **GLOBAL_UPDATE_SIGNATURE**
- `particleFastPathNonCameraInputsMatch` 全局比较:
- styleLoaded / spriteLoaded
- light
- images
- **sources**
- **layers**
- fontFaces / fileSource / glyphURL
- transition / placement / tile LOD 等全局输入。
2. **GLOBAL_CONTENT_REVISION**
- `particlePreparedContentRevision == orchestrator.getRetainedUnderlayContentRevision()`。
3. **GLOBAL_FADE**
- `particleFastPathDynamicState` 遍历全部 renderSources 汇总 fading;
- `retainedRangeIsStable` 也遍历全部 renderSources。
4. **GLOBAL_PENDING / TRANSITION / REPAINT**
- pendingChanges
- renderLight transition
- 任意 layer transition
- 任意非粒子 layer repaint
- placement transition。
5. **GLOBAL_ANNOTATION_MUTATION**
- AnnotationManager `hasPendingChanges()`。
6. **RETAINED REVISION / CACHE / TOPOLOGY / BOUNDARY**
- retained cache validity/static revision
- global retained revision tracker
- topology revision
- boundary/config revision。
7. **singleContinuousSegment**
- 仍要求 plan 恰好能由一个 Continuous segment 表达;
- multi-continuous 继续 fail closed,本 Step 未修改。
因此即使未来 cache invalidation 变成 source/segment-local,fast path 仍有多组 global gate 需要后续 Step 单独设计,不能在 STEP-099 顺手放宽。
### Q7:blocker 分类
**运行态 CONFIRMED:**
- `PERMANENT_SOURCE_UNLOADED`
- `DYNAMIC_SOURCE_READINESS_THRASH`
- `GLOBAL_CONTENT_REVISION`
**源码 CONFIRMED,本轮未主动触发独立运行场景:**
- `GLOBAL_FADE`
- `GLOBAL_UPDATE_SIGNATURE`
**OTHER:**
- global pendingChanges
- placement/light/non-particle transition/repaint
- annotation mutation
- topology/boundary/revision/cache validity
- singleContinuous fail-closed
## 9. 任务边界审计
确认未改:
- retained cache 核心算法;
- fast path gate 逻辑;
- `singleContinuousSegment`;
- multi-continuous 策略;
- segment-local invalidation 正式语义;
- RenderSegment execution;
- NavSea;
- private NavSea Native API;
- RN 业务 API;
- Source `isLoaded()` 定义。
新增 dependency / affectedSegmentIndices 只是观察信息;它没有参与正式 invalidation。
## 10. 证据文件
忽略目录:
`artifacts/maplibre-particle/android-validation/`
主要文件:
- `build-info.txt`
- `build.log`
- `MapLibreAndroidTestApp-validation-opengl-debug-x86_64.apk`
- `step099-logcat.txt`
- `step099-device-info.txt`
- `step099-metal-gtest.log`
- `step099-cmake-metal.log`
APK SHA-256:
`5de73f0358b835a78d3ae7e2ac22f4efdb2a66775b8c63654a8bb7313957d90a`
STEP-099 log SHA-256:
`0f15d75dde77e7dd2f4d98c46a4c1501a628205e8aae62130b0cb96db6b8482f`
## 11. 下一阶段建议
STEP-099 只负责定位,不在本 Step 修复。
下一阶段应基于本轮证据拆开处理:
1. 先决定 global source readiness 是否要引入“影响当前 style/render segment 的 readiness 范围”,解决 permanently unready / transient unrelated source 对全局 loaded 的影响;
2. 再设计 source/segment-local content revision 与 invalidation,避免 R1 GeoJSON mutation 重建 R0;
3. 最后单独审查 fast-path global signature/fade/pending/placement 等 gate,不能把 cache locality 与 fast-path locality 混成一个修改;
4. 保持 Style Z-order 唯一事实来源和当前通用 Render Segment 模型。
## 12. 最终状态
**STEP-099 CLOSED / PASS。**
Pass 含义仅为:
- diagnostics 能回答任务问题;
- Native 定向测试已有 PASS 证据;
- 最终 Android x86_64 OpenGL APK BUILD SUCCESS;
- FZG1-2 T1~T4 实跑完成;
- Q1~Q7 有源码/运行证据;
- 未改变正式渲染语义。
不代表:
- Vulkan host 已通过;
- iOS runtime 已测试;
- 下一阶段 localized readiness/invalidation 已实现;
- fast path global gates 已优化。

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

@@ -0,0 +1,184 @@
# STEP-101 RenderOrchestrator 销毁生命周期修复 Codex 完成报告 v1
## 1. 执行结论
**PASS(RDC Native / Android runtime):V18 teardown destroyed-mutex 回归已修复并通过设备闭环。** Codex 阶段仅交付源码;随后 RDC 独立完成 Android full Native build、focused C++ TestRunner、STEP-101 T1~T4、20 次真实 renderer reset/recreate、STEP-100 locality 回归与 crash buffer 检查。V19 尚未在本报告阶段公开发布。
## 2. Git 基线
- 工作区:`/Users/tei/dev/MapLibreParticleLab-step101`
- 分支:`step101-render-orchestrator-teardown`
- start HEAD:`d29e84c1a90daeea104b389d28b000c4f017d08d`
- implementation commit:`64cf8f44350eb8ec4b45360db4d3f9505ac4ac0b`
- implementation message:`fix: make render orchestrator teardown safe`
- push result:**PASS**,`origin/particle/main` 独立回读与 implementation commit 一致。
- 文档 closeout commit / 最终远端 HEAD:Git 提交无法在自身内容中自指其最终 SHA,按项目既有规则由最终交付回读记录。
- 子模块:`git submodule status` 列出的子模块均以 `-` 开头,表示当前工作区未初始化;本轮未修改或初始化子模块。
- V18 Android/RN `13.4.1-particle.18`、iOS `6.28.0-particle.18` 保持不变;V19 **未发布**。
## 3. 根因与实现
V18 `readinessDiagnosticsMutex` 等成员声明在 `renderSources` 之后,按 C++ 反序析构会先于 sources 消失。RenderSource/Tile 销毁存在同步 `Cancelled` callback 到 Orchestrator 的可达路径,随后可能锁已销毁 mutex。这是已证实的生命周期漏洞,与 Android FORTIFY 报错高度吻合。虽未取得精确到最终 callback 的完整符号化现场栈,但同一设备上 V18 条件稳定复现,而 STEP-101 修复后 T1~T4、20 次 reset、动态 GeoJSON 与 diagnostics 条件均不再崩溃,因此将其作为本次 Android teardown 回归的已确认工程根因。
当前源码的析构顺序为:处理 contextLost layers → 第一次 `threadPool.waitForEmpty()` → detach 各 RenderSource、GlyphManager、ImageManager observer → readiness state 仍活着时 `renderSources.clear()` → 第二次 `threadPool.waitForEmpty()` → 常规成员析构。`RenderSource::setObserver(nullptr)` 恢复 NullObserver,tile 回调无需新增空指针分支。显式 detach 与清理建立 owner teardown 合同,不依赖今后成员顺序碰巧安全;没有增加 teardown flag。
render-job 队列独立于 tagged scheduler work。静态检查显示 tile/worker 销毁可投递捕获 render data/bucket 的 render jobs;`waitForEmpty()` 不处理它们。OpenGL 同步 Android reset 路径先 `renderer.reset()`、再 `backend.reset()`,其 Context 析构调用 `runRenderJobs(true)`;若启用 async renderer cleanup,backend 的清理时点另由后续生命周期决定。Metal Context 与 Vulkan backend 也有各自的 `runRenderJobs(true)` 清理入口,均未在本轮运行验证。测试显式 `runRenderJobs()`,但没有构建或设备证据证明所有 backend 模式均安全。这是 RDC 必须核对的剩余风险。
正常 source remove 使用 `renderSources.erase()`,`clearData()` 使用 `renderSources.clear()`;两者均在 Orchestrator 实体及 readiness state 存活时执行。`TileCache::~TileCache()` 会清理 pending releases 并等待先前 deferred deletion 结束。这个审计没有证成任意外部并发调用都安全,本轮不修改正常 erase/clearData 行为,也不声称所有 source replacement 异步问题已解决。
## 4. 实际修改与审计结论
源码/测试差异共七个文件:
1. `src/mbgl/renderer/render_orchestrator.cpp`:显式 detach、source clear 与双 drain。
2. `src/mbgl/renderer/render_orchestrator.hpp`:仅为 focused test 开放 friend helper。
3. `src/mbgl/renderer/render_source.cpp`:nullptr observer 回退 NullObserver。
4. `test/renderer/render_orchestrator_error.test.cpp`:新增两个非 Jest teardown tests;helper 注册 Orchestrator observer,测试断言 source 销毁前 observer 已 detach、Cancelled 不再到旧 observer、第二次 drain 和独立 render-job 队列。
5. `platform/android/MapLibreAndroid/src/cpp/map_renderer.cpp`:`nativeReset` 与 `resetRenderer` 入口/完成日志,仅用于配对观测,未改变 reset 调用顺序。
6. `platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/renderer/surfaceview/SurfaceViewMapRenderer.java`:SurfaceView detach 与 Java `nativeReset()` 进入/返回日志。
7. `platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt`:T1~T4,tile ready/diagnostics、动态 GeoJSON、Activity recreate、重建与完成日志;保存状态里的空场景不会在配置变更后重新从 Intent 启动。
本轮新增/更新文档:本报告、`docs/50_Step记录/STEP-101_RenderOrchestrator销毁生命周期修复_v1.md`、`粒子开发记录.md`。既有未跟踪的 STEP-101 任务书 v1/v2 没有修改。
与 v2 合同静态核对:observer、双 drain、render-job 独立审计、四个 Android 场景均有对应代码;STEP-100 locality/fast path/source loaded 语义无本次差异。T2 代码计的是 reset **请求**,20 次 Native reset 必须由 log 中每轮 Java `nativeReset`、JNI `nativeReset`、`MapRenderer::resetRenderer entered/completed` 和新 renderer ready 独立确认,不能凭 UI DONE 计数认定。T3 使用已有 T4 R-C-R fixture 并持续更新动态 GeoJSON;T4 在 reset 前额外取 diagnostics。现有 C++ test 使用最小 tile-backed Source,并非实际 Android source;设备等价验收仍不可省略。
## 5. 实际检查与未执行项目
- `git diff --check`:**PASS**(已跟踪 diff 的 whitespace/error 静态检查;未跟踪的新文档不在此命令检查范围)。
- 源码 diff/合同静态审计:完成;补了 observer detach 断言、Java/JNI reset 边界日志、T3 mutation 证据及场景恢复边界。
- CMake/Ninja/Native compile、C++ GTest:**NOT RUN**。
- Android Gradle、Validation APK、AAR/RN candidate:**NOT RUN**。
- ADB、T1~T4、crash buffer、STEP-100 locality 运行态、NavSea 冷启动:**NOT RUN**。
- Jest、iOS/Metal build 与设备、publish/tag/commit/push:**NOT RUN**。
- 产物路径、大小、SHA-256、运行日志:本轮没有新产物,均无结果。
## 6. RDC 精确验证命令与验收证据
在本工作区执行 Mac Native 定向验证(若既有 `libuv` 环境问题仍在,保留首个错误并标为环境阻塞):
```bash
cmake --preset macos-metal
cmake --build build-macos-metal --target mbgl-test-runner --parallel 3
build-macos-metal/mbgl-test-runner --gtest_filter='RenderOrchestratorTeardown.*:RenderSegmentLocality.*'
```
Android Validation APK 使用固定入口,不手拼 Gradle:
```bash
./tools/particle_android_validation/build_validation_apk.sh
```
RDC 使用显式设备 serial。每个场景先清日志、停止旧 Activity,再单独启动;`T2` 等待 20 轮结束。安装前核对 APK 文件、SHA-256、ABI 与运行设备:
```bash
adb -s 192.168.200.139:5555 install -r artifacts/maplibre-particle/android-validation/MapLibreAndroidTestApp-validation-opengl-debug-x86_64.apk
adb -s 192.168.200.139:5555 logcat -c
adb -s 192.168.200.139:5555 shell am force-stop org.maplibre.android.testapp
adb -s 192.168.200.139:5555 shell am start -n org.maplibre.android.testapp/.activity.particle.ParticleCoreLayerValidationActivity --es step101_scenario T2_RESET_LOOP_20
adb -s 192.168.200.139:5555 logcat -v threadtime -d > /tmp/step101-t2-logcat.txt
rg -n 'STEP101_|SurfaceView detached; nativeReset|nativeReset returned|MapRenderer::nativeReset|MapRenderer::resetRenderer|pthread_mutex_lock called on a destroyed mutex|Fatal signal|ANR' /tmp/step101-t2-logcat.txt
```
对 T1/T3/T4 分别重复相同 `logcat -c`、`force-stop`、`am start`、日志保存步骤,将 `step101_scenario` 依次替换为 `T1_RESET_ONCE`、`T3_RESET_WITH_DYNAMIC_GEOJSON`、`T4_RESET_WITH_DIAGNOSTICS`。逐轮配对 `RESET_REQUEST`、`MAPVIEW_DESTROY_ENTER/RETURN`、`SurfaceView detached; nativeReset entered`、JNI `MapRenderer::nativeReset entered/completed`、Native `resetRenderer entered/completed`、新 `STYLE_READY` 与 `NEW_RENDERER_READY`;T2 需至少 20 个完整 reset/recreate 配对,且无 SIGABRT/ANR。T3 核对 `STEP101_DYNAMIC_SOURCE_MUTATION` 在每轮 reset 前持续出现,T4 核对 `PRE_RESET_DIAGNOSTICS` 与 reset 后 diagnostics。STEP-100 T4 R-C-R 另按既有 Validation 菜单复跑,核对无关 R0 `buildDelta=0`、`invalidationDelta=0`、`reuseDelta>0`,动态 R1 正常 build/invalidation。
上述只是交接命令,**本轮均未执行**。V19 candidate 必须先于 NavSea 临时锁包和五次超过 30 秒的冷启动;crash buffer 与 locality 均通过后,才可进入不可变发布。NavSea 外部工作区/候选制品命令由 RDC 按其实际环境另行确定,本轮不伪造路径或结果。
## 7. 已知问题、未完成事项与下一步
静态审计无法证明 Android 每次 Activity recreate 都实际完成 native reset,也无法证明异步 backend 清理和真实 Source 并发场景无 late callback。Native 编译与定向测试、四个 Validation 场景、STEP-100 locality、NavSea 等价五次冷启动、crash buffer 均待 RDC;任何失败均应保留现场并按首个真实错误回传。无理由在本阶段修改 NavSea、发布 V19 或重写 STEP-100 语义。
## 8. RDC 独立验收(2026-10-07)
### 8.1 Android full Native / Validation APK
标准入口:
```text
./tools/particle_android_validation/build_validation_apk.sh
```
结果:**PASS**。
```text
variant=OpenglDebug
abi=x86_64
apk_bytes=61957678
libmaplibre_bytes=39637512
sha256=14cbd35ed0bc1366389ddea69925901013e3b209ed3575462421a7920c73c721
```
该构建实际编译了本 Step 修改后的 C++ Core、`libmaplibre.so`、JNI/Java reset 日志和 Android Validation T1~T4。
### 8.2 STEP-101 Android teardown runtime
设备:FZG1-2 / Android 13 / API 33 / x86_64 / `192.168.200.139:5555`。
- T1 `RESET_ONCE`:真实 `MapRenderer::resetRenderer entered/completed`、`nativeReset entered/completed`、新 renderer `STYLE_READY / NEW_RENDERER_READY`;PASS。
- T2 `RESET_LOOP_20`:完整运行至 iteration 20,最终 `STEP101_SCENARIO_DONE ... resetsRequested=20`;逐轮出现 destroy/reset/new renderer ready;PASS。
- T3 `RESET_WITH_DYNAMIC_GEOJSON`:reset 前动态 GeoJSON mutation 持续产生,reset 完成后新 renderer 继续 ready;PASS。
- T4 `RESET_WITH_DIAGNOSTICS`:reset 前 `getRenderSegmentDiagnostics()` 可读,随后真实 reset 完成并重建;PASS。
- 四组验收 crash buffer 均为空;未出现 `pthread_mutex_lock called on a destroyed mutex`、SIGABRT 或 ANR。
### 8.3 STEP-100 locality 回归
R-C-R T4 重新运行 60+ 秒,稳定窗口:
```text
global revision: 62 -> 894
R0 retained:
build 1 -> 1 delta 0
invalidation 19 -> 19 delta 0
reuse 12 -> 3890 delta +3878
R1 retained:
build / invalidation 持续增长
```
动态 source mutation 仍只影响对应 segment;R0 source revision 保持不变。STEP-101 teardown 修复没有破坏 STEP-100 Source→Segment locality。
### 8.4 Focused Android C++ TestRunner
Mac host `mbgl-test-runner` 仍因本机缺失可用 `libuv` pkg-config 包而 ENV BLOCKED;没有安装或升级系统依赖来掩盖该环境事实。
改用仓库已有 Android Native C++ TestRunner,x86_64 build **PASS**。RDC 为 TestRunner 临时打包真实 `test/fixtures/` 到 `data.zip`,仅满足 Android runner 既有解压契约;该临时 asset 已删除,不进入 Git。
设备过滤执行:
```text
RenderSegmentLocality.SourceRevisionsAreScopedAndRecreationUsesNewIdentity PASS
RenderSegmentLocality.ReadinessAndFadeFollowOnlyDependencies PASS
RenderSegmentLocality.MixedExecutionDrawsEachLiveRangeOnceInStyleOrder PASS
RenderSegmentLocality.FailedBuildCannotReuseOldTexture PASS
RenderOrchestratorTeardown.NullObserverAndDetachCoverEveryTileCallback PASS
RenderOrchestratorTeardown.PendingTileCancellationAndSecondDrainRepeatSafely PASS
TestRunner finished with status: '0'
```
最终 TestRunner APK(含临时 fixtures,仅作验收证据)SHA-256:
```text
e1b39b3eb035175b79dd60dbe39496ea982c2dd6e2f1593fd60dd9630f393712
```
crash buffer 为空。
首次 TestRunner 编译暴露 6 个测试源码级 C++20 designated-initializer / protected-base conversion 问题;RDC 仅修正测试源码字段补齐和显式转换,未改变生产语义。修复后 Android C++ TestRunner build 与上述 filtered tests 均 PASS。
### 8.5 结论
STEP-101 的 Native 生命周期修复达到关闭条件:
1. RenderSource observer 在 owner teardown 前显式 detach;
2. readiness state 仍存活时显式销毁 renderSources;
3. source clear 前后 tagged work 双 drain;
4. glyph/image observer 同步 detach;
5. 20 次真实 Android renderer reset/recreate 无 destroyed-mutex;
6. 动态 GeoJSON、diagnostics 条件下仍安全;
7. STEP-100 locality 保持 PASS;
8. focused C++ tests PASS。
Jest:**NOT RUN**。
V19 candidate、NavSea 五次冷启动与正式 publish/finalize 属于后续发行 Gate,不改变本 Step Native 修复的 PASS 结论。

View File

@@ -0,0 +1,92 @@
# V15 iOS WeatherColor 纹理生命周期修复 Codex 完成报告 v1
日期:2026-09-27(JST)
结论:**IMPLEMENTED / RDC HOST VALIDATION PASS / WAITING FOR MANUAL IPAD VALIDATION**。源码修复与测试代码已交付;Codex 未运行构建或测试,RDC 独立完成 OpenGL/Metal host 验证。不能据此标记 iPad PASS。
## 1. 执行配置与 Git 基线
- 实际执行模型与档位:**GPT-6 Luna High**(按本轮用户指定记录;未切换或升级)。
- 仓库:`/Users/tei/dev/MapLibreParticleLab`;机器:Mac mini,Darwin 24.6.0,x86_64。
- 分支:`particle/main`;开工/当前 HEAD:`cd71640a84901212ce4681e296f027befdff4ee0`。
- V15 tag `maplibre-particle/rn/13.4.1-particle.15` peeled commit 与 HEAD 相同;`origin/particle/main` 与 HEAD 相同。
- manifest 中的 V15 `sourceCommit` 是 `1fdc5df625d405233703136eb2dc12032d932da2`;后续 finalize release 生成的 V15 tag 指向 HEAD `cd71640a84901212ce4681e296f027befdff4ee0`。两者角色不同且本任务相关已跟踪源码相同,因此不存在基线冲突。任务书保留原样,tag/发行内容未修改。
- 开工工作树只存在用户提供且未跟踪的 V15 任务书;本轮始终保留该文件且未纳入提交。源码阶段没有执行 reset/clean,也未更改版本/tag/发行包。
- `platform/android/VERSION` 为 `13.4.1-particle.15`;`platform/ios/VERSION` 当前内容为 `6.28.0`。本轮未改版本坐标。
## 2. 根因链和修复
源码复核确认:`RenderWeatherColorLayer::update()` 上传/替换 Resources 中的纹理,但旧 Drawable 仅在 wrap 区间变化时重建;Metal `LayerGroup::upload()` 先于 `render()`;旧 Tweaker 在 `render()` 阶段才将新纹理写入 Drawable。`gfx::Drawable::setTexture()` 仅更换资源指针,不会补做前序 upload,因此新 Metal texture 可能在 draw 时仍为 dirty。IPS 不包含 Drawable/Texture ID,不能据此声称事故中的确切对象身份已由日志证明。
修复在 update 阶段比较 Drawable 原有与期望的 T0/T1 实际纹理指针。wrap 范围或任一 endpoint 指针变化时,当场重建该层 drawables,确保 builder 在本帧 upload 前安装纹理。Tweaker 不再绑定纹理,只更新 UBO 动态值。`assert(!textureDirty)` 和 Metal 通用绑定逻辑未改。
指针转移结果:
| 场景 | 指针比较 | 预期动作 |
|---|---|---|
| T0/T1 均不变 | 两指针相同 | 不重建、不重传 |
| 仅 T0 或仅 T1 被替换 | 一指针不同 | update 阶段重建 |
| 两端均替换 | 两指针不同 | update 阶段重建 |
| 旧 T0/T1 交叉复用 | 顺序变化 | update 阶段重建 |
| T0=T1 | 相同共享指针 | 保持时不重建;从双端切换/反向时重建 |
| 仅 mix/opacity/stops 改变 | endpoint 不参与这些参数 | Tweaker 更新 UBO,不重建/重传 |
| wrap 改变 | endpoint 可相同 | 重建 wrap drawables |
| Resources 清空后恢复 | null → 新端点且 wrap 哨兵失配 | 新建资源与 drawables |
## 3. 实际修改文件
- `include/mbgl/renderer/layers/weather_color_texture_lifecycle.hpp`:新增 drawable endpoint/wrap 重建判定 helper。
- `src/mbgl/renderer/layers/render_weather_color_layer.cpp`:update 阶段检测 endpoint 指针并重建;Tweaker 删除 draw 阶段纹理绑定。
- `test/renderer/weather_color_texture_lifecycle.test.cpp`:新增六组指针/生命周期决策测试。
- `test/CMakeLists.txt`:将新测试加入 CMake test target;Bazel 通过既有测试 glob 收录。
- `docs/50_Step记录/V15_iOS_WeatherColor纹理生命周期修复_v1.md`:新增本阶段记录。
- `粒子开发记录.md`:追加本轮事实。
- 本 Codex 完成报告。
没有更改 V15 任务书、V12/V13、JS/RN、NavSea 宿主 API、Android、release pipeline、release tag/archive/manifest/version、粒子 advection/State/Trail/Retained、公共 API/ABI、Layer ID、Z-order、shader slot 或 Metal assert。
## 4. 检查和验证结果
| 检查 | 结果 |
|---|---|
| 源码调用链只读复核 | 已完成;事实和限制如上 |
| `git diff --check` | **PASS**(已跟踪 diff 无 whitespace error;新增文件另行查看,未纳入该命令的 Git diff 范围) |
| RDC 首次 OpenGL host 编译 | **FAIL**:`test/renderer/weather_color_texture_lifecycle.test.cpp:60:17` 的 `nullptr` 参数无法参与模板类型推导;RDC 同时反馈 production `render_weather_color_layer.cpp` object 已编译 |
| OpenGL host 修正 | 已将该断言调用写为 `weatherColorDrawableBindingsChanged<int>(...)`,仅修正测试 helper 的模板类型推导 |
| RDC OpenGL host build retry | **PASS**, exit 0 |
| RDC OpenGL 定向 GTest | **PASS**, 6/6 |
| RDC Metal host 编译 | **PASS**, exit 0 |
| RDC Metal host 定向 GTest | **PASS**, 6/6 |
| iOS/Xcode build、签名、安装 | 未执行,用户按项目边界手工完成 |
| iPad Metal 运行、崩溃断言/内存/纹理计数 | 未执行,等待人工集中验收 |
| Android/Gradle/APK/ADB/Jest/JS | 未执行 |
| scoped commit/push | 已完成,详见 §7 |
| release pipeline | 未执行 |
## 5. RDC Mac 精确命令
按 OpenGL → Metal host 顺序执行;每个 build 成功后运行该新增测试:
```bash
cmake --build build-step088-opengl --target mbgl-test-runner --parallel 3
build-step088-opengl/mbgl-test-runner --gtest_filter='WeatherColorTextureLifecycle.*'
cmake --build build-step097-metal-host --target mbgl-test-runner --parallel 3
build-step097-metal-host/mbgl-test-runner --gtest_filter='WeatherColorTextureLifecycle.*'
```
RDC 日志目录:`/tmp/navsea_v15_weathercolor_validation/`。日志文件:`opengl_build.log`(首轮失败)、`opengl_build_retry.log`(重试成功)、`opengl_test.log`(6/6)、`metal_host_build.log`(成功)、`metal_host_test.log`(6/6)。未从回传摘要/日志记录到耗时,因此不补写耗时。Metal host 结果不是 iOS 真机通过。
## 6. 限制、剩余风险与人工验收
- 新测试覆盖 endpoint 判定矩阵,不是 drawable/Metal GPU 端到端模拟;`upload()` 到 draw 的实际时序仍需 RDC Mac host 验证和 iPad 验收。
- 任务书引用的事故 IPS 与外部调查报告未在本机读取;没有推断其中未提供的内容。源代码链是可推导的候选根因,而非 IPS 已证明纹理 ID。
- iPad 需使用后续正确版本 Native 手工编译;先核对二进制 UUID,再执行冷启动风场、风→浪→风、风→雨→潮→风、邻帧/第九帧淘汰后重入、快速 Seek、天气关闭重开、平移缩放、前后台和旋转。检查 assert/crash、WeatherColor/Particle 画面、上传/重建次数、内存及异常日志。本船放大、上层闪烁和 Android 场景独立验收。
- 未发现 `STEP_RECORD.md`;本轮按根规则更新 Step 记录并追加 `粒子开发记录.md`。
- 当前结论为源码已实施、RDC OpenGL/Metal host 定向验证 PASS,等待用户手工 iPad 验收;host PASS 不代表设备 PASS。iPad 实测通过前状态不得提升为 CLOSED/PASS。
## 7. Git 收尾
- scoped 实现提交:`5af91fe7a105f0ac047e18db1dc063c9ffd69d44`,message:`fix: 修复 V15 WeatherColor Metal 纹理生命周期`;仅含本任务源码、测试、Step、报告和开发记录共 7 个文件。
- 已推送至 `origin/particle/main`。推送后执行 `git ls-remote origin refs/heads/particle/main`,读回 `5af91fe7a105f0ac047e18db1dc063c9ffd69d44`,与实现提交相同。
- 验证结果补录提交:`4712e1752af2a8238def5839c8754270de4195c9`,message:`docs: 记录 V15 WeatherColor RDC 验证与推送`;仅包含 Step、完成报告、开发记录。推送后远端 HEAD 回读为该提交。
- 用户提供的未跟踪 V15 任务书未加入提交。release tag、版本坐标和发行包未修改。
- 本报告与 Step 后续补录此 Git 事实;设备状态仍为 **WAITING FOR MANUAL IPAD VALIDATION**。

View File

@@ -17,6 +17,19 @@ class BufferResource;
class CommandEncoder;
class Context;
namespace detail {
struct ClearedAttachments {
bool color = false;
bool depth = false;
bool stencil = false;
};
ClearedAttachments applyRenderPassClears(MTL::RenderPassDescriptor&,
const std::optional<Color>&,
const std::optional<float>&,
const std::optional<int32_t>&);
} // namespace detail
class RenderPass final : public gfx::RenderPass {
public:
RenderPass(CommandEncoder&, const char* name, const gfx::RenderPassDescriptor&);

View File

@@ -101,6 +101,8 @@ struct ParticleFieldSnapshot {
std::vector<float> vector;
std::vector<uint8_t> valid;
std::optional<VectorSemantics> vectorSemantics;
// Internal interpretation identity. Never part of the public field ABI.
std::string gpuEncodingIdentity;
bool isValid() const;
bool isTimeInterpolationCompatibleWith(const ParticleFieldSnapshot&) const;
@@ -120,6 +122,28 @@ inline bool sameFieldGpuContent(const ParticleFieldSnapshot& current, const Part
current.vector == next.vector && current.valid == next.valid;
}
// A GPU slot may be reused only for the same immutable endpoint. A matching
// producer revision alone is insufficient, and equal bytes at another valid
// time must not silently inherit the previous endpoint's provenance.
inline bool sameFieldGpuEndpoint(const ParticleFieldSnapshot& a, const ParticleFieldSnapshot& b) {
return a.fieldID == b.fieldID && a.generation == b.generation && a.dataVersion == b.dataVersion &&
a.validTime == b.validTime && a.fieldType == b.fieldType && a.displayDomain == b.displayDomain &&
a.vectorSemantics == b.vectorSemantics && a.noData == b.noData &&
a.fieldSha256 == b.fieldSha256 && a.gpuEncodingIdentity == b.gpuEncodingIdentity &&
a.width == b.width && a.height == b.height && a.west == b.west && a.south == b.south &&
a.east == b.east && a.north == b.north && a.vector == b.vector && a.valid == b.valid;
}
enum class GpuEndpointSource : uint8_t { Upload, OldT0, OldT1 };
inline GpuEndpointSource gpuEndpointSource(const ParticleFieldSnapshot& oldT0,
const ParticleFieldSnapshot& oldT1,
const ParticleFieldSnapshot& wanted) {
if (sameFieldGpuEndpoint(oldT0, wanted)) return GpuEndpointSource::OldT0;
if (sameFieldGpuEndpoint(oldT1, wanted)) return GpuEndpointSource::OldT1;
return GpuEndpointSource::Upload;
}
struct ParticleFieldTimeWindow {
ParticleFieldSnapshot t0;
ParticleFieldSnapshot t1;

View File

@@ -78,6 +78,10 @@ struct DecodedFieldGrid {
struct DecodedFieldFrame {
FrameContentIdentity frameContentIdentity;
uint64_t referenceTimeMs = 0;
// Validation provenance retained so a stored frame cannot be interpolated
// across two otherwise byte-compatible but semantically different profiles.
std::string resolvedProfileID;
uint32_t resolvedProfileVersion = 0;
DecodedFieldGrid grid;
// One frame-wide validity domain. The descriptor and payload share storage;
// projections can retain the same payload without copying it.
@@ -87,8 +91,12 @@ struct DecodedFieldFrame {
std::vector<DecodedFieldPlane> planes;
bool isStructurallyValid() const;
bool isSeriesCompatibleWith(const DecodedFieldFrame&) const;
bool isInterpolationCompatibleWith(const DecodedFieldFrame&) const;
bool hasSameContentAs(const DecodedFieldFrame&) const;
// Raw decoded plane payload plus the shared validity bitset; excludes
// separately materialized CPU snapshots and renderer/GPU resources.
uint64_t decodedByteSize() const;
};
enum class FieldSeriesSubmitResult : uint8_t {
@@ -118,6 +126,7 @@ public:
std::optional<FieldSeriesWindow> resolve(uint64_t requestedForecastTimeMs) const;
size_t size() const { return frames.size(); }
size_t capacity() const { return capacity_; }
uint64_t decodedByteSize() const;
const std::vector<std::shared_ptr<const DecodedFieldFrame>>& entries() const { return frames; }
private:
@@ -158,6 +167,7 @@ public:
bool clear(const std::string& fieldKey);
void clearAll() { series.clear(); }
size_t fieldCount() const { return series.size(); }
uint64_t decodedByteSize() const;
private:
size_t perSeriesCapacity_;

View File

@@ -0,0 +1,33 @@
#pragma once
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
namespace mbgl::particle {
// Internal RGBA8 UNORM field representation. Nearest sampling and highp
// shader arithmetic reconstruct the byte with floor(sample*255 + 0.5).
inline uint8_t encodeSignedVelocity8(const float value, const float scale, const bool valid) {
if (!valid || !std::isfinite(value) || !std::isfinite(scale) || scale <= 0.0f) return 128;
return static_cast<uint8_t>(128 + static_cast<int>(
std::lround(std::clamp(value / scale, -1.0f, 1.0f) * 127.0f)));
}
inline float decodeSignedVelocity8(const uint8_t byte, const float scale) {
return static_cast<float>(static_cast<int>(byte) - 128) * scale / 127.0f;
}
inline std::array<double, 2> mercatorVelocityDelta(const double eastMps,
const double northMps,
const double latitudeDegrees,
const double visualSeconds) {
constexpr double circumference = 40075016.68557849;
constexpr double radiansPerDegree = 3.14159265358979323846 / 180.0;
const double cosine = std::max(std::cos(latitudeDegrees * radiansPerDegree), 0.01);
return {{eastMps * visualSeconds / (circumference * cosine),
-northMps * visualSeconds / (circumference * cosine)}};
}
} // namespace mbgl::particle

View File

@@ -0,0 +1,14 @@
#pragma once
namespace mbgl {
template <class Texture>
constexpr bool weatherColorDrawableBindingsChanged(const Texture* previous0,
const Texture* previous1,
const Texture* next0,
const Texture* next1,
bool wrapsChanged) noexcept {
return wrapsChanged || previous0 != next0 || previous1 != next1;
}
} // namespace mbgl

View File

@@ -2,7 +2,9 @@
#include <cstdint>
#include <optional>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>
namespace mbgl {
@@ -38,12 +40,139 @@ 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;
bool enabled = false;
bool isLoaded = false;
bool tileBacked = false;
std::optional<uint32_t> tileCount;
std::optional<uint32_t> incompleteTileCount;
std::optional<uint32_t> loadedTileCount;
bool hasFadingTiles = false;
uint64_t lastSourceMutationSequence = 0;
uint64_t lastTileChangedSequence = 0;
uint64_t lastTileActionSequence = 0;
std::string lastTileAction{"none"};
std::string lastMutationReason{"none"};
bool operator==(const RenderSourceReadinessDiagnostics&) const = default;
};
struct RenderMutationDiagnostics {
uint64_t sequence = 0;
uint64_t contentRevision = 0;
std::string eventType{"none"};
std::string reason{"none"};
std::optional<std::string> sourceId;
std::vector<uint32_t> affectedSegmentIndices;
std::string dependencyScope{"none"};
bool operator==(const RenderMutationDiagnostics&) const = default;
};
struct RendererReadinessDiagnostics {
bool styleLoaded = false;
bool imageManagerLoaded = false;
bool renderOrchestratorLoaded = false;
bool spriteLoaded = false;
bool hasFadingTiles = false;
uint64_t contentRevision = 0;
std::vector<RenderSourceReadinessDiagnostics> sources;
RenderMutationDiagnostics lastMutation;
bool operator==(const RendererReadinessDiagnostics&) const = default;
};
struct RenderSegmentCacheDiagnostics {
std::string cacheState{"disabled"};
uint64_t contentRevision = 0;
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;
@@ -59,6 +188,8 @@ struct RenderSegmentDiagnosticsEntry {
std::optional<std::string> resolvedStartLayerId;
std::optional<std::string> resolvedEndLayerId;
std::vector<StyleLayerDiagnostics> layers;
std::vector<std::string> dependentSourceIds;
std::vector<std::string> unknownDependencies;
std::optional<RenderSegmentCacheDiagnostics> cache;
};
@@ -83,6 +214,14 @@ struct RenderSegmentDiagnostics {
uint64_t fastPathHit = 0;
uint64_t fastPathMiss = 0;
uint64_t fullCpuFrame = 0;
bool styleLoaded = false;
bool imageManagerLoaded = false;
bool renderOrchestratorLoaded = false;
bool spriteLoaded = false;
bool hasFadingTiles = false;
uint64_t contentRevision = 0;
std::vector<RenderSourceReadinessDiagnostics> sources;
RenderMutationDiagnostics lastMutation;
std::vector<StyleLayerDiagnostics> styleLayers;
std::vector<RenderSegmentDiagnosticsEntry> segments;
};
@@ -109,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

@@ -34,8 +34,8 @@ layout (std140) uniform ParticleGpuValidationUBO {
uniform sampler2D u_state_texture;
uniform sampler2D u_previous;
uniform sampler2D u_field_texture;
uniform sampler2D u_next_field_texture;
uniform highp sampler2D u_field_texture;
uniform highp sampler2D u_next_field_texture;
vec2 fieldUv(vec2 world) {
return vec2((world.x - u_field_bounds.x) / (u_field_bounds.z - u_field_bounds.x),
@@ -47,8 +47,8 @@ bool validFieldUv(vec2 uv) {
}
bool resolveAdvection(vec4 field0, vec4 field1, out vec2 advection) {
vec2 mixed = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x,
(field1.rg * 2.0 - 1.0) * u_field_scale.y,
vec2 mixed = mix(((floor(field0.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.x,
((floor(field1.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.y,
u_marker.x);
if (any(isnan(mixed)) || any(greaterThan(abs(mixed), vec2(3.4e38)))) return false;
if (u_advection.x > 0.5) {
@@ -116,7 +116,11 @@ void main() {
}
}
)";
static constexpr const char* fragment = R"(layout (std140) uniform ParticleGpuValidationUBO {
static constexpr const char* fragment = R"(// The shared GL prelude defaults fragment float to mediump. Byte reconstruction
// from RGBA8 UNORM and Mercator state require highp on ES 3.x.
precision highp float;
layout (std140) uniform ParticleGpuValidationUBO {
highp vec4 u_pass;
highp vec4 u_marker;
highp vec4 u_mask;
@@ -135,8 +139,8 @@ void main() {
uniform sampler2D u_previous;
uniform sampler2D u_mask_texture;
uniform sampler2D u_field_texture;
uniform sampler2D u_next_field_texture;
uniform highp sampler2D u_field_texture;
uniform highp sampler2D u_next_field_texture;
uniform sampler2D u_state_texture;
in vec2 v_uv;
in vec2 v_world;
@@ -195,8 +199,8 @@ bool fieldAdvectionAt(vec2 position, out vec2 advection) {
vec2 uv = fieldUv(position);
vec4 field0 = texture(u_field_texture, uv);
vec4 field1 = texture(u_next_field_texture, uv);
vec2 mixed = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x,
(field1.rg * 2.0 - 1.0) * u_field_scale.y,
vec2 mixed = mix(((floor(field0.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.x,
((floor(field1.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.y,
u_marker.x);
if (!validFieldUv(uv) || field0.a < 0.5 || field1.a < 0.5 || any(isnan(mixed)) ||
any(greaterThan(abs(mixed), vec2(3.4e38)))) return false;
@@ -236,14 +240,14 @@ void main() {
if (field0.a < 0.5 || field1.a < 0.5) discard;
float fieldValue;
if (u_weather_ramp.y > 0.5) {
vec2 vectorValue = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x,
(field1.rg * 2.0 - 1.0) * u_field_scale.y,
vec2 vectorValue = mix(((floor(field0.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.x,
((floor(field1.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.y,
u_marker.x);
if (any(isnan(vectorValue))) discard;
fieldValue = length(vectorValue);
} else {
fieldValue = mix((field0.r * 2.0 - 1.0) * u_field_scale.x,
(field1.r * 2.0 - 1.0) * u_field_scale.y,
fieldValue = mix(((floor(field0.r * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.x,
((floor(field1.r * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.y,
u_marker.x);
if (isnan(fieldValue)) discard;
}
@@ -286,7 +290,7 @@ void main() {
if (!respawn) {
float latitude = mercatorLatitudeRadians(position.y);
vec2 displacement = vec2(advection.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS,
vec2 displacement = vec2(advection.x * u_pass.y / (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01)),
-advection.y * u_pass.y /
(EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01)));
vec2 nextPosition = position + displacement;
@@ -337,10 +341,10 @@ void main() {
// U/V remain canonical metres per second. u_pass.y is the explicit
// visual simulation delta in seconds; the validation clock may run
// faster than wall time, but it never changes the field contract.
// Web Mercator x spans Earth's circumference; y additionally scales
// with sec(latitude). V is north-positive while world y is southward.
// Both east and north metres scale with sec(latitude) in Web Mercator.
// V is north-positive while world y is southward.
float latitude = mercatorLatitudeRadians(position.y);
float dx = advection.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS;
float dx = advection.x * u_pass.y / (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01));
float dy = -advection.y * u_pass.y /
(EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01));
position = position + vec2(dx, dy);

View File

@@ -46,7 +46,7 @@ float2 fieldUv(float2 world, device const ParticleGpuValidationUBO& u) {
bool validFieldUv(float2 uv) { return all(uv >= float2(0.0)) && all(uv <= float2(1.0)); }
bool finite2(float2 value) { return all(isfinite(value)) && all(abs(value) <= float2(3.4e38)); }
bool resolveAdvection(float4 a, float4 b, device const ParticleGpuValidationUBO& u, thread float2& out) {
float2 mixed=mix((a.rg*2.0-1.0)*u.fieldScale.x,(b.rg*2.0-1.0)*u.fieldScale.y,u.marker.x);
float2 mixed=mix(((floor(a.rg*255.0+0.5)-128.0)/127.0)*u.fieldScale.x,((floor(b.rg*255.0+0.5)-128.0)/127.0)*u.fieldScale.y,u.marker.x);
if (!finite2(mixed)) return false;
if (u.advection.x > .5) { float l=length(mixed); if (!isfinite(l)||l>3.4e38||l<=u.advection.z) return false; out=mixed/l*u.advection.y; }
else { out=mixed; return length(out)>0.00001; }
@@ -126,12 +126,12 @@ float4 fragment fragmentMain(FragmentStage in [[stage_in]], device const Particl
sampler previousSampler [[sampler(0)]], sampler maskSampler [[sampler(1)]], sampler fieldSampler [[sampler(2)]], sampler nextFieldSampler [[sampler(3)]], sampler stateSampler [[sampler(4)]]) {
float mode=u.pass.x;
if(mode>5.5){if(in.valid<.5)discard_fragment();float speed=length(in.velocity);return float4(1,1,clamp(speed/30.,0.,1.),1);}
if(mode>4.5){float2 uv=fieldUv(in.world,u); if(!validFieldUv(uv)) discard_fragment(); float4 a=field.sample(fieldSampler,uv),b=nextField.sample(nextFieldSampler,uv);if(a.a<.5||b.a<.5)discard_fragment();float value=u.weatherRamp.y>.5?length(mix((a.rg*2.-1.)*u.fieldScale.x,(b.rg*2.-1.)*u.fieldScale.y,u.marker.x)):mix((a.r*2.-1.)*u.fieldScale.x,(b.r*2.-1.)*u.fieldScale.y,u.marker.x);if(!isfinite(value))discard_fragment();float4 c=ramp(value,u);float alpha=c.a*u.visual.x;return float4(c.rgb*alpha,alpha);}
if(mode>4.5){float2 uv=fieldUv(in.world,u); if(!validFieldUv(uv)) discard_fragment(); float4 a=field.sample(fieldSampler,uv),b=nextField.sample(nextFieldSampler,uv);if(a.a<.5||b.a<.5)discard_fragment();float value=u.weatherRamp.y>.5?length(mix(((floor(a.rg*255.+.5)-128.)/127.)*u.fieldScale.x,((floor(b.rg*255.+.5)-128.)/127.)*u.fieldScale.y,u.marker.x)):mix(((floor(a.r*255.+.5)-128.)/127.)*u.fieldScale.x,((floor(b.r*255.+.5)-128.)/127.)*u.fieldScale.y,u.marker.x);if(!isfinite(value))discard_fragment();float4 c=ramp(value,u);float alpha=c.a*u.visual.x;return float4(c.rgb*alpha,alpha);}
float4 old=previous.sample(previousSampler,in.uv);
if(mode<.5){
float2 position=u.mask.w>.5?mix(u.fieldBounds.xy,u.fieldBounds.zw,in.uv):old.rg; float age=old.b; bool initial=u.mask.w>.5; bool respawn=initial; float2 advection; float maxAge=u.marker.z*mix(.75,1.25,particleHash(in.uv+float2(17.31,83.17)));
if(u.mask.z>.5){age=initial?particleHash(in.uv+float2(71.19,29.47))*maxAge:old.b+u.pass.w;float2 uv=fieldUv(position,u);float4 a=field.sample(fieldSampler,uv),b=nextField.sample(nextFieldSampler,uv);bool ok=validFieldUv(uv)&&a.a>=.5&&b.a>=.5&&resolveAdvection(a,b,u,advection);respawn=initial||!ok||age>=maxAge||(u.fieldScale.z>.5&&!initial&&(position.x<u.respawnBounds.x||position.x>u.respawnBounds.z||position.y<u.respawnBounds.y||position.y>u.respawnBounds.w));if(!respawn){float lat=mercatorLatitudeRadians(position.y);float2 n=position+float2(advection.x*u.pass.y/40075016.68557849,-advection.y*u.pass.y/(40075016.68557849*max(cos(lat),.01)));float2 nuv=fieldUv(n,u);float4 na=field.sample(fieldSampler,nuv),nb=nextField.sample(nextFieldSampler,nuv);if(validFieldUv(nuv)&&na.a>=.5&&nb.a>=.5&&resolveAdvection(na,nb,u,advection))position=n;else respawn=true;}if(respawn){bool found=false;for(int i=0;i<3;i++){float2 c=respawnSeed(in.uv,old.rg,float(i),u);float2 cuv=fieldUv(c,u);float4 ca=field.sample(fieldSampler,cuv),cb=nextField.sample(nextFieldSampler,cuv);if(validFieldUv(cuv)&&ca.a>=.5&&cb.a>=.5&&resolveAdvection(ca,cb,u,advection)){position=c;found=true;break;}}if(!found)position=respawnSeed(in.uv,old.rg,3.,u);if(!initial)age=0.;}return float4(position.x,position.y,age,respawn?0.:1.);}
float2 uv=fieldUv(position,u);float4 a=field.sample(fieldSampler,uv),b=nextField.sample(nextFieldSampler,uv);if(!validFieldUv(uv)||a.a<.5||b.a<.5||!resolveAdvection(a,b,u,advection)){position=mix(u.fieldBounds.xy,u.fieldBounds.zw,in.uv);advection=float2(0);}float lat=mercatorLatitudeRadians(position.y);position+=float2(advection.x*u.pass.y/40075016.68557849,-advection.y*u.pass.y/(40075016.68557849*max(cos(lat),.01)));if(!validFieldUv(fieldUv(position,u))||!finite2(advection))position=mix(u.fieldBounds.xy,u.fieldBounds.zw,in.uv);return float4(position.x,position.y,fract(old.b+.03125*u.pass.y),1.);
if(u.mask.z>.5){age=initial?particleHash(in.uv+float2(71.19,29.47))*maxAge:old.b+u.pass.w;float2 uv=fieldUv(position,u);float4 a=field.sample(fieldSampler,uv),b=nextField.sample(nextFieldSampler,uv);bool ok=validFieldUv(uv)&&a.a>=.5&&b.a>=.5&&resolveAdvection(a,b,u,advection);respawn=initial||!ok||age>=maxAge||(u.fieldScale.z>.5&&!initial&&(position.x<u.respawnBounds.x||position.x>u.respawnBounds.z||position.y<u.respawnBounds.y||position.y>u.respawnBounds.w));if(!respawn){float lat=mercatorLatitudeRadians(position.y);float2 n=position+float2(advection.x*u.pass.y/(40075016.68557849*max(cos(lat),.01)),-advection.y*u.pass.y/(40075016.68557849*max(cos(lat),.01)));float2 nuv=fieldUv(n,u);float4 na=field.sample(fieldSampler,nuv),nb=nextField.sample(nextFieldSampler,nuv);if(validFieldUv(nuv)&&na.a>=.5&&nb.a>=.5&&resolveAdvection(na,nb,u,advection))position=n;else respawn=true;}if(respawn){bool found=false;for(int i=0;i<3;i++){float2 c=respawnSeed(in.uv,old.rg,float(i),u);float2 cuv=fieldUv(c,u);float4 ca=field.sample(fieldSampler,cuv),cb=nextField.sample(nextFieldSampler,cuv);if(validFieldUv(cuv)&&ca.a>=.5&&cb.a>=.5&&resolveAdvection(ca,cb,u,advection)){position=c;found=true;break;}}if(!found)position=respawnSeed(in.uv,old.rg,3.,u);if(!initial)age=0.;}return float4(position.x,position.y,age,respawn?0.:1.);}
float2 uv=fieldUv(position,u);float4 a=field.sample(fieldSampler,uv),b=nextField.sample(nextFieldSampler,uv);if(!validFieldUv(uv)||a.a<.5||b.a<.5||!resolveAdvection(a,b,u,advection)){position=mix(u.fieldBounds.xy,u.fieldBounds.zw,in.uv);advection=float2(0);}float lat=mercatorLatitudeRadians(position.y);position+=float2(advection.x*u.pass.y/(40075016.68557849*max(cos(lat),.01)),-advection.y*u.pass.y/(40075016.68557849*max(cos(lat),.01)));if(!validFieldUv(fieldUv(position,u))||!finite2(advection))position=mix(u.fieldBounds.xy,u.fieldBounds.zw,in.uv);return float4(position.x,position.y,fract(old.b+.03125*u.pass.y),1.);
}
if(mode<1.5){if(u.mask.z>.5){float f=pow(clamp(u.visual.w,.0001,1.),u.pass.y*u.marker.w);return float4(old.r*f,0,0,1);}float marker=0.;float2 samples[4]={float2(.125),float2(.375),float2(.625),float2(.875)};for(int i=0;i<4;i++)marker=max(marker,1.-smoothstep(0.,u.marker.z,distance(in.uv,state.sample(stateSampler,samples[i]).rg)));return float4(min(old.rgb*u.marker.w+marker*float3(.08,.55,1),float3(1)),1);}
if(mode>3.5){if(in.valid<.5)discard_fragment();float speed=length(in.velocity);return float4(1,1,clamp(speed/30.,0.,1.),1);}

View File

@@ -238,10 +238,17 @@ public:
void setForecastPlaybackState(particle::ForecastPlaybackState);
bool setForecastPlaybackSpeed(float);
bool advanceForecastClock(uint64_t elapsedMs);
// Called once per Map update on the owner thread; render layers only read
// the resulting immutable projection and never advance this clock.
bool tickForecastClock(std::chrono::steady_clock::time_point now);
particle::ColorProjectionResult resolveColorProjection() const;
particle::ParticleProjectionResult resolveParticleProjection() const;
bool clearEncodedField(const std::string& fieldKey);
void clearAllEncodedFields();
// Explicit terminal cleanup for an attached WeatherRuntime. Unlike an ordinary
// V4 window clear, this also drops bindings, forecast clock and V3 host ingress.
// Source and Style layers remain installed for the next runtime generation.
bool resetWeatherRuntimeState();
// Producer-facing timestamp seek. Core alone selects T0/T1 and MIX.
bool seekRuntimeTimelineToTimestamp(uint64_t timestamp);
// Low-frequency JNI commands for the Source-owned validation timeline.
@@ -302,6 +309,10 @@ private:
// across clear operations and resolve their own field-keyed windows.
particle::EncodedFieldStore encodedFieldStore;
particle::ForecastClock forecastClock;
std::chrono::steady_clock::time_point lastForecastTick{};
// A Source change synchronously invokes Style::onUpdate, which re-enters
// Map::onUpdate. Prevent a second forecast tick during the first notification.
bool forecastTickInProgress = false;
std::optional<particle::ColorBinding> colorBinding;
std::optional<particle::ParticleBinding> particleBinding;
mapbox::base::WeakPtrFactory<Source> weakFactory{this};

View File

@@ -9,7 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@maplibre/maplibre-react-native": "11.3.6",
"@navsea/maplibre-particle-rn": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.10.tgz",
"@navsea/maplibre-particle-rn": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.13.tgz",
"expo": "56.0.4",
"expo-asset": "~56.0.14",
"expo-file-system": "56.0.11",
@@ -1762,9 +1762,9 @@
}
},
"node_modules/@navsea/maplibre-particle-rn": {
"version": "13.4.1-particle.10",
"resolved": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.10.tgz",
"integrity": "sha512-SNXvZ71A+vhrWzTxYCLGLGNkg6evtl8hydgdMUzB2Ft78sf5o+SCLQFtJ7ieLJl9r4kVEYdmprG8uwRdJDlhYg==",
"version": "13.4.1-particle.13",
"resolved": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.13.tgz",
"integrity": "sha512-pXx41cV/1HcPQPAfOzvugsuVC1YIYtF4gWbRzsaDndEWFvXq0Kb1K+hONzzOKd7S5WU98XFLUpdVMGva4ZcZ9w==",
"license": "BSD-2-Clause",
"peerDependencies": {
"@maplibre/maplibre-react-native": "11.3.6",

View File

@@ -9,7 +9,7 @@
"android": "expo run:android"
},
"dependencies": {
"@navsea/maplibre-particle-rn": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.10.tgz",
"@navsea/maplibre-particle-rn": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.13.tgz",
"@maplibre/maplibre-react-native": "11.3.6",
"expo": "56.0.4",
"expo-asset": "~56.0.14",

View File

@@ -6,7 +6,7 @@ const marker = "// MapLibreParticleRNTestHost NavSea Particle release SDK";
module.exports = function withParticleNativeAndroid(config) {
return withProjectBuildGradle(config, (project) => {
if (project.modResults.contents.includes(marker)) return project;
project.modResults.contents += `\n${marker}\ngradle.projectsEvaluated {\n allprojects { p ->\n p.repositories.maven { repository -> repository.url = p.uri("https://pbf.jpcn.ccwu.cc/maven") }\n p.configurations.configureEach { configuration ->\n configuration.resolutionStrategy.dependencySubstitution {\n substitute module("org.maplibre.gl:android-sdk-opengl") using module("cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.13")\n }\n }\n }\n}\nrootProject.ext.set("org.maplibre.reactnative.nativeVersion", "13.4.1")\nrootProject.ext.set("org.maplibre.reactnative.nativeVariant", "opengl")\n`;
project.modResults.contents += `\n${marker}\ngradle.projectsEvaluated {\n allprojects { p ->\n p.repositories.maven { repository -> repository.url = p.uri("https://pbf.jpcn.ccwu.cc/maven") }\n p.configurations.configureEach { configuration ->\n configuration.resolutionStrategy.dependencySubstitution {\n substitute module("org.maplibre.gl:android-sdk-opengl") using module("cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.19")\n }\n }\n }\n}\nrootProject.ext.set("org.maplibre.reactnative.nativeVersion", "13.4.1")\nrootProject.ext.set("org.maplibre.reactnative.nativeVariant", "opengl")\n`;
return project;
});
};

View File

@@ -2,6 +2,21 @@
## Unreleased
## 13.4.1-particle.19(Android / iOS 统一发行)
- Mac unified release pipeline 统一生成 Android Native、RN package 与 iOS SwiftPM/XCFramework;共享 Particle revision 19。
## 13.4.1-particle.18(Android / iOS 统一发行)
- Mac unified release pipeline 统一生成 Android Native、RN package 与 iOS SwiftPM/XCFramework;共享 Particle revision 18。
## 13.4.1-particle.17(Android / iOS 统一发行)
- Mac unified release pipeline 统一生成 Android Native、RN package 与 iOS SwiftPM/XCFramework;共享 Particle revision 17。
## 13.4.1-particle.16(Android / iOS 统一发行)
- Mac unified release pipeline 统一生成 Android Native、RN package 与 iOS SwiftPM/XCFramework;共享 Particle revision 16。
## 13.4.1-particle.15(Android / iOS 统一发行)
- Mac unified release pipeline 统一生成 Android Native、RN package 与 iOS SwiftPM/XCFramework;共享 Particle revision 15。
## 13.4.1-particle.13(Android / iOS 统一发行)
- Mac unified release pipeline 统一生成 Android Native、RN package 与 iOS SwiftPM/XCFramework;共享 Particle revision 13。

View File

@@ -1,6 +1,6 @@
# @navsea/maplibre-particle-rn
当前版本:`13.4.1-particle.13`。Android Native 必须配套 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.13`。本包是 React Native 的薄控制面,不负责 Map 组件、provider、HTTP、manifest、下载、缓存或 NavSea 业务策略。
当前版本:`13.4.1-particle.19`。Android Native 必须配套 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.19`。本包是 React Native 的薄控制面,不负责 Map 组件、provider、HTTP、manifest、下载、缓存或 NavSea 业务策略。
## 当前推荐主链:QBIN V4
@@ -37,7 +37,7 @@ Podfile 的 `post_install do |installer|` 必须同时调用 `$MLRN.post_install
RN tgz:
```text
https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.13.tgz
https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.19.tgz
```
Native Maven repository:
@@ -49,7 +49,7 @@ https://pbf.jpcn.ccwu.cc/maven
Native coordinate:
```text
cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.13
cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.19
```
Expo plugin 与 `android/build.gradle` 必须指向同一 `.13`。正常 Consumer 不复制 AAR、不依赖 MapLibreParticleLab 源码路径,也不以 Maven Local 作为公网验证主路径。完整 Linux 发布与 Win11 验证流程见 `docs/50_Step记录/STEP-080_Android_AAR与RNTestHost标准构建流水线_v2.md`。
Expo plugin 与 `android/build.gradle` 必须指向同一 `.19`。正常 Consumer 不复制 AAR、不依赖 MapLibreParticleLab 源码路径,也不以 Maven Local 作为公网验证主路径。完整 Linux 发布与 Win11 验证流程见 `docs/50_Step记录/STEP-080_Android_AAR与RNTestHost标准构建流水线_v2.md`。

View File

@@ -14,8 +14,9 @@ android {
dependencies {
implementation "com.facebook.react:react-android"
implementation "org.jetbrains.kotlin:kotlin-stdlib:2.1.20"
implementation "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.13"
implementation "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.19"
compileOnly project(":maplibre_maplibre-react-native")
testImplementation "junit:junit:4.13.2"
}
react {

View File

@@ -0,0 +1,163 @@
package org.navsea.maplibre.particle.weather
import java.lang.ref.WeakReference
internal fun interface WeatherViewDetachCallback {
fun onDetached()
}
internal fun interface WeatherViewDetachRegistration {
fun remove()
}
internal fun interface WeatherViewDetachHost {
fun add(callback: WeatherViewDetachCallback): WeatherViewDetachRegistration
}
internal fun interface WeatherMapDestroyCallback {
fun onDestroyed()
}
internal fun interface WeatherMapDestroyRegistration {
fun remove()
}
internal fun interface WeatherMapDestroyHost<T : Any> {
fun add(target: T, callback: WeatherMapDestroyCallback): WeatherMapDestroyRegistration
}
/** One view-tag-owned registration, with separate attachment and map-lifetime generations. */
internal class WeatherRuntimeAttachmentOwner<T : Any>(
private val host: WeatherViewDetachHost,
private val mapDestroyHost: WeatherMapDestroyHost<T>,
private val disposeRuntime: (T) -> Unit,
) : Runnable {
private var generation = 0L
private var mapGeneration = 0L
private var currentTarget: WeakReference<T>? = null
private var mapTarget: WeakReference<T>? = null
private var viewRegistration: WeatherViewDetachRegistration? = null
private var mapRegistration: WeatherMapDestroyRegistration? = null
private var destroyed = false
fun attach(target: T): Boolean {
if (destroyed) return false
val previousTarget = currentTarget?.get()
removeViewRegistration()
val attachmentGeneration = ++generation
currentTarget = WeakReference(target)
if (previousTarget != null && previousTarget !== target) disposeRuntime(previousTarget)
if (!trackMap(target)) return false
val ownerReference = WeakReference(this)
val targetReference = WeakReference(target)
val viewRegistration = host.add(WeatherViewDetachCallback {
val owner = ownerReference.get() ?: return@WeatherViewDetachCallback
val attachedTarget = targetReference.get()
if (attachedTarget != null) owner.disposeIfCurrent(attachedTarget, attachmentGeneration)
else owner.clearIfCurrent(attachmentGeneration)
})
if (!isCurrent(target, attachmentGeneration)) {
viewRegistration.remove()
return false
}
this.viewRegistration = viewRegistration
return true
}
fun detach(target: T): Boolean {
if (currentTarget?.get() !== target) return false
return disposeIfCurrent(target, generation)
}
fun destroy(): Boolean {
if (destroyed) return false
destroyed = true
val target = currentTarget?.get()
currentTarget = null
generation++
removeRegistrations()
mapTarget = null
mapGeneration++
if (target == null) return false
disposeRuntime(target)
return true
}
private fun destroyMapIfCurrent(target: T?, expectedMapGeneration: Long) {
if (destroyed || expectedMapGeneration != mapGeneration) return
val trackedMap = mapTarget?.get()
if (target != null && trackedMap !== target) return
val attachedTarget = currentTarget?.get()?.takeIf { it === trackedMap }
destroyed = true
currentTarget = null
mapTarget = null
generation++
mapGeneration++
removeRegistrations()
if (attachedTarget != null) disposeRuntime(attachedTarget)
}
override fun run() {
destroy()
}
private fun disposeIfCurrent(target: T, expectedGeneration: Long): Boolean {
if (!isCurrent(target, expectedGeneration)) return false
currentTarget = null
generation++
removeViewRegistration()
disposeRuntime(target)
return true
}
private fun clearIfCurrent(expectedGeneration: Long) {
if (expectedGeneration != generation) return
currentTarget = null
generation++
removeViewRegistration()
}
private fun isCurrent(target: T, expectedGeneration: Long) =
!destroyed && expectedGeneration == generation && currentTarget?.get() === target
private fun isTrackedMap(target: T, expectedMapGeneration: Long) =
!destroyed && expectedMapGeneration == mapGeneration && mapTarget?.get() === target
private fun trackMap(target: T): Boolean {
if (mapTarget?.get() === target && mapRegistration != null) return true
removeMapRegistration()
val expectedMapGeneration = ++mapGeneration
mapTarget = WeakReference(target)
val ownerReference = WeakReference(this)
val targetReference = WeakReference(target)
val registration = mapDestroyHost.add(target, WeatherMapDestroyCallback {
ownerReference.get()?.destroyMapIfCurrent(targetReference.get(), expectedMapGeneration)
})
if (!isTrackedMap(target, expectedMapGeneration)) {
registration.remove()
return false
}
mapRegistration = registration
return true
}
private fun removeRegistrations() {
removeViewRegistration()
removeMapRegistration()
}
private fun removeViewRegistration() {
val previous = viewRegistration
viewRegistration = null
previous?.remove()
}
private fun removeMapRegistration() {
val previous = mapRegistration
mapRegistration = null
previous?.remove()
}
}

View File

@@ -46,20 +46,33 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) :
),
)
}
val runtime = map.replaceWeatherRuntime(WeatherRuntime.Config(WeatherIds.SOURCE, WeatherIds.COLOR_LAYER, WeatherIds.PARTICLE_LAYER))
// MLRN drops its MapView by detaching it. This listener captures only that concrete map,
// disposes its map-owned facade, and cannot target a replacement view.
view.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) = Unit
override fun onViewDetachedFromWindow(v: View) { map.disposeWeatherRuntime() }
})
val runtime = try {
map.replaceWeatherRuntime(WeatherRuntime.Config(WeatherIds.SOURCE, WeatherIds.COLOR_LAYER, WeatherIds.PARTICLE_LAYER))
} catch (error: IllegalStateException) {
return@withMapView reject(promise, "RUNTIME_CLEANUP_FAILED", error.message ?: "WeatherRuntime replacement cleanup failed")
}
if (!attachmentOwner(view).attach(map)) {
map.disposeWeatherRuntime()
return@withMapView reject(promise, "RUNTIME_DISPOSED", "The MapView attachment owner has been destroyed")
}
promise.resolve(snapshot(runtime))
}
@ReactMethod
override fun detach(reactTag: Double, promise: Promise) = withMap(reactTag, promise) { map ->
if (map.weatherRuntime == null) reject(promise, "RUNTIME_DISPOSED", "No attached WeatherRuntime exists for this map")
else { map.disposeWeatherRuntime(); promise.resolve(null) }
override fun detach(reactTag: Double, promise: Promise) = withMapView(reactTag, promise) { view, map ->
val owner = view.getTag(R.id.weather_runtime_attachment_owner) as? WeatherRuntimeAttachmentOwner<*>
// Duplicate detach is idempotent; Core failures must not be reported as success.
try {
val detachedByOwner = (owner as? WeatherRuntimeAttachmentOwner<MapLibreMap>)?.detach(map) ?: false
if (!detachedByOwner) map.disposeWeatherRuntime()
if (map.weatherRuntime != null) {
reject(promise, "RUNTIME_CLEANUP_FAILED", "WeatherRuntime remains attached after detach")
} else {
promise.resolve(null)
}
} catch (error: IllegalStateException) {
reject(promise, "RUNTIME_CLEANUP_FAILED", error.message ?: "WeatherRuntime detach cleanup failed")
}
}
@ReactMethod override fun getState(reactTag: Double, promise: Promise) = withRuntime(reactTag, promise) { runtime -> promise.resolve(snapshot(runtime)) }
@@ -312,6 +325,41 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) :
if (runtime == null || runtime.isDisposed) reject(promise, "RUNTIME_DISPOSED", "WeatherRuntime is not attached to this map") else fn(runtime)
}
private fun withMap(reactTag: Double, promise: Promise, fn: (MapLibreMap) -> Unit) = withMapView(reactTag, promise) { _, map -> fn(map) }
private fun attachmentOwner(view: View): WeatherRuntimeAttachmentOwner<MapLibreMap> {
val existing = view.getTag(R.id.weather_runtime_attachment_owner) as? WeatherRuntimeAttachmentOwner<*>
if (existing != null) return existing as WeatherRuntimeAttachmentOwner<MapLibreMap>
val host = AndroidWeatherViewDetachHost(view)
return WeatherRuntimeAttachmentOwner(host, AndroidWeatherMapDestroyHost, MapLibreMap::disposeWeatherRuntime).also {
view.setTag(R.id.weather_runtime_attachment_owner, it)
}
}
private object AndroidWeatherMapDestroyHost : WeatherMapDestroyHost<MapLibreMap> {
override fun add(target: MapLibreMap, callback: WeatherMapDestroyCallback): WeatherMapDestroyRegistration {
val registration = target.addOnDestroyListener(MapLibreMap.OnDestroyListener { callback.onDestroyed() })
return WeatherMapDestroyRegistration { registration.remove() }
}
}
private class AndroidWeatherViewDetachHost(private val view: View) : WeatherViewDetachHost {
override fun add(callback: WeatherViewDetachCallback): WeatherViewDetachRegistration {
val listener = object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) = Unit
override fun onViewDetachedFromWindow(v: View) = callback.onDetached()
}
view.addOnAttachStateChangeListener(listener)
return object : WeatherViewDetachRegistration {
private var removed = false
override fun remove() {
if (removed) return
removed = true
view.removeOnAttachStateChangeListener(listener)
}
}
}
}
private fun withMapView(reactTag: Double, promise: Promise, fn: (MLRNMapView, MapLibreMap) -> Unit) {
if (!reactTag.isFinite() || reactTag <= 0 || reactTag != kotlin.math.floor(reactTag)) return reject(promise, "INVALID_ARGUMENT", "reactTag must be a positive integer")
context.runOnUiQueueThread {

View File

@@ -0,0 +1,3 @@
<resources>
<item name="weather_runtime_attachment_owner" type="id" />
</resources>

View File

@@ -0,0 +1,150 @@
package org.navsea.maplibre.particle.weather
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class WeatherRuntimeAttachmentOwnerTest {
private class FakeMap
private class FakeViewHost : WeatherViewDetachHost {
private data class Entry(val callback: WeatherViewDetachCallback, var active: Boolean = true)
private val entries = mutableListOf<Entry>()
val activeCount: Int
get() = entries.count { it.active }
fun callbackAt(index: Int) = entries[index].callback
override fun add(callback: WeatherViewDetachCallback): WeatherViewDetachRegistration {
val entry = Entry(callback)
entries += entry
return WeatherViewDetachRegistration { entry.active = false }
}
}
private class FakeMapHost : WeatherMapDestroyHost<FakeMap> {
private data class Entry(val map: FakeMap, val callback: WeatherMapDestroyCallback, var active: Boolean = true)
private val entries = mutableListOf<Entry>()
val activeCount: Int
get() = entries.count { it.active }
fun callbackAt(index: Int) = entries[index].callback
override fun add(target: FakeMap, callback: WeatherMapDestroyCallback): WeatherMapDestroyRegistration {
val entry = Entry(target, callback)
entries += entry
return WeatherMapDestroyRegistration { entry.active = false }
}
}
@Test
fun sameViewCanAttachDetachOneHundredTimesWithoutAccumulatingListeners() {
val viewHost = FakeViewHost()
val mapHost = FakeMapHost()
val disposed = mutableListOf<FakeMap>()
val owner = WeatherRuntimeAttachmentOwner(viewHost, mapHost) { disposed += it }
val map = FakeMap()
repeat(100) {
assertTrue(owner.attach(map))
assertEquals(1, viewHost.activeCount)
assertEquals(1, mapHost.activeCount)
val staleViewCallback = viewHost.callbackAt(it)
assertTrue(owner.detach(map))
assertEquals(0, viewHost.activeCount)
assertEquals(1, mapHost.activeCount)
staleViewCallback.onDetached()
assertEquals(it + 1, disposed.size)
assertFalse(owner.detach(map))
}
assertTrue(owner.attach(map))
val viewCallbackBeforeDestroy = viewHost.callbackAt(100)
val mapCallbackBeforeDestroy = mapHost.callbackAt(0)
assertTrue(owner.destroy())
assertEquals(0, viewHost.activeCount)
assertEquals(0, mapHost.activeCount)
assertEquals(101, disposed.size)
viewCallbackBeforeDestroy.onDetached()
mapCallbackBeforeDestroy.onDestroyed()
assertFalse(owner.destroy())
assertFalse(owner.attach(map))
assertEquals(101, disposed.size)
}
@Test
fun mapDestroyWithoutViewDetachDisposesExactlyOnceAndRemovesBothListeners() {
val viewHost = FakeViewHost()
val mapHost = FakeMapHost()
val disposed = mutableListOf<FakeMap>()
val owner = WeatherRuntimeAttachmentOwner(viewHost, mapHost) { disposed += it }
val map = FakeMap()
assertTrue(owner.attach(map))
val mapDestroy = mapHost.callbackAt(0)
mapDestroy.onDestroyed()
assertEquals(listOf(map), disposed)
assertEquals(0, viewHost.activeCount)
assertEquals(0, mapHost.activeCount)
mapDestroy.onDestroyed()
assertFalse(owner.destroy())
assertFalse(owner.attach(map))
assertEquals(listOf(map), disposed)
}
@Test
fun mapDestroyedDuringViewDetachClosesOwnerWithoutDisposingTwice() {
val viewHost = FakeViewHost()
val mapHost = FakeMapHost()
val disposed = mutableListOf<FakeMap>()
val owner = WeatherRuntimeAttachmentOwner(viewHost, mapHost) { disposed += it }
val map = FakeMap()
assertTrue(owner.attach(map))
val mapDestroy = mapHost.callbackAt(0)
assertTrue(owner.detach(map))
assertEquals(listOf(map), disposed)
assertEquals(0, viewHost.activeCount)
assertEquals(1, mapHost.activeCount)
mapDestroy.onDestroyed()
assertEquals(listOf(map), disposed)
assertEquals(0, mapHost.activeCount)
assertFalse(owner.attach(map))
assertFalse(owner.destroy())
}
@Test
fun destroyedOldMapCallbackCannotAffectReplacementGeneration() {
val viewHost = FakeViewHost()
val mapHost = FakeMapHost()
val disposed = mutableListOf<FakeMap>()
val owner = WeatherRuntimeAttachmentOwner(viewHost, mapHost) { disposed += it }
val oldMap = FakeMap()
val currentMap = FakeMap()
assertTrue(owner.attach(oldMap))
val oldViewDetach = viewHost.callbackAt(0)
val oldMapDestroy = mapHost.callbackAt(0)
assertTrue(owner.attach(currentMap))
assertEquals(listOf(oldMap), disposed)
assertEquals(1, viewHost.activeCount)
assertEquals(1, mapHost.activeCount)
oldViewDetach.onDetached()
oldMapDestroy.onDestroyed()
assertEquals(listOf(oldMap), disposed)
assertEquals(1, viewHost.activeCount)
assertEquals(1, mapHost.activeCount)
mapHost.callbackAt(1).onDestroyed()
assertEquals(listOf(oldMap, currentMap), disposed)
assertEquals(0, viewHost.activeCount)
assertEquals(0, mapHost.activeCount)
}
}

View File

@@ -20,7 +20,7 @@ const iosTag = "navsea-maplibre-particle-rn-maplibre-spm";
module.exports = function withNavSeaMapLibreParticle(config) {
config = withProjectBuildGradle(config, (project) => {
if (project.modResults.contents.includes(androidMarker)) return project;
project.modResults.contents += `\n${androidMarker}\ngradle.projectsEvaluated {\n allprojects { p ->\n p.repositories.maven { repository -> repository.url = p.uri("https://pbf.jpcn.ccwu.cc/maven") }\n p.configurations.configureEach { configuration ->\n configuration.resolutionStrategy.dependencySubstitution {\n substitute module("org.maplibre.gl:android-sdk-opengl") using module("cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.13")\n }\n }\n }\n}\nrootProject.ext.set("org.maplibre.reactnative.nativeVersion", "13.4.1")\nrootProject.ext.set("org.maplibre.reactnative.nativeVariant", "opengl")\n`;
project.modResults.contents += `\n${androidMarker}\ngradle.projectsEvaluated {\n allprojects { p ->\n p.repositories.maven { repository -> repository.url = p.uri("https://pbf.jpcn.ccwu.cc/maven") }\n p.configurations.configureEach { configuration ->\n configuration.resolutionStrategy.dependencySubstitution {\n substitute module("org.maplibre.gl:android-sdk-opengl") using module("cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.19")\n }\n }\n }\n}\nrootProject.ext.set("org.maplibre.reactnative.nativeVersion", "13.4.1")\nrootProject.ext.set("org.maplibre.reactnative.nativeVariant", "opengl")\n`;
return project;
});

View File

@@ -155,7 +155,16 @@ RCT_EXPORT_MODULE(WeatherRuntimeBridge)
}];
}
- (void)detach:(double)reactTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
[self withMapForTag:reactTag requireRuntime:YES resolve:resolve reject:reject action:^(MLNMapView *map, MLNParticleFieldRuntime *runtime) { [map disposeParticleFieldRuntime]; resolve(nil); }];
[self withMapForTag:reactTag requireRuntime:NO resolve:resolve reject:reject action:^(MLNMapView *map, MLNParticleFieldRuntime *runtime) {
// Duplicate detach is a no-op, but an actual Source cleanup failure is not.
if (!runtime) { resolve(nil); return; }
NSError *error = nil;
if (![map disposeParticleFieldRuntimeWithError:&error]) {
reject(@"RUNTIME_CLEANUP_FAILED", error.localizedDescription ?: @"WeatherRuntime detach cleanup failed", error);
return;
}
resolve(nil);
}];
}
- (void)getState:(double)reactTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [self withMapForTag:reactTag requireRuntime:YES resolve:resolve reject:reject action:^(MLNMapView *map, MLNParticleFieldRuntime *runtime) { resolve(snapshot(runtime)); }]; }
- (void)setRetainedUnderlayEnabled:(double)reactTag enabled:(BOOL)enabled resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {

View File

@@ -1,6 +1,6 @@
{
"name": "@navsea/maplibre-particle-rn",
"version": "13.4.1-particle.13",
"version": "13.4.1-particle.19",
"description": "NavSea MapLibre Particle React Native pure Field Renderer API.",
"license": "BSD-2-Clause",
"main": "src/index.ts",

View File

@@ -19,6 +19,8 @@ export type {
RenderSegmentDefinition,
RenderSegmentDiagnostics,
RenderSegmentLayerDiagnostics,
RenderSourceReadinessDiagnostics,
RenderMutationDiagnostics,
RenderSegmentPlan,
RenderSegmentStrategy,
RetainedRenderSegmentDiagnostics,

View File

@@ -89,12 +89,21 @@ export type RetainedRenderSegmentDiagnostics = Readonly<{
resolvedEndLayerId: string | null;
layerCount: number;
layers: readonly RenderSegmentLayerDiagnostics[];
dependentSourceIds: readonly string[];
unknownDependencies: readonly string[];
cache: Readonly<{
cacheState: string;
contentRevision: number;
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;
@@ -111,8 +120,35 @@ export type ContinuousRenderSegmentDiagnostics = Readonly<{
resolvedEndLayerId: string | null;
layerCount: number;
layers: readonly RenderSegmentLayerDiagnostics[];
dependentSourceIds: readonly string[];
unknownDependencies: readonly string[];
cache: null;
}>;
export type RenderSourceReadinessDiagnostics = Readonly<{
sourceId: string;
sourceType: string;
enabled: boolean;
isLoaded: boolean;
tileBacked: boolean;
tileCount: number | null;
incompleteTileCount: number | null;
loadedTileCount: number | null;
hasFadingTiles: boolean;
lastSourceMutationSequence: number;
lastTileChangedSequence: number;
lastTileActionSequence: number;
lastTileAction: string;
lastMutationReason: string;
}>;
export type RenderMutationDiagnostics = Readonly<{
sequence: number;
contentRevision: number;
eventType: string;
reason: string;
sourceId: string | null;
affectedSegmentIndices: readonly number[];
dependencyScope: string;
}>;
export type RenderSegmentDiagnostics = Readonly<{
schemaVersion: 1;
enabled: boolean;
@@ -134,6 +170,14 @@ export type RenderSegmentDiagnostics = Readonly<{
fastPathHit: number;
fastPathMiss: number;
fullCpuFrame: number;
styleLoaded: boolean;
imageManagerLoaded: boolean;
renderOrchestratorLoaded: boolean;
spriteLoaded: boolean;
hasFadingTiles: boolean;
contentRevision: number;
sources: readonly RenderSourceReadinessDiagnostics[];
lastMutation: RenderMutationDiagnostics;
styleLayers: readonly RenderSegmentLayerDiagnostics[];
segments: readonly (RetainedRenderSegmentDiagnostics | ContinuousRenderSegmentDiagnostics)[];
}>;

View File

@@ -195,6 +195,7 @@ void MapRenderer::requestSnapshot(SnapshotCallback callback) {
// Called on OpenGL thread //
void MapRenderer::resetRenderer() {
Log::Info(Event::Android, "MapRenderer::resetRenderer entered");
renderer.reset();
if (!asyncRendererCleanup) {
@@ -203,6 +204,7 @@ void MapRenderer::resetRenderer() {
window.reset();
swapBehaviorFlush = false;
Log::Info(Event::Android, "MapRenderer::resetRenderer completed");
}
void MapRenderer::scheduleSnapshot(std::unique_ptr<SnapshotCallback> callback) {
@@ -322,9 +324,11 @@ void MapRenderer::onSurfaceChanged([[maybe_unused]] JNIEnv& env, jint width, jin
}
void MapRenderer::onRendererReset(JNIEnv&) {
Log::Info(Event::Android, "MapRenderer::nativeReset entered");
// Make sure to destroy the renderer on the GL Thread
auto self = ActorRef<MapRenderer>(*this, mailboxData.getMailbox());
self.ask(&MapRenderer::resetRenderer).wait();
Log::Info(Event::Android, "MapRenderer::nativeReset completed");
}
// needs to be called on GL thread

View File

@@ -729,12 +729,8 @@ jni::jboolean NativeMapView::setContinuousLayerRange(JNIEnv& env,
}
jni::jboolean NativeMapView::setRenderSegmentPlan(JNIEnv& env, const jni::String& planJson) {
const auto plan = parseRenderSegmentPlanJSON(jni::Make<std::string>(env, planJson));
if (plan) return map->setRenderSegmentPlan(*plan);
RenderSegmentPlan invalid;
invalid.schemaVersion = 0;
map->setRenderSegmentPlan(std::move(invalid));
return false;
auto plan = renderSegmentPlanFromJSONOrInvalid(jni::Make<std::string>(env, planJson));
return map->setRenderSegmentPlan(std::move(plan));
}
jni::Local<jni::String> NativeMapView::getRenderSegmentDiagnostics(JNIEnv& env) {
@@ -1476,6 +1472,16 @@ jni::jboolean NativeMapView::clearRasterArraySourceHostFields(JNIEnv& env, const
return cleared;
}
jni::jboolean NativeMapView::resetRasterArraySourceWeatherRuntime(JNIEnv& env, const jni::String& sourceId) {
const auto id = jni::Make<std::string>(env, sourceId);
auto* source = dynamic_cast<mbgl::style::RasterArraySource*>(map->getStyle().getSource(id));
if (!source) return false;
const bool reset = source->resetWeatherRuntimeState();
mbgl::Log::Info(mbgl::Event::JNI, "WeatherRuntime: event=TERMINAL_RESET sourceId=" + id +
" result=" + (reset ? "SUCCESS" : "REJECTED"));
return reset;
}
jni::jboolean NativeMapView::seekRasterArraySourceTimelineToTimestamp(JNIEnv& env,
const jni::String& sourceId,
const jni::jlong timestamp) {
@@ -1924,6 +1930,7 @@ void NativeMapView::registerNative(jni::JNIEnv& env) {
METHOD(&NativeMapView::submitRasterArraySourceHostField, "nativeSubmitRasterArraySourceHostField"),
METHOD(&NativeMapView::submitRasterArraySourceHostFieldV2, "nativeSubmitRasterArraySourceHostFieldV2"),
METHOD(&NativeMapView::clearRasterArraySourceHostFields, "nativeClearRasterArraySourceHostFields"),
METHOD(&NativeMapView::resetRasterArraySourceWeatherRuntime, "nativeResetRasterArraySourceWeatherRuntime"),
METHOD(&NativeMapView::seekRasterArraySourceTimelineToTimestamp, "nativeSeekRasterArraySourceTimelineToTimestamp"),
METHOD(&NativeMapView::controlRasterArraySourceTimeline, "nativeControlRasterArraySourceTimeline"),
METHOD(&NativeMapView::submitRasterArraySourceEncodedField, "nativeSubmitRasterArraySourceEncodedField"),

View File

@@ -318,6 +318,7 @@ public:
jni::jint,
jni::jint);
jni::jboolean clearRasterArraySourceHostFields(JNIEnv&, const jni::String&);
jni::jboolean resetRasterArraySourceWeatherRuntime(JNIEnv&, const jni::String&);
jni::jboolean seekRasterArraySourceTimelineToTimestamp(JNIEnv&, const jni::String&, jni::jlong);
jni::jboolean controlRasterArraySourceTimeline(JNIEnv&, const jni::String&, jni::jint, jni::jfloat);
jni::jint submitRasterArraySourceEncodedField(JNIEnv&, const jni::String&, const jni::String&, const jni::String&, jni::jlong, jni::jlong, const jni::String&, const jni::Array<jbyte>&);

View File

@@ -12,6 +12,7 @@ import androidx.annotation.FloatRange;
import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.Size;
import androidx.annotation.UiThread;
@@ -43,6 +44,7 @@ import org.maplibre.android.log.Logger;
import org.maplibre.android.offline.OfflineRegionDefinition;
import org.maplibre.android.style.expressions.Expression;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
@@ -68,6 +70,7 @@ public final class MapLibreMap {
private final OnGesturesManagerInteractionListener onGesturesManagerInteractionListener;
private final List<Style.OnStyleLoaded> awaitingStyleGetters = new ArrayList<>();
private final List<OnDeveloperAnimationListener> developerAnimationStartedListeners;
private final List<OnDestroyListener> onDestroyListeners = new ArrayList<>();
@Nullable
private Style.OnStyleLoaded styleLoadedCallback;
@@ -91,6 +94,19 @@ public final class MapLibreMap {
private boolean debugActive;
private boolean started;
private boolean destroyed;
/** Internal map-lifetime observer used by map-owned integrations. */
@RestrictTo(RestrictTo.Scope.LIBRARY)
public interface OnDestroyListener {
void onDestroy();
}
/** Removable registration for an internal observer of this concrete map's destruction. */
@RestrictTo(RestrictTo.Scope.LIBRARY)
public interface OnDestroyListenerRegistration {
void remove();
}
MapLibreMap(NativeMap map, Transform transform, UiSettings ui, Projection projection,
OnGesturesManagerInteractionListener listener, CameraChangeDispatcher cameraChangeDispatcher,
@@ -254,6 +270,24 @@ public final class MapLibreMap {
}
}
/** Registers a removable observer for destruction of this concrete map instance. */
@NonNull
@RestrictTo(RestrictTo.Scope.LIBRARY)
public OnDestroyListenerRegistration addOnDestroyListener(@NonNull OnDestroyListener listener) {
if (destroyed) {
listener.onDestroy();
return () -> { };
}
onDestroyListeners.add(listener);
WeakReference<MapLibreMap> mapReference = new WeakReference<>(this);
return () -> {
MapLibreMap map = mapReference.get();
if (map != null) {
map.onDestroyListeners.remove(listener);
}
};
}
/**
* Called when the hosting Activity/Fragment onStart() method is called.
*/
@@ -304,6 +338,16 @@ public final class MapLibreMap {
* Called when the hosting Activity/Fragment onDestroy()/onDestroyView() method is called.
*/
void onDestroy() {
if (destroyed) {
return;
}
destroyed = true;
List<OnDestroyListener> listeners = new ArrayList<>(onDestroyListeners);
onDestroyListeners.clear();
for (OnDestroyListener listener : listeners) {
listener.onDestroy();
}
disposeWeatherRuntime();
locationComponent.onDestroy();
if (style != null) {
style.clear();
@@ -447,6 +491,11 @@ public final class MapLibreMap {
return nativeMapView.clearRasterArraySourceHostFields(sourceId);
}
/** Internal terminal reset for V3+V4 state on the same existing Style source. */
boolean resetRasterArraySourceWeatherRuntime(String sourceId) {
return nativeMapView.resetRasterArraySourceWeatherRuntime(sourceId);
}
/** Requests a Core-owned T0/T1/MIX selection for a producer timestamp in milliseconds. */
public boolean seekRasterArraySourceTimelineToTimestamp(String sourceId, long timestamp) {
return nativeMapView.seekRasterArraySourceTimelineToTimestamp(sourceId, timestamp);

View File

@@ -172,6 +172,7 @@ interface NativeMap {
boolean vectorPresent, int vectorLength, int validityLength);
boolean clearRasterArraySourceHostFields(String sourceId);
boolean resetRasterArraySourceWeatherRuntime(String sourceId);
boolean seekRasterArraySourceTimelineToTimestamp(String sourceId, long timestamp);

View File

@@ -1070,6 +1070,12 @@ final class NativeMapView implements NativeMap {
&& nativeClearRasterArraySourceHostFields(sourceId);
}
@Override
public boolean resetRasterArraySourceWeatherRuntime(String sourceId) {
return !checkState("resetRasterArraySourceWeatherRuntime")
&& nativeResetRasterArraySourceWeatherRuntime(sourceId);
}
@Override
public boolean seekRasterArraySourceTimelineToTimestamp(String sourceId, long timestamp) {
return !checkState("seekRasterArraySourceTimelineToTimestamp")
@@ -1876,6 +1882,9 @@ final class NativeMapView implements NativeMap {
@Keep
private native boolean nativeClearRasterArraySourceHostFields(String sourceId);
@Keep
private native boolean nativeResetRasterArraySourceWeatherRuntime(String sourceId);
@Keep
private native boolean nativeSeekRasterArraySourceTimelineToTimestamp(String sourceId, long timestamp);

View File

@@ -66,6 +66,7 @@ public final class WeatherRuntime {
// This is a Host-command bookkeeping hint for scheduling only. Core still
// decides whether a resolved V4 window may actually advance simulation.
@Nullable private String v4ParticleBindingFieldKey;
private boolean v4ForecastPlaying;
private final Set<String> submittedEncodedFieldKeys = new HashSet<>();
private boolean disposed;
private Handler cadenceHandler;
@@ -80,7 +81,25 @@ public final class WeatherRuntime {
public Config getConfig() { return config; }
public boolean isDisposed() { return disposed; }
/** Ends this map/style-scoped facade. It is safe to call repeatedly. */
public void dispose() { if (disposed) return; disposed = true; cancelCadenceWake(); }
public void dispose() {
if (disposed) return;
cancelCadenceWake();
// If Style has already dropped the source, there is no Source-owned V4 store
// left to reset; otherwise disposal must not claim success before Core reset.
if (map.hasStyleSource(config.sourceId)) {
map.setStyleLayerVisibility(config.weatherColorLayerId, false);
map.setStyleLayerVisibility(config.particleLayerId, false);
if (!map.resetRasterArraySourceWeatherRuntime(config.sourceId)) {
throw new IllegalStateException("WeatherRuntime terminal Core reset failed");
}
}
submittedEncodedFieldKeys.clear();
v4ParticleBindingFieldKey = null;
v4ForecastPlaying = false;
playbackState = PlaybackState.PAUSE;
state = State.DISABLED;
disposed = true;
}
/** Core-owned capacity; Host never sees the shader's UBO layout. */
public int getWeatherColorStopCapacity() { return weatherColorStopCapacity; }
@@ -123,7 +142,7 @@ public final class WeatherRuntime {
}
public CommandResult setEncodedForecastTime(long timestamp) { requireAttached(); return timestamp < 0 ? CommandResult.REJECTED : encodedCommand(map.setRasterArraySourceEncodedForecastTime(config.sourceId, timestamp)); }
/** State and speed intentionally remain independent V4 Core commands. */
public CommandResult setEncodedForecastPlaybackState(boolean playing) { requireAttached(); return encodedCommand(map.setRasterArraySourceEncodedForecastPlaybackState(config.sourceId, playing)); }
public CommandResult setEncodedForecastPlaybackState(boolean playing) { requireAttached(); CommandResult result = encodedCommand(map.setRasterArraySourceEncodedForecastPlaybackState(config.sourceId, playing)); if (result == CommandResult.ACCEPTED) { v4ForecastPlaying = playing; cancelCadenceWake(); scheduleCadenceWake(); } return result; }
public CommandResult setEncodedForecastPlaybackSpeed(float speed) { requireAttached(); return !Float.isFinite(speed) || speed <= 0 ? CommandResult.REJECTED : encodedCommand(map.setRasterArraySourceEncodedForecastPlaybackSpeed(config.sourceId, speed)); }
/** v2 provider-neutral ingress; missing scalar/vector is expressed only by its explicit presence flag. */
public FieldSubmitResult submitField(@NonNull String metadataJson, @Nullable byte[] scalarBytes, @Nullable byte[] vectorBytes, @NonNull byte[] validityBytes) {
@@ -153,8 +172,14 @@ public final class WeatherRuntime {
map.controlRasterArraySourceTimeline(config.sourceId, 1, 0f);
boolean color = map.setStyleLayerVisibility(config.weatherColorLayerId, false);
boolean particle = map.setStyleLayerVisibility(config.particleLayerId, false);
boolean clear = map.clearRasterArraySourceHostFields(config.sourceId);
boolean clear = map.resetRasterArraySourceWeatherRuntime(config.sourceId);
state = stateAfterDisable(color && particle && clear);
if (clear) {
submittedEncodedFieldKeys.clear();
v4ParticleBindingFieldKey = null;
v4ForecastPlaying = false;
playbackState = PlaybackState.PAUSE;
}
if (state != State.READY) cancelCadenceWake();
return result(color && particle && clear);
}
@@ -223,7 +248,9 @@ public final class WeatherRuntime {
private static CommandResult encodedCommand(int code) { if (code == 0) throw new IllegalStateException("SOURCE_MISSING"); return code > 0 ? CommandResult.ACCEPTED : CommandResult.REJECTED; }
public enum FieldValueSource { SCALAR(0f), VECTOR_MAGNITUDE(1f); final float code; FieldValueSource(float code) { this.code = code; } }
private boolean shouldWakeWeather() {
if (disposed || state == State.DISABLED || targetFps == TargetFps.AUTO) return false;
if (disposed || state == State.DISABLED) return false;
if (v4ForecastPlaying && !submittedEncodedFieldKeys.isEmpty()) return true;
if (targetFps == TargetFps.AUTO) return false;
final boolean v4ParticleFieldReady = v4ParticleBindingFieldKey != null && submittedEncodedFieldKeys.contains(v4ParticleBindingFieldKey);
return (v4ParticleFieldReady && particleVisible) ||
(state == State.READY && (particleVisible || (weatherColorVisible && playbackState == PlaybackState.PLAY)));
@@ -233,7 +260,7 @@ public final class WeatherRuntime {
try {
if (cadenceHandler == null) cadenceHandler = new Handler(Looper.getMainLooper());
cadenceHandler.removeCallbacks(cadenceWake);
cadenceHandler.postDelayed(cadenceWake, targetFps == TargetFps.FPS_30 ? 34L : 17L);
cadenceHandler.postDelayed(cadenceWake, targetFps == TargetFps.FPS_60 ? 17L : 34L);
} catch (RuntimeException ignored) {
// Local JVM tests may not provide an Android Looper; Core cadence remains authoritative.
}

View File

@@ -1,6 +1,7 @@
package org.maplibre.android.maps.renderer.surfaceview;
import android.content.Context;
import android.util.Log;
import android.view.Surface;
import android.view.View;
@@ -28,11 +29,13 @@ public class SurfaceViewMapRenderer extends MapRenderer {
surfaceView.setDetachedListener(new MapLibreSurfaceView.OnSurfaceViewDetachedListener() {
@Override
public void onSurfaceViewDetached() {
Log.i("Mbgl-SurfaceRenderer", "SurfaceView detached; nativeReset entered");
// because the GL thread is destroyed when the view is detached from window,
// we need to ensure releasing the native renderer as well.
// This avoids releasing it only when the view is being recreated, which is already on a new GL thread,
// and leads to JNI crashes like https://github.com/mapbox/mapbox-gl-native/issues/14618
nativeReset();
Log.i("Mbgl-SurfaceRenderer", "nativeReset returned");
}
});
}

View File

@@ -0,0 +1,52 @@
package org.maplibre.android.maps;
import org.junit.Test;
import org.mockito.Mockito;
import java.util.Collections;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
/** Exercises the concrete MapLibreMap.onDestroy() dispatch path used by Android integrations. */
public class MapLibreMapDestroyListenerTest {
private MapLibreMap newMap() {
MapLibreMap map = new MapLibreMap(
null,
null,
null,
null,
null,
Mockito.mock(CameraChangeDispatcher.class),
Collections.emptyList());
map.injectLocationComponent(Mockito.mock(org.maplibre.android.location.LocationComponent.class));
return map;
}
@Test
public void onDestroyNotifiesRegisteredListenerOnceAndLateRegistrationImmediately() {
MapLibreMap map = newMap();
AtomicInteger notifications = new AtomicInteger();
map.addOnDestroyListener(notifications::incrementAndGet);
map.onDestroy();
map.onDestroy();
assertEquals(1, notifications.get());
map.addOnDestroyListener(notifications::incrementAndGet);
assertEquals(2, notifications.get());
}
@Test
public void removedListenerIsNotCalledByOnDestroy() {
MapLibreMap map = newMap();
AtomicInteger notifications = new AtomicInteger();
MapLibreMap.OnDestroyListenerRegistration registration =
map.addOnDestroyListener(notifications::incrementAndGet);
registration.remove();
map.onDestroy();
assertEquals(0, notifications.get());
}
}

View File

@@ -103,6 +103,40 @@ public class WeatherRuntimeStateTest {
Mockito.verify(map, Mockito.never()).submitRasterArraySourceHostField(Mockito.anyString(), Mockito.anyString(), Mockito.any(byte[].class));
}
@Test public void disableResetsV3AndV4ViaOneCoreCommandAndCanEnableAgain() {
MapLibreMap map = Mockito.mock(MapLibreMap.class);
Mockito.when(map.getWeatherColorStopCapacity()).thenReturn(8);
Mockito.when(map.setStyleLayerVisibility(Mockito.anyString(), Mockito.anyBoolean())).thenReturn(true);
Mockito.when(map.resetRasterArraySourceWeatherRuntime("source")).thenReturn(true);
WeatherRuntime runtime = new WeatherRuntime(map, new WeatherRuntime.Config("source", "color", "particle"));
assertEquals(WeatherRuntime.CommandResult.ACCEPTED, runtime.setWeatherEnabled(false));
assertEquals(WeatherRuntime.State.DISABLED, runtime.getState());
Mockito.verify(map).resetRasterArraySourceWeatherRuntime("source");
Mockito.verify(map, Mockito.never()).clearRasterArraySourceHostFields(Mockito.anyString());
assertEquals(WeatherRuntime.CommandResult.ACCEPTED, runtime.setWeatherEnabled(true));
assertEquals(WeatherRuntime.State.WAITING_FOR_FIELDS, runtime.getState());
}
@Test public void terminalDisposeResetsSourceAndFailureDoesNotClaimDisposed() {
MapLibreMap map = Mockito.mock(MapLibreMap.class);
Mockito.when(map.getWeatherColorStopCapacity()).thenReturn(8);
Mockito.when(map.hasStyleSource("source")).thenReturn(true);
Mockito.when(map.resetRasterArraySourceWeatherRuntime("source")).thenReturn(false).thenReturn(true);
WeatherRuntime runtime = new WeatherRuntime(map, new WeatherRuntime.Config("source", "color", "particle"));
try {
runtime.dispose();
org.junit.Assert.fail("Core reset failure must be reported");
} catch (IllegalStateException expected) {
assertEquals(false, runtime.isDisposed());
}
runtime.dispose();
assertEquals(true, runtime.isDisposed());
Mockito.verify(map, Mockito.times(2)).resetRasterArraySourceWeatherRuntime("source");
Mockito.clearInvocations(map);
runtime.dispose();
Mockito.verifyNoInteractions(map);
}
@Test public void disposeIsIdempotentAndRejectsFurtherControls() {
MapLibreMap map = Mockito.mock(MapLibreMap.class);
Mockito.when(map.getWeatherColorStopCapacity()).thenReturn(8);

View File

@@ -0,0 +1 @@
{"contractSchemaVersion":1,"profileID":"wind","profileVersion":1,"allowAdditionalPlanes":true,"sharedValidityRequired":true,"timeRelation":"VALID_GTE_REFERENCE","requiredPlanes":[{"semanticID":1,"valueClass":"VELOCITY_COMPONENT","unitID":1,"groupID":1,"component":"U_EAST","requiredFlag":true},{"semanticID":2,"valueClass":"VELOCITY_COMPONENT","unitID":1,"groupID":1,"component":"V_NORTH","requiredFlag":true},{"semanticID":3,"valueClass":"VALIDITY","unitID":0,"groupID":0,"component":"NONE","requiredFlag":true}]}

View File

@@ -4,6 +4,7 @@ import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.text.InputType
import android.util.Log
import android.widget.Button
@@ -23,7 +24,10 @@ import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.Style
import org.maplibre.android.maps.WeatherRuntime
import org.maplibre.android.style.layers.PaintPropertyValue
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.android.testapp.R
import org.maplibre.geojson.Feature
import org.maplibre.geojson.Point
/** Android OpenGL host for the formal raster-array to raster-particle fixture path. */
class ParticleCoreLayerValidationActivity : AppCompatActivity() {
@@ -46,6 +50,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private var timeWindowMode = false
private var productionFeedMode = false
private var hostSubmissionMode = false
private var step097V4GoldenReady = false // Test-App-only state; never a Core window mirror.
private var realWeatherMode = false
private var pureParticleMode = false
private var purePbfMode = false
@@ -55,6 +60,153 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private var navSeaPbfLayerVisibility = emptyList<NavSeaPbfLayerVisibility>()
private var retainedCacheEnabled = true
private var step093Mode = false
private var step097Topology: Step097StencilFixture.Topology? = null
private var step099Scenario: Step099ReadinessFixture.Scenario? = null
private var step101Scenario: String? = null
private var step101Completed = 0
private var step101DynamicTick = 0L
private var step101ReadySinceMs = 0L
private var step101LoadSinceMs = 0L
private val step101Handler = Handler(Looper.getMainLooper())
private val step101Poll = object : Runnable {
override fun run() {
val scenario = step101Scenario ?: return
if (!this@ParticleCoreLayerValidationActivity::maplibreMap.isInitialized || !styleReady) {
if (SystemClock.elapsedRealtime() - step101LoadSinceMs > 20000L) {
Log.e(TAG, "STEP101_SCENARIO_BLOCKED scenario=$scenario completed=$step101Completed reason=style-not-ready")
status.text = "STEP-101 BLOCKED: style not ready"
step101Scenario = null
return
}
step101Handler.postDelayed(this, 250L)
return
}
val snapshot = JSONObject(maplibreMap.getRenderSegmentDiagnostics())
val sources = snapshot.optJSONArray("sources")
var tileReady = false
for (index in 0 until (sources?.length() ?: 0)) {
val source = sources?.optJSONObject(index) ?: continue
if (source.optString("sourceId") == Step099ReadinessFixture.STATIC_SOURCE &&
source.optBoolean("isLoaded") && source.optInt("tileCount") > 0) tileReady = true
}
if (!tileReady) {
if (SystemClock.elapsedRealtime() - step101LoadSinceMs > 20000L) {
Log.e(TAG, "STEP101_SCENARIO_BLOCKED scenario=$scenario completed=$step101Completed reason=tile-not-ready")
status.text = "STEP-101 BLOCKED: tile not ready"
step101Scenario = null
return
}
step101Handler.postDelayed(this, 250L)
return
}
if (step101ReadySinceMs == 0L) {
step101ReadySinceMs = SystemClock.elapsedRealtime()
Log.i(TAG, "STEP101_SOURCE_TILE_READY scenario=$scenario completed=$step101Completed " +
"sources=${sources?.length()} diagnostics=${snapshot.optLong("contentRevision")}")
}
if (scenario == "T3_RESET_WITH_DYNAMIC_GEOJSON") {
step101DynamicTick += 1
updateStep099GeoJson(Step099ReadinessFixture.DYNAMIC_SOURCE, step101DynamicTick)
Log.i(TAG, "STEP101_DYNAMIC_SOURCE_MUTATION scenario=$scenario completed=$step101Completed " +
"tick=$step101DynamicTick")
}
if (SystemClock.elapsedRealtime() - step101ReadySinceMs < 2500L) {
step101Handler.postDelayed(this, 250L)
return
}
Log.i(TAG, "STEP101_NEW_RENDERER_READY scenario=$scenario completed=$step101Completed " +
"styleReady=$styleReady diagnostics=${snapshot.optLong("contentRevision")}")
val target = if (scenario == "T2_RESET_LOOP_20") 20 else 1
if (step101Completed >= target) {
Log.i(TAG, "STEP101_SCENARIO_DONE scenario=$scenario resetsRequested=$step101Completed " +
"nativeResetPairsMustMatch=$step101Completed")
status.text = "STEP-101 $scenario DONE ${step101Completed}/$target"
step101Scenario = null
return
}
if (scenario == "T4_RESET_WITH_DIAGNOSTICS") {
Log.i(TAG, "STEP101_PRE_RESET_DIAGNOSTICS ${maplibreMap.getRenderSegmentDiagnostics()}")
}
val next = step101Completed + 1
Log.i(TAG, "STEP101_RESET_REQUEST scenario=$scenario iteration=$next oldMapViewActive=true " +
"tileReady=$tileReady")
step101Completed = next
step101Handler.removeCallbacks(this)
recreate() // Activity destroys the old MapView and constructs a new native renderer.
}
}
private var step099Tick = 0L
private var step099LastOverallLoaded: Boolean? = null
private var step099FalseSinceMs: Long? = null
private var step099FalseWindowCount = 0L
private var step099FalseWindowTotalMs = 0L
private var step099FalseWindowMaxMs = 0L
private var step099Running = false
private val step099Handler = Handler(Looper.getMainLooper())
private val step099ReadinessTransitionPoll = object : Runnable {
override fun run() {
if (step099Running && step099Scenario != null && this@ParticleCoreLayerValidationActivity::maplibreMap.isInitialized && styleReady) {
val now = SystemClock.elapsedRealtime()
val snapshot = JSONObject(maplibreMap.getRenderSegmentDiagnostics())
val loaded = snapshot.optBoolean("renderOrchestratorLoaded")
val previous = step099LastOverallLoaded
if (previous == null || previous != loaded) {
var falseWindowMs: Long? = null
if (!loaded) {
step099FalseSinceMs = now
} else {
falseWindowMs = step099FalseSinceMs?.let { start ->
(now - start).also { duration ->
step099FalseWindowCount += 1
step099FalseWindowTotalMs += duration
step099FalseWindowMaxMs = maxOf(step099FalseWindowMaxMs, duration)
}
}
step099FalseSinceMs = null
}
Log.i(
TAG,
"STEP099_LOADED_TRANSITION elapsedMs=${now} from=${previous} to=${loaded} " +
"falseWindowMs=${falseWindowMs ?: -1} source=${snapshot.optJSONObject("lastMutation")?.optString("sourceId")}"
)
step099LastOverallLoaded = loaded
}
}
if (step099Running) step099Handler.postDelayed(this, STEP099_STATE_SAMPLE_INTERVAL_MS)
}
}
private val step099DiagnosticsPoll = object : Runnable {
override fun run() {
val scenario = step099Scenario ?: return
if (!step099Running) return
if (this@ParticleCoreLayerValidationActivity::maplibreMap.isInitialized && styleReady) {
step099Tick += 1
when (scenario) {
Step099ReadinessFixture.Scenario.T2_GEOJSON_1HZ -> {
val sourceId = if (step099Tick % 2L == 0L) {
Step099ReadinessFixture.DYNAMIC_SOURCE
} else {
Step099ReadinessFixture.DYNAMIC_SOURCE_B
}
updateStep099GeoJson(sourceId, step099Tick)
if (step099Tick % 2L == 0L) refreshStep099Diagnostics(log = true)
}
Step099ReadinessFixture.Scenario.T4_RCR -> {
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)
}
}
if (step099Running) step099Handler.postDelayed(this, STEP099_TICK_INTERVAL_MS)
}
}
private var step093Topology = Step093Topology.BOTTOM_CONTINUOUS_TOP
private var step093Variant = Step093Variant.DEFAULT
private var step093StartVisible = true
@@ -63,8 +215,9 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private val step093Handler = Handler(Looper.getMainLooper())
private val step093DiagnosticsPoll = object : Runnable {
override fun run() {
if (step093Mode && this@ParticleCoreLayerValidationActivity::maplibreMap.isInitialized) {
refreshStep093Diagnostics(log = false)
if ((step093Mode || step097Topology != null) && this@ParticleCoreLayerValidationActivity::maplibreMap.isInitialized) {
if (step097Topology != null) refreshStep097Diagnostics(log = false)
else refreshStep093Diagnostics(log = false)
step093Handler.postDelayed(this, STEP093_DIAGNOSTICS_INTERVAL_MS)
}
}
@@ -107,6 +260,14 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
navSeaPbfRenderEnabled = savedInstanceState?.getBoolean(STATE_NAVSEA_PBF_RENDER_ENABLED, true) ?: true
retainedCacheEnabled = savedInstanceState?.getBoolean(STATE_RETAINED_CACHE_ENABLED, true) ?: true
step093Mode = savedInstanceState?.getBoolean(STATE_STEP093_MODE, false) ?: false
step097Topology = savedInstanceState?.getString(STATE_STEP097_TOPOLOGY)
?.let { value -> Step097StencilFixture.Topology.values().firstOrNull { it.name == value } }
step101Scenario = if (savedInstanceState?.containsKey(STATE_STEP101_SCENARIO) == true) {
savedInstanceState.getString(STATE_STEP101_SCENARIO)
} else {
intent.getStringExtra("step101_scenario")?.takeIf { it in STEP101_SCENARIOS }
}
step101Completed = savedInstanceState?.getInt(STATE_STEP101_COMPLETED, 0) ?: 0
step093Topology = savedInstanceState?.getString(STATE_STEP093_TOPOLOGY)
?.let { value -> Step093Topology.values().firstOrNull { it.name == value } }
?: Step093Topology.BOTTOM_CONTINUOUS_TOP
@@ -145,7 +306,9 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
updateNavSeaPbfButtonState()
updatePbfModeButtonState()
updateRetainedCacheButtonState()
if (step093Mode) loadStep093Style(configureBeforeStyle = false)
if (step101Scenario != null) loadStep101Style()
else if (step097Topology != null) loadStep097StencilStyle()
else if (step093Mode) loadStep093Style(configureBeforeStyle = false)
else loadValidationStyle(active = active, enabled = enabled, reason = "INITIAL")
}
@@ -317,7 +480,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
private fun activateRealWeather(anchor: android.view.View?) {
if (isPbfMatrixMode() || step093Mode) leaveRealWeatherMode()
if (isPbfMatrixMode() || step093Mode || step097Topology != null) leaveRealWeatherMode()
realWeatherMenuAnchor = anchor ?: findViewById(R.id.validationMenuRealWeather)
if (realWeatherMode) {
if (realWeatherTuningMenu == null) showRealWeatherTuning(realWeatherMenuAnchor!!) else dismissRealWeatherTuning()
@@ -349,6 +512,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private fun leaveRealWeatherMode() {
step093Mode = false
step097Topology = null
step093Handler.removeCallbacks(step093DiagnosticsPoll)
dismissRealWeatherTuning()
weatherRuntime?.dispose()
@@ -446,6 +610,9 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
menu.add(0, 13, 13, "CAMERA NEXT")
menu.add(0, 14, 14, "READ DIAGNOSTICS")
menu.add(0, 15, 15, "RESET DEFAULT A")
menu.add(0, 16, 16, "STEP-097 SAME SOURCE STENCIL")
menu.add(0, 17, 17, "STEP-099 READINESS")
menu.add(0, 18, 18, "STEP-101 TEARDOWN")
setOnMenuItemClickListener { item ->
when (item.itemId) {
in Step093Topology.values().indices -> applyStep093Scene(Step093Topology.values()[item.itemId])
@@ -455,6 +622,9 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
13 -> applyStep093CameraPreset()
14 -> refreshStep093Diagnostics(log = true)
15 -> applyStep093Scene(Step093Topology.BOTTOM_CONTINUOUS_TOP)
16 -> showStep097StencilMenu(anchor)
17 -> showStep099Menu(anchor)
18 -> showStep101Menu(anchor)
}
true
}
@@ -462,6 +632,252 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
}
private fun showStep097StencilMenu(anchor: android.view.View) {
PopupMenu(this, anchor).apply {
Step097StencilFixture.Topology.values().forEachIndexed { index, topology ->
menu.add(0, index, index, "${topology.name} · same source / same tiles")
}
menu.add(0, 10, 10, "REBUILD CURRENT")
menu.add(0, 11, 11, "READ DIAGNOSTICS")
setOnMenuItemClickListener { item ->
when (item.itemId) {
in Step097StencilFixture.Topology.values().indices ->
applyStep097StencilScene(Step097StencilFixture.Topology.values()[item.itemId])
10 -> if (step097Topology != null) loadStep097StencilStyle()
11 -> if (step097Topology != null) refreshStep097Diagnostics(log = true)
}
true
}
show()
}
}
private fun showStep099Menu(anchor: android.view.View) {
PopupMenu(this, anchor).apply {
Step099ReadinessFixture.Scenario.values().forEachIndexed { index, scenario ->
menu.add(0, index, index, scenario.label)
}
menu.add(0, 10, 10, "STOP / RETURN TO DEFAULT")
setOnMenuItemClickListener { item ->
if (item.itemId in Step099ReadinessFixture.Scenario.values().indices) {
applyStep099Scenario(Step099ReadinessFixture.Scenario.values()[item.itemId])
} else if (item.itemId == 10) {
stopStep099Scenario()
loadValidationStyle(active, enabled, "STEP099_EXIT")
}
true
}
show()
}
}
private fun showStep101Menu(anchor: android.view.View) {
PopupMenu(this, anchor).apply {
STEP101_SCENARIOS.forEachIndexed { index, scenario -> menu.add(0, index, index, scenario) }
setOnMenuItemClickListener { item ->
val scenario = STEP101_SCENARIOS.getOrNull(item.itemId) ?: return@setOnMenuItemClickListener false
step101Scenario = scenario
step101Completed = 0
loadStep101Style()
true
}
show()
}
}
private fun loadStep101Style() {
val scenario = step101Scenario ?: return
stopStep099Scenario()
step101Handler.removeCallbacksAndMessages(null)
step101ReadySinceMs = 0L
step101LoadSinceMs = SystemClock.elapsedRealtime()
val fixture = Step099ReadinessFixture.Scenario.T4_RCR
val accepted = maplibreMap.setRenderSegmentPlan(Step099ReadinessFixture.planJson(fixture))
styleReady = false
val epoch = ++styleLoadEpoch
Log.i(TAG, "STEP101_OLD_RESET_COMPLETED_OR_INITIAL scenario=$scenario completed=$step101Completed " +
"planAccepted=$accepted")
maplibreMap.setStyle(Style.Builder().fromJson(Step099ReadinessFixture.styleJson(fixture))) {
if (step101Scenario != scenario || epoch != styleLoadEpoch) return@setStyle
styleReady = true
maplibreMap.moveCamera(CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder().target(LatLng(28.0, 135.0)).zoom(3.0).build()
))
Log.i(TAG, "STEP101_STYLE_READY scenario=$scenario completed=$step101Completed")
step101Handler.removeCallbacks(step101Poll)
step101Handler.post(step101Poll)
}
step101Handler.post(step101Poll)
}
private fun applyStep099Scenario(scenario: Step099ReadinessFixture.Scenario) {
stopStep093DiagnosticsPolling()
step093Mode = false
step097Topology = null
step099Scenario = scenario
step099Running = true
step099Tick = 0L
step099LastOverallLoaded = null
step099FalseSinceMs = null
step099FalseWindowCount = 0L
step099FalseWindowTotalMs = 0L
step099FalseWindowMaxMs = 0L
timeWindowMode = false
productionFeedMode = false
hostSubmissionMode = false
realWeatherMode = false
weatherRuntime?.dispose()
weatherRuntime = null
step099Handler.removeCallbacksAndMessages(null)
val accepted = maplibreMap.setRenderSegmentPlan(Step099ReadinessFixture.planJson(scenario))
styleReady = false
val epoch = ++styleLoadEpoch
status.text = "STEP-099 · ${scenario.label} · LOADING"
Log.i(TAG, "STEP099_SCENARIO_START scenario=${scenario.name} planAccepted=$accepted")
maplibreMap.setStyle(Style.Builder().fromJson(Step099ReadinessFixture.styleJson(scenario))) {
if (!step099Running || step099Scenario != scenario || epoch != styleLoadEpoch) return@setStyle
styleReady = true
status.text = "STEP-099 · ${scenario.label}"
refreshStep099Diagnostics(log = true)
step099Handler.post(step099ReadinessTransitionPoll)
step099Handler.post(step099DiagnosticsPoll)
maplibreMap.moveCamera(CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder().target(LatLng(28.0, 135.0)).zoom(3.0).build()
))
}
}
private fun stopStep099Scenario() {
step099Running = false
step099Handler.removeCallbacksAndMessages(null)
val previous = step099Scenario
step099Scenario = null
if (previous != null) {
val average = if (step099FalseWindowCount == 0L) 0 else step099FalseWindowTotalMs / step099FalseWindowCount
val open = step099FalseSinceMs?.let { SystemClock.elapsedRealtime() - it } ?: 0L
Log.i(TAG, "STEP099_AGGREGATE scenario=${previous.name} falseWindowCount=$step099FalseWindowCount " +
"falseWindowAvgMs=$average falseWindowMaxMs=$step099FalseWindowMaxMs openFalseWindowMs=$open")
}
step099LastOverallLoaded = null
step099FalseSinceMs = null
}
private fun updateStep099GeoJson(sourceId: String, tick: Long) {
val source = maplibreMap.style?.getSourceAs<GeoJsonSource>(sourceId) ?: return
val offset = (tick % 20L) * 0.12
val longitude = if (sourceId == Step099ReadinessFixture.DYNAMIC_SOURCE) 135.0 + offset else 136.0 - offset
val latitude = if (sourceId == Step099ReadinessFixture.DYNAMIC_SOURCE) 28.0 + offset * 0.3 else 29.0 - offset * 0.3
source.setGeoJson(Feature.fromGeometry(Point.fromLngLat(longitude, latitude)))
Log.i(TAG, "STEP099_SOURCE_UPDATE scenario=${step099Scenario?.name} sourceId=$sourceId tick=$tick")
}
private fun refreshStep099Diagnostics(log: Boolean) {
if (!this::maplibreMap.isInitialized || !styleReady) return
val snapshot = JSONObject(maplibreMap.getRenderSegmentDiagnostics())
val sources = snapshot.optJSONArray("sources")
val sourceSummary = buildList {
for (index in 0 until (sources?.length() ?: 0)) {
val source = sources?.optJSONObject(index) ?: continue
add("${source.optString("sourceId")}:${source.optString("sourceType")}:" +
"${source.optBoolean("enabled")}/${source.optBoolean("isLoaded")}" +
" tiles=${source.opt("tileCount")}/${source.opt("incompleteTileCount")}/${source.opt("loadedTileCount")}" +
" fade=${source.optBoolean("hasFadingTiles")}")
}
}
val segments = snapshot.optJSONArray("segments")
val segmentSummary = buildList {
for (index in 0 until (segments?.length() ?: 0)) {
val segment = segments?.optJSONObject(index) ?: continue
val dependencies = segment.optJSONArray("dependentSourceIds")
val dependencyText = buildList {
for (dependencyIndex in 0 until (dependencies?.length() ?: 0)) {
add(dependencies?.optString(dependencyIndex).orEmpty())
}
}.joinToString(",")
val cache = segment.optJSONObject("cache")
val cacheText = if (cache == null) {
"cache=-"
} else {
"cache=${cache.optString("cacheState")}@${cache.optLong("contentRevision")}" +
" b=${cache.optLong("build")} r=${cache.optLong("reuse")}" +
" 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")})" +
" deps=[$dependencyText] $cacheText")
}
}
val mutation = snapshot.optJSONObject("lastMutation")
val affectedSegments = mutation?.optJSONArray("affectedSegmentIndices")
val affectedText = buildList {
for (index in 0 until (affectedSegments?.length() ?: 0)) {
add(affectedSegments?.optInt(index).toString())
}
}.joinToString(",")
val message = "scenario=${step099Scenario?.name} overall=${snapshot.optBoolean("renderOrchestratorLoaded")} " +
"style=${snapshot.optBoolean("styleLoaded")} image=${snapshot.optBoolean("imageManagerLoaded")} " +
"sprite=${snapshot.optBoolean("spriteLoaded")} fade=${snapshot.optBoolean("hasFadingTiles")} " +
"rev=${snapshot.optLong("contentRevision")} sources=${sourceSummary.joinToString(";")} " +
"last=${mutation?.optLong("sequence")}/${mutation?.optString("sourceId")}/${mutation?.optString("reason")}" +
" affected=[$affectedText] scope=${mutation?.optString("dependencyScope")} " +
"fast=${snapshot.optBoolean("fastPathActive")}/${snapshot.optBoolean("fastPathEligible")}" +
"/${snapshot.optString("fastPathFallbackReason")}" +
" h=${snapshot.optLong("fastPathHit")} m=${snapshot.optLong("fastPathMiss")}" +
" full=${snapshot.optLong("fullCpuFrame")} segments=${segmentSummary.joinToString(";")}"
diagnostics.text = "STEP-099 $message"
if (log) Log.i(TAG, "STEP099_AGGREGATE_SAMPLE $message")
}
private fun applyStep097StencilScene(topology: Step097StencilFixture.Topology) {
stopStep099Scenario()
leaveRealWeatherMode()
step097Topology = topology
retainedCacheEnabled = true
maplibreMap.setRetainedUnderlayEnabled(true)
updateRetainedCacheButtonState()
loadStep097StencilStyle()
}
private fun loadStep097StencilStyle() {
val topology = step097Topology ?: return
styleReady = false
val epoch = ++styleLoadEpoch
status.text = "STEP-097 · ${topology.name} · LOADING"
val accepted = maplibreMap.setRenderSegmentPlan(Step097StencilFixture.planJson(topology))
Log.i(TAG, "STEP097_STENCIL_PLAN topology=${topology.name} accepted=$accepted beforeStyle=true")
maplibreMap.setStyle(Style.Builder().fromJson(Step097StencilFixture.styleJson())) {
if (step097Topology != topology || epoch != styleLoadEpoch) return@setStyle
styleReady = true
maplibreMap.moveCamera(CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder().target(LatLng(0.0, 0.0)).zoom(2.0).build()
))
status.text = statusText()
startStep093DiagnosticsPolling()
refreshStep097Diagnostics(log = true)
Log.i(TAG, "STEP097_STENCIL_READY topology=${topology.name} source=step097-shared-polygon")
}
}
private fun refreshStep097Diagnostics(log: Boolean) {
val topology = step097Topology ?: return
val snapshot = JSONObject(maplibreMap.getRenderSegmentDiagnostics())
diagnostics.text = buildString {
append("STEP-097 ${topology.name} valid=${snapshot.optBoolean("valid")} fallback=${snapshot.optBoolean("fallback")} topology=${snapshot.optLong("topologyRevision")} reason=${snapshot.optString("lastReason")}\n")
val segments = snapshot.optJSONArray("segments")
for (index in 0 until (segments?.length() ?: 0)) {
val segment = segments?.optJSONObject(index) ?: continue
val cache = segment.optJSONObject("cache")
append("$index ${segment.optString("strategy")} ${cache?.optString("cacheState")} build=${cache?.optLong("build")} reuse=${cache?.optLong("reuse")}\n")
}
}
if (log) Log.i(TAG, "STEP097_STENCIL_DIAGNOSTICS ${diagnostics.text.toString().replace('\n', ' ')}")
}
private fun showStep093MutationMenu(anchor: android.view.View) {
val actions = arrayOf(
"CONTINUOUS ADD", "CONTINUOUS REORDER", "BOTTOM ADD", "BOTTOM REORDER",
@@ -557,6 +973,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
variant: Step093Variant = Step093Variant.DEFAULT,
configureBeforeStyle: Boolean = false
) {
stopStep099Scenario()
leaveRealWeatherMode()
step093Mode = true
step093Topology = topology
@@ -647,6 +1064,10 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
step093Handler.post(step093DiagnosticsPoll)
}
private fun stopStep093DiagnosticsPolling() {
step093Handler.removeCallbacks(step093DiagnosticsPoll)
}
private fun refreshStep093Diagnostics(log: Boolean) {
val snapshot = maplibreMap.retainedUnderlayDiagnostics
val cacheCount = (if (snapshot.bottomPresent) 1 else 0) + (if (snapshot.topPresent) 1 else 0)
@@ -845,7 +1266,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
private fun enterPbfMatrix() {
if (realWeatherMode || step093Mode) leaveRealWeatherMode()
if (realWeatherMode || step093Mode || step097Topology != null) leaveRealWeatherMode()
if (!isPbfMatrixMode()) {
preRealWeatherVisuals = ParticleVisuals(particleCount, lineWidth, particleLength, particleOpacity, trailOpacity, trailFade)
particleCount = REAL_WEATHER_PARTICLE_COUNT
@@ -926,7 +1347,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private fun showTimeMenu(anchor: android.view.View) {
PopupMenu(this, anchor).apply {
val labels = arrayOf("SCENARIO NORMAL", "PLAY / RESTART", "PAUSE", "SPEED 0.5x", "SPEED 1x", "SPEED 2x", "STEP PREV", "STEP NEXT", "SEEK START", "SEEK MID", "SEEK END", "SCENARIO GAP", "SCENARIO INVALID NEXT", "RECOVER NEXT")
val labels = arrayOf("SCENARIO NORMAL", "PLAY / RESTART", "PAUSE", "SPEED 0.5x", "SPEED 1x", "SPEED 2x", "STEP PREV", "STEP NEXT", "SEEK START", "SEEK MID", "SEEK END", "SCENARIO GAP", "SCENARIO INVALID NEXT", "RECOVER NEXT", "A2 V4 WIND TEST")
labels.forEachIndexed { index, label -> menu.add(0, index, index, label) }
setOnMenuItemClickListener { item ->
when (item.itemId) {
@@ -944,12 +1365,69 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
11 -> controlTimeline(6, 0.0f)
12 -> controlTimeline(7, 0.0f)
13 -> controlTimeline(8, 0.0f)
14 -> showStep097V4Menu(anchor)
}; true
}
show()
}
}
private fun showStep097V4Menu(anchor: android.view.View) {
PopupMenu(this, anchor).apply {
val labels = arrayOf("LOAD WIND T0/T1", "PLAY", "PAUSE", "SEEK 25%", "SEEK 75%", "SPEED 2x", "SPEED 1x", "CLEAR")
labels.forEachIndexed { index, label -> menu.add(0, index, index, label) }
setOnMenuItemClickListener { item ->
if (item.itemId == 0) loadStep097V4Golden() else controlStep097V4Golden(item.itemId)
true
}
show()
}
}
private fun loadStep097V4Golden() {
if (!styleReady || !enabled || !hostSubmissionMode || realWeatherMode) {
Log.i(TAG, "STEP097_A2_V4_REJECT reason=SELECT_HOST_FIELD_FIRST styleReady=$styleReady")
return
}
step097V4GoldenReady = false
try {
val runtime = weatherRuntime ?: WeatherRuntime(maplibreMap,
WeatherRuntime.Config(PARTICLE_SOURCE_ID, WEATHER_COLOR_LAYER_ID, PARTICLE_LAYER_ID))
.also { weatherRuntime = it }
Step097V4GoldenFixture.load(assets, runtime)
// The RN Golden QBIN covers Japan; default world center (0,0)
// would leave the valid field outside this device's viewport.
maplibreMap.cameraPosition = CameraPosition.Builder()
.target(LatLng(36.2, 137.5)).zoom(4.8).build()
step097V4GoldenReady = true
timelineState = "V4 READY"
status.text = statusText()
} catch (error: Exception) {
step097V4GoldenReady = false
timelineState = "V4 ERROR"
status.text = statusText()
Log.e(TAG, "STEP097_A2_V4_ERROR reason=GOLDEN_LOAD", error)
}
}
private fun controlStep097V4Golden(action: Int) {
if (!styleReady || !step097V4GoldenReady) {
Log.i(TAG, "STEP097_A2_V4_REJECT reason=LOAD_GOLDEN_FIRST action=$action")
return
}
val runtime = weatherRuntime ?: return
val result = Step097V4GoldenFixture.command(runtime, action)
if (action == 7 && result == WeatherRuntime.CommandResult.ACCEPTED) step097V4GoldenReady = false
if (result == WeatherRuntime.CommandResult.ACCEPTED) {
timelineState = when (action) {
1 -> "V4 PLAY"; 2 -> "V4 PAUSE"; 3 -> "V4 25%"; 4 -> "V4 75%"
5 -> "V4 2x"; 6 -> "V4 1x"; 7 -> "V4 CLEARED"; else -> timelineState
}
status.text = statusText()
}
Log.i(TAG, "STEP097_A2_V4_COMMAND action=$action result=$result targetLabel=$timelineState")
}
private fun controlTimeline(command: Int, value: Float) {
if (!styleReady || !enabled || !timeWindowMode) { Log.i(TAG, "ParticleTimeline: event=REJECTED reason=STYLE_NOT_READY"); return }
val runtime = weatherRuntime
@@ -1015,6 +1493,8 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
private fun loadValidationStyle(active: Boolean, enabled: Boolean, reason: String = "STYLE_CHANGE") {
if (step099Scenario != null) stopStep099Scenario()
step097V4GoldenReady = false
if (pureParticleMode) {
loadPureParticleStyle(active, enabled, reason)
return
@@ -1558,6 +2038,9 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
private fun statusText(): String {
step097Topology?.let { topology ->
return "STEP-097 · ${topology.name} · SAME SOURCE STENCIL · CACHE ${if (retainedCacheEnabled) "ON" else "OFF"}"
}
if (step093Mode) {
return "STEP-093 · ${step093Topology.label} · ${step093Variant.name} · " +
"CACHE ${if (retainedCacheEnabled) "ON" else "OFF"}"
@@ -1582,6 +2065,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
purePbfMode -> "PURE PBF"
navSeaPbfMode -> "NAVSEA PBF"
realWeatherMode -> "REAL WEATHER"
step097V4GoldenReady -> "V4 GOLDEN"
hostSubmissionMode -> "HOST"
productionFeedMode -> "PRODUCTION"
else -> "FIXTURE"
@@ -1769,11 +2253,42 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
""".trimIndent()
}
override fun onStart() { super.onStart(); mapView.onStart(); if (step093Mode) startStep093DiagnosticsPolling() }
override fun onStart() {
super.onStart()
mapView.onStart()
if (step101Scenario != null && styleReady) step101Handler.post(step101Poll)
if (step099Scenario != null) {
step099Running = true
step099LastOverallLoaded = null
step099FalseSinceMs = null
step099Handler.removeCallbacksAndMessages(null)
if (styleReady) {
step099Handler.post(step099ReadinessTransitionPoll)
step099Handler.post(step099DiagnosticsPoll)
}
} else if (step093Mode || step097Topology != null) startStep093DiagnosticsPolling()
}
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { mapView.onPause(); super.onPause() }
override fun onStop() { step093Handler.removeCallbacks(step093DiagnosticsPoll); mapView.onStop(); super.onStop() }
override fun onDestroy() { step093Handler.removeCallbacks(step093DiagnosticsPoll); weatherRuntime?.dispose(); weatherRuntime = null; mapView.onDestroy(); super.onDestroy() }
override fun onStop() {
step101Handler.removeCallbacksAndMessages(null)
step093Handler.removeCallbacks(step093DiagnosticsPoll)
step099Running = false
step099Handler.removeCallbacksAndMessages(null)
mapView.onStop()
super.onStop()
}
override fun onDestroy() {
step101Handler.removeCallbacksAndMessages(null)
if (step101Scenario != null) Log.i(TAG, "STEP101_MAPVIEW_DESTROY_ENTER scenario=$step101Scenario completed=$step101Completed")
step093Handler.removeCallbacks(step093DiagnosticsPoll)
stopStep099Scenario()
weatherRuntime?.dispose()
weatherRuntime = null
mapView.onDestroy()
if (step101Scenario != null) Log.i(TAG, "STEP101_MAPVIEW_DESTROY_RETURN scenario=$step101Scenario completed=$step101Completed")
super.onDestroy()
}
override fun onLowMemory() { super.onLowMemory(); mapView.onLowMemory() }
override fun onSaveInstanceState(outState: Bundle) {
@@ -1799,6 +2314,9 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
outState.putBoolean(STATE_NAVSEA_PBF_RENDER_ENABLED, navSeaPbfRenderEnabled)
outState.putBoolean(STATE_RETAINED_CACHE_ENABLED, retainedCacheEnabled)
outState.putBoolean(STATE_STEP093_MODE, step093Mode)
outState.putString(STATE_STEP097_TOPOLOGY, step097Topology?.name)
outState.putString(STATE_STEP101_SCENARIO, step101Scenario)
outState.putInt(STATE_STEP101_COMPLETED, step101Completed)
outState.putString(STATE_STEP093_TOPOLOGY, step093Topology.name)
outState.putString(STATE_STEP093_VARIANT, step093Variant.name)
super.onSaveInstanceState(outState)
@@ -1835,12 +2353,20 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private const val STATE_NAVSEA_PBF_RENDER_ENABLED = "particle_validation.navsea_pbf_render_enabled"
private const val STATE_RETAINED_CACHE_ENABLED = "particle_validation.retained_cache_enabled"
private const val STATE_STEP093_MODE = "particle_validation.step093_mode"
private const val STATE_STEP097_TOPOLOGY = "particle_validation.step097_topology"
private const val STATE_STEP101_SCENARIO = "particle_validation.step101_scenario"
private const val STATE_STEP101_COMPLETED = "particle_validation.step101_completed"
private val STEP101_SCENARIOS = listOf(
"T1_RESET_ONCE", "T2_RESET_LOOP_20", "T3_RESET_WITH_DYNAMIC_GEOJSON", "T4_RESET_WITH_DIAGNOSTICS"
)
private const val STATE_STEP093_TOPOLOGY = "particle_validation.step093_topology"
private const val STATE_STEP093_VARIANT = "particle_validation.step093_variant"
private const val PARTICLE_LAYER_ID = "particle-raster-core"
private const val WEATHER_COLOR_LAYER_ID = "weather-color-real"
private const val PARTICLE_SOURCE_ID = "particle-fixture"
private const val STEP093_DIAGNOSTICS_INTERVAL_MS = 1000L
private const val STEP099_STATE_SAMPLE_INTERVAL_MS = 200L
private const val STEP099_TICK_INTERVAL_MS = 500L
private const val STEP093_WEATHER_LAYER_ID = WEATHER_COLOR_LAYER_ID
private const val STEP093_PARTICLE_LAYER_ID = PARTICLE_LAYER_ID
private const val STEP093_BOTTOM_BACKGROUND_ID = "step093-bottom-background"

View File

@@ -0,0 +1,76 @@
package org.maplibre.android.testapp.activity.particle
import org.json.JSONArray
import org.json.JSONObject
/** Offline same-source tile fixture for retained target stencil regression. */
internal object Step097StencilFixture {
enum class Topology { RR, RCR, CRR }
const val FIRST_FILL = "step097-shared-fill-a"
const val SECOND_FILL = "step097-shared-fill-b"
const val THIRD_FILL = "step097-shared-fill-c"
private const val FOURTH_FILL = "step097-shared-fill-d"
private const val SOURCE = "step097-shared-polygon"
fun styleJson(): String {
val geometry = JSONObject()
.put("type", "Polygon")
.put("coordinates", JSONArray().put(
JSONArray().put(JSONArray().put(-45).put(-35))
.put(JSONArray().put(45).put(-35))
.put(JSONArray().put(45).put(35))
.put(JSONArray().put(-45).put(35))
.put(JSONArray().put(-45).put(-35))
))
val source = JSONObject().put("type", "geojson").put("data", JSONObject()
.put("type", "Feature")
.put("properties", JSONObject())
.put("geometry", geometry))
val layers = JSONArray()
.put(JSONObject().put("id", "step097-background").put("type", "background")
.put("paint", JSONObject().put("background-color", "#15243a")))
.put(fill(FIRST_FILL, "#f94144"))
.put(fill(SECOND_FILL, "#43aa8b"))
.put(fill(THIRD_FILL, "#577590"))
.put(fill(FOURTH_FILL, "#f9c74f"))
return JSONObject()
.put("version", 8)
.put("name", "STEP-097 same-source stencil fixture")
.put("sources", JSONObject().put(SOURCE, source))
.put("layers", layers)
.toString()
}
fun planJson(topology: Topology): String {
val segments = JSONArray()
fun add(strategy: String, start: String? = null) {
val segment = JSONObject().put("strategy", strategy)
if (start != null) segment.put("startLayerId", start)
segments.put(segment)
}
when (topology) {
Topology.RR -> {
add("RETAINED")
add("RETAINED", SECOND_FILL)
}
Topology.RCR -> {
add("RETAINED")
add("CONTINUOUS", SECOND_FILL)
add("RETAINED", THIRD_FILL)
}
Topology.CRR -> {
add("CONTINUOUS")
add("RETAINED", SECOND_FILL)
add("RETAINED", THIRD_FILL)
}
}
return JSONObject().put("schemaVersion", 1).put("segments", segments).toString()
}
private fun fill(id: String, color: String): JSONObject = JSONObject()
.put("id", id)
.put("type", "fill")
.put("source", SOURCE)
.put("paint", JSONObject().put("fill-color", color).put("fill-opacity", 0.55))
}

View File

@@ -0,0 +1,46 @@
package org.maplibre.android.testapp.activity.particle
import android.content.res.AssetManager
import android.util.Log
import org.maplibre.android.maps.WeatherRuntime
/** STEP-097 A2 test-only ingress. Uses the existing public Android V4 API. */
internal object Step097V4GoldenFixture {
const val KEY = "fixture.wind"
const val T0_MS = 1_789_239_600_000L
const val T1_MS = 1_789_243_200_000L
const val MID_MS = 1_789_241_400_000L
private const val ROOT = "integration/step097-v4/wind/"
private const val TAG = "ParticleValidation"
fun load(assets: AssetManager, runtime: WeatherRuntime) {
val contract = assets.open(ROOT + "contract.json").bufferedReader().use { it.readText() }
val clear = runtime.clearAllEncodedFields()
val color = runtime.setEncodedColorBinding(KEY, 1, 1, 1, 1)
val particle = runtime.setEncodedParticleBinding(KEY, 0, 1, 1, 2, 1, 0, 0, 1)
val t0 = runtime.submitEncodedField(KEY, "fixture-wind", 1L, 1L,
contract, assets.open(ROOT + "T0.qbin").use { it.readBytes() })
val t1 = runtime.submitEncodedField(KEY, "fixture-wind", 2L, 2L,
contract, assets.open(ROOT + "T1.qbin").use { it.readBytes() })
val time = runtime.setEncodedForecastTime(MID_MS)
Log.i(TAG, "STEP097_A2_V4_SUBMIT clear=$clear color=$color particle=$particle t0=$t0 t1=$t1 time=$time")
check(clear == WeatherRuntime.CommandResult.ACCEPTED &&
color == WeatherRuntime.CommandResult.ACCEPTED &&
particle == WeatherRuntime.CommandResult.ACCEPTED &&
t0 == WeatherRuntime.EncodedFieldSubmitResult.ACCEPTED_INSERTED &&
t1 == WeatherRuntime.EncodedFieldSubmitResult.ACCEPTED_INSERTED &&
time == WeatherRuntime.CommandResult.ACCEPTED) { "V4 golden ingress rejected" }
Log.i(TAG, "STEP097_A2_V4_READY fieldKey=$KEY T0=$T0_MS T1=$T1_MS target=$MID_MS")
}
fun command(runtime: WeatherRuntime, action: Int): WeatherRuntime.CommandResult = when (action) {
1 -> runtime.setEncodedForecastPlaybackState(true)
2 -> runtime.setEncodedForecastPlaybackState(false)
3 -> runtime.setEncodedForecastTime(T0_MS + 900_000L)
4 -> runtime.setEncodedForecastTime(T0_MS + 2_700_000L)
5 -> runtime.setEncodedForecastPlaybackSpeed(2.0f)
6 -> runtime.setEncodedForecastPlaybackSpeed(1.0f)
7 -> runtime.clearAllEncodedFields()
else -> WeatherRuntime.CommandResult.REJECTED
}
}

View File

@@ -0,0 +1,149 @@
package org.maplibre.android.testapp.activity.particle
import org.json.JSONArray
import org.json.JSONObject
/** STEP-099/100 offline source readiness and retained segment fixtures. */
internal object Step099ReadinessFixture {
enum class Scenario(val label: String) {
T1_STATIC("T1 STATIC"),
T2_GEOJSON_1HZ("T2 GEOJSON 1HZ"),
T3_PERMANENT_UNREADY("T3 PERMANENT UNREADY"),
T4_RCR("T4 R-C-R")
}
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"
const val STATIC_LAYER = "step099-static-fill"
const val WEATHER_LAYER = "step099-weather"
const val PARTICLE_LAYER = "step099-particle"
const val DYNAMIC_LAYER = "step099-dynamic-circle"
const val DYNAMIC_LAYER_B = "step099-dynamic-circle-b"
const val UPPER_STATIC_LAYER = "step099-static-upper-fill"
fun styleJson(scenario: Scenario): String {
val sources = JSONObject()
.put(PARTICLE_SOURCE, JSONObject()
.put("type", "raster-array")
.put("fixture", "uniform-east")
.put("metadata", "asset://integration/particle-fixtures/uniform-east/metadata.json")
.put("field", "asset://integration/particle-fixtures/uniform-east/field.f32"))
.put(STATIC_SOURCE, geoJsonSource(
"""{"type":"Polygon","coordinates":[[[112,0],[158,0],[158,56],[112,56],[112,0]]]}"""
))
if (scenario == Scenario.T2_GEOJSON_1HZ || scenario == Scenario.T4_RCR) {
sources.put(DYNAMIC_SOURCE, geoJsonSource(
"""{"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]}"""
))
}
if (scenario == Scenario.T3_PERMANENT_UNREADY) {
// A valid host-submission RasterArraySource with no submitted field is intentionally isLoaded=false.
// It has no layer on purpose: STEP-099 verifies whether a globally present but unrelated Source
// can still hold RenderOrchestrator::isLoaded() false.
sources.put(UNREADY_SOURCE, JSONObject()
.put("type", "raster-array")
.put("host-field-submission", true))
}
val layers = JSONArray()
.put(JSONObject()
.put("id", "step099-background")
.put("type", "background")
.put("paint", JSONObject().put("background-color", "#0b2942")))
.put(JSONObject()
.put("id", STATIC_LAYER)
.put("type", "fill")
.put("source", STATIC_SOURCE)
.put("paint", JSONObject()
.put("fill-color", "#176b87")
.put("fill-opacity", 0.58)))
.put(JSONObject()
.put("id", WEATHER_LAYER)
.put("type", "weather-color")
.put("source", PARTICLE_SOURCE)
.put("paint", JSONObject().put("weather-color-opacity", 0.32)))
.put(JSONObject()
.put("id", PARTICLE_LAYER)
.put("type", "raster-particle")
.put("source", PARTICLE_SOURCE)
.put("paint", JSONObject()
.put("raster-particle-active", true)
.put("raster-particle-particle-count", 1024)))
if (scenario == Scenario.T2_GEOJSON_1HZ || scenario == Scenario.T4_RCR) {
layers.put(JSONObject()
.put("id", DYNAMIC_LAYER)
.put("type", "circle")
.put("source", DYNAMIC_SOURCE)
.put("paint", JSONObject()
.put("circle-radius", 12)
.put("circle-color", "#ffcc00")
.put("circle-opacity", 0.95)))
}
if (scenario == Scenario.T2_GEOJSON_1HZ) {
layers.put(JSONObject()
.put("id", DYNAMIC_LAYER_B)
.put("type", "circle")
.put("source", DYNAMIC_SOURCE_B)
.put("paint", JSONObject()
.put("circle-radius", 10)
.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", UPPER_STATIC_SOURCE)
.put("paint", JSONObject().put("fill-color", "#d3a640").put("fill-opacity", 0.38)))
}
return JSONObject()
.put("version", 8)
.put("name", "STEP-099 Source Readiness Diagnostics")
.put("sources", sources)
.put("layers", layers)
.toString()
}
fun planJson(scenario: Scenario): String {
val segments = JSONArray()
fun add(strategy: String, start: String? = null) {
val segment = JSONObject().put("strategy", strategy)
if (start != null) segment.put("startLayerId", start)
segments.put(segment)
}
add("RETAINED")
add("CONTINUOUS", WEATHER_LAYER)
add("RETAINED", if (scenario == Scenario.T2_GEOJSON_1HZ || scenario == Scenario.T4_RCR) DYNAMIC_LAYER else UPPER_STATIC_LAYER)
return JSONObject()
.put("schemaVersion", 1)
.put("segments", segments)
.toString()
}
private fun geoJsonSource(geometryJson: String): JSONObject = JSONObject()
.put("type", "geojson")
.put("data", JSONObject()
.put("type", "Feature")
.put("properties", JSONObject())
.put("geometry", JSONObject(geometryJson)))
}

View File

@@ -1 +1 @@
13.4.1-particle.13
13.4.1-particle.19

View File

@@ -3163,12 +3163,10 @@ static void *windowScreenContext = &windowScreenContext;
- (BOOL)setRenderSegmentPlanJSON:(NSString *)planJSON {
if (!_mbglMap) return NO;
const auto plan = mbgl::parseRenderSegmentPlanJSON(planJSON.UTF8String);
if (plan) return self.mbglMap.setRenderSegmentPlan(*plan);
mbgl::RenderSegmentPlan invalid;
invalid.schemaVersion = 0;
self.mbglMap.setRenderSegmentPlan(std::move(invalid));
return NO;
const char *utf8 = planJSON.UTF8String;
const NSUInteger length = [planJSON lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
auto plan = mbgl::renderSegmentPlanFromJSONOrInvalid(std::string(utf8, length));
return self.mbglMap.setRenderSegmentPlan(std::move(plan));
}
- (NSString *)renderSegmentDiagnosticsJSON {

View File

@@ -131,7 +131,8 @@ MLN_EXPORT
@property (nonatomic, readonly) MLNParticleFieldRuntimeTargetFps targetFps;
- (instancetype)init NS_UNAVAILABLE;
- (void)dispose;
- (void)dispose; // Existing API; use the error-returning form for Bridge operations.
- (BOOL)disposeWithError:(NSError * _Nullable * _Nullable)error;
- (MLNParticleHostFieldSubmitResult)submitWindFieldWithMetadataJSON:(NSString *)metadataJSON payload:(NSData *)payload error:(NSError * _Nullable * _Nullable)error;
- (MLNParticleHostFieldSubmitResult)submitFieldWithMetadataJSON:(NSString *)metadataJSON scalar:(nullable NSData *)scalar vector:(nullable NSData *)vector validity:(NSData *)validity error:(NSError * _Nullable * _Nullable)error;
@@ -196,7 +197,11 @@ MLN_EXPORT
- (nullable MLNParticleFieldRuntime *)replaceParticleFieldRuntimeWithConfiguration:(MLNParticleFieldRuntimeConfiguration *)configuration
error:(NSError * _Nullable * _Nullable)error;
- (nullable MLNParticleFieldRuntime *)particleFieldRuntime;
// Legacy public selector retained for existing Native consumers.
- (void)disposeParticleFieldRuntime;
// Completes V3+V4 Source cleanup before detaching; a failure leaves the runtime
// associated so the caller may retry and must not be reported as success.
- (BOOL)disposeParticleFieldRuntimeWithError:(NSError * _Nullable * _Nullable)error;
@end

View File

@@ -103,6 +103,7 @@ std::vector<char> charsFromData(NSData *data) {
BOOL _particleVisible;
NSString *_v4ParticleBindingFieldKey;
NSMutableSet<NSString *> *_submittedEncodedFieldKeys;
BOOL _v4ForecastPlaying;
NSTimer *_v4CadenceTimer;
}
- (BOOL)isV4ParticleCadenceReady;
@@ -137,19 +138,46 @@ std::vector<char> charsFromData(NSData *data) {
- (MLNParticleFieldRuntimePlaybackState)playbackState { return _playbackState; }
- (MLNParticleFieldRuntimeTargetFps)targetFps { return _targetFps; }
- (void)dispose {
- (BOOL)disposeWithError:(NSError **)error {
if (_disposed) return YES;
// A terminal dispose must clear the Source while the map and Style are valid.
// Otherwise the Source outlives this ObjC runtime and retains V4 field windows.
[_v4CadenceTimer invalidate];
_v4CadenceTimer = nil;
_v4ForecastPlaying = NO;
const BOOL completed = [self withCurrentSource:error action:^bool(
mbgl::style::RasterArraySource &source,
mbgl::style::WeatherColorLayer &weather,
mbgl::style::RasterParticleLayer &particle) {
weather.setVisibility(mbgl::style::VisibilityType::None);
particle.setVisibility(mbgl::style::VisibilityType::None);
return source.resetWeatherRuntimeState();
}];
if (!completed) return NO;
[_submittedEncodedFieldKeys removeAllObjects];
_v4ParticleBindingFieldKey = nil;
_playbackState = MLNParticleFieldRuntimePlaybackStatePause;
_state = MLNParticleFieldRuntimeStateDisabled;
_disposed = YES;
_mapView = nil;
return YES;
}
- (void)dispose {
NSError *error = nil;
if (![self disposeWithError:&error]) {
NSLog(@"WeatherRuntime: legacy dispose cleanup failed: %@", error.localizedDescription ?: @"unknown");
}
}
- (void)refreshV4CadenceWake {
[_v4CadenceTimer invalidate];
_v4CadenceTimer = nil;
if (_disposed || ![self isV4ParticleCadenceReady] || !_particleVisible ||
_targetFps == MLNParticleFieldRuntimeTargetFpsAuto) return;
const NSTimeInterval interval = _targetFps == MLNParticleFieldRuntimeTargetFps30 ? 0.034 : 0.017;
const BOOL forecastWake = _v4ForecastPlaying && _submittedEncodedFieldKeys.count > 0;
if (_disposed || _state == MLNParticleFieldRuntimeStateDisabled ||
(!forecastWake && (![self isV4ParticleCadenceReady] || !_particleVisible ||
_targetFps == MLNParticleFieldRuntimeTargetFpsAuto))) return;
const NSTimeInterval interval = _targetFps == MLNParticleFieldRuntimeTargetFps60 ? 0.017 : 0.034;
_v4CadenceTimer = [NSTimer scheduledTimerWithTimeInterval:interval
target:self
selector:@selector(runV4CadenceWake:)
@@ -160,7 +188,7 @@ std::vector<char> charsFromData(NSData *data) {
- (void)runV4CadenceWake:(NSTimer *)timer {
if (timer != _v4CadenceTimer) return;
_v4CadenceTimer = nil;
if (_disposed || ![self isV4ParticleCadenceReady] || !_particleVisible) return;
if (_disposed) return;
[_mapView triggerRepaint];
[self refreshV4CadenceWake];
}
@@ -289,7 +317,9 @@ std::vector<char> charsFromData(NSData *data) {
}
- (BOOL)setEncodedForecastPlaybackState:(BOOL)playing error:(NSError **)error {
return [self withCurrentSource:error action:^bool(mbgl::style::RasterArraySource &source, mbgl::style::WeatherColorLayer &, mbgl::style::RasterParticleLayer &) { source.setForecastPlaybackState(playing ? mbgl::particle::ForecastPlaybackState::Playing : mbgl::particle::ForecastPlaybackState::Paused); return true; }];
const BOOL completed = [self withCurrentSource:error action:^bool(mbgl::style::RasterArraySource &source, mbgl::style::WeatherColorLayer &, mbgl::style::RasterParticleLayer &) { source.setForecastPlaybackState(playing ? mbgl::particle::ForecastPlaybackState::Playing : mbgl::particle::ForecastPlaybackState::Paused); return true; }];
if (completed) { _v4ForecastPlaying = playing; [self refreshV4CadenceWake]; }
return completed;
}
- (BOOL)setEncodedForecastPlaybackSpeed:(float)speed error:(NSError **)error {
@@ -350,11 +380,34 @@ std::vector<char> charsFromData(NSData *data) {
if (_disposed) return MLNParticleFieldRuntimeCommandResultRejected;
if (enabled && _state == MLNParticleFieldRuntimeStateError) return MLNParticleFieldRuntimeCommandResultRejected;
if (enabled && _state != MLNParticleFieldRuntimeStateDisabled) return MLNParticleFieldRuntimeCommandResultAccepted;
__block bool accepted = false; BOOL completed = [self withCurrentSource:error action:^bool(mbgl::style::RasterArraySource &source, mbgl::style::WeatherColorLayer &weather, mbgl::style::RasterParticleLayer &particle) {
if (!enabled) { source.controlRuntimeTimeline(1); weather.setVisibility(mbgl::style::VisibilityType::None); particle.setVisibility(mbgl::style::VisibilityType::None); accepted = source.clearHostFields(); }
else { weather.setVisibility(_weatherColorVisible ? mbgl::style::VisibilityType::Visible : mbgl::style::VisibilityType::None); particle.setVisibility(_particleVisible ? mbgl::style::VisibilityType::Visible : mbgl::style::VisibilityType::None); accepted = true; } return true;
if (!enabled && _state == MLNParticleFieldRuntimeStateDisabled) return MLNParticleFieldRuntimeCommandResultAccepted;
__block bool accepted = false;
BOOL completed = [self withCurrentSource:error action:^bool(
mbgl::style::RasterArraySource &source,
mbgl::style::WeatherColorLayer &weather,
mbgl::style::RasterParticleLayer &particle) {
if (!enabled) {
weather.setVisibility(mbgl::style::VisibilityType::None);
particle.setVisibility(mbgl::style::VisibilityType::None);
accepted = source.resetWeatherRuntimeState();
} else {
weather.setVisibility(_weatherColorVisible ? mbgl::style::VisibilityType::Visible : mbgl::style::VisibilityType::None);
particle.setVisibility(_particleVisible ? mbgl::style::VisibilityType::Visible : mbgl::style::VisibilityType::None);
accepted = true;
}
return accepted;
}];
if (completed && accepted) _state = enabled ? MLNParticleFieldRuntimeStateWaitingForFields : MLNParticleFieldRuntimeStateDisabled; else if (completed) _state = MLNParticleFieldRuntimeStateError;
if (completed && accepted) {
_state = enabled ? MLNParticleFieldRuntimeStateWaitingForFields : MLNParticleFieldRuntimeStateDisabled;
if (!enabled) {
[_submittedEncodedFieldKeys removeAllObjects];
_v4ParticleBindingFieldKey = nil;
_v4ForecastPlaying = NO;
_playbackState = MLNParticleFieldRuntimePlaybackStatePause;
}
} else if (completed) {
_state = MLNParticleFieldRuntimeStateError;
}
[self refreshV4CadenceWake];
return commandResult(completed && accepted);
}
@@ -395,7 +448,7 @@ namespace { const void *ParticleFieldRuntimeAssociationKey = &ParticleFieldRunti
@implementation MLNMapView (ParticleFieldRuntime)
- (MLNParticleFieldRuntime *)replaceParticleFieldRuntimeWithConfiguration:(MLNParticleFieldRuntimeConfiguration *)configuration error:(NSError **)error {
[self disposeParticleFieldRuntime];
if (![self disposeParticleFieldRuntimeWithError:error]) return nil;
if (!configuration) { setError(error, MLNParticleFieldRuntimeErrorInvalidArgument, @"A particle field runtime configuration is required."); return nil; }
MLNParticleFieldRuntime *runtime = [[MLNParticleFieldRuntime alloc] initWithMapView:self configuration:configuration error:error];
if (!runtime) return nil;
@@ -408,9 +461,18 @@ namespace { const void *ParticleFieldRuntimeAssociationKey = &ParticleFieldRunti
}
- (void)disposeParticleFieldRuntime {
NSError *error = nil;
if (![self disposeParticleFieldRuntimeWithError:&error]) {
NSLog(@"WeatherRuntime: legacy map dispose cleanup failed: %@", error.localizedDescription ?: @"unknown");
}
}
- (BOOL)disposeParticleFieldRuntimeWithError:(NSError **)error {
MLNParticleFieldRuntime *runtime = self.particleFieldRuntime;
[runtime dispose];
if (!runtime) return YES;
if (![runtime disposeWithError:error]) return NO;
objc_setAssociatedObject(self, ParticleFieldRuntimeAssociationKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
return YES;
}
@end

View File

@@ -9,8 +9,8 @@ let package = Package(
targets: [
.binaryTarget(
name: "MapLibre",
url: "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.13/MapLibre.dynamic.xcframework.zip",
checksum: "8d47d9f6d44479aff51299c213887bd9be204da320da4fd4fff15dd064930556"
url: "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.19/MapLibre.dynamic.xcframework.zip",
checksum: "221c069b6ca073854716799d8593784310d4f0050be9c9d9a88215b352512efd"
),
]
)

View File

@@ -1,14 +1,14 @@
# MapLibre Particle iOS 发行
当前正式发行:6.28.0-particle.13
当前正式发行:6.28.0-particle.19
- Renderer:Metal
- Product / Module:MapLibre
- Binary:https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.13/MapLibre.dynamic.xcframework.zip
- SHA-256:8d47d9f6d44479aff51299c213887bd9be204da320da4fd4fff15dd064930556
- Binary:https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.19/MapLibre.dynamic.xcframework.zip
- SHA-256:221c069b6ca073854716799d8593784310d4f0050be9c9d9a88215b352512efd
- SwiftPM:https://pbf.jpcn.ccwu.cc/swift/maplibre-particle-spm.git
- Exact Version:6.28.0-particle.13
- Source Commit:ddf20dec9fe9cd05b77274827b6825829500dd5a
- Exact Version:6.28.0-particle.19
- Source Commit:7c5ed27b0b3ae5fe74ab245cbef9dabf913dde4f
构建命令固定为:

View File

@@ -1,19 +1,19 @@
{
"schemaVersion": 2,
"status": "PUBLISHED_PUBLIC_READBACK_PASS",
"distributionVersion": "6.28.0-particle.13",
"distributionVersion": "6.28.0-particle.19",
"moduleName": "MapLibre",
"artifact": {
"fileName": "MapLibre.dynamic.xcframework.zip",
"sizeBytes": 8418198,
"sha256": "8d47d9f6d44479aff51299c213887bd9be204da320da4fd4fff15dd064930556",
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.13/MapLibre.dynamic.xcframework.zip"
"sizeBytes": 8480397,
"sha256": "221c069b6ca073854716799d8593784310d4f0050be9c9d9a88215b352512efd",
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.19/MapLibre.dynamic.xcframework.zip"
},
"sourceCommit": "ddf20dec9fe9cd05b77274827b6825829500dd5a",
"sourceCommit": "7c5ed27b0b3ae5fe74ab245cbef9dabf913dde4f",
"swiftPackage": {
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle-spm.git",
"requirement": "exactVersion",
"version": "6.28.0-particle.13",
"version": "6.28.0-particle.19",
"productName": "MapLibre"
},
"notes": "Metal renderer release built with bazel build --//:renderer=metal //platform/ios:MapLibre.dynamic"

View File

@@ -1,48 +1,48 @@
{
"schemaVersion": 2,
"releaseState": "PUBLISHED_PUBLIC_READBACK_PASS",
"particleRevision": 13,
"sourceCommit": "ddf20dec9fe9cd05b77274827b6825829500dd5a",
"generatedAtUtc": "2026-09-25T05:55:57.573671+00:00",
"particleRevision": 19,
"sourceCommit": "7c5ed27b0b3ae5fe74ab245cbef9dabf913dde4f",
"generatedAtUtc": "2026-10-07T07:17:04.530231+00:00",
"reactNative": {
"name": "@navsea/maplibre-particle-rn",
"version": "13.4.1-particle.13",
"version": "13.4.1-particle.19",
"artifact": {
"fileName": "navsea-maplibre-particle-rn-13.4.1-particle.13.tgz",
"sizeBytes": 25966,
"sha256": "3a726db06cb967fe56de77ca4a72feed98cd642a2d1b73fd51b1531d15ef7539",
"url": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.13.tgz"
"fileName": "navsea-maplibre-particle-rn-13.4.1-particle.19.tgz",
"sizeBytes": 28825,
"sha256": "c98f1aef868c671a99556dbdd2518cdaa3a88a3d6133e620e5e8a38080d19b1a",
"url": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.19.tgz"
},
"mapLibreReactNative": "11.3.6"
},
"android": {
"version": "13.4.1-particle.13",
"coordinate": "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.13",
"version": "13.4.1-particle.19",
"coordinate": "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.19",
"renderer": "opengl",
"artifact": {
"fileName": "android-sdk-particle-opengl-13.4.1-particle.13.aar",
"sizeBytes": 169786873,
"sha256": "ddf1deced00cc963d1d625c73c68c449232602113a1490349e69100473dda82b",
"url": "https://pbf.jpcn.ccwu.cc/maven/cc/navsea/maplibre/android-sdk-particle-opengl/13.4.1-particle.13/android-sdk-particle-opengl-13.4.1-particle.13.aar"
"fileName": "android-sdk-particle-opengl-13.4.1-particle.19.aar",
"sizeBytes": 170726451,
"sha256": "8ef9de51564aad6d5d8a9678534120cee34d3ee65c41f9f266c29bf057029712",
"url": "https://pbf.jpcn.ccwu.cc/maven/cc/navsea/maplibre/android-sdk-particle-opengl/13.4.1-particle.19/android-sdk-particle-opengl-13.4.1-particle.19.aar"
},
"ndk": "28.2.13676358",
"gradle": "9.5.1",
"jdk": "openjdk version \"17.0.20.1\" 2026-08-18"
},
"ios": {
"distributionVersion": "6.28.0-particle.13",
"distributionVersion": "6.28.0-particle.19",
"renderer": "metal",
"moduleName": "MapLibre",
"artifact": {
"fileName": "MapLibre.dynamic.xcframework.zip",
"sizeBytes": 8418198,
"sha256": "8d47d9f6d44479aff51299c213887bd9be204da320da4fd4fff15dd064930556",
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.13/MapLibre.dynamic.xcframework.zip"
"sizeBytes": 8480397,
"sha256": "221c069b6ca073854716799d8593784310d4f0050be9c9d9a88215b352512efd",
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.19/MapLibre.dynamic.xcframework.zip"
},
"swiftPackage": {
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle-spm.git",
"requirement": "exactVersion",
"version": "6.28.0-particle.13",
"version": "6.28.0-particle.19",
"productName": "MapLibre"
},
"xcode": "Xcode 26.3 Build version 17C529 ",

View File

@@ -1,3 +1,7 @@
// The shared GL prelude defaults fragment float to mediump. Byte reconstruction
// from RGBA8 UNORM and Mercator state require highp on ES 3.x.
precision highp float;
layout (std140) uniform ParticleGpuValidationUBO {
highp vec4 u_pass;
highp vec4 u_marker;
@@ -17,8 +21,8 @@ layout (std140) uniform ParticleGpuValidationUBO {
uniform sampler2D u_previous;
uniform sampler2D u_mask_texture;
uniform sampler2D u_field_texture;
uniform sampler2D u_next_field_texture;
uniform highp sampler2D u_field_texture;
uniform highp sampler2D u_next_field_texture;
uniform sampler2D u_state_texture;
in vec2 v_uv;
in vec2 v_world;
@@ -77,8 +81,8 @@ bool fieldAdvectionAt(vec2 position, out vec2 advection) {
vec2 uv = fieldUv(position);
vec4 field0 = texture(u_field_texture, uv);
vec4 field1 = texture(u_next_field_texture, uv);
vec2 mixed = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x,
(field1.rg * 2.0 - 1.0) * u_field_scale.y,
vec2 mixed = mix(((floor(field0.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.x,
((floor(field1.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.y,
u_marker.x);
if (!validFieldUv(uv) || field0.a < 0.5 || field1.a < 0.5 || any(isnan(mixed)) ||
any(greaterThan(abs(mixed), vec2(3.4e38)))) return false;
@@ -118,14 +122,14 @@ void main() {
if (field0.a < 0.5 || field1.a < 0.5) discard;
float fieldValue;
if (u_weather_ramp.y > 0.5) {
vec2 vectorValue = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x,
(field1.rg * 2.0 - 1.0) * u_field_scale.y,
vec2 vectorValue = mix(((floor(field0.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.x,
((floor(field1.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.y,
u_marker.x);
if (any(isnan(vectorValue))) discard;
fieldValue = length(vectorValue);
} else {
fieldValue = mix((field0.r * 2.0 - 1.0) * u_field_scale.x,
(field1.r * 2.0 - 1.0) * u_field_scale.y,
fieldValue = mix(((floor(field0.r * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.x,
((floor(field1.r * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.y,
u_marker.x);
if (isnan(fieldValue)) discard;
}
@@ -168,7 +172,7 @@ void main() {
if (!respawn) {
float latitude = mercatorLatitudeRadians(position.y);
vec2 displacement = vec2(advection.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS,
vec2 displacement = vec2(advection.x * u_pass.y / (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01)),
-advection.y * u_pass.y /
(EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01)));
vec2 nextPosition = position + displacement;
@@ -219,10 +223,10 @@ void main() {
// U/V remain canonical metres per second. u_pass.y is the explicit
// visual simulation delta in seconds; the validation clock may run
// faster than wall time, but it never changes the field contract.
// Web Mercator x spans Earth's circumference; y additionally scales
// with sec(latitude). V is north-positive while world y is southward.
// Both east and north metres scale with sec(latitude) in Web Mercator.
// V is north-positive while world y is southward.
float latitude = mercatorLatitudeRadians(position.y);
float dx = advection.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS;
float dx = advection.x * u_pass.y / (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01));
float dy = -advection.y * u_pass.y /
(EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01));
position = position + vec2(dx, dy);

View File

@@ -24,8 +24,8 @@ layout (std140) uniform ParticleGpuValidationUBO {
uniform sampler2D u_state_texture;
uniform sampler2D u_previous;
uniform sampler2D u_field_texture;
uniform sampler2D u_next_field_texture;
uniform highp sampler2D u_field_texture;
uniform highp sampler2D u_next_field_texture;
vec2 fieldUv(vec2 world) {
return vec2((world.x - u_field_bounds.x) / (u_field_bounds.z - u_field_bounds.x),
@@ -37,8 +37,8 @@ bool validFieldUv(vec2 uv) {
}
bool resolveAdvection(vec4 field0, vec4 field1, out vec2 advection) {
vec2 mixed = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x,
(field1.rg * 2.0 - 1.0) * u_field_scale.y,
vec2 mixed = mix(((floor(field0.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.x,
((floor(field1.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.y,
u_marker.x);
if (any(isnan(mixed)) || any(greaterThan(abs(mixed), vec2(3.4e38)))) return false;
if (u_advection.x > 0.5) {

View File

@@ -3,6 +3,7 @@
#include <mbgl/renderer/update_parameters.hpp>
#include <mbgl/storage/file_source.hpp>
#include <mbgl/style/style_impl.hpp>
#include <mbgl/style/sources/raster_array_source.hpp>
#include <mbgl/util/exception.hpp>
#include <mbgl/util/logging.hpp>
#include <mbgl/util/traits.hpp>
@@ -111,6 +112,14 @@ void Map::Impl::onUpdate() {
}
TimePoint timePoint = mode == MapMode::Continuous ? Clock::now() : Clock::time_point::max();
if (mode == MapMode::Continuous) {
const auto now = std::chrono::steady_clock::now();
for (auto* source : style->getSources()) {
if (source->is<style::RasterArraySource>()) {
static_cast<style::RasterArraySource*>(source)->tickForecastClock(now);
}
}
}
transform.updateTransitions(timePoint);

View File

@@ -5,6 +5,7 @@
#include <mbgl/mtl/texture2d.hpp>
#include <Metal/Metal.hpp>
#include <stdexcept>
namespace mbgl {
namespace mtl {
@@ -15,10 +16,12 @@ public:
const Size size_,
const gfx::TextureChannelDataType type_,
bool depth,
[[maybe_unused]] bool stencil)
bool stencil)
: context(context_),
size(size_),
type(type_) {
type(type_),
needsDepth(depth),
needsStencil(stencil) {
assert(!size.isEmpty());
colorTexture = context.createTexture2D();
colorTexture->setSize(size);
@@ -29,7 +32,12 @@ public:
static_cast<Texture2D*>(colorTexture.get())
->setUsage(MTL::TextureUsageShaderRead | MTL::TextureUsageShaderWrite | MTL::TextureUsageRenderTarget);
if (depth) {
#if TARGET_OS_SIMULATOR || defined(__x86_64__)
constexpr bool combinedDepthStencil = true;
#else
constexpr bool combinedDepthStencil = false;
#endif
if (depth || (stencil && combinedDepthStencil)) {
depthTexture = context.createTexture2D();
depthTexture->setSize(size);
depthTexture->setFormat(gfx::TexturePixelType::Depth, gfx::TextureChannelDataType::Float);
@@ -41,7 +49,7 @@ public:
}
// On iOS simulator, the depth target is PixelFormatDepth32Float_Stencil8
#if !TARGET_OS_SIMULATOR
#if !TARGET_OS_SIMULATOR && !defined(__x86_64__)
if (stencil) {
stencilTexture = context.createTexture2D();
stencilTexture->setSize(size);
@@ -63,23 +71,45 @@ public:
assert(context.getBackend().getCommandQueue());
commandBuffer = NS::RetainPtr(context.getBackend().getCommandQueue()->commandBuffer());
colorTexture->create();
auto* colorMetalTexture = static_cast<Texture2D*>(colorTexture.get())->getMetalTexture();
if (!commandBuffer || !colorMetalTexture) throw std::runtime_error("Metal offscreen color attachment unavailable");
renderPassDescriptor = NS::TransferPtr(MTL::RenderPassDescriptor::alloc()->init());
if (!renderPassDescriptor) throw std::runtime_error("Metal offscreen render pass descriptor unavailable");
if (auto* colorTarget = renderPassDescriptor->colorAttachments()->object(0)) {
colorTarget->setTexture(static_cast<Texture2D*>(colorTexture.get())->getMetalTexture());
colorTarget->setTexture(colorMetalTexture);
} else {
throw std::runtime_error("Metal offscreen color attachment missing");
}
if (depthTexture) {
depthTexture->create();
auto* depthMetalTexture = static_cast<Texture2D*>(depthTexture.get())->getMetalTexture();
if (!depthMetalTexture) throw std::runtime_error("Metal offscreen depth attachment unavailable");
if (auto* depthTarget = renderPassDescriptor->depthAttachment()) {
depthTarget->setTexture(static_cast<Texture2D*>(depthTexture.get())->getMetalTexture());
depthTarget->setTexture(depthMetalTexture);
} else {
throw std::runtime_error("Metal offscreen depth attachment missing");
}
}
if (stencilTexture) {
stencilTexture->create();
auto* stencilMetalTexture = static_cast<Texture2D*>(stencilTexture.get())->getMetalTexture();
if (!stencilMetalTexture) throw std::runtime_error("Metal offscreen stencil attachment unavailable");
if (auto* stencilTarget = renderPassDescriptor->stencilAttachment()) {
stencilTarget->setTexture(static_cast<Texture2D*>(stencilTexture.get())->getMetalTexture());
stencilTarget->setTexture(stencilMetalTexture);
} else {
throw std::runtime_error("Metal offscreen stencil attachment missing");
}
} else if (needsStencil && depthTexture) {
// Simulator/x86 Metal uses one Depth32Float_Stencil8 texture for both attachments.
if (auto* stencilTarget = renderPassDescriptor->stencilAttachment()) {
stencilTarget->setTexture(static_cast<Texture2D*>(depthTexture.get())->getMetalTexture());
} else {
throw std::runtime_error("Metal offscreen combined stencil attachment missing");
}
} else if (needsStencil) {
throw std::runtime_error("Metal offscreen stencil attachment unavailable");
}
}
@@ -108,6 +138,25 @@ public:
return colorTexture;
}
bool isRenderable() {
try {
colorTexture->create();
if (!static_cast<Texture2D*>(colorTexture.get())->getMetalTexture()) return false;
if (needsDepth || (needsStencil && !stencilTexture)) {
if (!depthTexture) return false;
depthTexture->create();
if (!static_cast<Texture2D*>(depthTexture.get())->getMetalTexture()) return false;
}
if (stencilTexture) {
stencilTexture->create();
if (!static_cast<Texture2D*>(stencilTexture.get())->getMetalTexture()) return false;
}
return true;
} catch (...) {
return false;
}
}
const RendererBackend& getBackend() const override { return context.getBackend(); }
const MTLCommandBufferPtr& getCommandBuffer() const override { return commandBuffer; }
@@ -125,6 +174,8 @@ private:
Context& context;
const Size size;
const gfx::TextureChannelDataType type;
const bool needsDepth;
const bool needsStencil;
gfx::Texture2DPtr colorTexture;
gfx::Texture2DPtr depthTexture;
gfx::Texture2DPtr stencilTexture;
@@ -137,15 +188,7 @@ OffscreenTexture::OffscreenTexture(
: gfx::OffscreenTexture(size_, std::make_unique<OffscreenTextureResource>(context, size_, type, depth, stencil)) {}
bool OffscreenTexture::isRenderable() {
try {
auto& texture = getResource<OffscreenTextureResource>().getTexture();
if (!texture) return false;
auto* metalTexture = static_cast<Texture2D*>(texture.get());
metalTexture->create();
return metalTexture->getMetalTexture() != nullptr;
} catch (...) {
return false;
}
return getResource<OffscreenTextureResource>().isRenderable();
}
PremultipliedImage OffscreenTexture::readStillImage() {

View File

@@ -6,10 +6,44 @@
#include <mbgl/util/logging.hpp>
#include <Metal/Metal.hpp>
#include <stdexcept>
namespace mbgl {
namespace mtl {
detail::ClearedAttachments detail::applyRenderPassClears(MTL::RenderPassDescriptor& rpd,
const std::optional<Color>& clearColor,
const std::optional<float>& clearDepth,
const std::optional<int32_t>& clearStencil) {
ClearedAttachments result;
if (clearColor) {
if (auto* target = rpd.colorAttachments()->object(0); target && target->texture()) {
const auto& c = *clearColor;
target->setLoadAction(MTL::LoadActionClear);
target->setClearColor(MTL::ClearColor::Make(c.r, c.g, c.b, c.a));
target->setStoreAction(MTL::StoreActionStore);
result.color = true;
}
}
if (clearDepth) {
if (auto* target = rpd.depthAttachment(); target && target->texture()) {
target->setLoadAction(MTL::LoadActionClear);
target->setClearDepth(*clearDepth);
target->setStoreAction(MTL::StoreActionStore);
result.depth = true;
}
}
if (clearStencil) {
if (auto* target = rpd.stencilAttachment(); target && target->texture()) {
target->setLoadAction(MTL::LoadActionClear);
target->setClearStencil(static_cast<uint32_t>(*clearStencil));
target->setStoreAction(MTL::StoreActionStore);
result.stencil = true;
}
}
return result;
}
RenderPass::RenderPass(CommandEncoder& commandEncoder_, const char* name, const gfx::RenderPassDescriptor& descriptor)
: descriptor(descriptor),
commandEncoder(commandEncoder_) {
@@ -18,26 +52,22 @@ RenderPass::RenderPass(CommandEncoder& commandEncoder_, const char* name, const
resource.bind();
if (const auto& buffer = resource.getCommandBuffer()) {
if (auto rpd = resource.getRenderPassDescriptor()) {
if (descriptor.clearColor) {
if (auto copy = NS::TransferPtr(rpd->copy())) {
if (auto* colorTarget = copy->colorAttachments()->object(0)) {
const auto& c = *descriptor.clearColor;
colorTarget->setLoadAction(MTL::LoadActionClear);
colorTarget->setClearColor(MTL::ClearColor::Make(c.r, c.g, c.b, c.a));
rpd = std::move(copy);
}
}
}
if (const auto& original = resource.getRenderPassDescriptor()) {
auto rpd = NS::TransferPtr(original->copy());
if (!rpd) throw std::runtime_error("Metal render pass descriptor copy failed");
detail::applyRenderPassClears(*rpd.get(), descriptor.clearColor, descriptor.clearDepth, descriptor.clearStencil);
encoder = NS::RetainPtr(buffer->renderCommandEncoder(rpd.get()));
if (!encoder) throw std::runtime_error("Metal render command encoder unavailable");
const auto& texture = rpd->colorAttachments()->object(0)->texture();
const auto* colorTarget = rpd->colorAttachments()->object(0);
if (!colorTarget || !colorTarget->texture()) throw std::runtime_error("Metal color attachment unavailable");
const auto& texture = colorTarget->texture();
width = texture->width();
height = texture->height();
}
}
assert(encoder);
if (!encoder) throw std::runtime_error("Metal render pass unavailable");
// Push the groups already accumulated by the encoder
commandEncoder.visitDebugGroups([this](const auto& group) {

View File

@@ -73,6 +73,9 @@ ParticleFieldAdapterResult ParticleFieldDataAdapter::decode(const ParticleFieldD
field.fieldID = d.fieldID; field.fieldType = d.fieldType; field.displayDomain = d.displayDomain; field.validTime = d.validTime;
field.width = d.width; field.height = d.height; field.west = d.west; field.south = d.south; field.east = d.east; field.north = d.north;
field.dataVersion = d.dataVersion; field.generation = d.generation; field.fieldSha256 = d.fieldSha256; field.vectorSemantics = d.vectorSemantics;
field.gpuEncodingIdentity = d.encoding + ":" + d.layout;
if (d.vector) field.gpuEncodingIdentity += ":" + d.vector->encoding + ":" + d.vector->layout;
if (d.validity) field.gpuEncodingIdentity += ":" + d.validity->encoding + ":" + d.validity->layout;
field.valid.assign(payloads.validity->begin(), payloads.validity->end());
if (scalar) { field.scalar.resize(cells); std::memcpy(field.scalar.data(), payloads.scalar->data(), payloads.scalar->size()); }
if (vector) { field.vector.resize(cells * 2); std::memcpy(field.vector.data(), payloads.vector->data(), payloads.vector->size()); }

View File

@@ -2,6 +2,7 @@
#include <algorithm>
#include <cmath>
#include <cstring>
#include <iterator>
#include <limits>
#include <map>
@@ -72,6 +73,84 @@ bool directionScaleIsCanonical(const float scale) {
return std::fabs(scale - canonical) <= tolerance;
}
uint32_t readU32LE(const uint8_t* data) {
return static_cast<uint32_t>(data[0]) | (static_cast<uint32_t>(data[1]) << 8) |
(static_cast<uint32_t>(data[2]) << 16) | (static_cast<uint32_t>(data[3]) << 24);
}
bool validNumericSamples(const DecodedFieldFrame& frame, const DecodedFieldPlane& plane) {
const uint64_t cells = static_cast<uint64_t>(frame.grid.width) * frame.grid.height;
size_t bytesPerSample = 0;
switch (plane.dtype) {
case FieldDType::Int16:
case FieldDType::UInt16:
bytesPerSample = 2;
break;
case FieldDType::UInt8:
case FieldDType::Int8:
bytesPerSample = 1;
break;
case FieldDType::Float32:
bytesPerSample = 4;
break;
case FieldDType::Bitset:
return false;
}
if (cells > std::numeric_limits<size_t>::max() / bytesPerSample ||
plane.storage->size() != static_cast<size_t>(cells) * bytesPerSample) return false;
for (uint64_t cell = 0; cell < cells; ++cell) {
// The shared validity mask is the protocol's no-data indicator. Invalid
// cells are deliberately ignored; their zero placeholder is not a field
// value and must not reject an otherwise usable frame.
if (((*frame.validityBits)[static_cast<size_t>(cell >> 3)] & (1u << (cell & 7u))) == 0) continue;
const uint8_t* sample = plane.storage->data();
double raw = 0.0;
switch (plane.dtype) {
case FieldDType::Int16: {
const auto offset = static_cast<size_t>(cell) * 2;
const uint16_t bits = static_cast<uint16_t>(sample[offset]) |
(static_cast<uint16_t>(sample[offset + 1]) << 8);
raw = static_cast<int16_t>(bits);
break;
}
case FieldDType::UInt16: {
const auto offset = static_cast<size_t>(cell) * 2;
raw = static_cast<uint16_t>(sample[offset]) |
(static_cast<uint16_t>(sample[offset + 1]) << 8);
break;
}
case FieldDType::UInt8:
raw = sample[static_cast<size_t>(cell)];
break;
case FieldDType::Int8:
raw = static_cast<int8_t>(sample[static_cast<size_t>(cell)]);
break;
case FieldDType::Float32: {
float value = 0.0f;
const uint32_t bits = readU32LE(sample + static_cast<size_t>(cell) * 4);
std::memcpy(&value, &bits, sizeof(value));
if (!std::isfinite(value)) return false;
raw = value;
break;
}
case FieldDType::Bitset:
return false;
}
if (!std::isfinite(raw)) return false;
const double scaledValue = raw * static_cast<double>(plane.scale);
if (!std::isfinite(scaledValue) ||
std::fabs(scaledValue) > static_cast<double>(std::numeric_limits<float>::max())) return false;
const float scaled = static_cast<float>(scaledValue);
const double decodedValue = static_cast<double>(scaled) + static_cast<double>(plane.offset);
if (!std::isfinite(decodedValue) ||
std::fabs(decodedValue) > static_cast<double>(std::numeric_limits<float>::max()) ||
!std::isfinite(static_cast<float>(decodedValue))) return false;
}
return true;
}
} // namespace
FieldProfileValidationResult FieldProfileValidator::validate(const ResolvedFieldProfileContract& contract,
@@ -163,6 +242,10 @@ FieldProfileValidationResult FieldProfileValidator::validate(const ResolvedField
if (!u || !v || u->count != v->count || u->unitID != v->unitID)
return result(FieldProfileValidationCode::InvalidVelocityGroup, "incomplete velocity group");
}
for (const auto plane : actual) {
if (plane->valueClass != FieldValueClass::Validity && !validNumericSamples(frame, *plane))
return result(FieldProfileValidationCode::InvalidFrame, "non-finite valid-cell numeric value");
}
return result(FieldProfileValidationCode::Accepted, "accepted");
}

View File

@@ -89,6 +89,27 @@ ParticleFieldSnapshot baseSnapshot(const DecodedFieldFrame& frame, const std::st
snapshot.dataVersion = frame.frameContentIdentity.dataVersion;
snapshot.generation = frame.frameContentIdentity.generation;
snapshot.fieldSha256 = identity;
// Include all plane interpretation metadata, including validity and grid
// layout. Decoded floats alone cannot prove that a new revision has the
// same source semantics.
snapshot.gpuEncodingIdentity = std::to_string(frame.grid.layout) + ":" +
std::to_string(frame.grid.projection) + ":" +
std::to_string(frame.grid.sampleOrder) + ":" +
std::to_string(frame.grid.gridRegistration);
const auto appendPlane = [&snapshot](const DecodedFieldPlane& plane) {
uint32_t scaleBits = 0, offsetBits = 0;
std::memcpy(&scaleBits, &plane.scale, sizeof(scaleBits));
std::memcpy(&offsetBits, &plane.offset, sizeof(offsetBits));
snapshot.gpuEncodingIdentity += ":" + std::to_string(plane.key.semanticID) + ":" +
std::to_string(plane.key.groupID) + ":" +
std::to_string(static_cast<int>(plane.key.component)) + ":" +
std::to_string(static_cast<int>(plane.dtype)) + ":" +
std::to_string(static_cast<int>(plane.valueClass)) + ":" +
std::to_string(plane.unitID) + ":" +
std::to_string(scaleBits) + ":" + std::to_string(offsetBits);
};
appendPlane(frame.validityPlane);
for (const auto& plane : frame.planes) appendPlane(plane);
return snapshot;
}
bool compatible(const DecodedFieldFrame& a, const DecodedFieldFrame& b) {
@@ -171,11 +192,13 @@ std::optional<MaterializedColorProjection> materializeColorProjection(const Colo
};
auto t0 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t0Frame, u0->contentIdentity + "|" + v0->contentIdentity));
if (!vectorSnapshot(*projection.t0Frame, vectorBinding, *t0)) return {};
if (projection.t0Frame == projection.t1Frame)
return MaterializedColorProjection{t0, t0, projection.contentIdentity, projection.sampleState,
MaterializedColorValueSource::VectorMagnitude};
auto t1 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t1Frame, u1->contentIdentity + "|" + v1->contentIdentity));
if (!vectorSnapshot(*projection.t0Frame, vectorBinding, *t0) ||
!vectorSnapshot(*projection.t1Frame, vectorBinding, *t1))
return {};
if (!vectorSnapshot(*projection.t1Frame, vectorBinding, *t1)) return {};
return MaterializedColorProjection{std::move(t0),
std::move(t1),
projection.contentIdentity,
@@ -188,9 +211,13 @@ std::optional<MaterializedColorProjection> materializeColorProjection(const Colo
if (!p0 || !p1) return {};
auto t0 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t0Frame, projection.contentIdentity.t0Plane));
if (!scalarSnapshot(*projection.t0Frame, *p0, *t0)) return {};
if (projection.t0Frame == projection.t1Frame)
return MaterializedColorProjection{t0, t0, projection.contentIdentity, projection.sampleState,
MaterializedColorValueSource::Scalar};
auto t1 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t1Frame, projection.contentIdentity.t1Plane));
if (!scalarSnapshot(*projection.t0Frame, *p0, *t0) || !scalarSnapshot(*projection.t1Frame, *p1, *t1)) return {};
if (!scalarSnapshot(*projection.t1Frame, *p1, *t1)) return {};
return MaterializedColorProjection{std::move(t0),
std::move(t1),
projection.contentIdentity,
@@ -202,11 +229,12 @@ std::optional<MaterializedParticleProjection> materializeParticleProjection(cons
if (!projection.t0Frame || !projection.t1Frame || !compatible(*projection.t0Frame, *projection.t1Frame)) return {};
auto t0 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t0Frame, projection.contentIdentity.t0FirstPlane));
if (!vectorSnapshot(*projection.t0Frame, projection.contentIdentity.binding, *t0)) return {};
if (projection.t0Frame == projection.t1Frame)
return MaterializedParticleProjection{t0, t0, projection.contentIdentity, projection.sampleState};
auto t1 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t1Frame, projection.contentIdentity.t1FirstPlane));
if (!vectorSnapshot(*projection.t0Frame, projection.contentIdentity.binding, *t0) ||
!vectorSnapshot(*projection.t1Frame, projection.contentIdentity.binding, *t1))
return {};
if (!vectorSnapshot(*projection.t1Frame, projection.contentIdentity.binding, *t1)) return {};
return MaterializedParticleProjection{
std::move(t0), std::move(t1), projection.contentIdentity, projection.sampleState};
}

View File

@@ -2,6 +2,7 @@
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <limits>
#include <set>
@@ -119,7 +120,7 @@ bool DecodedFieldFrame::isStructurallyValid() const {
return true;
}
bool DecodedFieldFrame::isInterpolationCompatibleWith(const DecodedFieldFrame& other) const {
bool DecodedFieldFrame::isSeriesCompatibleWith(const DecodedFieldFrame& other) const {
if (!(grid == other.grid) || !validityPlane.isInterpolationCompatibleWith(other.validityPlane) ||
planes.size() != other.planes.size()) return false;
for (size_t i = 0; i < planes.size(); ++i) {
@@ -128,8 +129,15 @@ bool DecodedFieldFrame::isInterpolationCompatibleWith(const DecodedFieldFrame& o
return true;
}
bool DecodedFieldFrame::isInterpolationCompatibleWith(const DecodedFieldFrame& other) const {
return frameContentIdentity.fieldID == other.frameContentIdentity.fieldID &&
referenceTimeMs == other.referenceTimeMs && resolvedProfileID == other.resolvedProfileID &&
resolvedProfileVersion == other.resolvedProfileVersion && isSeriesCompatibleWith(other);
}
bool DecodedFieldFrame::hasSameContentAs(const DecodedFieldFrame& other) const {
if (referenceTimeMs != other.referenceTimeMs || !(grid == other.grid) ||
if (referenceTimeMs != other.referenceTimeMs || resolvedProfileID != other.resolvedProfileID ||
resolvedProfileVersion != other.resolvedProfileVersion || !(grid == other.grid) ||
validityContentIdentity != other.validityContentIdentity ||
!samePlaneSchema(validityPlane, other.validityPlane) || planes.size() != other.planes.size()) return false;
for (size_t i = 0; i < planes.size(); ++i) {
@@ -139,8 +147,29 @@ bool DecodedFieldFrame::hasSameContentAs(const DecodedFieldFrame& other) const {
return true;
}
uint64_t DecodedFieldFrame::decodedByteSize() const {
uint64_t total = validityBits ? validityBits->size() : 0;
for (const auto& plane : planes) {
if (!plane.storage) continue;
const auto bytes = static_cast<uint64_t>(plane.storage->size());
if (bytes > std::numeric_limits<uint64_t>::max() - total) return std::numeric_limits<uint64_t>::max();
total += bytes;
}
return total;
}
FieldSeries::FieldSeries(const size_t capacity) : capacity_(capacity) {}
uint64_t FieldSeries::decodedByteSize() const {
uint64_t total = 0;
for (const auto& frame : frames) {
const auto bytes = frame->decodedByteSize();
if (bytes > std::numeric_limits<uint64_t>::max() - total) return std::numeric_limits<uint64_t>::max();
total += bytes;
}
return total;
}
FieldSeriesSubmitResult FieldSeries::submit(std::shared_ptr<const DecodedFieldFrame> frame) {
if (!frame || !frame->isStructurallyValid()) return FieldSeriesSubmitResult::RejectedInvalidFrame;
const auto timestamp = frame->frameContentIdentity.validTimeMs;
@@ -156,30 +185,51 @@ FieldSeriesSubmitResult FieldSeries::submit(std::shared_ptr<const DecodedFieldFr
}
if (!newerRevision(frame->frameContentIdentity, current.frameContentIdentity))
return FieldSeriesSubmitResult::RejectedStaleRevision;
if (!frame->isInterpolationCompatibleWith(current))
if (!frame->isSeriesCompatibleWith(current))
return FieldSeriesSubmitResult::RejectedIncompatibleFrame;
if (it != frames.begin() && !frame->isInterpolationCompatibleWith(**std::prev(it)))
if (it != frames.begin() && !frame->isSeriesCompatibleWith(**std::prev(it)))
return FieldSeriesSubmitResult::RejectedIncompatibleFrame;
if (std::next(it) != frames.end() && !frame->isInterpolationCompatibleWith(**std::next(it)))
if (std::next(it) != frames.end() && !frame->isSeriesCompatibleWith(**std::next(it)))
return FieldSeriesSubmitResult::RejectedIncompatibleFrame;
*it = std::move(frame);
return FieldSeriesSubmitResult::ReplacedRevision;
}
if (frames.size() >= capacity_) return FieldSeriesSubmitResult::RejectedCapacity;
if (it != frames.begin() && !frame->isInterpolationCompatibleWith(**std::prev(it)))
if (capacity_ == 0) return FieldSeriesSubmitResult::RejectedCapacity;
// Build and validate the complete replacement before mutating the live
// window. A failed frame must never evict a previously accepted endpoint.
auto candidate = frames;
size_t insertionIndex = static_cast<size_t>(std::distance(frames.begin(), it));
if (candidate.size() >= capacity_) {
const uint64_t earliest = frames.front()->frameContentIdentity.validTimeMs;
const uint64_t latest = frames.back()->frameContentIdentity.validTimeMs;
const uint64_t distanceToEarliest = timestamp >= earliest ? timestamp - earliest : earliest - timestamp;
const uint64_t distanceToLatest = timestamp >= latest ? timestamp - latest : latest - timestamp;
// C-002: equidistant candidates evict the original earliest endpoint.
const size_t victimIndex = distanceToEarliest >= distanceToLatest ? 0 : frames.size() - 1;
if (victimIndex < insertionIndex) --insertionIndex;
candidate.erase(candidate.begin() + static_cast<std::ptrdiff_t>(victimIndex));
}
candidate.insert(candidate.begin() + static_cast<std::ptrdiff_t>(insertionIndex), std::move(frame));
const auto inserted = candidate.begin() + static_cast<std::ptrdiff_t>(insertionIndex);
if (inserted != candidate.begin() && !(*inserted)->isSeriesCompatibleWith(**std::prev(inserted)))
return FieldSeriesSubmitResult::RejectedIncompatibleFrame;
if (it != frames.end() && !frame->isInterpolationCompatibleWith(**it))
if (std::next(inserted) != candidate.end() && !(*inserted)->isSeriesCompatibleWith(**std::next(inserted)))
return FieldSeriesSubmitResult::RejectedIncompatibleFrame;
frames.insert(it, std::move(frame));
frames.swap(candidate);
return FieldSeriesSubmitResult::Inserted;
}
std::optional<FieldSeriesWindow> FieldSeries::resolve(const uint64_t requestedForecastTimeMs) const {
if (frames.empty()) return std::nullopt;
const auto exact = std::lower_bound(frames.begin(), frames.end(), requestedForecastTimeMs,
[](const auto& item, const uint64_t value) {
return item->frameContentIdentity.validTimeMs < value;
});
if (exact != frames.end() && (*exact)->frameContentIdentity.validTimeMs == requestedForecastTimeMs)
return FieldSeriesWindow{*exact, *exact, 0.0f, requestedForecastTimeMs};
if (frames.size() < 2 || requestedForecastTimeMs < frames.front()->frameContentIdentity.validTimeMs ||
requestedForecastTimeMs > frames.back()->frameContentIdentity.validTimeMs) return std::nullopt;
if (requestedForecastTimeMs == frames.back()->frameContentIdentity.validTimeMs) {
return FieldSeriesWindow{frames[frames.size() - 2], frames.back(), 1.0f, requestedForecastTimeMs};
}
const auto upper = std::upper_bound(frames.begin(), frames.end(), requestedForecastTimeMs,
[](const uint64_t value, const auto& item) {
return value < item->frameContentIdentity.validTimeMs;
@@ -217,7 +267,7 @@ bool ForecastClock::advance(const uint64_t elapsedMs) {
if (wholeMilliseconds > std::numeric_limits<uint64_t>::max() - requestedForecastTimeMs_) return false;
requestedForecastTimeMs_ += wholeMilliseconds;
fractionalHalfMillisecond_ = static_cast<uint8_t>(accumulatedHalfMilliseconds % 2);
return true;
return wholeMilliseconds != 0;
}
FieldSeriesSubmitResult EncodedFieldStore::submit(const std::string& fieldKey,
@@ -241,6 +291,16 @@ const FieldSeries* EncodedFieldStore::find(const std::string& fieldKey) const {
return it == series.end() ? nullptr : &it->second;
}
uint64_t EncodedFieldStore::decodedByteSize() const {
uint64_t total = 0;
for (const auto& [_, fieldSeries] : series) {
const auto bytes = fieldSeries.decodedByteSize();
if (bytes > std::numeric_limits<uint64_t>::max() - total) return std::numeric_limits<uint64_t>::max();
total += bytes;
}
return total;
}
bool EncodedFieldStore::clear(const std::string& fieldKey) {
return series.erase(fieldKey) != 0;
}

Some files were not shown because too many files have changed in this diff Show More