diff --git a/docs/30_设计/STEP-098_QBIN_V4单帧与八帧滚动缓存设计_v1.md b/docs/30_设计/STEP-098_QBIN_V4单帧与八帧滚动缓存设计_v1.md new file mode 100644 index 000000000..5aa923b01 --- /dev/null +++ b/docs/30_设计/STEP-098_QBIN_V4单帧与八帧滚动缓存设计_v1.md @@ -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 完成报告。 diff --git a/docs/40_任务/STEP-098_QBIN_V4单帧首显与八帧滚动缓存_Codex任务_v1.md b/docs/40_任务/STEP-098_QBIN_V4单帧首显与八帧滚动缓存_Codex任务_v1.md new file mode 100644 index 000000000..70b949476 --- /dev/null +++ b/docs/40_任务/STEP-098_QBIN_V4单帧首显与八帧滚动缓存_Codex任务_v1.md @@ -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 更新兼容性及正式状态。 diff --git a/docs/50_Step记录/STEP-098_QBIN_V4单帧与八帧滚动缓存_v1.md b/docs/50_Step记录/STEP-098_QBIN_V4单帧与八帧滚动缓存_v1.md new file mode 100644 index 000000000..0f9d419d1 --- /dev/null +++ b/docs/50_Step记录/STEP-098_QBIN_V4单帧与八帧滚动缓存_v1.md @@ -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 +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 此时尚未启动。下一关必须为同源码发布构建和分端实测。 diff --git a/docs/报告/STEP-098_QBIN_V4单帧与八帧滚动缓存_Codex完成报告_v1.md b/docs/报告/STEP-098_QBIN_V4单帧与八帧滚动缓存_Codex完成报告_v1.md new file mode 100644 index 000000000..381e9802c --- /dev/null +++ b/docs/报告/STEP-098_QBIN_V4单帧与八帧滚动缓存_Codex完成报告_v1.md @@ -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 全部验收完成。 diff --git a/docs/报告/STEP-098_RDC定向验收报告_v1.md b/docs/报告/STEP-098_RDC定向验收报告_v1.md new file mode 100644 index 000000000..50085562c --- /dev/null +++ b/docs/报告/STEP-098_RDC定向验收报告_v1.md @@ -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 | diff --git a/docs/报告/STEP-098_公共契约阻塞报告_v1.md b/docs/报告/STEP-098_公共契约阻塞报告_v1.md new file mode 100644 index 000000000..123559e9c --- /dev/null +++ b/docs/报告/STEP-098_公共契约阻塞报告_v1.md @@ -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 发行。** diff --git a/include/mbgl/particle/field/field_runtime.hpp b/include/mbgl/particle/field/field_runtime.hpp index 35305460d..fa3aa1b34 100644 --- a/include/mbgl/particle/field/field_runtime.hpp +++ b/include/mbgl/particle/field/field_runtime.hpp @@ -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 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 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>& 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_; diff --git a/src/mbgl/particle/field/field_projection_materialization.cpp b/src/mbgl/particle/field/field_projection_materialization.cpp index 083a6a813..59c2574c9 100644 --- a/src/mbgl/particle/field/field_projection_materialization.cpp +++ b/src/mbgl/particle/field/field_projection_materialization.cpp @@ -192,11 +192,13 @@ std::optional materializeColorProjection(const Colo }; auto t0 = std::make_shared( 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( 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, @@ -209,9 +211,13 @@ std::optional materializeColorProjection(const Colo if (!p0 || !p1) return {}; auto t0 = std::make_shared( 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( 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, @@ -223,11 +229,12 @@ std::optional materializeParticleProjection(cons if (!projection.t0Frame || !projection.t1Frame || !compatible(*projection.t0Frame, *projection.t1Frame)) return {}; auto t0 = std::make_shared( 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( 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}; } diff --git a/src/mbgl/particle/field/field_runtime.cpp b/src/mbgl/particle/field/field_runtime.cpp index 6adc50115..c86b16538 100644 --- a/src/mbgl/particle/field/field_runtime.cpp +++ b/src/mbgl/particle/field/field_runtime.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -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(plane.storage->size()); + if (bytes > std::numeric_limits::max() - total) return std::numeric_limits::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::max() - total) return std::numeric_limits::max(); + total += bytes; + } + return total; +} + FieldSeriesSubmitResult FieldSeries::submit(std::shared_ptr frame) { if (!frame || !frame->isStructurallyValid()) return FieldSeriesSubmitResult::RejectedInvalidFrame; const auto timestamp = frame->frameContentIdentity.validTimeMs; @@ -156,30 +185,51 @@ FieldSeriesSubmitResult FieldSeries::submit(std::shared_ptrframeContentIdentity, 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(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(victimIndex)); + } + candidate.insert(candidate.begin() + static_cast(insertionIndex), std::move(frame)); + const auto inserted = candidate.begin() + static_cast(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 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; @@ -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::max() - total) return std::numeric_limits::max(); + total += bytes; + } + return total; +} + bool EncodedFieldStore::clear(const std::string& fieldKey) { return series.erase(fieldKey) != 0; } diff --git a/src/mbgl/particle/ingress/qbin_v2_decoder.cpp b/src/mbgl/particle/ingress/qbin_v2_decoder.cpp index 06bf489ff..f43ca4ad0 100644 --- a/src/mbgl/particle/ingress/qbin_v2_decoder.cpp +++ b/src/mbgl/particle/ingress/qbin_v2_decoder.cpp @@ -119,6 +119,9 @@ EncodedFieldIngressResult EncodedFieldIngress::decodeAndValidate(const EncodedFi validation.code, {}}; } - return {EncodedFieldIngressCode::Accepted, "accepted", decodeCode, validation.code, std::move(frame)}; + auto resolvedFrame = std::make_shared(*frame); + resolvedFrame->resolvedProfileID = c->profileID; + resolvedFrame->resolvedProfileVersion = c->profileVersion; + return {EncodedFieldIngressCode::Accepted, "accepted", decodeCode, validation.code, std::move(resolvedFrame)}; } } // namespace mbgl::particle::ingress diff --git a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp index 6a626672b..9da2f32b3 100644 --- a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp +++ b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp @@ -1574,8 +1574,16 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, gfx::Texture2DPtr textureT1; float scaleT0 = 1.0f; float scaleT1 = 1.0f; - if (!takeSlot(nextT0, "NEW_T0", assignedT0, imageT0, textureT0, scaleT0) || - !takeSlot(nextT1, "NEW_T1", assignedT1, imageT1, textureT1, scaleT1)) { + if (!takeSlot(nextT0, "NEW_T0", assignedT0, imageT0, textureT0, scaleT0)) { + Log::Error(Event::Render, "ParticleGPU: field switch resource creation failed"); + return; + } + if (particle::sameFieldGpuEndpoint(nextT0, nextT1)) { + assignedT1 = assignedT0; + imageT1 = imageT0; + textureT1 = textureT0; + scaleT1 = scaleT0; + } else if (!takeSlot(nextT1, "NEW_T1", assignedT1, imageT1, textureT1, scaleT1)) { Log::Error(Event::Render, "ParticleGPU: field switch resource creation failed"); return; } diff --git a/src/mbgl/renderer/layers/render_weather_color_layer.cpp b/src/mbgl/renderer/layers/render_weather_color_layer.cpp index 04198ad7b..dde0a0672 100644 --- a/src/mbgl/renderer/layers/render_weather_color_layer.cpp +++ b/src/mbgl/renderer/layers/render_weather_color_layer.cpp @@ -275,10 +275,16 @@ void RenderWeatherColorLayer::update(gfx::ShaderRegistry& shaders, RenderStaticData::heatmapTextureVertices()); state->segments = RenderStaticData::heatmapTextureSegments(); state->valueSource = valueSource; - if (!state->shader || - !uploadField(context, *sourceField, valueSource, state->image0, state->texture0, state->scale0) || - !uploadField(context, *sourceNextField, valueSource, state->image1, state->texture1, state->scale1)) + if (!state->shader || !uploadField(context, *sourceField, valueSource, state->image0, state->texture0, + state->scale0)) return; + if (particle::sameFieldGpuEndpoint(*sourceField, *sourceNextField)) { + state->image1 = state->image0; + state->texture1 = state->texture0; + state->scale1 = state->scale0; + } else if (!uploadField(context, *sourceNextField, valueSource, state->image1, state->texture1, state->scale1)) { + return; + } state->t0 = *sourceField; state->t1 = *sourceNextField; resources = state; diff --git a/test/particle/field_binding.test.cpp b/test/particle/field_binding.test.cpp index 48b284297..b9dd23e38 100644 --- a/test/particle/field_binding.test.cpp +++ b/test/particle/field_binding.test.cpp @@ -1,4 +1,5 @@ #include +#include #include @@ -27,7 +28,8 @@ std::shared_ptr makeFrame(std::string field, const auto bits = std::make_shared>(std::vector{15}); particle::DecodedFieldFrame frame; frame.frameContentIdentity = {std::move(field), generation, generation, time}; - frame.referenceTimeMs = time; + // All frames in these series represent one forecast reference cycle. + frame.referenceTimeMs = Base; frame.grid = {.layout = 1, .projection = 1, .sampleOrder = 1, .gridRegistration = 1, .width = 2, .height = 2, .west = 0, .south = 0, .east = 1, .north = 1}; frame.validityContentIdentity = "valid-" + suffix; @@ -81,6 +83,51 @@ TEST(ParticleFieldBinding, ResolvesIndependentFieldKeyCadence) { EXPECT_NE(c.projection->t0ValidityStorage, p.projection->t0ValidityStorage); } +TEST(ParticleFieldBinding, SingleFrameProjectsAtItsExactTimeAndUpgradesToInterpolation) { + particle::EncodedFieldStore store; + submit(store, "weather.wind", makeFrame("wind", Base, 1, "single")); + + const auto colorAtFrame = particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base); + const auto particleAtFrame = particle::BindingProjectionResolver::resolve(store, wind(), Base); + ASSERT_TRUE(colorAtFrame.accepted()); + ASSERT_TRUE(particleAtFrame.accepted()); + EXPECT_EQ(colorAtFrame.projection->t0Frame, colorAtFrame.projection->t1Frame); + EXPECT_EQ(particleAtFrame.projection->t0Frame, particleAtFrame.projection->t1Frame); + EXPECT_FLOAT_EQ(colorAtFrame.projection->sampleState.mix, 0.0f); + EXPECT_FLOAT_EQ(particleAtFrame.projection->sampleState.mix, 0.0f); + EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + 1).error, + particle::BindingProjectionError::WindowUnavailable); + + const auto colorSnapshot = particle::materializeColorProjection(*colorAtFrame.projection); + const auto particleSnapshot = particle::materializeParticleProjection(*particleAtFrame.projection); + ASSERT_TRUE(colorSnapshot); + ASSERT_TRUE(particleSnapshot); + EXPECT_EQ(colorSnapshot->t0, colorSnapshot->t1); + EXPECT_EQ(particleSnapshot->t0, particleSnapshot->t1); + EXPECT_EQ(colorSnapshot->t0->scalar, (std::vector{1, 2, 3, 4})); + EXPECT_TRUE(particleSnapshot->t0->hasVector()); + + particle::EncodedFieldStore scalarOnly; + submit(scalarOnly, "weather.rain", makeFrame("rain", Base, 1, "scalar-only", false)); + const auto scalarProjection = particle::BindingProjectionResolver::resolve( + scalarOnly, color("weather.rain"), Base); + ASSERT_TRUE(scalarProjection.accepted()); + const auto scalarSnapshot = particle::materializeColorProjection(*scalarProjection.projection); + ASSERT_TRUE(scalarSnapshot); + EXPECT_TRUE(scalarSnapshot->t0->hasScalar()); + EXPECT_FALSE(scalarSnapshot->t0->hasVector()); + auto invalidParticleBinding = wind(); + invalidParticleBinding.fieldKey = "weather.rain"; + EXPECT_EQ(particle::BindingProjectionResolver::resolve(scalarOnly, invalidParticleBinding, Base).error, + particle::BindingProjectionError::PlaneMissing); + + submit(store, "weather.wind", makeFrame("wind", Base + 4 * Hour, 1, "later")); + const auto interpolated = particle::BindingProjectionResolver::resolve(store, wind(), Base + 2 * Hour); + ASSERT_TRUE(interpolated.accepted()); + EXPECT_NE(interpolated.projection->t0Frame, interpolated.projection->t1Frame); + EXPECT_FLOAT_EQ(interpolated.projection->sampleState.mix, 0.5f); +} + TEST(ParticleFieldBinding, MixChangesSampleStateButNotContentIdentityOrStorage) { particle::EncodedFieldStore store; submit(store, "weather.wave", makeFrame("wave", Base, 1, "0")); @@ -92,9 +139,9 @@ TEST(ParticleFieldBinding, MixChangesSampleStateButNotContentIdentityOrStorage) ASSERT_TRUE(at0.accepted()); ASSERT_TRUE(at1.accepted()); ASSERT_TRUE(at2.accepted()); ASSERT_TRUE(at3.accepted()); EXPECT_FLOAT_EQ(at0.projection->sampleState.mix, 0.0f); EXPECT_FLOAT_EQ(at1.projection->sampleState.mix, 0.25f); EXPECT_FLOAT_EQ(at2.projection->sampleState.mix, 0.5f); EXPECT_FLOAT_EQ(at3.projection->sampleState.mix, 0.99f); - EXPECT_EQ(at0.projection->contentIdentity, at1.projection->contentIdentity); - EXPECT_EQ(at0.projection->contentIdentity, at2.projection->contentIdentity); - EXPECT_EQ(at0.projection->contentIdentity, at3.projection->contentIdentity); + EXPECT_EQ(at0.projection->t0Frame, at0.projection->t1Frame); + EXPECT_EQ(at1.projection->contentIdentity, at2.projection->contentIdentity); + EXPECT_EQ(at1.projection->contentIdentity, at3.projection->contentIdentity); EXPECT_EQ(at0.projection->t0PlaneStorage, at3.projection->t0PlaneStorage); } diff --git a/test/particle/field_runtime.test.cpp b/test/particle/field_runtime.test.cpp index 621f96b59..65d6f5f2c 100644 --- a/test/particle/field_runtime.test.cpp +++ b/test/particle/field_runtime.test.cpp @@ -20,6 +20,10 @@ particle::DecodedFieldPlane plane(uint16_t semantic, particle::FieldValueClass v .contentIdentity = std::move(identity), .storage = std::move(storage)}; } +std::shared_ptr> fourLittleEndian16BitSamples() { + return std::make_shared>(std::vector{1, 0, 2, 0, 3, 0, 4, 0}); +} + particle::DecodedFieldFrame frame() { const auto numbers = std::make_shared>(std::vector{1, 2, 3, 4}); const auto bits = std::make_shared>(std::vector{0x0F}); @@ -126,7 +130,7 @@ TEST(ParticleFieldProfileValidator, EnforcesVelocityDirectionAndValidityCoreRule EXPECT_EQ(particle::FieldUnitNone, 0); EXPECT_EQ(particle::FieldUnitMetersPerSecond, 1); EXPECT_EQ(particle::FieldUnitDegrees, 4); - auto c = contract(); auto f = frame(); const auto bytes = f.planes.front().storage; + auto c = contract(); auto f = frame(); const auto bytes = fourLittleEndian16BitSamples(); f.planes.push_back(plane(100, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 3, particle::FieldComponent::UEast, particle::FieldDType::Int16, false, bytes)); EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidVelocityGroup); @@ -153,6 +157,7 @@ TEST(ParticleFieldProfileValidator, EnforcesVelocityDirectionAndValidityCoreRule f.planes.front().scale = 360.0f / 65536.0f; c.requiredPlanes.front().semanticID = 10; c.requiredPlanes.front().valueClass = particle::FieldValueClass::DirectionAngle; c.requiredPlanes.front().unitID = particle::FieldUnitDegrees; + f.planes.front().storage = bytes; EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted); f.planes.front().unitID = 2; c.requiredPlanes.front().unitID = 2; @@ -178,7 +183,7 @@ TEST(ParticleFieldProfileValidator, EnforcesReservedCoreSemanticContractsFromPro particle::FieldComponent::UEast, true}; f.planes.front() = plane(2, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 4, particle::FieldComponent::UEast, particle::FieldDType::Int16, true, - f.planes.front().storage, "u-spoof"); + fourLittleEndian16BitSamples(), "u-spoof"); EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::SemanticContractMismatch); c = contract(); f = frame(); @@ -195,7 +200,7 @@ TEST(ParticleFieldProfileValidator, EnforcesDirectionCircularQuantizationContrac particle::FieldComponent::None, true}; f.planes.front() = plane(10, particle::FieldValueClass::DirectionAngle, particle::FieldUnitDegrees, 0, particle::FieldComponent::None, particle::FieldDType::UInt16, true, - f.planes.front().storage, "direction"); + fourLittleEndian16BitSamples(), "direction"); f.planes.front().scale = 360.0f / 65536.0f; EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted); @@ -269,6 +274,160 @@ TEST(ParticleFieldRuntime, RevisionIdentityAndIndependentCadence) { ASSERT_TRUE(wind); ASSERT_TRUE(wave); EXPECT_FLOAT_EQ(wind->mix, 0.5f); EXPECT_FLOAT_EQ(wave->mix, 1.0f / 6.0f); } +TEST(ParticleFieldRuntime, ResolvesExactSingleFrameAndRejectsUncoveredTimes) { + constexpr uint64_t base = 1'700'000'000'000ULL; + particle::FieldSeries series; + ASSERT_EQ(series.submit(runtimeFrame(base, 1, 1, "only")), particle::FieldSeriesSubmitResult::Inserted); + EXPECT_EQ(series.decodedByteSize(), 5u); + const auto exact = series.resolve(base); + ASSERT_TRUE(exact); + EXPECT_EQ(exact->t0, exact->t1); + EXPECT_FLOAT_EQ(exact->mix, 0.0f); + EXPECT_EQ(exact->requestedForecastTimeMs, base); + EXPECT_FALSE(series.resolve(base + 1)); + + ASSERT_EQ(series.submit(runtimeFrame(base + 10'000, 1, 2, "next")), + particle::FieldSeriesSubmitResult::Inserted); + EXPECT_EQ(series.decodedByteSize(), 10u); + const auto middle = series.resolve(base + 5'000); + ASSERT_TRUE(middle); + EXPECT_NE(middle->t0, middle->t1); + EXPECT_FLOAT_EQ(middle->mix, 0.5f); + EXPECT_FALSE(series.resolve(base - 1)); + EXPECT_FALSE(series.resolve(base + 10'001)); +} + +TEST(ParticleFieldRuntime, FullWindowRollsBothDirectionsAndBreaksTiesAtEarliest) { + constexpr uint64_t base = 1'700'000'000'000ULL; + constexpr uint64_t hour = 3'600'000ULL; + const auto fill = [&](particle::FieldSeries& series) { + for (uint64_t i = 0; i < 8; ++i) + ASSERT_EQ(series.submit(runtimeFrame(base + i * hour, 1, i + 1, "frame-" + std::to_string(i))), + particle::FieldSeriesSubmitResult::Inserted); + }; + const auto times = [](const particle::FieldSeries& series) { + std::vector values; + for (const auto& entry : series.entries()) values.push_back(entry->frameContentIdentity.validTimeMs); + return values; + }; + + particle::FieldSeries future; + fill(future); + ASSERT_EQ(future.submit(runtimeFrame(base + 8 * hour, 1, 9, "future")), + particle::FieldSeriesSubmitResult::Inserted); + EXPECT_EQ(future.size(), 8u); + EXPECT_EQ(times(future).front(), base + hour); + EXPECT_EQ(times(future).back(), base + 8 * hour); + + particle::FieldSeries past; + fill(past); + ASSERT_EQ(past.submit(runtimeFrame(base - hour, 1, 9, "past")), particle::FieldSeriesSubmitResult::Inserted); + EXPECT_EQ(past.size(), 8u); + EXPECT_EQ(times(past).front(), base - hour); + EXPECT_EQ(times(past).back(), base + 6 * hour); + + particle::FieldSeries internal; + fill(internal); + ASSERT_EQ(internal.submit(runtimeFrame(base + 3 * hour + hour / 2, 1, 9, "internal")), + particle::FieldSeriesSubmitResult::Inserted); + EXPECT_EQ(internal.size(), 8u); + EXPECT_EQ(times(internal), (std::vector{base + hour, base + 2 * hour, base + 3 * hour, + base + 3 * hour + hour / 2, base + 4 * hour, + base + 5 * hour, base + 6 * hour, base + 7 * hour})); +} + +TEST(ParticleFieldRuntime, UnorderedSubmissionsAreSortedAndReferenceCyclesStaySeparate) { + constexpr uint64_t base = 1'700'000'000'000ULL; + constexpr uint64_t hour = 3'600'000ULL; + particle::EncodedFieldStore store; + for (const uint64_t index : {4ULL, 1ULL, 3ULL, 0ULL, 2ULL}) + ASSERT_EQ(store.submit("wind", runtimeFrame(base + index * hour, 1, index + 1, + "wind-" + std::to_string(index))), + particle::FieldSeriesSubmitResult::Inserted); + const auto* series = store.find("wind"); + ASSERT_TRUE(series); + ASSERT_EQ(series->size(), 5u); + for (size_t i = 0; i < series->size(); ++i) + EXPECT_EQ(series->entries()[i]->frameContentIdentity.validTimeMs, base + i * hour); + + auto differentReference = std::make_shared(*runtimeFrame( + base + 5 * hour, 1, 6, "new-reference")); + differentReference->referenceTimeMs = 2'000; + differentReference->resolvedProfileID = "wind-profile-v2"; + differentReference->resolvedProfileVersion = 2; + EXPECT_EQ(store.submit("wind", differentReference), particle::FieldSeriesSubmitResult::Inserted); + EXPECT_EQ(store.find("wind")->size(), 6u); + EXPECT_FALSE(store.resolve("wind", base + 4 * hour + hour / 2)); + const auto exactNewBatch = store.resolve("wind", base + 5 * hour); + ASSERT_TRUE(exactNewBatch); + EXPECT_EQ(exactNewBatch->t0, exactNewBatch->t1); + + auto incompatibleGrid = std::make_shared(*runtimeFrame( + base + 6 * hour, 1, 7, "wrong-grid")); + incompatibleGrid->grid.east = 132.0; // valid grid, incompatible with 131.0 bounds + EXPECT_EQ(store.submit("wind", incompatibleGrid), particle::FieldSeriesSubmitResult::RejectedIncompatibleFrame); + EXPECT_EQ(store.find("wind")->size(), 6u); + EXPECT_EQ(store.submit("wave", runtimeFrame(base, 1, 1, "wave", "wave")), + particle::FieldSeriesSubmitResult::Inserted); + EXPECT_EQ(store.find("wind")->size(), 6u); + EXPECT_EQ(store.find("wave")->size(), 1u); +} + +TEST(ParticleFieldRuntime, RejectedFullWindowInsertDoesNotEvictAndKeysRemainIndependent) { + constexpr uint64_t base = 1'700'000'000'000ULL; + constexpr uint64_t hour = 3'600'000ULL; + particle::EncodedFieldStore store; + for (uint64_t i = 0; i < 8; ++i) + ASSERT_EQ(store.submit("wind", runtimeFrame(base + i * hour, 1, i + 1, "wind-" + std::to_string(i))), + particle::FieldSeriesSubmitResult::Inserted); + const auto bytesBeforeRejectedFrame = store.decodedByteSize(); + auto incompatible = std::make_shared(*runtimeFrame( + base + 8 * hour, 1, 9, "wrong-grid")); + incompatible->grid.east = 132.0; // valid grid, incompatible with 131.0 bounds + ASSERT_EQ(store.submit("wind", incompatible), particle::FieldSeriesSubmitResult::RejectedIncompatibleFrame); + ASSERT_EQ(store.find("wind")->size(), 8u); + EXPECT_EQ(store.decodedByteSize(), bytesBeforeRejectedFrame); + EXPECT_EQ(store.find("wind")->entries().front()->frameContentIdentity.validTimeMs, base); + for (uint64_t i = 0; i < 8; ++i) + ASSERT_EQ(store.submit("wave", runtimeFrame(base + (i + 20) * hour, 1, i + 1, + "wave-" + std::to_string(i), "wave-dataset")), + particle::FieldSeriesSubmitResult::Inserted); + EXPECT_EQ(store.find("wind")->size(), 8u); + EXPECT_EQ(store.find("wave")->size(), 8u); + EXPECT_EQ(store.find("wave")->entries().front()->frameContentIdentity.validTimeMs, base + 20 * hour); + EXPECT_FALSE(store.resolve("wave", base + 1)); +} + +TEST(ParticleFieldRuntime, RepeatedBidirectionalRollingAndClearStayBounded) { + constexpr uint64_t base = 1'700'000'000'000ULL; + constexpr uint64_t hour = 3'600'000ULL; + particle::EncodedFieldStore store; + auto fill = [&] { + for (uint64_t i = 0; i < 8; ++i) + ASSERT_EQ(store.submit("wind", runtimeFrame(base + i * hour, 1, i + 1, + "seed-" + std::to_string(i))), + particle::FieldSeriesSubmitResult::Inserted); + }; + fill(); + for (uint64_t iteration = 0; iteration < 120; ++iteration) { + const uint64_t time = iteration % 2 == 0 ? base + (8 + iteration) * hour + : base - (iteration + 1) * hour; + ASSERT_EQ(store.submit("wind", runtimeFrame(time, 1, iteration + 20, + "rolling-" + std::to_string(iteration))), + particle::FieldSeriesSubmitResult::Inserted); + ASSERT_EQ(store.find("wind")->size(), 8u); + EXPECT_EQ(store.decodedByteSize(), 40u); + if (iteration % 10 == 9) { + EXPECT_TRUE(store.clear("wind")); + EXPECT_EQ(store.decodedByteSize(), 0u); + fill(); + } + } + store.clearAll(); + EXPECT_EQ(store.fieldCount(), 0u); + EXPECT_EQ(store.decodedByteSize(), 0u); +} + TEST(ParticleFieldRuntime, ForecastClockOwnsRequestedTimeAndPlayback) { particle::ForecastClock clock; EXPECT_TRUE(clock.setRequestedForecastTime(1'700'000'000'000ULL)); EXPECT_FALSE(clock.advance(1'000)); diff --git a/test/particle/qbin_v2_decoder.test.cpp b/test/particle/qbin_v2_decoder.test.cpp index 11ad2f485..5c3659e6c 100644 --- a/test/particle/qbin_v2_decoder.test.cpp +++ b/test/particle/qbin_v2_decoder.test.cpp @@ -91,3 +91,12 @@ TEST(ParticleQbinV2Decoder, PlaneIdentityIsIndependentAndCanonical) { auto ps=ba TEST(ParticleQbinV2Decoder, DecodesLargeFixtureByteForByte) { auto b=fixture(513,769);auto r=particle::ingress::QbinV2Decoder::decode(meta(),b);ASSERT_TRUE(r.accepted())<planes[0].storage->size(),uv);EXPECT_EQ(r.frame->planes[1].storage->size(),uv);EXPECT_EQ(r.frame->validityBits->size(),validity);EXPECT_EQ(r.frame->validityPlane.storage,r.frame->validityBits);EXPECT_EQ(*r.frame->planes[0].storage,std::vector(b.begin()+208,b.begin()+208+uv));EXPECT_EQ(*r.frame->planes[1].storage,std::vector(b.begin()+208+a16(uv),b.begin()+208+a16(uv)+uv));EXPECT_EQ(*r.frame->validityBits,std::vector(b.begin()+208+a16(a16(uv)+uv),b.begin()+208+a16(a16(uv)+uv)+validity)); } TEST(ParticleEncodedFieldIngress, AcceptsAndRejectsContracts) { auto good=wind();EXPECT_TRUE(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),&good,fixture()).accepted());EXPECT_EQ(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),nullptr,fixture()).code,particle::ingress::EncodedFieldIngressCode::ContractMissing);auto invalid=good;invalid.contractSchemaVersion=2;EXPECT_EQ(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),&invalid,fixture()).code,particle::ingress::EncodedFieldIngressCode::ContractRejected);auto missing=good;missing.requiredPlanes.pop_back();EXPECT_EQ(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),&missing,fixture()).code,particle::ingress::EncodedFieldIngressCode::ContractRejected); } TEST(ParticleEncodedFieldIngress, RejectsKnownCoreConflictAndAcceptsUnknownOpaqueScalar) { auto ps=basePlanes();ps[0]={1,65535,0,3,1,0,0,1,0,{1,2,3,4}};auto c=contract({{1,particle::FieldValueClass::Scalar,65535,0,particle::FieldComponent::None,true},{2,particle::FieldValueClass::VelocityComponent,1,1,particle::FieldComponent::VNorth,true},{3,particle::FieldValueClass::Validity,0,0,particle::FieldComponent::None,true}});EXPECT_EQ(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),&c,fixture(2,2,ps)).code,particle::ingress::EncodedFieldIngressCode::ContractRejected);ps={{3,0,0,6,1,3,0,1,0,{15}},{99,65535,0,3,1,0,0,1,0,{1,2,3,4}}};c=contract({{3,particle::FieldValueClass::Validity,0,0,particle::FieldComponent::None,true},{99,particle::FieldValueClass::Scalar,65535,0,particle::FieldComponent::None,true}});EXPECT_TRUE(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),&c,fixture(2,2,ps)).accepted()); } +TEST(ParticleEncodedFieldIngress, RetainsResolvedProfileIdentityForRuntimeWindowing) { + auto resolved = wind(); + resolved.profileID = "wind.test.v2"; + resolved.profileVersion = 2; + const auto ingress = particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(), &resolved, fixture()); + ASSERT_TRUE(ingress.accepted()) << ingress.detail; + EXPECT_EQ(ingress.frame->resolvedProfileID, "wind.test.v2"); + EXPECT_EQ(ingress.frame->resolvedProfileVersion, 2u); +} diff --git a/test/source/raster_array_v4_host_api.test.cpp b/test/source/raster_array_v4_host_api.test.cpp index aa48c3c59..84cfafef9 100644 --- a/test/source/raster_array_v4_host_api.test.cpp +++ b/test/source/raster_array_v4_host_api.test.cpp @@ -10,6 +10,7 @@ #include #include +#include #include using namespace mbgl; @@ -63,7 +64,8 @@ struct Plane { std::vector bytes; }; -std::vector qbin(uint64_t validTime, bool scalar = false) { +std::vector qbin(uint64_t validTime, bool scalar = false, uint8_t validity = 0x0f, + uint64_t referenceTime = Base) { std::vector planes; if (scalar) planes.push_back({99, 77, 0, 3, 0, 0, {1, 2, 3, 4}}); @@ -71,7 +73,15 @@ std::vector qbin(uint64_t validTime, bool scalar = false) { planes.push_back({1, 1, 1, 1, 1, 1, {1, 0, 2, 0, 3, 0, 4, 0}}); planes.push_back({2, 1, 1, 1, 1, 2, {5, 0, 6, 0, 7, 0, 8, 0}}); } - planes.push_back({3, 0, 0, 6, 3, 0, {15}}); + for (auto& plane : planes) { + if (plane.type == 6) continue; + for (size_t cell = 0; cell < 4; ++cell) { + if (validity & (1u << cell)) continue; + const size_t bytesPerCell = plane.type == 1 || plane.type == 2 ? 2 : plane.type == 5 ? 4 : 1; + std::fill_n(plane.bytes.begin() + cell * bytesPerCell, bytesPerCell, uint8_t{0}); + } + } + planes.push_back({3, 0, 0, 6, 3, 0, {validity}}); std::sort(planes.begin(), planes.end(), [](const Plane& a, const Plane& b) { return a.semantic < b.semantic; }); size_t payloadLength = 0; for (const auto& plane : planes) payloadLength = aligned16(payloadLength + plane.bytes.size()); @@ -84,7 +94,7 @@ std::vector qbin(uint64_t validTime, bool scalar = false) { u16(bytes, 12, planes.size()); u32(bytes, 16, 2); u32(bytes, 20, 2); - i64(bytes, 24, static_cast(validTime / 1000)); + i64(bytes, 24, static_cast(referenceTime / 1000)); i64(bytes, 32, static_cast(validTime / 1000)); f64(bytes, 40, -10); f64(bytes, 48, -10); @@ -117,6 +127,11 @@ std::vector qbin(uint64_t validTime, bool scalar = false) { return bytes; } +void recomputeQbinCrc(std::vector& bytes) { + u32(bytes, 76, 0); + u32(bytes, 76, crc(bytes)); +} + particle::ingress::EncodedFieldRuntimeMetadata metadata(const char* id, uint64_t generation, uint64_t version = 1) { return {id, generation, version}; } @@ -296,6 +311,62 @@ TEST(Source, RasterArrayV4HostApiRejectsKeepPublicProjectionStable) { expectNoPartialMutation(); } +TEST(Source, RasterArrayV4RejectsNumericOverflowBeforeReplacingAcceptedRevision) { + style::RasterArraySource source{"v4-numeric-transaction"}; + submit(source, "wind", "wind", Base, 1); + submit(source, "wind", "wind", Base + Hour, 2); + source.setParticleBinding({particle::ParticleBindingMode::VectorComponents, + "wind", + {1, 1, particle::FieldComponent::UEast}, + {2, 1, particle::FieldComponent::VNorth}, + {}, + 1}); + ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2)); + const auto baseline = source.resolveParticleProjection(); + ASSERT_TRUE(baseline.accepted()); + const auto materialized = source.impl().v4RendererState.particle; + ASSERT_TRUE(materialized); + const auto countersBefore = particle::rendererDebugCounterSnapshot(); + + auto overflowing = qbin(Base + Hour); + const size_t firstPlaneDescriptor = Header; + f32(overflowing, firstPlaneDescriptor + 4, std::numeric_limits::max()); + f32(overflowing, firstPlaneDescriptor + 8, std::numeric_limits::max()); + recomputeQbinCrc(overflowing); + EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 3, 2), windContract(), overflowing), + style::RasterArrayEncodedFieldSubmitResult::RejectedContract); + + const auto after = source.resolveParticleProjection(); + ASSERT_TRUE(after.accepted()); + expectSameParticleProjection(*baseline.projection, *after.projection); + EXPECT_EQ(after.projection->t1Frame, baseline.projection->t1Frame); + EXPECT_EQ(source.impl().v4RendererState.particle, materialized); + const auto countersAfter = particle::rendererDebugCounterSnapshot(); + EXPECT_EQ(countersAfter.v4ProjectionMaterialize, countersBefore.v4ProjectionMaterialize); + EXPECT_EQ(countersAfter.fieldTextureUpload, countersBefore.fieldTextureUpload); +} + +TEST(Source, RasterArrayV4KeepsValidityZeroCellsAsLegalNoData) { + style::RasterArraySource source{"v4-validity-nodata"}; + EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 1), windContract(), qbin(Base, false, 0x0e)), + style::RasterArrayEncodedFieldSubmitResult::AcceptedInserted); + submit(source, "wind", "wind", Base + Hour, 2); + source.setParticleBinding({particle::ParticleBindingMode::VectorComponents, + "wind", + {1, 1, particle::FieldComponent::UEast}, + {2, 1, particle::FieldComponent::VNorth}, + {}, + 1}); + ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2)); + const auto projection = source.resolveParticleProjection(); + ASSERT_TRUE(projection.accepted()); + const auto snapshots = particle::materializeParticleProjection(*projection.projection); + ASSERT_TRUE(snapshots); + ASSERT_EQ(snapshots->t0->valid.size(), 4u); + EXPECT_EQ(snapshots->t0->valid[0], 0); + EXPECT_EQ(snapshots->t0->valid[1], 1); +} + TEST(Source, RasterArrayV4HostApiResolvesIndependentBindingsFromOneForecastClock) { style::RasterArraySource source{"v4"}; submit(source, "wind", "wind", Base, 1); @@ -573,6 +644,128 @@ TEST(Source, RasterArrayV4RendererStateMaterializesBindingsAndMixWithoutTouching EXPECT_FALSE(source.impl().v4RendererState.particle); EXPECT_FALSE(source.impl().v4RendererState.color); } + +TEST(Source, RasterArrayV4SingleExactFrameMaterializesAndSecondFrameEnablesInterpolation) { + style::RasterArraySource source{"v4-single-first"}; + submit(source, "wind", "wind", Base, 1); + source.setParticleBinding({particle::ParticleBindingMode::VectorComponents, + "wind", + {1, 1, particle::FieldComponent::UEast}, + {2, 1, particle::FieldComponent::VNorth}, + {}, + 1}); + source.setColorBinding({"wind", {1, 1, particle::FieldComponent::UEast}, + particle::FieldValueClass::VelocityComponent, 1}); + ASSERT_TRUE(source.setRequestedForecastTime(Base)); + ASSERT_TRUE(source.resolveParticleProjection().accepted()); + ASSERT_TRUE(source.resolveColorProjection().accepted()); + ASSERT_TRUE(source.impl().v4RendererState.particle); + ASSERT_TRUE(source.impl().v4RendererState.color); + EXPECT_EQ(source.impl().v4RendererState.particle->t0, source.impl().v4RendererState.particle->t1); + EXPECT_EQ(source.impl().v4RendererState.color->t0, source.impl().v4RendererState.color->t1); + EXPECT_FLOAT_EQ(source.impl().v4RendererState.particle->sampleState.mix, 0.0f); + EXPECT_EQ(source.impl().v4RendererState.particle->t0->validTime, Base); + EXPECT_TRUE(source.impl().v4RendererState.particle->t0->hasVector()); + + submit(source, "wind", "wind", Base + Hour, 2); + ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2)); + const auto pair = source.resolveParticleProjection(); + ASSERT_TRUE(pair.accepted()); + EXPECT_NE(pair.projection->t0Frame, pair.projection->t1Frame); + EXPECT_FLOAT_EQ(pair.projection->sampleState.mix, 0.5f); + EXPECT_EQ(source.impl().v4RendererState.particle->t0->validTime, Base); + EXPECT_EQ(source.impl().v4RendererState.particle->t1->validTime, Base + Hour); +} + +TEST(Source, RasterArrayV4RollingWindowPreservesAnUnchangedActivePair) { + style::RasterArraySource source{"v4-rolling-window"}; + for (uint64_t index = 0; index < 8; ++index) + submit(source, "wind", "wind", Base + index * Hour, index + 1); + source.setParticleBinding({particle::ParticleBindingMode::VectorComponents, + "wind", + {1, 1, particle::FieldComponent::UEast}, + {2, 1, particle::FieldComponent::VNorth}, + {}, + 1}); + ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour + Hour / 2)); + ASSERT_TRUE(source.resolveParticleProjection().accepted()); + // Source-level test: verify public projection behavior; Core tests own the + // internal FieldSeries size/order assertions. + const auto retainedProjection = source.impl().v4RendererState.particle; + ASSERT_TRUE(retainedProjection); + + submit(source, "wind", "wind", Base + 8 * Hour, 9); + EXPECT_EQ(source.impl().v4RendererState.particle->sampleState.requestedForecastTimeMs, + Base + Hour + Hour / 2); + EXPECT_EQ(source.impl().v4RendererState.particle, retainedProjection); + + submit(source, "wind", "wind", Base - Hour, 10); + EXPECT_EQ(source.impl().v4RendererState.particle, retainedProjection); + ASSERT_TRUE(source.setRequestedForecastTime(Base - Hour)); + const auto pastProjection = source.resolveParticleProjection(); + ASSERT_TRUE(pastProjection.accepted()); + ASSERT_TRUE(pastProjection.projection); + EXPECT_EQ(pastProjection.projection->t0Frame->frameContentIdentity.validTimeMs, Base - Hour); + EXPECT_EQ(pastProjection.projection->t1Frame->frameContentIdentity.validTimeMs, Base - Hour); + ASSERT_TRUE(source.setRequestedForecastTime(Base + 8 * Hour)); + EXPECT_FALSE(source.resolveParticleProjection().accepted()); +} + +TEST(Source, RasterArrayV4EvictedActiveFrameClearsItsMaterializedProjection) { + style::RasterArraySource source{"v4-evicted-active-frame"}; + for (uint64_t index = 0; index < 8; ++index) + submit(source, "wind", "wind", Base + index * Hour, index + 1); + source.setParticleBinding({particle::ParticleBindingMode::VectorComponents, + "wind", + {1, 1, particle::FieldComponent::UEast}, + {2, 1, particle::FieldComponent::VNorth}, + {}, + 1}); + ASSERT_TRUE(source.setRequestedForecastTime(Base)); + ASSERT_TRUE(source.impl().v4RendererState.particle); + EXPECT_EQ(source.impl().v4RendererState.particle->t0->validTime, Base); + EXPECT_EQ(source.impl().v4RendererState.particle->t0, source.impl().v4RendererState.particle->t1); + + submit(source, "wind", "wind", Base + 8 * Hour, 9); + EXPECT_FALSE(source.resolveParticleProjection().accepted()); + EXPECT_FALSE(source.impl().v4RendererState.particle); +} + +TEST(Source, RasterArrayV4StoresNewReferenceAndProfileAsSingleFrameWithoutCrossBatchMix) { + style::RasterArraySource source{"v4-batch-boundary"}; + ASSERT_EQ(source.submitEncodedField("wind", metadata("wind", 1), windContract(), qbin(Base)), + style::RasterArrayEncodedFieldSubmitResult::AcceptedInserted); + auto nextProfile = windContract(); + nextProfile.profileID = "test.wind.v2"; + ASSERT_EQ(source.submitEncodedField("wind", metadata("wind", 2), nextProfile, + qbin(Base + Hour, false, 0x0f, Base + Hour)), + style::RasterArrayEncodedFieldSubmitResult::AcceptedInserted); + source.setParticleBinding({particle::ParticleBindingMode::VectorComponents, + "wind", + {1, 1, particle::FieldComponent::UEast}, + {2, 1, particle::FieldComponent::VNorth}, + {}, + 1}); + ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2)); + EXPECT_EQ(source.resolveParticleProjection().error, particle::BindingProjectionError::WindowUnavailable); + ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour)); + const auto latestExact = source.resolveParticleProjection(); + ASSERT_TRUE(latestExact.accepted()); + EXPECT_EQ(latestExact.projection->t0Frame, latestExact.projection->t1Frame); + EXPECT_EQ(latestExact.projection->t0Frame->resolvedProfileID, "test.wind.v2"); + + ASSERT_EQ(source.submitEncodedField("wind", metadata("wind", 3), nextProfile, + qbin(Base + 2 * Hour, false, 0x0f, Base + Hour)), + style::RasterArrayEncodedFieldSubmitResult::AcceptedInserted); + ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour + Hour / 2)); + const auto nextPair = source.resolveParticleProjection(); + ASSERT_TRUE(nextPair.accepted()); + EXPECT_FLOAT_EQ(nextPair.projection->sampleState.mix, 0.5f); + ASSERT_TRUE(source.setRequestedForecastTime(Base)); + const auto returnedToEarlierBatch = source.resolveParticleProjection(); + ASSERT_TRUE(returnedToEarlierBatch.accepted()); + EXPECT_EQ(returnedToEarlierBatch.projection->t0Frame, returnedToEarlierBatch.projection->t1Frame); +} TEST(Source, RasterArrayV4VelocityColorReconstructsDecodedUVIndependentlyOfParticles) { style::RasterArraySource source{"v4-vector-color"}; submit(source, "wind", "wind", Base, 1); diff --git a/粒子开发记录.md b/粒子开发记录.md index 99e3534e4..38ddec360 100644 --- a/粒子开发记录.md +++ b/粒子开发记录.md @@ -990,3 +990,21 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2` ### 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 STEP-097 B 阶段(独立核查中,未关闭) +- 08/09/10/20 的 B 源码及测试入口已形成;Codex 使用 GPT-6 Luna High 的两次 `exec --json` 均取得 `turn.completed`,保存 JSONL/status.json。 +- RDC Core 专项 4/4 PASS;旧 16-bit fixture 修正后 OpenGL broad 134/134 PASS。 +- B10 新 Map 销毁 hook 不在正式 .13 Android AAR,Test Host 默认 .13 无法直接编译此 Kotlin 调用;B 记 PARTIAL/BLOCKED,不提交、不进入 C,见 B 独立验收报告。 + +- 2026-09-26 STEP-097 B:RDC 恢复 B10 / fixture 两个 `codex exec --json` 完成状态;Mac Core 6/6、139/139,Android Native JUnit 2/2、Java 编译、隔离 owner JUnit 4/4 通过。默认 .13 AAR 不含新销毁 hook,B 仍 PARTIAL/BLOCKED,C 和 STEP-098 未启动;证据见 B 专项报告。 + +- STEP-097 B 已将 21 个源码、测试和阻塞报告保存至独立远端分支 `step097-b-blocked-20260926`,首提交 `e463ba04ce956a54eed02b77f981eeeedd9a5534`。主线未推送不兼容桥接;B 仍 BLOCKED,Linux 隔离构建结果另记。 + +### 2026-09-26 STEP-098 Codex 源码交付 + +- 用户批准按 Integration `1dd576d3304e267e53ed64d31f9711d2350ce10a` 的 RFC-004、ADR-001、C-002、COORD-005 实施;该新接受契约解除旧 RFC-003 历史阻塞。原任务书与旧阻塞报告均未修改。 +- Core 增加精确单帧窗口、同 key 不同批次共存但严格禁止跨 fieldID/reference/profile 插值、先候选验证再原子滚动淘汰、等距离淘汰原最早端;解码 profile 身份与 decoded raw-plane/validity byte count 可观测。单帧 materialized snapshot 和首次 GPU endpoint 安装复用。 +- 新增/扩展 Core、Binding、Ingress、RasterArraySource 定向测试代码,覆盖 T01–T17 的源代码场景;所有新增/扩展测试均未运行。仅执行 `git diff --check`;没有 CMake、Ninja、Gradle、Pod、npm、Jest、ADB、设备、发布、commit 或 push。 +- 完成报告 `docs/报告/STEP-098_QBIN_V4单帧与八帧滚动缓存_Codex完成报告_v1.md`;源码交付等待 RDC 按 B→C 构建测试和后续 Android/iOS 配套实测。当前不进入 V15 pipeline。 + +- 2026-09-27 STEP-098 RDC:Core B 19/19、OpenGL Host 119/119、Metal Host 119/119 定向 GTest PASS;修复 Source 私有 Store 测试断言和两处无效 Grid fixture。Android/RN/iOS 制品、真机 GPU/内存与 V15 Pipeline 尚未验证;报告见 docs/报告/STEP-098_RDC定向验收报告_v1.md。