diff --git a/CMakeLists.txt b/CMakeLists.txt index b60e5f0bf..3c9b6ce04 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -219,6 +219,7 @@ list(APPEND SRC_FILES ${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_profile_validator.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_profile_contract_json.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/particle/ingress/qbin_v2_decoder.cpp + ${PROJECT_SOURCE_DIR}/src/mbgl/particle/ingress/qbin_v3_decoder.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/local_particle_field_feed.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/shaders/shader_program_base.cpp ) @@ -230,7 +231,9 @@ list(APPEND INCLUDE_FILES ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_profile_contract_json.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_runtime.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_binding.hpp + ${PROJECT_SOURCE_DIR}/include/mbgl/particle/renderer_debug_counters.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/particle/ingress/qbin_v2_decoder.hpp + ${PROJECT_SOURCE_DIR}/include/mbgl/particle/ingress/qbin_v3_decoder.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/particle/ingress/encoded_field_transport.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/local_particle_field_feed.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor_ref.hpp diff --git a/bazel/core.bzl b/bazel/core.bzl index a60e24f5e..de9da8e24 100644 --- a/bazel/core.bzl +++ b/bazel/core.bzl @@ -214,6 +214,7 @@ MLN_CORE_SOURCE = [ "src/mbgl/particle/field/field_profile_validator.cpp", "src/mbgl/particle/field/field_profile_contract_json.cpp", "src/mbgl/particle/ingress/qbin_v2_decoder.cpp", + "src/mbgl/particle/ingress/qbin_v3_decoder.cpp", "src/mbgl/particle/field/local_particle_field_feed.cpp", "src/mbgl/renderer/backend_scope.cpp", "src/mbgl/renderer/bucket.hpp", @@ -659,7 +660,9 @@ MLN_CORE_HEADERS = [ "include/mbgl/particle/field/field_projection_materialization.hpp", "include/mbgl/particle/field/field_runtime.hpp", "include/mbgl/particle/field/field_binding.hpp", + "include/mbgl/particle/renderer_debug_counters.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", "include/mbgl/particle/field/local_particle_field_feed.hpp", "include/mbgl/gfx/command_encoder.hpp", diff --git a/docs/30_设计/MapLibreParticle_QBIN_V3_Decoder与BlockGeometry实施决策_v1.md b/docs/30_设计/MapLibreParticle_QBIN_V3_Decoder与BlockGeometry实施决策_v1.md new file mode 100644 index 000000000..a0beb7198 --- /dev/null +++ b/docs/30_设计/MapLibreParticle_QBIN_V3_Decoder与BlockGeometry实施决策_v1.md @@ -0,0 +1,68 @@ +# MapLibreParticle QBIN V3 Decoder 与 Block Geometry 实施决策 v1 + +## 1. 结论 + +STEP-086 将 `QBV3` / version 3 作为新的压缩天气场容器格式接入 MapLibre Native。生产 Decoder 固定使用 **16×16 Block Geometry**,不在运行时尝试 16×16 / 32×32 双解码,也不通过启发式猜测块尺寸。 + +既有 `QBV2` 解码链继续保留。`EncodedFieldIngress` 依据 magic 显式分流:`QBV2` → `QbinV2Decoder`,`QBV3` → `QbinV3Decoder`;version 由对应 Decoder 严格校验。解码完成后继续统一进入 `FieldProfileValidator`,不改变既有 profile contract 分层。 + +## 2. Block Geometry 决策依据 + +使用仓库既有 Current / Rain / Wave / Wind 四类真实 Golden 数据,共 T0/T1 8 帧,按 v2.2 冻结稿规则实现确定性的 C++ benchmark encoder/decoder,对 16×16 与 32×32 做相同输入对比。 + +| 指标 | 16×16 | 32×32 | 结论 | +|---|---:|---:|---| +| 数值 payload 总字节 | 2,470,419 | 2,514,288 | 32×32 增加 43,869 字节,约 +1.7758% | +| 总 block 数 | 22,704 | 5,984 | 32×32 block 更少 | +| 非空 block 数 | 19,120 | 5,190 | 32×32 block 更少 | +| C++ encode 总时间 | 218.713 ms | 200.101 ms | 32×32 约快 8.5% | +| C++ decode 总时间 | 104.121 ms | 101.451 ms | 32×32 约快 2.6% | + +8 帧合计 decode 差异约 2.67 ms,平均每帧约 0.33 ms;相比之下,32×32 对 payload 尺寸有稳定负收益,Rain 两帧的增幅尤其明显。QBIN V3 的核心目标之一是降低网络、缓存和跨 RN/Native 传输成本,因此选择 **16×16**。 + +此决定同时冻结: + +- `QbinV3Decoder::BlockWidth = 16`; +- `QbinV3Decoder::BlockHeight = 16`; +- Golden Fixture manifest 中 blockWidth/blockHeight 均为 16; +- 后续生产 Writer 必须输出相同 ABI,不得自行改为 32×32。 + +## 3. Decoder 责任边界 + +`QbinV3Decoder` 只负责把已取得的 QBV3 二进制解码成现有 `DecodedFieldFrame`。它不负责 Weather provider、HTTP、下载、重试、缓存、manifest 获取、路线调度或 React 业务逻辑。 + +实现包含: + +- 固定 Header / Descriptor 布局与 little-endian 读取; +- file size、plane count、cell count、decoded bytes 等硬上限; +- CRC32 校验; +- canonical descriptor 排序、唯一键、16-byte packing 与 padding 校验; +- VALIDITY 唯一性、长度、unused bits 校验; +- Numeric dtype 约束与 all-invalid 零长度编码; +- 16×16 block 的 Predictor + ZigZag + Rice 解码; +- Direction 的 canonical scale、环形预测与 half-circle tie 规则; +- 非法 Rice-K、截断、额外字节、非零 bit padding、残差越界等 fail-closed; +- 解码后构造既有 `DecodedFieldFrame`,继续使用既有 FieldProfileValidator。 + +## 4. Golden Fixtures + +正式 QBIN V3 Golden Fixture 位于: + +`integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/` + +覆盖: + +- current:T0 / T1,513×513; +- rain:T0 / T1,513×513; +- wave:T0 / T1,513×769; +- wind:T0 / T1,513×769。 + +`manifest.json` 固定每个文件的 bytes、SHA-256、width、height 以及 16×16 Block Geometry。校验脚本为: + +`integration/react-native/MapLibreParticleRNTestHost/scripts/verify-qbin-v3-golden-fixtures.py` + +8 个 V3 fixture 已与对应 V2 canonical raw 解码结果做逐 plane / validity byte-for-byte 对比,结果一致。 + +## 5. 兼容性与版本 + +本次不移除 QBV2,不改变 RN `submitEncodedField` 公共调用形态,不把 V3 压缩细节暴露给 JS/TS。Native / RN 发行版本统一升级为 `13.4.1-particle.9`。 diff --git a/docs/30_设计/WeatherV2_QBIN_V3通用天气场二进制格式与React独立Render_Binding设计_v2.2_最终冻结稿.md b/docs/30_设计/WeatherV2_QBIN_V3通用天气场二进制格式与React独立Render_Binding设计_v2.2_最终冻结稿.md new file mode 100644 index 000000000..f2b067091 --- /dev/null +++ b/docs/30_设计/WeatherV2_QBIN_V3通用天气场二进制格式与React独立Render_Binding设计_v2.2_最终冻结稿.md @@ -0,0 +1,4444 @@ +# WeatherV2 QBIN V3 通用天气场二进制格式与 React 独立 Render Binding 设计 + +文档修订:v2.2 +QBIN Container Version:3 +Magic:`QBV3` +日期:2026-09-20 +状态:设计冻结候选 / 可进入 Writer + C++ Decoder Prototype;正式发布前仍需冻结 Block Geometry、完成 Round-trip 与 Native 性能验证 +适用范围:WeatherV2 → NavSea → MapLibreParticle +上一代权威基线:`WeatherV2_QBIN_V2通用天气场二进制格式与React独立Render_Binding设计_v4.4.md` +实施前补充依据:`WeatherV2 QBIN V3 实施前补充要求(给 Weather 生产方) v1.0` + + + + + +v2.2 相对 v2.1 的最终文档清理: + +```text +1. 修正验收表对 non-finite physical sample 的表述: + validity=1 时 physical MUST finite; + validity=0 时 numeric physical value 不参与 Quantizer, + 可为 NaN / Inf / missing。 + +2. 将 Corruption / Fail-closed 验收拆分为: + Binary Reject Cases + Binary Accept Cases + Writer Validation Cases + 避免把 MUST accept / Writer-only 行为误写成统一 fail-closed。 + +3. 不再新增任何 QBV3 Binary ABI 设计项。 + 除最终 BLOCK_WIDTH / BLOCK_HEIGHT 仍需 benchmark 后冻结外, + v2.2 作为进入 Writer + Decoder Prototype 的最终设计冻结稿。 +``` + +v2.1 相对 v2.0 的最终收口重点: + +```text +1. Rice canonicality 正式冻结为 Writer-canonical / Decoder-permissive: + Writer MUST 计算并写入最优 k*; + Decoder 只验证 riceK∈[0,31] 与 bitstream / symbolCount / padding / + residual / raw range 等合法性,不为 canonicality 重算 32 个候选 k。 + 非最优但可合法解码的 riceK 不属于 QBV3 binary corruption。 + +2. WEB_MERCATOR + NODE 的数学定义补全: + 直接冻结 dimensionless spherical Web Mercator + X=λ,Y=ln(tan(π/4+φ/2)), + 并给出 inverse 公式,消除“Mercator”实现歧义。 + +3. VALIDITY semantic 增加双向约束: + semantic==3 ⇔ valueClass==VALIDITY ⇔ dtype==BITSET; + semantic=3 不得被 Numeric Plane 复用。 + +4. Quantizer 增加 intermediate finite 要求: + delta64 / normalized64 / normalizedDirection64 MUST finite; + 否则 Writer FAIL。 + +5. fieldType=0 明确冻结为 UNKNOWN / UNSPECIFIED opaque hint, + binary-valid,不得单独导致 reject。 + +6. 清理正文中遗留的 v1.9 / v2.0 临时字样,并连续整理后半部章节编号。 + +7. v2.1 之后,除最终 BLOCK_WIDTH / BLOCK_HEIGHT 仍需通过 + 16×16 / 32×32 C++ benchmark 实测选择外, + 不再保留其他核心 QBV3 Binary ABI 未决语义。 +``` + +v2.0 相对 v1.9 的最终收口重点: + +```text +1. Invalid-cell Writer 语义冻结: + validity=1 的 physical sample MUST finite; + validity=0 的 physical payload value 被忽略,可为 NaN/Inf/missing, + 不参与 Quantizer、不产生 Rice symbol,decoded canonical raw 固定为 0。 + Writer 不得通过 NaN/Inf 自动推导 validity=0。 + +2. ZigZag / inverse ZigZag 冻结为精确数学公式, + 不再依赖“标准 ZigZag”约定或实现自行解释。 + +3. Quantizer binary64 计算语义收口: + Descriptor binary32 scale/offset 先提升为 binary64; + subtraction / division 使用 IEEE-754 binary64 round-to-nearest, + ties-to-even 语义后,再执行 round_half_away_from_zero。 + Direction Writer 要求 canonical input 已归一化到 [0,360), + QBV3 Writer 不承担天气输入清洗。 + +4. unknown fieldType 的 Binary 行为冻结: + fieldType 是 opaque uint16 hint; + 未知值 MUST NOT solely cause binary rejection, + 保证所有 conforming Decoder 对同一文件得到一致 Accept/Reject。 + +5. Numeric offset canonical zero 规则全局化: + 任意 Numeric Plane 若 offset 数值等于 0, + MUST 使用 binary32 +0.0;-0.0 MUST reject。 + +6. Validity BITSET 增加精确索引公式: + byteIndex=index>>3, bitIndex=index&7, + valid=(byte>>bitIndex)&1。 + +7. 修正文档遗留: + §17 的 v1.8 字样统一为 v2.0; + v2.0 之后,除 BLOCK_WIDTH/BLOCK_HEIGHT benchmark freeze 外, + 不再保留其他核心 Binary ABI 未决语义。 +``` + +v1.9 相对 v1.8 的收口重点: + +```text +1. round_half_away_from_zero 冻结为明确数学公式; + Quantizer 使用 Descriptor 已确定的 binary32 scale/offset 提升到 binary64 计算, + non-finite physical sample Writer MUST reject。 +2. 补齐 Descriptor byte-level structural invariants 与必要机器值: + valueClass / component / core unit / VALIDITY semantic 的 Container 约束集中定义。 +3. referenceTimeUtc / validTimeUtc 只要求各自为正 Unix UTC seconds; + QBV3 Container 不规定 validTimeUtc 与 referenceTimeUtc 的相对大小。 +4. CRC32 参数完全展开: + CRC-32/ISO-HDLC,poly=0x04C11DB7、reflected poly=0xEDB88320、 + init=0xFFFFFFFF、refin/refout=true、xorout=0xFFFFFFFF。 +5. Block partition 数学公式冻结; + 最终只需在 16×16 / 32×32 benchmark 后回填 BLOCK_WIDTH / BLOCK_HEIGHT。 +6. 当前 Parser conformance limits 继续作为 QBV3 Version 3 normative acceptance limits; + 现阶段容量确认够用。未来如需扩大,不在已发布 Version 3 上静默放宽, + 应通过后续 Container Version / successor spec 明确升级。 +7. 将 ContentIdentity、QBV2 legacy validator、Manifest、Distribution、 + Runtime/Binding 等非 Binary ABI 内容进一步移出 normative 主体, + 只保留为 Non-normative Appendix。 +8. v1.9 之后,Block Geometry 是正式发布前唯一尚未通过实测冻结的核心 Binary ABI 参数。 +``` + +v1.8 相对 v1.7 的收口重点: + +```text +1. 明确 QBV3 binary-valid 与外部 Profile-valid 是两层: + QbinV3Decoder 只负责 Container / byte-level validation; + Profile Contract validation 属于 Encoded Field Ingress, + 不属于 QBV3 Binary ABI,也不得被称为 binary corruption。 +2. 在 normative 主体补全 DType byte-level enum: + 1=INT16, 2=UINT16, 3=UINT8, 4=INT8, 5=FLOAT32, 6=BITSET; + QBV3 Numeric 仅允许 1/2/3/4,Validity 固定 6,FLOAT32 在 V3 第一版 reject。 +3. 补全 fieldType byte-level machine hint 语义与已知值; + fieldType 不得决定 Plane layout / Profile completeness / Rice 解释。 +4. 补全 WEB_MERCATOR + NODE Grid 的精确空间含义: + Header bounds 为 WGS84 lon/lat degrees, + Grid sample 在 Web Mercator projected space 等间距; + row0=north,col0=west,index=row×width+col。 +5. 冻结 QBV3 Parser conformance limits: + MAX_PLANE_COUNT=32、 + MAX_CELL_COUNT=2,097,152、 + MAX_QBIN_BYTES=64 MiB、 + MAX_DECODED_BYTES_PER_FRAME=32 MiB。 +6. Header float64 Bounds 增加 canonical zero: + 数值为 0 时 MUST serialize 为 IEEE-754 binary64 +0.0, + 禁止 -0.0,保证 byte-for-byte deterministic Writer。 +7. Block Geometry 仍是唯一尚未冻结的核心 Binary ABI 参数; + v1.8 不擅自选择 16×16/32×32,正式 production QBV3/version=3 + 仍必须等 C++ benchmark 后回填唯一尺寸。 +``` + +v1.7 相对 v1.6 的收口重点: + +```text +1. 文档规范范围重新收口到 QBIN V3 Binary Container 本身; + NavSea / React / Store / Binding / Renderer / 平台 Bridge / Contract JSON + 等消费与集成内容移出 normative 主体,仅保留为 Non-normative Integration Notes。 +2. Quantization 冻结为“先序列化 float32,再用该 float32 值量化”: + Descriptor 中 scale / offset 的 IEEE-754 binary32 bit-pattern + 是 Quantizer 的唯一参数来源,禁止使用序列化前更高精度配置值直接量化。 +3. Shared Validity 明确采用两阶段逻辑解析: + 先解析全部 Descriptor 并定位唯一 Validity Plane, + 再验证/读取 Validity,最后解 Numeric Rice Plane; + 不要求 Validity 在 payload 中物理排在 Numeric Plane 前面。 +4. Numeric Plane byteLength 精确定义为所有 non-empty block 的 + `1-byte riceK + ceil(RiceSymbolBits/8)` 之和, + 不包含 Plane 间 align16 padding、Descriptor 或其他 Plane。 +5. Rice k 的 deterministic 选择公式正式冻结: + `bits(k)=Σ((z_i>>k)+1+k)`,k∈[0,31]; + 只按 Rice symbol bit count 比较,tie 选较小 k, + 不把 1-byte riceK 与 byte-boundary padding 纳入优化目标。 +6. QBV3 Header 的 FRAME 固定值直接写入本规范: + layout=1、projection=1、sampleOrder=1、gridRegistration=1、 + tileZ=0xffff、tileX=0、tileY=0; + referenceTimeUtc / validTimeUtc 明确为正 int64 Unix UTC seconds。 +7. Descriptor canonical float 增加 +0.0 规则: + 语义要求 offset=0 时 MUST 写 binary32 +0.0 (0x00000000), + Validity 固定 scale=+1.0、offset=+0.0,禁止 -0.0。 +8. 第一阶段验收表同步收口为 Binary ABI / Writer / Parser / + Round-trip / Codec Benchmark,不再把消费侧行为作为 QBV3 冻结条件。 +``` + +v1.6 相对 v1.5 的收口重点: + +```text +1. Field Profile Contract Schema 升级方向明确: + 为 required Plane 增加 expectedDType / expectedScaleBits / expectedOffsetBits, + 使 wind/2、wave/2、rain/2、current/2 的量化参数由 Profile Contract + data-driven 校验,而不是只依赖 Container 或 Generic Unit 验证。 +2. MAX_DECODED_BYTES_PER_FRAME 口径补齐 Validity: + decodedFrameBytes = decodedNumericBytes + decodedValidityBytes, + validityPlane.storage / validityBits 同一 storage 只计一次。 +3. Direction N 固定为 floor(ratioToN + 0.5),不依赖 host FPU rounding mode。 +4. QBV2 frozen behavior 定义为 authoritative ingress acceptance 不变。 +5. MAX_RETAINED_DECODED_BYTES 仅约束 EncodedFieldStore 当前 retained Frames 可达的 canonical decoded storage。 +6. Validity ContentIdentity 冻结为与 Numeric Plane 相同的 hash algorithm / input order,并调用同一 identity helper。 +``` + +v1.5 相对 v1.4 的收口重点: + +```text +1. QBV2 Direction legacy acceptance 精确对齐当前 C++: + 保留 canonical=360/65536 与 4×float epsilon tolerance; + QBV3 继续使用 canonical float32(360/N) bit-pattern,二者不互相放宽或收紧。 +2. Plane ContentIdentity 进一步冻结当前 QBV2 的实际 hash 常数、 + uint64 wrap 语义、逐 byte XOR→multiply 顺序与最终 hex 输出格式; + 禁止“顺手修正”为标准 FNV-1a offset basis。 +3. EncodedFieldIngress::decodeAndValidate() 升级为正式运行时唯一 authoritative + decode + contract validation 入口;RasterArraySource::submitEncodedField() + 不得继续直接调用 QbinV2Decoder。 +4. MAX_RETAINED_DECODED_BYTES 明确为 V3 实施阶段新增 Store safety policy, + 不是当前 v4.4 已存在行为;按唯一 retained storage 计数, + shared_ptr alias / validityPlane.storage 与 validityBits 不重复计费。 +5. 补齐 v1.3 revision history,并把当前版本引用统一更新为 v1.5。 +``` + +v1.4 相对 v1.3 的收口重点: + +```text +1. Direction validation 分为 Container-specific + Generic 两层: + QBV2 保留 legacy 360/65536° frozen acceptance; + QBV3 使用 Generic Circular Contract,不反向放宽 V2。 +2. Plane ContentIdentity 冻结为与当前 QBV2 完全相同的 hash algorithm、 + input byte order 与 field order。 +3. MAX_DECODED_BYTES_PER_FRAME 由 Decoder 在 allocation 前检查; + MAX_RETAINED_DECODED_BYTES 由 EncodedFieldStore / owning Store layer + 在接受 Frame 前检查。 +4. all-invalid 下 zero-length Numeric Plane 的 canonical packing 明确允许 + 相邻 zero-length Plane / 下一 non-empty Plane 共享 byteOffset。 +5. Direction canonical scale 的 float64→float32 转换固定为 + IEEE-754 round-to-nearest, ties-to-even; + 计算 N 前先验证 ratio finite 且处于允许范围。 +``` + +v1.3 相对 v1.2 的收口重点: + +```text +1. 明确 FieldSeries::submit() 遇到不兼容 Frame 时返回 + RejectedIncompatibleFrame;V2→V3 / schema 切换由上层 clear + rebuild, + 不假设 Store 自动 replacement/reset。 +2. Direction decode 除 UINT16 dtype range 外,额外要求 + 0 <= rawDirection < N;超出 Circular Contract 必须 fail-closed。 +3. 修正 Rice bitstream 章节引用,并统一文档状态/版本号为 v1.3。 +``` + +v1.2 相对 v1.1 的收口重点: + +```text +1. Direction scale 改为 canonical float32(360/N) bit-pattern 校验;固定 half-circle tie=-N/2,并给出整数 circular 参考公式。 +2. Plane / Validity ContentIdentity 精确对齐当前 QBV2 C++ hash input;Grid identity 保持独立。 +3. 不新增 FieldSeriesCompatibilityKey;继续以 DecodedFieldFrame::isInterpolationCompatibleWith() 的 canonical schema + grid 为权威。 +4. 固定 Parser 顺序:结构+Budget → CRC → Validity → Rice decode。 +5. 增加 dtype/circular logical residual 上界与 unary MAX_Q hardening。 +6. Block Geometry 未冻结的实验文件禁止进入 production Manifest/CDN qbin_v3;V2/V3 benchmark 使用同一 CRC 实现。 +``` + +--- + +# 0. 与 QBIN V2 / v4.4 的关系 + +本文是 **QBIN V3 Container 的第一版设计文档修订版**。 + +上一代权威设计基线为: + +```text +WeatherV2_QBIN_V2通用天气场二进制格式与React独立Render_Binding设计_v4.4.md +``` + +其中: + +```text +文档设计版本:v4.4 +QBIN Container Version:2 +Magic:QBV2 +``` + +本文中的 **QBIN V3** 是上述 QBIN V2 / v4.4 基线的直接后继版本。 + +## 0.1 继承关系 + +QBIN V3 不重新设计 WeatherV2 → NavSea → MapLibreParticle 的整体架构。 + +除本文明确修改的 Container / Encoded Plane 编码规则外,QBIN V2 v4.4 已冻结的下列架构原则继续有效: + +```text +WeatherV2 +负责 Provider 获取、物理语义归一化、Canonical Unit、 +Quantization、QBIN Writer、Manifest 和协议数据。 + +NavSea / React +负责 Manifest / Registry / Field Profile Contract Resolve、 +fieldKey、下载、缓存、预取、requestedForecastTime、 +Color Binding 和 Particle Binding。 + +MapLibreParticle Encoded Field Ingress +负责 QBIN Parser / Decoder、CRC、Generic Field Profile Validator, +并输出格式无关的 DecodedFieldFrame / Plane Registry。 + +Runtime / Renderer +继续只处理通用 Scalar / Velocity / Direction / Validity, +不认识 Weather Provider、Manifest 或具体天气业务类型。 +``` + +以下 v4.4 原则继续冻结: + +```text +Semantic Registry +Unit Registry +Field Profile Contract +ResolvedFieldProfileContract + +Canonical Unit +Value Class +Component / groupId + +REQUIRED 完整性规则 +Unknown Profile / Unknown Scalar Semantic 通用处理 + +Color Binding 与 Particle Binding 独立 +Color Projection 与 Particle Projection 独立 + +Core-owned Forecast Clock / Playback +每个 fieldKey 独立 Runtime Field Window +Native Core 自行解析 T0 / T1 / mix + +FrameContentIdentity +generation / dataVersion 修订识别 + +Renderer 不增加: +if WIND +if WAVE +if RAIN +等天气业务分支 +``` + +因此: + +```text +QBIN V2 Decoder + ↓ +DecodedFieldFrame + ↓ +Runtime / Binding / Renderer +``` + +升级为: + +```text +QBIN V3 Decoder + ↓ +DecodedFieldFrame + ↓ +Runtime / Binding / Renderer +``` + +**Decoder 之后的公共架构原则保持不变。** + +## 0.2 V3 为什么需要新的 Container Version + +QBIN V2 将 Numeric Plane 定义为固定宽度的直接量化整数数组,例如: + +```text +INT16 → byteLength = count × 2 +UINT16 → byteLength = count × 2 +``` + +Numeric Plane 的 `byteLength` 可以直接由: + +```text +count × dtype size +``` + +推导。 + +QBIN V3 引入新的 Numeric Plane 编码: + +```text +Physical Value +↓ +Quantization +↓ +Integer Grid +↓ +2D Spatial Predictor +↓ +Signed Residual +↓ +ZigZag +↓ +固定 Block Geometry 的 adaptive Rice Coding +↓ +Variable-length Encoded Plane +``` + +因此 Numeric Plane 的编码长度不再等于: + +```text +count × dtype size +``` + +而成为真正的: + +```text +variable-length encoded payload +``` + +这改变了 QBIN V2 的二进制 Plane Contract,因此不能继续标记为 Container Version 2。 + +正式升级为: + +```text +QBIN Container Version:3 +Magic:QBV3 +Version:3 +``` + +## 0.3 V3 不代表业务协议重做 + +Container Version 升级只表示: + +```text +Encoded Binary Representation +``` + +发生不兼容变化。 + +它不意味着以下协议重新开始: + +```text +Semantic ID +Unit ID +Value Class +Component +groupId +Field Profile +Render Binding +Forecast Runtime +Renderer +``` + +这些仍继承 v4.4 的长期通用设计。 + +因此: + +```text +Container Version +``` + +和: + +```text +Field Profile Version +``` + +必须继续独立管理。 + +第一阶段建议: + +```text +QBIN V3 + +wind / 2 +wave / 2 +rain / 2 +current / 2 +``` + +表示: + +```text +Container 使用 QBIN V3 ++ +各天气 Field 使用自己的 Profile Version +``` + +而不是把天气业务规则硬编码进 Container Version。 + +## 0.4 v4.4 文档的历史地位 + +`WeatherV2_QBIN_V2通用天气场二进制格式与React独立Render_Binding设计_v4.4.md` + +从 QBIN V3 生效后仍永久保留,作为: + +```text +QBIN V2 最终冻结规范 +QBIN V3 架构来源基线 +旧 QBV2 文件解释依据 +V2/V3 兼容与迁移依据 +``` + +不得覆盖、改名或修改其已经冻结的历史定义。 + +本文只定义: + +```text +QBIN V3 +``` + +以后对 V3 的小幅修订使用本文自身的: + +```text +Document Revision +``` + +管理,例如: + +```text +v1.0 +v1.1 +v1.2 +``` + +不得再用文档修订号代替 Container Version。 + +因此版本体系统一为: + +```text +QBIN V2 +└── 最终设计基线:v4.4 + +QBIN V3 +└── 本文初始设计版本:v1.0 +``` + + +## 0.5 本文的 Normative Scope + +从 v1.7 开始,本文的 normative 主体只定义: + +```text +QBV3 Header +Plane Descriptor +Endian / canonical float +Quantization +Shared Validity +2D Predictor +Direction Circular Predictor +ZigZag +Rice Block / bitstream +Numeric Plane byteLength +Plane Packing / Alignment +CRC32 +Parser Safety / Fail-closed +Deterministic Writer +Golden Binary / bit-exact Round-trip +Codec encode/decode benchmark +``` + +下列内容**不属于 QBV3 Binary ABI**: + +```text +NavSea 如何选择 / 下载 / 缓存 QBV3 +React / RN API +Profile Contract JSON 的 wire schema +EncodedFieldStore / FieldSeries +T0 / T1 / mix +Binding / Projection / Renderer +Android / iOS Bridge transport +Store retained-memory policy +业务层 V2→V3 切换流程 +``` + +这些内容只可作为 Non-normative Integration Notes 保存,不能反向改变同一个: + +```text +magic = QBV3 +version = 3 +``` + +的文件解释。 + +目标是: + +> Weather 生产方只阅读本文 normative 主体,就能生成 byte-for-byte deterministic、跨平台可解码、可严格校验的 QBV3 文件;消费方实现细节不属于 Container 语义。 + +--- + +# 1. V3 设计目标 + +QBIN V3 的核心目标只有一个: + +> **在保持 v4.4 通用天气场架构不变的前提下,大幅降低每个时间 Frame 的网络与缓存体积,并保持 Native 解码简单、确定、可校验。** + +设计目标: + +```text +1. 一个 targetTime 仍对应一个独立 Frame 文件。 +2. 不把多个时间点打成一个大归档。 +3. NavSea 可只下载当前 T0/T1 和必要预取 Frame。 +4. 数值误差只来自明确的 Quantization Profile。 +5. Predictor / ZigZag / Rice 全部可逆,不增加额外物理误差。 +6. Decoder 之后仍输出既有 DecodedFieldFrame / Plane Registry。 +7. Runtime / Renderer 不感知 QBIN V2/V3 差异。 +8. Wind / Wave / Rain / Current 共用同一 Container 与编码方法。 +9. 不为每种天气增加独立二进制格式。 +10. Writer 必须 deterministic;相同输入与同一 Profile 产生 byte-for-byte 相同文件。 +``` + +--- + +# 2. 不在 V3 中解决的问题 + +V3 不负责: + +```text +颜色表 +粒子颜色 +粒子数量 +Trail +UI +Provider 网络请求 +Route / ETA 业务 +Forecast 下载调度 +缓存淘汰策略 +``` + +V3 也不改变: + +```text +ColorBinding / ParticleBinding +Core Forecast Clock +FieldSeries +T0 / T1 / mix +GPU Renderer +``` + +--- + +# 3. 最终数据链 + +```text +Provider + ↓ +WeatherV2 Normalizer + ↓ +Server Canonical Field + ↓ +Unified Quantizer / Field Profile + ↓ +QBIN V3 Writer + ↓ +2D Predictor + ↓ +ZigZag + ↓ +固定 Block Geometry 的 adaptive Rice + ↓ +QBV3 Frame + ↓ +HTTP Distribution Encoding(可 gzip) + ↓ +NavSea Download / Cache + ↓ +MapLibreParticle Encoded Field Ingress + ↓ +QbinV3Decoder + ↓ +DecodedFieldFrame / Plane Registry + ↓ +EncodedFieldStore / FieldSeries + ↓ +Binding Resolver + ├── Color Projection + └── Particle Projection + ↓ +Core-owned Forecast Clock / Runtime Field Window + ↓ +WeatherColorLayer / RasterParticleLayer +``` + +QBIN V2 与 V3 的分界只允许存在于 Encoded Field Ingress: + +```text +QBV2 → QbinV2Decoder ┐ + ├→ DecodedFieldFrame +QBV3 → QbinV3Decoder ┘ +``` + +不得把 V3 编码细节泄漏到 Runtime/Renderer。 + +--- + +# 4. 文件总体结构 + +V3 第一版继续采用简单固定结构: + +```text +┌──────────────────────────────┐ +│ Fixed Header │ 112 bytes +├──────────────────────────────┤ +│ Plane Descriptor Table │ 32 bytes × planeCount +├──────────────────────────────┤ +│ Payload │ variable +└──────────────────────────────┘ +``` + +继续不保存: + +```text +headerSize +descriptorSize +descriptorOffset +payloadOffset +totalLength +``` + +固定: + +```text +HEADER_SIZE = 112 +DESCRIPTOR_SIZE = 32 + +descriptorOffset = 112 +payloadOffset = 112 + planeCount × 32 +expectedTotalLength = payloadOffset + payloadLength +``` + +V3 第一版不利用 Header/Descriptor 的 reserved 字段承载新业务信息。 + +所有 reserved 字段仍必须: + +```text +0 +``` + +这样保留二进制结构的简单性。 + +--- + +# 5. Magic / Version / Endian + +```text +magic = ASCII "QBV3" +version = 3 +``` + +未知 Version: + +```text +fail-closed +``` + +所有 multi-byte integer 与 IEEE-754 floating point: + +```text +Little Endian +``` + +Rice bitstream 的 bit order 由本文第 18 节单独定义,不使用 host-native bitfield。 + +## 5.1 QBV3 第一版 FRAME 固定 Header ABI + +QBV3 第一版只定义: + +```text +layout = FRAME +``` + +对应 Header 固定值: + +```text +layout = 1 +projection = 1 # WEB_MERCATOR +sampleOrder = 1 # NORTH_TO_SOUTH_WEST_TO_EAST +gridRegistration = 1 # NODE + +tileZ = 0xffff +tileX = 0 +tileY = 0 +``` + +以下时间字段: + +```text +referenceTimeUtc +validTimeUtc +``` + +固定为: + +```text +int64 +Unix UTC seconds +MUST > 0 +``` + +不得在 Header 中直接写毫秒。 + +QBV3 Container **不规定**二者的相对大小: + +```text +不得在 Binary Parser 中强制: +validTimeUtc >= referenceTimeUtc +``` + +以下情况都可以由外部 Profile / Dataset 语义决定: + +```text +Forecast +Analysis +Observation +Reanalysis / Hindcast +``` + +因此 Binary-valid 只要求两个时间字段各自满足格式与范围要求, +不因 `validTimeUtc < referenceTimeUtc` 自动判定 corruption。 + +Bounds: + +```text +west / south / east / north +``` + +必须是 finite `float64`,第一版 WEB_MERCATOR FRAME 至少满足: + +```text +-180 <= west < east <= 180 +-85.0511287798066 <= south < north <= 85.0511287798066 +``` + +未知 layout / projection / sampleOrder / gridRegistration: + +```text +fail-closed +``` + +这些值直接属于 QBV3 Container ABI,不要求 Writer 通过查阅 QBIN V2 文档才能生成合法 Header。 + +## 5.2 WEB_MERCATOR + NODE Grid 的精确空间语义 + +Header: + +```text +west / south / east / north +``` + +是: + +```text +WGS84 longitude / latitude degrees +``` + +但 Grid node 在南北方向的等间距不是按 latitude degree 线性插值, +而是在 Web Mercator projected space 中等间距。 + +定义数学上的 dimensionless spherical Web Mercator: + +```text +λ = longitudeDeg × π / 180 +φ = latitudeDeg × π / 180 + +mercatorX(longitudeDeg) = + λ + +mercatorY(latitudeDeg) = + ln( + tan( + π/4 + φ/2 + ) + ) +``` + +其 inverse: + +```text +longitudeDeg = + X × 180 / π + +latitudeDeg = + ( + 2 × atan(exp(Y)) + - π/2 + ) × 180 / π +``` + +由于 Header 已限制: + +```text +-85.0511287798066 <= latitude <= 85.0511287798066 +``` + +正文不再额外定义 latitude clamp。 + +若某实现使用 EPSG:3857 meter coordinate: + +```text +X_meter = 6378137 × X +Y_meter = 6378137 × Y +``` + +其 Grid node 位置与本文 dimensionless 公式完全等价; +规范语义以本文 dimensionless 公式为准。 + +实现用于 Grid sample 定位时应采用 IEEE-754 binary64 计算。 + +因此: + +```text +xWest = mercatorX(west) +xEast = mercatorX(east) + +yNorth = mercatorY(north) +ySouth = mercatorY(south) +``` + +对于 NODE grid: + +```text +dx = (xEast - xWest) / (width - 1) +dy = (yNorth - ySouth) / (height - 1) +``` + +逻辑 sample `(row, col)`: + +```text +x = xWest + col * dx +y = yNorth - row * dy +``` + +因此: + +```text +row = 0 +→ north edge + +row = height - 1 +→ south edge + +col = 0 +→ west edge + +col = width - 1 +→ east edge +``` + +flattened cell index: + +```text +index = row * width + col +``` + +sample order: + +```text +NORTH_TO_SOUTH_WEST_TO_EAST +``` + +等价于: + +```text +row-major +每一 row 内 west → east +rows north → south +``` + +Writer / Decoder / Golden Fixture 不得把 `south..north` latitude degree +直接做线性 sample spacing 来替代上述 Web Mercator projected-space 语义。 + + +--- + +# 6. Fixed Header + +V3 第一版继续沿用 v4.4 的 112-byte Header 字段位置,只修改 Magic / Version: + +| Offset | Size | Type | Field | +|---:|---:|---|---| +| 0 | 4 | char[4] | magic=`QBV3` | +| 4 | 1 | uint8 | version=3 | +| 5 | 1 | uint8 | layout | +| 6 | 2 | uint16 | fieldType | +| 8 | 1 | uint8 | projection | +| 9 | 1 | uint8 | sampleOrder | +| 10 | 1 | uint8 | gridRegistration | +| 11 | 1 | uint8 | reserved0=0 | +| 12 | 2 | uint16 | planeCount | +| 14 | 2 | uint16 | reserved1=0 | +| 16 | 4 | uint32 | width | +| 20 | 4 | uint32 | height | +| 24 | 8 | int64 | referenceTimeUtc | +| 32 | 8 | int64 | validTimeUtc | +| 40 | 8 | float64 | west | +| 48 | 8 | float64 | south | +| 56 | 8 | float64 | east | +| 64 | 8 | float64 | north | +| 72 | 4 | uint32 | payloadLength | +| 76 | 4 | uint32 | fileCrc32 | +| 80 | 2 | uint16 | tileZ | +| 82 | 2 | uint16 | reserved2=0 | +| 84 | 4 | uint32 | tileX | +| 88 | 4 | uint32 | tileY | +| 92 | 4 | uint32 | reserved3=0 | +| 96 | 4 | uint32 | reserved4=0 | +| 100 | 4 | uint32 | reserved5=0 | +| 104 | 4 | uint32 | reserved6=0 | +| 108 | 4 | uint32 | reserved7=0 | + +时间、Bounds、Projection、Sample Order、Grid Registration 的语义继续继承 v4.4。 + + +## 6.1 fieldType:Machine Hint,不是 Layout Authority + +`fieldType` 是 `uint16` machine-readable hint,沿用上一代已知值: + +```text +1 = WIND +2 = WAVE +3 = CURRENT +4 = RAIN +5 = AIR_TEMPERATURE +6 = SEA_TEMPERATURE +7 = AIR_PRESSURE +8 = HUMIDITY +9 = VISIBILITY +``` + +未知 `fieldType`: + +```text +MUST NOT solely cause binary rejection +``` + +只要 Header / Descriptor / Plane / CRC 等其他 QBV3 Binary Contract 全部合法, +Generic QBV3 Parser 必须继续按 binary-valid 处理。 + +也就是说 `fieldType` 是: + +```text +opaque uint16 machine hint +``` + +已知值具有当前约定含义,未知值仍保持 forward-compatible hint 语义。 + +特别冻结: + +```text +fieldType = 0 += UNKNOWN / UNSPECIFIED += binary-valid opaque hint +``` + +`fieldType=0` 或其他未知 `uint16` 值都不得单独导致 Binary reject。 + +`fieldType` MUST NOT 决定: + +```text +Plane count +Plane semantic set +dtype +scale / offset +REQUIRED completeness +Rice block interpretation +Profile compatibility +Renderer behavior +``` + +因此: + +```text +fieldType == WIND +``` + +不能替代外部: + +```text +fieldProfileId = wind +fieldProfileVersion = 2 +``` + +也不能使 Parser 自动要求 U/V Plane。 + +`fieldType` 与具体 Profile 是否一致属于外部 Profile / Product validation, +不属于 QBV3 binary corruption。 + +## 6.2 Header Bounds 的 Canonical Float64 Zero + +`west / south / east / north` 使用 IEEE-754 binary64 little-endian。 + +为了 deterministic Writer: + +```text +如果任一 bound 的数学值 == 0: +MUST serialize as +0.0 binary64 + +bit-pattern: +0x0000000000000000 +``` + +禁止: + +```text +-0.0 +bit-pattern = 0x8000000000000000 +``` + +其他有限非零值按其 canonical binary64 value 写入。 + +--- + +# 7. Plane Descriptor + +V3 第一版继续保持每个 Descriptor 32 bytes: + +| Offset | Size | Type | Field | +|---:|---:|---|---| +| 0 | 2 | uint16 | semantic | +| 2 | 1 | uint8 | dtype | +| 3 | 1 | uint8 | flags | +| 4 | 4 | float32 | scale | +| 8 | 4 | float32 | offset | +| 12 | 4 | uint32 | count | +| 16 | 4 | uint32 | byteOffset | +| 20 | 4 | uint32 | byteLength | +| 24 | 2 | uint16 | unit | +| 26 | 2 | uint16 | groupId | +| 28 | 1 | uint8 | valueClass | +| 29 | 1 | uint8 | component | +| 30 | 2 | uint16 | reserved=0 | + +## 7.1 DType Byte-level Enum + +QBV3 `dtype` 是 `uint8`,byte-level 值冻结为: + +```text +1 = INT16 +2 = UINT16 +3 = UINT8 +4 = INT8 +5 = FLOAT32 +6 = BITSET +``` + +QBV3 Version 3 第一版合法组合: + +```text +Numeric Plane: +dtype ∈ {INT16, UINT16, UINT8, INT8} +即 byte value ∈ {1,2,3,4} + +Validity Plane: +dtype = BITSET +即 byte value = 6 +``` + +以下在 QBV3 Version 3 第一版必须 reject: + +```text +Numeric dtype = FLOAT32 (5) +Numeric dtype = BITSET (6) + +Validity dtype != BITSET (6) + +unknown dtype +``` + +`FLOAT32=5` 的 enum 值保留是为了保持跨代 registry / decoded type 一致性, +但它不代表 QBV3 Version 3 第一版允许 FLOAT32 Rice Plane。 + +V3 与 V2 的关键区别: + +### Numeric Plane + +`dtype` 表示 **解码后的逻辑 Quantized Integer 类型**,不是 Payload 的固定宽度存储长度。 + +```text +count = width × height +byteLength = 该 Plane 的实际 Rice encoded byte 数 +``` + +因此 V3 Numeric Plane 不再要求: + +```text +byteLength == count × dtypeSize +``` + +### Validity Plane + +第一版仍保持直接 BITSET: + +```text +dtype = BITSET +count = width × height +byteLength = ceil(count / 8) +``` + +## 7.2 Descriptor Canonical Float Representation + +Descriptor 中: + +```text +scale +offset +``` + +都是 IEEE-754 binary32 little-endian。 + +QBV3 deterministic Writer 必须使用 canonical binary32 representation。 + +特别冻结: + +```text +如果某语义要求 offset == 0: + offset MUST = +0.0f + bit-pattern = 0x00000000 + +不得写: + -0.0f + bit-pattern = 0x80000000 +``` + +Validity Plane 固定: + +```text +scale = +1.0f +bit-pattern = 0x3f800000 + +offset = +0.0f +bit-pattern = 0x00000000 +``` + +对于所有 Numeric Plane: + +```text +scale MUST finite +scale > 0 +offset MUST finite +``` + +并冻结全局 canonical zero: + +```text +if offset == 0.0 numerically: + bit-pattern MUST = +0.0f + 0x00000000 + +-0.0f +0x80000000 +→ MUST reject +``` + +该规则不依赖外部 Profile 是否显式声明“offset=0”。 + +该规则属于 binary canonicalization,目的包括: + +```text +byte-for-byte deterministic Writer +CRC 稳定 +Content Identity 稳定 +跨语言 / 跨平台一致 +``` + +--- + + +## 7.3 Descriptor Byte-level Structural Invariants + +为了让 QBV3 Binary Parser 不依赖外部 Profile 名称, +以下机器值与通用结构约束直接属于 Container Version 3。 + +### Value Class + +```text +0 = SCALAR +1 = VELOCITY_COMPONENT +2 = DIRECTION_ANGLE +3 = VALIDITY +``` + +未知 `valueClass`: + +```text +MUST reject +``` + +### Component + +```text +0 = NONE +1 = U_EAST +2 = V_NORTH +3 = W_UP +``` + +未知 `component`: + +```text +MUST reject +``` + +### Core Unit IDs + +QBV3 第一版直接使用: + +```text +0 = NONE +1 = MPS +4 = DEGREE +``` + +Scalar 的其他 unit ID 可以由外部 Unit Registry 定义; +Binary Parser 不因为“不认识某个 Scalar unit ID”而自动判定 corruption。 + +### Core Semantic + +Shared Validity 的 frozen core semantic: + +```text +VALIDITY semantic = 3 +``` + +并冻结双向约束: + +```text +semantic == 3 +⇔ valueClass == VALIDITY +⇔ dtype == BITSET +``` + +因此: + +```text +semantic = 3 ++ SCALAR / VELOCITY_COMPONENT / DIRECTION_ANGLE +→ MUST reject + +valueClass = VALIDITY ++ semantic != 3 +→ MUST reject + +dtype = BITSET ++ valueClass != VALIDITY +→ MUST reject +``` + +一个合法 QBV3 Frame 仍必须有且只有一个满足上述关系的 Shared Validity Plane。 + +Numeric semantic: + +```text +semantic = 0 +→ reserved / invalid +``` + +除 `semantic=3` 已被冻结为 VALIDITY 外, +其他 Numeric semantic ID 的业务含义由外部 Semantic Registry 决定。 + +### 通用组合约束 + +```text +SCALAR: + dtype ∈ {INT16, UINT16, UINT8, INT8} + component = NONE + +VELOCITY_COMPONENT: + dtype ∈ {INT16, UINT16, UINT8, INT8} + unit = MPS + component ∈ {U_EAST, V_NORTH, W_UP} + groupId != 0 + +DIRECTION_ANGLE: + dtype = UINT16 + unit = DEGREE + component = NONE + groupId = 0 + offset = canonical +0.0f + scale obeys 第 15 节 Circular Contract + +VALIDITY: + semantic = 3 + dtype = BITSET + unit = NONE + groupId = 0 + component = NONE + flags.REQUIRED = 1 + scale = canonical +1.0f + offset = canonical +0.0f +``` + +一个 Frame: + +```text +MUST 有且只有一个 VALIDITY Plane +``` + +Binary Parser **不负责**验证: + +```text +某天气 Profile 是否需要 U/V 成对 +某 Scalar semantic 是否 REQUIRED +某 Field Profile 是否允许 additional Plane +``` + +这些属于外部 Profile Contract,而不是 QBV3 corruption。 + +# 8. Value Class / Semantic / Unit / groupId + +QBV3 Binary Parser 使用第 7.3 节冻结的: + +```text +valueClass +component +core unit +VALIDITY semantic +``` + +作为 byte-level structural contract。 + +外部 Semantic Registry / Unit Registry 可以为: + +```text +Scalar semantic +Scalar unit +未来扩展语义 +``` + +赋予更具体含义,但不得改变 `QBV3/version=3` 已冻结的 byte-level 解释。 + +`groupId` 的 Container 级基本规则: + +```text +VELOCITY_COMPONENT → groupId != 0 +DIRECTION_ANGLE → groupId = 0 +VALIDITY → groupId = 0 +``` + +SCALAR 的 `groupId` 是否具有额外业务含义由外部 Profile 决定。 + +V3 不因为编码变化增加: + +```text +if WIND +if WAVE +if RAIN +if CURRENT +``` + +等天气业务分支。 + +# 9. REQUIRED Flag 与 Binary-valid / Profile-valid 分层 + +Descriptor `flags`: + +```text +bit0 = REQUIRED +bit1..7 = reserved +``` + +在 QBV3 Binary Container 层,Parser 只验证: + +```text +flags 的 reserved bits == 0 +Descriptor 本身 structurally valid +Plane bytes / semantic / dtype / validity / packing 等满足本文 Binary Contract +``` + +QBV3 Binary Container **不根据天气 Profile 名称决定 required Plane 集合**。 + +因此必须区分: + +```text +Binary-valid +``` + +和: + +```text +Profile-valid +``` + +## 9.1 Binary-valid + +只由: + +```text +QbinV3Decoder +``` + +根据本文 normative Binary Contract 判断。 + +一个 QBV3 文件即使缺少某个产品 Profile 所需 Plane, +只要文件自身 Header / Descriptor / Payload / CRC / Validity / Rice 等合法, +仍可以是: + +```text +binary-valid +``` + +## 9.2 Profile-valid + +具体: + +```text +wind / 2 +wave / 2 +rain / 2 +current / 2 +``` + +要求哪些 Plane、dtype、scale、offset、unit、group/component, +属于外部 Profile Contract / Registry。 + +例如一个 binary-valid 文件: + +```text +VECTOR_U +VALIDITY +``` + +拿 `wind / 2` Contract 验证时因为缺失 `VECTOR_V`: + +```text +Profile validation → reject +``` + +但不能称为: + +```text +QBV3 corruption +``` + +## 9.3 REQUIRED flag 的职责 + +Descriptor REQUIRED flag 是: + +```text +文件内 requirement declaration / mirror +``` + +外部 Profile Contract 可以要求: + +```text +required Plane 必须存在 +对应 Descriptor REQUIRED bit 必须一致 +``` + +这种一致性属于: + +```text +Encoded Field Ingress / Profile Contract Validation +``` + +而不是 QBV3 binary parser 对天气业务完整性的判断。 + +不得在 `QbinV3Decoder` 内写: + +```text +if fieldType == WIND: + require U + require V +``` + +也不得让 Binary Decoder 依赖: + +```text +NavSea +React +Contract JSON +fieldProfileId +fieldProfileVersion +``` + +## 9.4 QBV3 Quantization Contract 的边界 + +QBV3 Binary Container 自身承载: + +```text +dtype +scale +offset +``` + +Parser 必须验证它们满足本文通用 Binary Contract: + +```text +dtype 合法 +scale / offset finite +canonical float / +0.0 +Direction Circular Contract +Validity fixed descriptor +``` + +具体 Profile 是否要求: + +```text +INT16 +scale = 0.1 +offset = +0.0 +``` + +由外部 Profile Contract 决定。 + +因此: + +> 同一个合法 QBV3 的 byte-level 解释不能依赖 Profile 名称或外部 Contract。 + + +# 10. Quantization 与编码必须分离 + +V3 强制区分: + +```text +Physical Quantization +``` + +和: + +```text +Binary Entropy Coding +``` + +物理误差只允许来自 Quantization: + +```text +physical → quantized integer +``` + +以下步骤必须完全可逆: + +```text +2D Predictor +ZigZag +Rice +``` + +因此: + +```text +V3 decode 后的 quantized integer +MUST byte/number-exact 等于 Writer Quantizer 的输出整数 +``` + +不得在 Rice/Predictor 阶段引入第二次近似。 + +--- + +# 11. Generic Numeric Quantization + +普通非循环 Numeric Plane: + +```text +physical = raw × scale + offset +``` + +## 11.1 Quantizer 参数以 Descriptor binary32 为唯一来源 + +Writer 必须先确定最终要写入 Descriptor 的: + +```text +scaleBits : IEEE-754 binary32 +offsetBits : IEEE-754 binary32 +``` + +然后: + +```text +scaleF32 = binary32_from_bits(scaleBits) +offsetF32 = binary32_from_bits(offsetBits) +``` + +实际 Quantizer 计算必须使用**已经序列化确定的 binary32 值**,必要时提升为 binary64: + +```text +scaleQ = double(scaleF32) +offsetQ = double(offsetF32) + +normalized = (physical - offsetQ) / scaleQ +raw = round_half_away_from_zero(normalized) +``` + +禁止: + +```text +用配置文件中的 decimal 原值 +用序列化前更高精度的 double scale / offset +量化完成后再把 scale / offset 截成 float32 +``` + +原因: + +> QBV3 的 Quantization 参数必须与文件 Descriptor 中实际携带的 bit-pattern 完全一致。 + +在“相同 canonical physical input + 相同 Descriptor bit-pattern”的前提下,Writer 必须产生相同 quantized integer。 + +普通 Writer: + +```text +normalized = (physical - offsetQ) / scaleQ +raw = round_half_away_from_zero(normalized) +``` + +Validity 必须在 Numeric Quantizer 之前已经确定。 + +对每个 cell: + +```text +if validity[index] == 1: + physical MUST be finite IEEE-754 binary64 + 执行 Numeric / Direction Quantization + +if validity[index] == 0: + physical value is ignored + MAY be NaN / +Infinity / -Infinity / missing + MUST NOT 进入 Quantizer + MUST NOT 产生 Rice symbol + decoded canonical raw MUST = 0 +``` + +禁止: + +```text +通过 physical 是 NaN / Inf 自动推导 validity = 0 +``` + +Validity 是独立、权威的 Shared Validity Domain; +non-finite numeric payload 不能替代 Validity BITSET。 + +`round_half_away_from_zero(x)` 的数学定义冻结为: + +```text +if x >= 0: + result = floor(x + 0.5) +else: + result = ceil(x - 0.5) +``` + +Quantizer 的规范计算域: + +```text +physical : IEEE-754 binary64 +scaleQ : exact binary64(double(scaleF32)) +offsetQ : exact binary64(double(offsetF32)) +``` + +规范计算步骤: + +```text +delta64 = + roundTiesToEven_binary64( + physical - offsetQ + ) + +normalized64 = + roundTiesToEven_binary64( + delta64 / scaleQ + ) + +delta64 MUST finite +normalized64 MUST finite + +如果任一 intermediate 为: + +```text +NaN ++Infinity +-Infinity +``` + +Writer MUST FAIL。 + +raw = + round_half_away_from_zero(normalized64) +``` + +这里的: + +```text +roundTiesToEven_binary64 +``` + +表示 IEEE-754 binary64 round-to-nearest, ties-to-even。 + +Writer 必须得到与上述步骤相同的 binary64 结果,不得依赖: + +```text +host locale +decimal string formatting +host default integer cast rounding +FPU 当前 rounding mode +excess precision / extended register precision +``` + +来决定最终 raw integer。 + +超出逻辑 dtype: + +```text +Writer FAIL +``` + +禁止: + +```text +silent clamp +integer wrap +隐式 modulo +``` + +Invalid Cell 不进入 Numeric Rice symbol stream;Decoder materialize 时其 raw 值必须恢复为 0。 + +--- + +# 12. V3 第一版支持的 Numeric dtype + +第一版编码支持: + +```text +INT16 +UINT16 +INT8 +UINT8 +``` + +所有上述 dtype 在进入 Predictor 前转换到至少 `int32` 运算域。 + +Predictor residual: + +```text +int32 +``` + +ZigZag 输出: + +```text +uint32 +``` + +`FLOAT32` 不进入 V3 第一版 Rice Numeric Plane。 + +需要 FLOAT32 的 Field: + +```text +继续使用其他已支持 Container +或未来明确扩展 V3 codec contract +``` + +不得私自把 FLOAT32 bytes 当整数 Rice 编码。 + +--- + +# 13. Shared Validity + +V3 第一版继续保留 v4.4 的单 Frame Shared Validity Domain: + +```text +semantic = VALIDITY +valueClass = VALIDITY +dtype = BITSET +unit = NONE +groupId = 0 +component = NONE +flags = REQUIRED +``` + +BITSET: + +```text +LSB first +byteLength = ceil(count / 8) +``` + +最后 byte 未使用 high bits: + +```text +MUST = 0 +``` + +Validity bit 与 flattened cell index 的对应关系冻结为: + +```text +index = row * width + col + +byteIndex = index >> 3 +bitIndex = index & 7 + +valid = + (validityBytes[byteIndex] >> bitIndex) & 1 +``` + +因此每个 byte 内: + +```text +bit 0 → 较小 index +bit 7 → 较大 index +``` + +即明确的 LSB-first。 + +一个 Frame 内: + +```text +所有 Numeric Plane MUST 共享完全相同 validity domain +``` + +不同 validity domain 的数据继续拆成不同 Field/QBIN。 + +V3 第一版**不增加**: + +```text +ALL_VALID special payload +ALL_INVALID special payload +RLE validity +per-plane validity +per-group validity +``` + +原因:保持 Container 简单统一。 + +Distribution 层 gzip 可以自然压缩全 1 或高度重复的 BITSET。 + +## 13.1 Validity 的逻辑解析顺序 + +Descriptor Table 仍按本文 canonical descriptor order 排序,Payload 仍按 Descriptor 顺序打包。 + +QBV3 **不要求** Validity Plane 在物理 Payload 中排在 Numeric Plane 前面。 + +Decoder 必须采用两阶段逻辑解析: + +```text +1. 解析全部 Header / Descriptor +2. 定位唯一 Shared Validity Plane +3. 完成 CRC / padding / Validity BITSET 校验 +4. 读取完整 Validity BITSET +5. 计算每个 Rice block 的 valid symbolCount +6. 再解各 Numeric Plane +``` + +因此: + +```text +逻辑上 Validity 必须先可用 +!= +物理上 Validity 必须排在最前 +``` + +不得为了 Rice 解码方便而改变 Descriptor canonical order 或 Plane packing order。 + +--- + +# 14. 2D Spatial Predictor + +V3 第一版所有 Numeric Plane 使用同一个二维 predictor。 + +预测在**整个 Grid 的全局坐标**上进行,不因 Rice block 边界 reset。 + +对当前 cell `(r,c)`: + +```text +L = (r, c-1) +U = (r-1, c) +UL = (r-1, c-1) +``` + +预测规则: + +```text +如果 current valid 且 L/U/UL 全部 valid: + predicted = raw(L) + raw(U) - raw(UL) + +否则如果 current valid 且 L valid: + predicted = raw(L) + +否则如果 current valid 且 U valid: + predicted = raw(U) + +否则: + predicted = 0 +``` + +普通 Numeric residual: + +```text +residual = raw(current) - predicted +``` + +所有中间计算至少使用: + +```text +int32 +``` + +不得用 int16 中间运算导致 overflow。 + +Invalid cell: + +```text +不产生 Rice symbol +Decoder materialize raw = 0 +``` + +--- + +# 15. Direction Circular Quantization / Predictor + +`DIRECTION_ANGLE` 继续使用 v4.4 的物理约定: + +```text +DIRECTION_TO +0° True North +90° East +180° South +270° West +clockwise positive +[0°, 360°) +``` + +QBIN V3 不再把 `360 / 65536°` 作为唯一合法 Direction scale。 +Direction quantization 正式升级为**通用 Circular Contract**。 + +Descriptor 基本约束: + +```text +valueClass = DIRECTION_ANGLE +dtype = UINT16 +unit = DEGREE +component = NONE +offset = 0 +scale > 0 +scale finite +``` + +## 15.0 QBV3 Direction Validation Scope + +本节只定义: + +```text +QBV3 / version=3 +``` + +自身的 Direction Circular Contract。 + +QBV3 Parser 对 `DIRECTION_ANGLE` 必须验证: + +```text +dtype = UINT16 +unit = DEGREE +component = NONE +groupId = 0 +offset = canonical +0.0f +scale = canonical float32(360/N) +rawDirection ∈ [0,N) +``` + +QBV2 的 legacy acceptance、共享 Validator 如何重构等兼容实现问题: + +```text +不属于 QBV3 Binary ABI +``` + +移至 Appendix A。 + +## 15.1 Canonical Direction Scale + +Direction scale 不使用实现方自行决定的 epsilon / tolerance。 +协议直接冻结 canonical float32 计算规则。 + +Reader / Writer 均定义: + +```text +scaleF32 = Descriptor 中 scale 的 IEEE-754 float32 值 + +ratioToN = 360.0 / double(scaleF32) +``` + +在把 `ratioToN` 转为整数前必须先验证: + +```text +ratioToN finite +1 <= ratioToN <= 65536 +``` + +然后固定: + +```text +N = floor(ratioToN + 0.5) +``` + +由于 `ratioToN > 0`,该规则等价于正数域的 `round_half_away_from_zero`,不依赖 host FPU rounding mode。 + +必须满足: + +```text +1 <= N <= 65536 + +ratio64 = 360.0 / double(N) +ratio64 finite + +canonicalScaleF32 = +IEEE-754 binary32( + ratio64, + round-to-nearest, + ties-to-even +) + +bit_pattern(scaleF32) == bit_pattern(canonicalScaleF32) +``` + +即:Descriptor 中的 `scale` 必须是 `360/N` 转为 IEEE-754 float32 后的**唯一 canonical bit pattern**。 +不得接受“足够接近”的任意浮点值。 + +因此不再需要 `DIRECTION_SCALE_TOLERANCE`。 + +逻辑 raw 范围: + +```text +rawDirection ∈ [0, N) +``` + +合法 Profile 示例: + +```text +N = 65536 → scale = float32(360/65536)° +N = 360 → scale = 1° +N = 720 → scale = 0.5° +``` + +QBV3 Writer 的 Direction input 必须在进入 Writer 前已经是 canonical: + +```text +physicalDirection finite +0 <= physicalDirection < 360 +DIRECTION_TO +clockwise positive +True North = 0° +``` + +QBV3 Writer 不负责: + +```text +FROM → TO 转换 +任意角度 wrap +天气 Provider 方向约定修正 +``` + +这些属于上游 Normalizer。 + +Writer 使用: + +```text +scaleQ = double(scaleF32) + +normalizedDirection64 = + roundTiesToEven_binary64( + physicalDirection / scaleQ + ) + +normalizedDirection64 MUST finite +否则 Writer MUST FAIL。 + +rawDirection = + round_half_away_from_zero(normalizedDirection64) mod N +``` + +其中 `mod N` 只用于处理接近 360° 边界时量化得到 `N` 的合法 wrap, +输入 physicalDirection 本身仍必须位于 `[0,360)`。 + +QBV3 Binary Parser 只验证上述通用 Circular Contract,禁止: + +```text +if profile == wave: + allow 1 degree +``` + +合法性来自 `scale → N → canonical float32(360/N)`, +不得把具体天气 Profile 名称写进 Container Parser。 + +## 15.2 Direction Integer Normalization + +所有 Direction predictor / residual 运算必须使用**整数圆周语义**,不得依赖不同语言对负数 `%` 的行为。 + +定义 `normalize_direction(x, N)`,其结果必须位于 `[0, N)`。 +对于本协议 predictor 产生的范围,固定参考规则为: + +```text +if x < 0: + x = x + N +else if x >= N: + x = x - N +return x +``` + +该规则成立是因为输入 raw 均在 `[0,N)`,三邻居 predictor `L + U - UL` 的结果必定位于: + +```text +[-(N-1), 2(N-1)] +``` + +因此最多需要一次 `+N` 或 `-N`。 + +如果实现使用通用 modulo helper,也必须保证得到相同的 non-negative modulo 结果。 + +## 15.3 Circular Predictor + +空间邻居选择和 fallback 顺序继续使用第 14 节 validity-aware 规则。 + +当 `L/U/UL` 全部 valid 时,Direction predictor 必须先计算 widened integer: + +```text +p = int32(raw(L)) + int32(raw(U)) - int32(raw(UL)) +predictedRaw = normalize_direction(p, N) +``` + +Fallback: + +```text +L valid → predictedRaw = raw(L) +U valid → predictedRaw = raw(U) +否则 → predictedRaw = 0 +``` + +不得把未经 `mod N` 规范化的 `L + U - UL` 直接用于 Direction residual。 + +## 15.4 circular_shortest_delta() 精确公式 + +设: + +```text +d = int32(currentRaw) - int32(predictedRaw) +``` + +先将差值规范到 `[0,N)`: + +```text +m = d +if m < 0: + m = m + N +``` + +此时 `m ∈ [0,N)`。 + +然后固定: + +```text +if 2*m > N: + residual = m - N +else if 2*m < N: + residual = m +else: + # 仅 N 为偶数且恰好半圈时成立 + residual = -(N / 2) +``` + +因此 half-circle tie **固定选择负方向 `-N/2`**。 + +该选择属于 QBV3 Version 3 ABI,不得由 Writer / Decoder 自行选择 `+N/2`。 + +Residual 范围: + +```text +N 为偶数:[-N/2, N/2) +N 为奇数:[-floor(N/2), floor(N/2)] +``` + +Decoder: + +```text +x = int32(predictedRaw) + residual +rawDirection = normalize_direction(x, N) +``` + +边界 Golden Test 至少覆盖: + +```text +359° → 0° +0° → 359° +N-1 → 0 +0 → N-1 +偶数 N 的 +N/2 / -N/2 tie → 固定解为 -N/2 +三邻居 L+U-UL < 0 +三邻居 L+U-UL >= N +``` + +在 `scale = 1°` 时,`359° → 0°` 必须得到最短圆周残差,而不是 `±359`。 + +--- + +# 16. ZigZag 与固定宽度整数语义 + +Signed residual 使用 32-bit 逻辑 ZigZag。 + +精确数学映射冻结为: + +```text +zigzag(r): + +if r >= 0: + z = 2 * r +else: + z = -2 * r - 1 +``` + +inverse: + +```text +unzigzag(z): + +if (z & 1) == 0: + r = z / 2 +else: + r = -(z / 2) - 1 +``` + +示例: + +```text +0 → 0 +-1 → 1 +1 → 2 +-2 → 3 +2 → 4 +... +``` + +文档中的位运算表达只用于说明数学关系,**不得直接要求 C++ 对 signed integer 执行可能产生 UB / implementation-defined 行为的 shift**。 + +正式 Writer / Decoder 必须冻结: + +```text +residual / predictor arithmetic:至少 int32 +ZigZag 数学计算:使用 int64 / uint64 widened domain +最终 ZigZag symbol:验证后收敛为 uint32 +bit manipulation:uint32 / uint64 +shift count:先验证再执行 +add / sub / multiply:checked 或 widened arithmetic +``` + +ZigZag 与 inverse ZigZag 必须提供 Android / iOS 共用的固定宽度 C++ reference implementation, +不得依赖: + +```text +signed left shift overflow +signed right shift implementation behavior +host integer width +compiler bitfield layout +``` + +Rice remainder mask 也必须使用明确的 unsigned 宽度,例如逻辑上: + +```text +uint64_t{1} << k +``` + +并保证: + +```text +k = 0..31 +``` + +时不会发生 signed `1 << 31` 或非法 shift。 + +Golden Test 至少覆盖: + +```text +0 +1 +-1 +INT16_MIN +INT16_MAX +最大合法 predictor residual +``` + +Predictor、ZigZag、Rice 必须完全可逆,不能引入任何新的数值误差。 + +--- + +# 17. Rice Block Geometry:冻结前候选与最终 ABI + +V3 已确定采用: + +```text +validity-aware 2D Predictor +→ ZigZag +→ block-adaptive Rice +``` + +**截至文档 v2.2,Block Geometry 是 QBV3 Version 3 尚未冻结的唯一核心 Binary ABI 参数。** + +因此: + +```text +v2.2 仍是 freeze candidate +不是 production-final ABI +``` + +在 16×16 / 32×32 benchmark 完成并回填唯一尺寸前, +任何实验文件都不得作为长期可解释的 production `QBV3/version=3` 发布。 + +但在正式发布 `QBV3 / version=3` 前,**Block Geometry 尚需用真实 C++ Decoder 做最后一次 16×16 / 32×32 对比**。 + +当前候选: + +```text +Candidate A:16×16 +Candidate B:32×32 +``` + +原因:现有 Wind 143 帧实验中,两者编码体积差异约只有: + +```text +Rice16 ≈ 43.929 MiB +Rice32 ≈ 43.953 MiB +差约 25 KiB / 143 帧 +≈ 175 B / Frame +≈ 0.056% +``` + +而 32×32 的 block 数约为 16×16 的 1/4,可能减少: + +```text +block metadata +loop / branch 次数 +per-block k 搜索次数 +``` + +因此正式冻结前必须在同一 C++ prototype 中记录: + +```text +encoded bytes +decode ms +encode ms +peak temporary bytes +block count +``` + +分别比较四类真实数据: + +```text +Wind +Rain +Wave +Current +``` + + +## 17.0 Block Partition 数学定义 + +在 Block Geometry 最终冻结前,用参数: + +```text +BLOCK_WIDTH +BLOCK_HEIGHT +``` + +表示候选尺寸。 + +定义: + +```text +blockCols = + (width + BLOCK_WIDTH - 1) / BLOCK_WIDTH + # integer floor division + +blockRows = + (height + BLOCK_HEIGHT - 1) / BLOCK_HEIGHT +``` + +对: + +```text +br ∈ [0, blockRows) +bc ∈ [0, blockCols) +``` + +定义: + +```text +rowBegin = br * BLOCK_HEIGHT +rowEnd = min(rowBegin + BLOCK_HEIGHT, height) + +colBegin = bc * BLOCK_WIDTH +colEnd = min(colBegin + BLOCK_WIDTH, width) +``` + +Block traversal: + +```text +for br = 0 .. blockRows-1: + for bc = 0 .. blockCols-1: + process block(br, bc) +``` + +等价: + +```text +blockIndex = br * blockCols + bc +``` + +Block 内: + +```text +for row = rowBegin .. rowEnd-1: + for col = colBegin .. colEnd-1: + index = row * width + col + + if validity[index] == 1: + emit one Rice symbol + else: + emit no symbol +``` + +边缘 block 自然裁切到实际 Grid 边界,不补虚拟 cell。 + +Predictor 继续使用全局 Grid 邻居, +不得因为进入新 block 而 reset。 + +## 17.1 正式发布规则 + +QBV3 第一版**不增加 blockSize 字段,也不增加 codec enum**。 + +这意味着:一旦 benchmark 选择最终 Block Geometry 并正式发布: + +```text +magic = QBV3 +version = 3 +``` + +选定的 block width / height 就成为 Container V3 ABI 的固定组成部分。 + +在正式冻结前,测试 Writer / Decoder 可以通过编译参数或测试参数运行 16×16 / 32×32; +**测试参数不得写进正式 QBV3 文件,也不得形成两种 production version=3 文件解释。** + +Block Geometry 未冻结期间产生的实验文件: + +```text +不得进入 production Manifest +不得进入正式 CDN / qbin_v3 发布路径 +不得被 NavSea production 配置发现或缓存 +不得标记为正式 QBV3 Golden Fixture +``` + +实验产物必须位于明确的: + +```text +/tmp +_runtime +experimental/ +或其他非 production 路径 +``` + +只有 Block Geometry 选定、文档回填唯一尺寸并完成对应 Writer/Decoder round-trip 后,才允许启用 production `QBIN_V3 / container_version=3` Manifest。 + +边缘 block 可以小于正式 block width / height。 + +Block 顺序: + +```text +north → south +west → east +``` + +即 block row-major。 + +Block 内 symbol 顺序: + +```text +cell row-major +仅遍历 validity=1 的 cell +``` + +如果 block 中没有 valid cell: + +```text +该 block 写 0 bytes +``` + +如果至少存在一个 valid cell: + +```text +1 byte riceK +随后为 Rice bitstream +最后补 0 bit 到 byte boundary +``` + +`riceK`: + +```text +uint8 +允许范围 0..31 +``` + +Writer 必须选择使该 block Rice bit 数最小的 `k`。 + +Tie: + +```text +选择较小 k +``` + +保证 deterministic output。 + +--- + +# 18. Rice Bitstream 精确定义 + +每个 ZigZag value `z`: + +```text +q = z >> k +r = z & ((1u << k) - 1) # 数学表达;实现使用安全 unsigned width +``` + +编码: + +```text +1. 写 q 个 0 bit +2. 写 1 个终止 bit +3. 写 k 个 remainder bit +``` + +remainder bit 顺序: + +```text +Least Significant Bit first +``` + +整个 block bitstream 也采用: + +```text +LSB-first within each byte +``` + +block 最后不足 8 bit: + +```text +high unused bits MUST = 0 +``` + +Decoder 根据 validity mask 可以提前知道该 block 的 `symbolCount`。 + +Decoder 必须: + +```text +精确解出 symbolCount 个 symbol +然后到达/验证该 block 的 byte boundary +``` + +不得依赖 sentinel。 + +不得读过 Descriptor.byteLength。 + +在 unary quotient 扫描过程中,Decoder 必须在每次 bit advance 前检查剩余 encoded range; +以下情况全部 fail-closed: + +```text +终止 bit 在 Plane 结束前不存在 +q 超过可安全重建 uint32 residual 的范围 +q << k / q * 2^k 发生溢出 +remainder 不完整 +非零 block padding bit +symbolCount 不足或超出 +``` + + +## 18.1 Logical Residual Upper Bound + +除 bitstream / uint32 overflow 检查外,Decoder 还必须根据逻辑 dtype 拒绝**数学上不可能出现**的 residual,避免 CRC 合法但恶意构造的超长 unary quotient 消耗无意义 CPU。 + +对普通非 Direction Numeric Plane,设 logical raw dtype 的最小/最大值为: + +```text +minRaw +maxRaw +``` + +在第 14 节 predictor 下,最大可能 residual 绝对值为: + +```text +MAX_ABS_RESIDUAL = 2 × (maxRaw - minRaw) +``` + +因此: + +```text +INT16 / UINT16 → MAX_ABS_RESIDUAL = 131070 +INT8 / UINT8 → MAX_ABS_RESIDUAL = 510 +``` + +对 Direction: + +```text +MAX_ABS_RESIDUAL = floor(N / 2) +``` + +并遵守第 15 节 half-circle tie 固定负方向规则。 + +Decoder 可据此得到安全上界: + +```text +MAX_ZIGZAG <= 2 × MAX_ABS_RESIDUAL +MAX_Q = MAX_ZIGZAG >> k +``` + +在扫描 unary zeros 时,一旦: + +```text +q > MAX_Q +``` + +即可立即 reject,不必继续扫描到 Plane 尾部。 + +完成 inverse ZigZag 后仍必须再次验证: + +```text +abs(residual) <= MAX_ABS_RESIDUAL +``` + +然后再执行 predictor reconstruction 与最终 logical raw dtype range 检查。 + +这些检查属于 parser hardening,不改变任何合法 QBV3 bitstream。 + +--- + + +## 18.2 Rice k 的 Deterministic 选择公式 + +对一个 non-empty block,设其按 block 内 canonical cell order 得到的 ZigZag symbol 为: + +```text +z_0, z_1, ... z_(n-1) +``` + +对每个候选: + +```text +k ∈ [0, 31] +``` + +定义纯 Rice symbol bit count: + +```text +bits(k) = +Σ_i [ (z_i >> k) + 1 + k ] +``` + +其中: + +```text +(z_i >> k) = unary zero count q ++1 = unary terminator bit ++k = remainder bits +``` + +Writer 必须选择: + +```text +k* = argmin bits(k) +``` + +如果多个 `k` 得到相同 `bits(k)`: + +```text +选择数值较小的 k +``` + +该 `k*` 属于 **Deterministic Writer Contract**: + +```text +Writer MUST 写入 k* +``` + +但 QBV3 Decoder 的 Binary acceptance 不要求重新计算全部 32 个候选 `k`。 + +Decoder 对每个 non-empty block 只要求: + +```text +0 <= riceK <= 31 +bitstream 可在 descriptor.byteLength 内完整解出 +symbolCount 精确 +padding 合法 +residual / reconstructed raw 合法 +``` + +因此: + +```text +riceK != k* +``` + +如果其他 Binary Contract 全部合法: + +```text +MUST NOT solely cause Decoder reject +``` + +也就是说: + +```text +最优 k* += canonical deterministic Writer output + +非最优但合法的 riceK +!= QBV3 binary corruption +``` + +Decoder MUST NOT 为 canonicality 验证而强制执行: + +```text +对已解出的每个 block 再扫描 k=0..31 重算最优 k* +``` + +优化目标**只计算 Rice symbol bits**。 + +以下固定开销不参与 k 比较: + +```text +1-byte riceK +block 尾部补到 byte boundary 的 zero padding +``` + +原因是必须冻结与当前编码实验一致的 deterministic 选择语义,避免不同 Writer 分别按: + +```text +raw bit count +``` + +或: + +```text +ceil(bit count / 8) +``` + +选择出不同 `k`。 + +选定 `k*` 后,该 non-empty block 的 encoded byte 数: + +```text +blockByteLength = +1 ++ +ceil(bits(k*) / 8) +``` + +其中前 `1` byte 为 `riceK`。 + +# 19. Numeric Plane Payload + +一个 Numeric Plane payload: + +```text +Block(0,0) +Block(0,1) +... +Block(last) +``` + +Block Geometry 使用第 17 节在正式 ABI freeze 前选定的唯一尺寸。 +正式 QBV3 不增加独立 block index table。 + +原因: + +```text +Grid size 已知 +Block size 在 Container V3 ABI 中固定 +Validity 已知 +每个非空 block 自带 1-byte k +Decoder 可以按 symbolCount 顺序解析 +``` + +V3 第一版优先完整 Frame decode,不为随机访问单个 block 增加额外索引成本。 + +## 19.0 Numeric Plane byteLength 的精确定义 + +对 Numeric Plane: + +```text +Descriptor.byteLength +``` + +严格定义为该 Plane 自身所有 block encoded bytes 的总和。 + +对每个 block: + +```text +validCount == 0 +→ block contributes 0 bytes + +validCount > 0 +→ block contributes: + 1 byte riceK + + ceil(RiceSymbolBits / 8) +``` + +因此: + +```text +NumericPlane.byteLength = +Σ(non-empty blockByteLength) +``` + +`byteLength` **不包含**: + +```text +Plane 之前的 align16 padding +Plane 之后的 align16 padding +Descriptor bytes +Header bytes +其他 Plane bytes +HTTP / gzip 外层编码 +``` + +Block 内最后一个 byte 的未使用 high bits仍必须为 0; +这些 bit 位属于该 block 的最后一个 encoded byte,因此属于 `byteLength`。 + +Plane 间的 16-byte alignment padding 不属于任何 Plane 的 `byteLength`,只由 `byteOffset / payloadLength` canonical packing 规则体现。 + +## 19.1 All-invalid Frame + +V3 第一版正式允许: + +```text +validity 全 0 +``` + +此时: + +```text +Validity Plane: +正常 BITSET,全 0 + +Numeric Plane: +byteLength = 0 +``` + +Decoder: + +```text +不读取 Rice block +直接 materialize 全 0 canonical raw Plane +``` + +成立条件必须严格为: + +```text +validCount == 0 +``` + +如果: + +```text +validCount > 0 +&& numeric byteLength == 0 +``` + +必须 reject。 + +`all-invalid`: + +```text +不是 corruption +不需要特殊 codec +不写虚假的 1-byte block +``` + +产品层如果不希望发布 all-invalid Frame,可以在 Weather Writer / Product Validation 拒绝, +但 Container 行为必须保持上述确定语义。 + +--- + +# 20. Plane Packing / Alignment + +Descriptor Table 排序继续继承 v4.4: + +```text +semantic +→ groupId +→ component +``` + +Plane payload 按 Descriptor 顺序排列。 + +```text +plane[0].byteOffset = 0 +plane[n].byteOffset = align16(previousEnd) +payloadLength = align16(lastEnd) +``` + +其中: + +```text +previousEnd = previous.byteOffset + previous.byteLength +align16(x) = (x + 15) & ~15 +``` + +Plane 间与尾部 padding: + +```text +MUST = 0 +``` + +区别仅在: + +```text +Numeric Plane byteLength += Rice encoded bytes +``` + +Validity: + +```text +byteLength = ceil(count/8) +``` + +V3 仍要求 canonical minimal packing,不允许额外预留空间。 + +--- + +# 21. CRC32 + +QBV3 使用: + +```text +CRC-32 / ISO-HDLC +``` + +完整参数冻结为: + +```text +width = 32 +poly = 0x04C11DB7 +init = 0xFFFFFFFF +refin = true +refout = true +xorout = 0xFFFFFFFF +check("123456789") = 0xCBF43926 +``` + +对 reflected 实现,等价使用: + +```text +reflected polynomial = 0xEDB88320 +``` + +与: + +```text +zlib.crc32 +``` + +兼容。 + +CRC 覆盖完整未经 HTTP Distribution Encoding 的 QBV3 bytes: + +```text +Header +Descriptor Table +Payload +Plane padding +Tail padding +``` + +计算时: + +```text +Header offset 76..79 +fileCrc32 的 4 bytes +按 0x00 0x00 0x00 0x00 参与计算 +``` + +Writer: + +```text +1. 先写 fileCrc32 = 0 +2. 对完整 QBV3 bytes 计算 CRC +3. 将 uint32 CRC little-endian 写回 offset 76..79 +``` + +Parser: + +```text +读取 stored CRC +↓ +计算时把 offset 76..79 逻辑视为 0 +↓ +比较 uint32 +``` + +不得对 gzip / HTTP transport 后的 bytes 计算 QBV3 CRC。 + +如果同时 benchmark QBV2 / QBV3, +CRC helper 必须一致,并单独记录 `crcMs`。 + +# 22. Parser 安全规则:Encoded Budget 与 Decoded Budget 分离 + +QBV3 Version 3 的 parser conformance limits 冻结为: + +```text +MAX_PLANE_COUNT = 32 +MAX_CELL_COUNT = 2,097,152 +MAX_QBIN_BYTES = 64 MiB +MAX_DECODED_BYTES_PER_FRAME = 32 MiB +``` + +其中: + +```text +64 MiB = 64 × 1024 × 1024 bytes +32 MiB = 32 × 1024 × 1024 bytes +``` + +这些值属于 QBV3 Version 3 的 normative acceptance limits。 + +当前 Wind / Rain / Wave / Current 生产需求确认在该容量范围内, +因此 QBV3 Version 3 继续冻结这些上限。 + +任何 conforming Decoder 对超过上述限制的文件: + +```text +MUST fail-closed +``` + +实现可以额外拥有更严格的本地资源 policy, +但不得把一个超出上述 QBV3 conformance limit 的文件视为 Version 3 合法文件。 + +未来如果业务确实需要突破任一上限: + +```text +MAX_PLANE_COUNT +MAX_CELL_COUNT +MAX_QBIN_BYTES +MAX_DECODED_BYTES_PER_FRAME +``` + +不得在已发布 `QBV3/version=3` 上静默放宽后仍声称 ABI 未改变。 + +应通过: + +```text +新的 Container Version +或明确 successor binary specification +``` + +升级并重新冻结对应 conformance limits。 + + + +QBIN V3 的 encoded size 与 decoded size 不再近似相等,因此必须分别限制,并冻结 Parser 执行顺序。 + +## 22.1 Stage A:便宜的结构与 Per-frame Decoded Budget 预检查 + +在 CRC 与任何 Rice entropy decode 之前,先执行不需要解释 Rice bitstream 的结构检查: + +```text +magic == QBV3 +version == 3 + +planeCount > 0 +planeCount <= MAX_PLANE_COUNT + +width >= 2 +height >= 2 +cells = checked(width × height) +cells <= MAX_CELL_COUNT + +payloadLength 合理 +actualFileLength == 112 + planeCount×32 + payloadLength +actualFileLength <= MAX_QBIN_BYTES + +reserved == 0 +Header 固定 ABI 值合法 +bounds / time 合法 + +Descriptor order canonical +(semantic, groupId) unique +count == cells +dtype / valueClass / component 合法 +scale / offset 满足 canonical float 规则 +byteOffset / byteLength 范围合法 +canonical align16 packing / payloadLength 公式合法 +``` + +同时执行 Encoded Budget: + +```text +actual file bytes +payloadLength +每个 Plane encoded byteLength +所有 offset + length checked arithmetic +``` + +并在任何大 Plane allocation 前执行 Decoder 自己负责的 per-frame decoded budget: + +```text +decodedNumericPlaneBytes = + checked(count × logical numeric dtype width) + +decodedNumericBytes = + checked(sum(decodedNumericPlaneBytes)) + +decodedValidityBytes = + ceil(count / 8) + +decodedFrameBytes = + checked(decodedNumericBytes + decodedValidityBytes) +``` + +同一个 canonical Validity BITSET 只计一次。 + +必须满足: + +```text +decodedFrameBytes <= MAX_DECODED_BYTES_PER_FRAME +``` + +否则: + +```text +fail-closed +``` + +QBV3 Binary ABI 只冻结“必须有 per-frame decoded allocation budget 与 checked arithmetic”这一安全要求。 + +跨多个 retained Frame 的 Store memory policy: + +```text +不属于 QBV3 Binary Container +``` + +移至 Appendix A 的 Non-normative Integration Notes。 + +Stage A 不扫描 Rice unary,不根据 Rice 内容做 entropy decode。 + +## 22.2 Stage B:CRC32 + +Stage A 通过后,**先验证完整 QBV3 CRC32**。 + +```text +结构 / length / bounds / budget +↓ +CRC32 +``` + +CRC 失败: + +```text +立即 fail-closed +``` + +不得先对 CRC 已损坏文件执行 Rice entropy decode。 + +## 22.3 Stage C:Canonical Payload / Validity + +CRC 通过后再验证 Payload 内容级规则: + +```text +Plane / tail padding byte == 0 +Validity dtype == BITSET +Validity byteLength == ceil(count/8) +Validity tail unused high bits == 0 +``` + +随后计算: + +```text +validCount +``` + +并冻结 all-invalid 对 Numeric Plane 的约束: + +```text +validCount == 0 → Numeric byteLength MUST == 0 +validCount > 0 → Numeric byteLength MUST > 0 +``` + +### 22.3.1 Zero-length Plane Canonical Packing + +all-invalid Frame 中可能出现多个 zero-length Numeric Plane。 + +canonical packing 继续按: + +```text +nextOffset = align16(previousEnd) +``` + +计算。 + +因此: + +```text +zero-length Plane MAY share byteOffset +with adjacent zero-length Plane +or the next non-empty Plane. +``` + +合法示例: + +```text +Plane A offset=0 length=0 +Plane B offset=0 length=0 +Plane C offset=0 length=validityBytes +``` + +这属于合法 canonical representation,不是真实 payload overlap。 + +Parser 的 overlap validation MUST: + +```text +只对实际占用非零 byte range 的 Plane 做 overlap 判断; +zero-length interval 不得被误判为 overlapping payload corruption。 +``` + +## 22.4 Stage D:Rice Entropy Decode + +只有 Stage A/B/C 全部通过后,才进入 Numeric Rice decode。 + +逐 block / symbol 验证: + +```text +riceK <= 31 +非最优 riceK 本身不属于 corruption,不要求 Decoder 重算 k* +block exact symbolCount +unary quotient <= 第 18.1 节 logical MAX_Q +bitstream 不越界 +q / remainder / ZigZag arithmetic 不溢出 +inverse ZigZag residual 在逻辑上限内 +predictor reconstruction checked arithmetic + +普通 Numeric: +decoded raw 必须落入 logical dtype range + +Direction: +decoded raw 必须同时满足: +0 <= rawDirection < N + +否则: +fail-closed + +block padding bit == 0 +Plane 解码后精确消费 descriptor.byteLength +``` + +Malformed Rice stream: + +```text +fail-closed +``` + +Direction corruption / acceptance 必须额外覆盖: + +```text +decoded rawDirection >= N +→ reject +``` + +`UINT16` dtype range 合法但超出当前 Circular Contract `[0,N)` 的值不得被接受。 + +## 22.5 固定执行顺序 + +正式 Parser 顺序冻结为: + +```text +Header / Descriptor / file length / bounds +↓ +Encoded + Decoded budget 预检查 +↓ +CRC32 +↓ +Canonical padding + Validity BITSET 检查 / validCount +↓ +Rice entropy decode + ZigZag + Predictor reconstruction +↓ +canonical raw materialization +↓ +QBV3 Binary Decoder Accepted +``` + +之后如果存在外部 Profile Contract: + +```text +Encoded Field Ingress +↓ +Profile Contract Validation +↓ +Product / Runtime acceptance +``` + +该第二阶段不属于 QBV3 Binary Container validation。 + +实现可以合并无副作用的小检查,但不得把 Rice entropy decode 提前到 CRC 之前,也不得在 budget 预检查前进行大内存 allocation。 + +--- + +# 23. Canonical Decoded Raw Reference Model + +本节不是 Runtime API 规范,而是 QBV3 Round-trip 的参考解码结果定义。 + +Numeric Plane: + +```text +QBV3 Rice Plane +↓ +Rice decode +↓ +inverse ZigZag +↓ +Predictor reconstruction +↓ +logical quantized integer +↓ +canonical little-endian fixed-width raw bytes +``` + +Canonical raw: + +```text +INT16 → count × 2 bytes, little-endian two's-complement +UINT16 → count × 2 bytes, little-endian +UINT8 → count × 1 byte +INT8 → count × 1 byte, two's-complement +``` + +Invalid cell: + +```text +validity = 0 +→ canonical decoded raw MUST = 0 +``` + +Validity reference decoded form: + +```text +保持 canonical BITSET bytes +LSB-first +tail unused high bits = 0 +``` + +bit-exact Round-trip 的权威比较对象: + +```text +Writer Quantizer integer grid ++ +Writer Validity BITSET +``` + +与: + +```text +Decoder canonical integer grid ++ +Decoder Validity BITSET +``` + +必须完全一致。 + +任何 Runtime-specific: + +```text +DecodedFieldFrame +ContentIdentity +hash +FieldSeries +Projection +Renderer +``` + +均不属于 QBV3 Binary ABI,移至 Appendix A。 + +# 24. 非 Normative 的 Profile / 实验 / Distribution 信息 + +Wind / Rain / Wave / Current 第一阶段候选 Profile、压缩实验统计、 +HTTP Distribution Encoding 与 Manifest 示例都属于产品 / Profile / 发布层信息, +不属于 `QBV3/version=3` Binary ABI。 + +统一移至 Appendix B。 + +QBV3 Binary Parser 不得根据: + +```text +wind / 2 +wave / 2 +rain / 2 +current / 2 +Manifest +文件扩展名 +HTTP Content-Encoding +``` + +改变 Header / Descriptor / Validity / Predictor / ZigZag / Rice / Packing / CRC 的解释。 + +# 25. Container Version Dispatch 的最小要求 + +如果同一系统同时支持 QBV2 / QBV3: + +```text +QBV2 → magic "QBV2", version=2 +QBV3 → magic "QBV3", version=3 +``` + +Unknown magic/version combination: + +```text +fail-closed +``` + +除以上识别规则外,迁移、Store、Runtime、平台实现均不属于 QBV3 Binary ABI。 + +# 26. Binary Spec 与消费实现分离 + +QBV3 Binary Container 只规定: + +```text +bytes 如何写 +bytes 如何验证 +bytes 如何无损恢复 quantized integer grid +``` + +NavSea、React、Store、Binding、Renderer、平台 Bridge 等消费方式: + +```text +不属于本文 normative Binary ABI +``` + +相关背景统一放入 Appendix A。 + +# 27. Decoder 实现语言中立 + +QBV3 不规定 Decoder 必须使用何种语言。 + +任何 conforming implementation 都必须对同一合法 QBV3 bytes 得到相同: + +```text +Header +Descriptor +Validity +canonical quantized integer grid +``` + +并对同一 malformed bytes 得到相同 Binary Accept / Reject 结论。 + +# 28. Deterministic Writer 与 Container V3 Codec ABI + +相同: + +```text +Canonical Field +Field Profile Version +Container Version +``` + +必须生成: + +```text +byte-for-byte identical QBV3 +``` + +正式 `QBV3 / version=3` 发布前,必须先完成第 17 节 Block Geometry benchmark 并选择唯一尺寸。 +一旦正式发布,下列内容全部成为 Container V3 Binary ABI: + +```text +validity-aware predictor 规则 +predictor fallback 顺序 +Direction circular residual 规则 +ZigZag 定义 +最终冻结的 Block Geometry +Block traversal order +Cell traversal order +valid-cell-only symbol stream +Rice k search 0..31 +Rice k tie → smaller k +Rice unary form +Rice remainder bit order +byte 内 bit order +block padding +Descriptor canonical order +Plane canonical packing +Padding = 0 +reserved = 0 +CRC algorithm +``` + +不得依赖: + +```text +hash/map iteration order +platform endian +compiler bitfield layout +undefined signed overflow / shift behavior +``` + +如果正式发布后要改变: + +```text +Block Geometry +Predictor +Rice bitstream +bit order +新增第二种内部 codec +``` + +不能只修改文档修订号让相同 `QBV3/version=3` 出现两种解释。 +必须: + +```text +升级新的 Container Version +``` + +或者在**新的 Container Version 设计阶段**明确引入可识别 codec 字段。 +V3 第一版继续保持单一固定 codec,不为未来可能性提前增加 codec switch。 + +--- + +# 29. Golden Binary Fixtures + +正式冻结后至少准备: + +```text +Wind +Wave +Rain +Current +``` + +四类真实数据的 Golden QBV3 Fixture。 + +每个 Fixture 至少记录: + +```text +QBV3 document revision +container version +magic + +referenceTimeUtc +validTimeUtc +grid / bounds + +Header bytes +Descriptor bytes + +canonical physical baseline +quantized integer baseline +validity baseline + +正式 QBV3 bytes +CRC32 +SHA-256 + +raw QBV3 byteLength +gzip byteLength(若另外做 distribution benchmark) + +per-plane encoded byteLength +blockCount +nonEmptyBlockCount +Rice k distribution + +writer timing +decoder timing +peak temporary memory +``` + +Block Geometry 冻结前允许生成: + +```text +experimental/16x16/ +experimental/32x32/ +``` + +两组 benchmark fixture。 + +最终 Block Geometry 冻结后必须重新生成唯一正式 Golden Fixture 集合。 + +# 30. Round-trip 验收 + +最重要的 Round-trip 先验证整数 bit-exact: + +```text +Writer Quantizer output integer array +↓ +QBV3 Writer +↓ +QBV3 file +↓ +C++ QbinV3Decoder +↓ +Decoded canonical raw integer array +``` + +结果必须: + +```text +array_equal == true +``` + +不是近似比较。 + +同时: + +```text +validity array_equal == true +invalid cell decoded raw == 0 +``` + +物理误差测试单独比较: + +```text +原始 canonical physical +vs +decoded raw × scale + offset +``` + +确保: + +```text +Predictor / ZigZag / Rice 不贡献任何额外误差 +物理误差只来自 Field Profile Quantization +``` + +全量验证阶段建议覆盖全部生成 Frame,而不是只抽样。 + +Direction 必须包含: + +```text +359° ↔ 0° +N-1 ↔ 0 +half-circle tie +``` + +All-invalid fixture 必须验证: + +```text +Validity 全 0 +Numeric byteLength = 0 +Decoded raw 全 0 +zero-length Numeric Plane 可共享 canonical byteOffset +``` + + +--- + +# 31. Binary Reject / Accept / Writer Validation 验收 + +本节按职责拆分为三组,避免把: + +```text +Binary corruption +Binary canonical-but-unusual input +Writer input validation +``` + +混为同一类。 + +## 31.1 Binary Reject Cases + +以下 malformed / corruption 情况必须: + +```text +MUST fail-closed +``` + +至少覆盖: + +```text +Bad magic +Bad version +Big-endian/mixed endian + +Header reserved != 0 +Descriptor reserved != 0 + +Invalid planeCount +Invalid cell count +Overflowed width×height + +Bad byteOffset +Bad byteLength +Non-canonical descriptor order + +Extra padding +Nonzero padding + +Bad CRC + +Truncated Rice block +Rice unary never terminates before end +riceK > 31 + +Block symbol count insufficient +Block symbol count extra data +Nonzero block padding bits + +Invalid validity tail bits + +Numeric offset = -0.0f +Header bound = -0.0 binary64 + +Direction invalid canonical scale +QBV3 decoded rawDirection >= N + +non-zero Plane actual byte-range overlap + +semantic = 3 but valueClass != VALIDITY +semantic = 3 but dtype != BITSET +valueClass = VALIDITY but semantic != 3 +dtype = BITSET but valueClass != VALIDITY +``` + +以上情况: + +```text +QbinV3Decoder +→ reject +``` + +## 31.2 Binary Accept Cases + +以下情况本身不得单独导致 Binary reject: + +```text +non-optimal riceK ++ otherwise-valid block +→ MUST accept + +Unknown fieldType +→ MUST accept as opaque hint + +fieldType = 0 +→ MUST accept as UNKNOWN / UNSPECIFIED opaque hint + +all-invalid Frame +Validity 全 0 +Numeric byteLength = 0 +→ MUST accept + +all-invalid zero-length adjacent Plane shared byteOffset +→ MUST accept as canonical packing +``` + +特别冻结: + +```text +riceK != Writer 最优 k* +``` + +如果其余: + +```text +riceK range +symbolCount +padding +residual +raw range +descriptor.byteLength +``` + +全部合法,则: + +```text +MUST NOT solely cause Decoder reject +``` + +Decoder 不要求为 canonicality 重新计算: + +```text +k = 0..31 +``` + +的全部候选。 + +## 31.3 Writer Validation Cases + +以下属于 Writer input / deterministic output 验证, +不是“Decoder corruption”: + +```text +validity=1 ++ physical = NaN / +Inf / -Inf +→ Writer MUST reject + +validity=0 ++ numeric physical = NaN / +Inf / -Inf / missing +→ Writer MAY accept +→ value MUST be ignored +→ MUST NOT enter Quantizer +→ MUST NOT emit Rice symbol + +Writer MUST NOT: +通过 NaN / Inf 自动推导 validity=0 + +delta64 non-finite +→ Writer MUST reject + +normalized64 non-finite +→ Writer MUST reject + +normalizedDirection64 non-finite +→ Writer MUST reject + +physicalDirection 不满足: +finite +0 <= angle < 360 +DIRECTION_TO +→ Writer MUST reject / 上游先 normalize + +Writer 对每个 non-empty block: +MUST 写 deterministic 最优 k* +``` + +## 31.4 外部 Profile / Integration Validation + +以下属于外部 Integration / Profile Contract validation, +不得标记为 QBV3 binary corruption: + +```text +Missing Profile-required Plane +Undeclared REQUIRED relative to external Contract +Contract mismatch +fieldType / fieldProfile mismatch +QBV2 Direction legacy acceptance +``` + +QBV2 的 Direction legacy regression 应在 V2 ingress compatibility test 中覆盖, +不属于 QBV3 corruption suite。 + + +# 32. Codec 性能验收 + +QBV3 的 Binary ABI 不规定消费方 Runtime 生命周期,但正式冻结 Block Geometry 前必须完成 codec-level benchmark。 + +同一测试平台、同一 build 配置下至少记录: + +```text +raw QBV3 bytes +gzip QBV3 bytes(若测试 distribution gzip) + +crcMs +entropyDecodeMs +totalDecodeMs +encodeMs + +peakTemporaryBytes +decodedFrameBytes + +blockCount +nonEmptyBlockCount +Rice k distribution +``` + +必须比较: + +```text +16×16 +vs +32×32 +``` + +并覆盖: + +```text +Wind +Rain +Wave +Current +``` + +如果同时比较 QBV2 / QBV3: + +```text +CRC 实现必须一致 +crcMs 与 entropyDecodeMs 必须分开 +``` + +不要求 QBV3 entropy decode 比固定宽度 QBV2 decode 更快。 + +Block Geometry 的选择依据应综合: + +```text +encoded bytes +encode CPU +decode CPU +peak temporary memory +实现复杂度 +``` + +而不是只看压缩率。 + +## 32.1 Streaming reconstruction 实现目标 + +推荐 Decoder: + +```text +compressed Plane input +↓ +BitReader +↓ +逐 symbol Rice decode +↓ +inverse ZigZag +↓ +立即 Predictor reconstruction +↓ +直接写最终 canonical fixed-width raw storage +``` + +应避免为了实现方便同时建立多个 full-grid 临时数组: + +```text +residual[] +zigzagDecoded[] +predictorOutput[] +rawOutput[] +``` + +这是性能实现目标,不改变 QBV3 bitstream。 + +# 33. QBV3 第一阶段 Binary ABI 验收基线 + +正式冻结 / 发布 `QBV3/version=3` 前至少满足: + +```text +1. Header 112-byte layout、Little Endian、reserved=0 已冻结。 +2. layout=1 / projection=1 / sampleOrder=1 / gridRegistration=1 已冻结。 +3. tileZ=0xffff / tileX=0 / tileY=0 已冻结。 +4. referenceTimeUtc / validTimeUtc 为正 int64 Unix UTC seconds。 +5. Binary Parser 不强制 validTimeUtc >= referenceTimeUtc。 +6. Bounds 是 finite binary64 WGS84 lon/lat,0 必须编码为 +0.0。 +7. WEB_MERCATOR + NODE projected-space sample 公式有 Golden Test。 +8. Descriptor 32-byte layout 与 dtype byte enum 已冻结。 +9. valueClass / component / core unit / VALIDITY semantic structural invariants 已冻结。 +10. Numeric dtype 仅允许 INT16/UINT16/UINT8/INT8;FLOAT32 Rice Plane reject。 +11. Shared Validity 有且只有一个,semantic=3、dtype=BITSET、REQUIRED。 +12. Validity scale=+1.0f、offset=+0.0f。 +13. Numeric scale > 0 finite;offset finite;零 offset 使用 +0.0f。 +14. Quantizer 先确定 Descriptor binary32 scale/offset,再提升到 binary64 计算。 +15. validity=1 的 canonical physical sample MUST 为 finite binary64; + validity=0 的 numeric physical value 不参与 Quantizer,可为 NaN / Inf / missing。 +16. round_half_away_from_zero 精确公式有 ±0.5 边界 Golden Test。 +17. Shared Validity BITSET LSB-first,tail unused high bits=0。 +18. Decoder 先解析全部 Descriptor,逻辑上先定位/读取 Validity,再解 Numeric。 +19. 2D Predictor 在整个 Grid 连续,不在 block 边界 reset。 +20. Direction canonical float32(360/N)、N=floor(ratio+0.5)、raw∈[0,N)。 +21. Direction half-circle tie 固定为 -N/2。 +22. ZigZag / inverse ZigZag 使用固定宽度无 UB 语义。 +23. Block partition 数学公式、block row-major、cell row-major 已冻结。 +24. 16×16 / 32×32 使用同一 Writer/Decoder prototype 完成四类真实 benchmark。 +25. Rice k 使用 bits(k)=Σ((z>>k)+1+k),tie 取较小 k。 +26. k 优化不把 riceK byte / byte padding 纳入目标。 +27. non-empty blockByteLength = 1 + ceil(RiceSymbolBits/8)。 +28. Numeric Plane byteLength = Σ(non-empty blockByteLength),不含 align16 padding。 +29. all-invalid:Validity 全 0、Numeric byteLength=0、decoded raw 全 0。 +30. zero-length Plane shared byteOffset 不误判 overlap。 +31. Plane / tail align16 padding 全 0。 +32. CRC-32/ISO-HDLC 完整参数、check value 与 CRC field-zeroing 已冻结。 +33. Parser 顺序:结构+budget → CRC → Validity → Rice。 +34. MAX_Q / residual logical upper-bound hardening 生效。 +35. malformed Rice / truncation / overflow / nonzero padding 全部 fail-closed。 +36. MAX_PLANE_COUNT=32。 +37. MAX_CELL_COUNT=2,097,152。 +38. MAX_QBIN_BYTES=64 MiB。 +39. MAX_DECODED_BYTES_PER_FRAME=32 MiB。 +40. 超出上述 Version 3 conformance limits 的文件 fail-closed。 +41. 全部 Numeric Plane integer bit-exact round-trip。 +42. strict Validity bit-exact round-trip。 +43. Direction 359°↔0° / N-1↔0 / half-circle / predictor wrap 通过。 +44. Writer deterministic:相同 canonical input + 相同 Descriptor → byte-for-byte 相同 QBV3。 +45. Golden Binary Fixtures 覆盖 Wind / Rain / Wave / Current。 +46. Block Geometry 未冻结前实验文件不进入 production。 +47. 最终选择唯一 BLOCK_WIDTH / BLOCK_HEIGHT 后重新生成正式 Golden Binary。 +48. validity=1 的 physical sample 必须 finite;validity=0 的 numeric physical value 被忽略,不参与 Quantizer。 +49. Writer 不得通过 NaN/Inf 自动推导 Validity。 +50. ZigZag / inverse ZigZag 精确数学公式有 Golden Test。 +51. Quantizer binary64 subtraction/division 使用 round-to-nearest, ties-to-even 语义。 +52. Direction input 在 Writer 前已经 canonical 为 DIRECTION_TO 且 0<=angle<360。 +53. unknown fieldType 不得单独导致 binary reject。 +54. 任意 Numeric offset 数值为 0 时必须使用 +0.0f,-0.0f reject。 +55. Validity BITSET 的 byteIndex / bitIndex 公式有 byte-exact Golden。 +56. semantic==3 ⇔ valueClass==VALIDITY ⇔ dtype==BITSET 的双向规则通过 corruption test。 +57. delta64 / normalized64 / normalizedDirection64 non-finite → Writer FAIL。 +58. fieldType=0 作为 UNKNOWN / UNSPECIFIED opaque hint 可 binary-accept。 +59. Writer 对每个 non-empty block 写最优 k*。 +60. Decoder 不因非最优但 otherwise-valid 的 riceK 单独 reject,也不要求重算 k*。 +61. WEB_MERCATOR mercatorX / mercatorY / inverse 数学公式有定位 Golden Test。 +62. 一个 targetTime 一个独立 QBV3 Frame。 +``` + +在 v2.2 中: + +> **除最终 `BLOCK_WIDTH / BLOCK_HEIGHT` 尚需通过 16×16 / 32×32 C++ benchmark 实测选择外,其他核心 QBV3 Binary ABI 语义已完成文档冻结;Rice canonicality 采用 Writer-canonical / Decoder-permissive。v2.2 之后不再因实现偏好继续修改 Binary Spec。** + +消费侧 Store、Binding、Renderer、NavSea、React、Profile Contract、Manifest 与平台 Bridge 不作为 QBV3 Binary ABI 的冻结条件。 + +# 34. 版本与命名最终规则 + +以后统一使用: + +```text +QBIN V2 += Container Version 2 += Magic QBV2 += 最终权威设计基线文档 v4.4 + +QBIN V3 += Container Version 3 += Magic QBV3 += 本文从 Document Revision v1.0 开始;当前修订为 v2.2 +``` + +禁止再使用: + +```text +“QBIN V4” +``` + +来代指 Container V2。 + +如果需要说明历史: + +```text +QBIN V2 / v4.4 design baseline +``` + +如果需要说明新格式: + +```text +QBIN V3 +``` + +Field Profile Version 独立: + +```text +wind / 2 +wave / 2 +rain / 2 +current / 2 +``` + +--- + +# 35. 最终冻结候选语句 + +**架构继承:** + +> QBIN V3 继承 QBIN V2 / v4.4 的 Semantic、Unit、Field Profile Contract、Independent Binding、Forecast Runtime 与 Generic Renderer 架构;只在 Encoded Field Ingress 之前改变二进制 Numeric Plane 表达。 + +**数值误差:** + +> V3 的物理误差只来自 Field Profile Quantization;2D Predictor、ZigZag 与 Rice 必须 bit-exact 可逆。 + +**统一编码:** + +> V3 第一版所有整数 Numeric Plane 统一使用 validity-aware 2D Predictor + ZigZag + 单一固定 Block Geometry 的 adaptive Rice;Validity 继续使用 shared BITSET。Block Geometry 在正式 `QBV3/version=3` 发布前通过 16×16 / 32×32 C++ benchmark 选定,发布后成为不可兼容修改的 Container V3 ABI。 + +**Direction:** + +> QBV3 `DIRECTION_ANGLE` 使用 Generic Circular Quantization Contract;合法 scale 必须满足 canonical `float32(360/N)`,Container Parser 不根据具体天气 Profile 名称放宽或改变该规则。 + +**Decoded contract:** + +> QBV3 Decoder 的 reference decoded result 是 canonical fixed-width quantized raw grid;Predictor / ZigZag / Rice 必须完全可逆,Invalid cell 的 canonical raw 固定为 0。 + +**职责边界:** + +> QBV3 Binary ABI 只规定 Writer 如何生成 bytes、Parser 如何验证 bytes、Decoder 如何无损恢复 quantized integer grid;消费侧架构不属于 Container 规范。 + +**时间单位:** + +> 一个 targetTime = 一个独立 QBV3 Frame,不把多个时间点合并成一个网络归档。 + +**四类第一阶段 Profile:** + +```text +Wind U/V 0.1 m/s +Rain 0.1 mm/h +Wave Height 0.05 m / Period 0.1 s / Direction 1° +Current U/V 0.02 m/s +``` + +**Wave Range:** + +> Wave Height 不以 10 m 为格式上限;第一阶段至少验证 0–30 m,10 m 必须正常表达。 + +**版本:** + +> 新编码不继续冒充 QBIN V2,也不创建“QBIN V5”第二套编号;直接使用 QBIN V3 / QBV3 / version=3。 + +--- + +# 36. 当前状态与下一步 + +本文当前状态: + +```text +设计冻结候选 v2.2 +``` + +已经完成: + +```text +Header / Descriptor byte layout +Little Endian / canonical float +Header fixed FRAME ABI +WEB_MERCATOR + NODE Grid 空间语义 +DType / ValueClass / Component / Core Unit 机器值 +Shared Validity BITSET +Generic Numeric Quantization +round_half_away_from_zero +Direction Circular Contract +2D Predictor +ZigZag +Rice bitstream +Rice k deterministic 选择公式 +Block partition 数学公式 +Numeric Plane byteLength +Plane Packing / Alignment +All-invalid representation +CRC-32/ISO-HDLC 完整参数 +Parser 执行顺序与 fail-closed +Parser conformance limits +Canonical decoded raw reference model +Deterministic Writer 规则 +Golden / Round-trip 验收规则 +Invalid-cell / non-finite physical Writer 语义 +ZigZag / inverse ZigZag 精确数学 ABI +Quantizer binary64 rounding 语义 +unknown fieldType forward-compatible Binary 行为 +Validity BITSET 精确位索引 +Numeric canonical +0.0 offset 全局规则 +WEB_MERCATOR 精确数学公式 +VALIDITY semantic 双向约束 +Quantizer intermediate finite 规则 +fieldType=0 UNKNOWN / UNSPECIFIED 语义 +Rice Writer-canonical / Decoder-permissive 规则 +``` + +尚未完成: + +```text +16×16 / 32×32 真实 Writer/Decoder benchmark +最终 BLOCK_WIDTH / BLOCK_HEIGHT freeze +正式 QBV3 Writer +正式 QBV3 Decoder +四类 full integer/validity bit-exact round-trip +正式 Golden Binary Fixtures +corruption / fail-closed 全量验证 +codec encode/decode CPU + peak temporary memory benchmark +``` + +其中: + +```text +最终 BLOCK_WIDTH / BLOCK_HEIGHT +``` + +是正式发布前唯一仍需通过实测选择的核心 Binary ABI 参数。 +其余 Binary 结构与数据语义在 v2.2 中不再保留未决分支。 + +从 v2.2 起,除非 Prototype / Golden / Corruption Test 证明现有 Binary Contract +存在真实不可实现、不可唯一解释或自相矛盾的问题,否则不得再因实现偏好修改 QBV3 Binary ABI。 + +在以上实现与验证完成前: + +```text +QBIN V2 / v4.4 仍是已实施历史基线 +QBIN V3 v2.2 是下一代候选生产格式设计 +``` + +下一步建议严格按以下顺序: + +```text +1. 以本文 v2.2 作为最终设计冻结稿,实现最小 deterministic QBV3 Writer + Decoder prototype。 +2. 同一 prototype 仅以测试参数运行 16×16 / 32×32。 +3. 用 Wind / Rain / Wave / Current 真实 Frame 做 integer/validity bit-exact round-trip。 +4. 同时记录 raw/gzip bytes、encodeMs、crcMs、entropyDecodeMs、totalDecodeMs、peakTemporaryBytes。 +5. 选择唯一 BLOCK_WIDTH / BLOCK_HEIGHT。 +6. 将唯一 Block Geometry 回填本文,删除所有实验双选语义。 +7. 生成正式 Golden Binary Fixtures。 +8. 完成 corruption / fail-closed 全量验证。 +9. 将该版本作为 QBV3/version=3 production-final Binary ABI 冻结稿。 +``` + +--- + +# Appendix A. Non-normative Integration / Compatibility Notes + +本 Appendix **不属于 QBV3 Binary ABI**。 + +## A.1 QBV2 Legacy Direction Compatibility + +当前项目若继续支持 QBV2,应保持其既有 authoritative ingress acceptance, +包括历史 Direction scale tolerance。 + +该规则属于: + +```text +QBV2 compatibility +``` + +不是 QBV3 Container 规则。 + +QBV3 只使用正文第 15 节的 canonical Circular Contract。 + +## A.2 ContentIdentity / Runtime Hash + +当前项目可为了 QBV2/QBV3 共存,让 decoded Plane 复用既有 runtime identity helper。 + +这些: + +```text +uint64 hash +runtime contentIdentity string +``` + +不写入 QBV3 文件,也不参与 QBV3 CRC,因此不是 Container Version 3 ABI。 + +## A.3 Profile Contract + +外部 Profile Contract 可以比 QBV3 Binary Parser 更严格,例如校验: + +```text +expectedDType +expectedScaleBits +expectedOffsetBits +required Plane 集合 +``` + +这些属于: + +```text +Profile-valid +``` + +而不是: + +```text +Binary-valid +``` + +一个 binary-valid 但 profile-invalid 的 QBV3 文件不应被称为 binary corruption。 + +## A.4 Store / Runtime / NavSea / React / Platform Bridge + +以下都不是 QBV3 Binary ABI: + +```text +EncodedFieldStore +FieldSeries +T0 / T1 / mix +Binding +Renderer +NavSea 下载/缓存/预取 +React / RN API +Android / iOS transport +retained-memory policy +``` + +这些实现不得反向改变正文对 `QBV3/version=3` bytes 的解释。 + +--- + +# Appendix B. Informative Profile / Product / Distribution Notes + +本 Appendix 只保留当前 WeatherV2 第一阶段实验与产品背景, +用于 Writer Prototype 和 Golden Fixture 选样。 + +## B.1 第一阶段候选 Profile + +```text +Wind / 2 + U/V INT16 + 0.1 m/s + offset +0.0 + +Rain / 2 + UINT16 + 0.1 mm/h + offset +0.0 + +Wave / 2 + Height UINT16 0.05 m + Period UINT16 0.1 s + Direction UINT16 1° + offset +0.0 + +Current / 2 + U/V INT16 + 0.02 m/s + offset +0.0 +``` + +这些 Profile 用于当前真实数据 benchmark, +但 QBV3 Parser 不得写 `if WIND / WAVE / RAIN / CURRENT`。 + +## B.2 当前量化误差实验 + +```text +Wind: +speed MAE ≈ 0.02461 m/s +speed p95 ≈ 0.05205 m/s +speed p99 ≈ 0.06201 m/s +speed max ≈ 0.07071 m/s + +Rain: +MAE ≈ 0.0149 mm/h +max ≈ 0.0500 mm/h + +Wave: +Height max additional error ≈ 0.02 m +Direction max additional error ≈ 0.5° + +Current: +speed MAE ≈ 0.00646 m/s +speed max ≈ 0.0141 m/s +``` + +Predictor / ZigZag / Rice 不允许增加额外数值误差。 + +## B.3 Block-size 既有实验 + +Wind 143 帧既有模型结果: + +```text +Rice16 ≈ 43.929 MiB +Rice32 ≈ 43.953 MiB + +差约: +25 KiB / 143 Frames +≈ 175 B / Frame +≈ 0.056% +``` + +因此最终 Block Geometry 不能只看压缩率, +必须等待真实 Writer/Decoder CPU 与 memory benchmark。 + +## B.4 Distribution / Manifest + +QBV3 Container 与 HTTP Distribution Encoding 分离。 + +可以外层发布: + +```text +.qbin +.qbin.gz +HTTP Content-Encoding +``` + +但解压后必须恢复同一 QBV3 bytes。 + +Manifest / CDN path / Profile metadata: + +```text +不属于 QBV3 Binary ABI +``` diff --git a/docs/40_任务/STEP-087_QBIN_V3解码与13.4.1-particle.9升级_Codex任务_v1.md b/docs/40_任务/STEP-087_QBIN_V3解码与13.4.1-particle.9升级_Codex任务_v1.md new file mode 100644 index 000000000..d1f3f1bbc --- /dev/null +++ b/docs/40_任务/STEP-087_QBIN_V3解码与13.4.1-particle.9升级_Codex任务_v1.md @@ -0,0 +1,198 @@ +# STEP-087 QBIN V3 解码与 13.4.1-particle.9 升级 Codex 任务 v1 + +## 1. 任务目标 + +在 Mac 专用工作树 `/Users/tei/dev/MapLibreParticleLab-qbv3-p9` 上完成 QBIN V3(Magic=`QBV3`, version=3)正式解码支持,并将统一 Native / RN 源码版本升级到 `13.4.1-particle.9`。 + +本任务必须以以下设计稿为权威依据: +- `docs/30_设计/WeatherV2_QBIN_V3通用天气场二进制格式与React独立Render_Binding设计_v2.2_最终冻结稿.md` + +该设计稿是用户提供原文,必须保留,不得改写、覆盖或删减。实现中如发现规范真实矛盾,只能在完成报告中记录并停止相关 ABI 决策,不得自行修改规范来迁就代码。 + +## 2. 当前基线 + +执行前必须核对: +- 工作树分支:`particle/qbv3-p9` +- 基线必须包含 `release/particle-8` 的两笔发布提交,至少到 `77744dd9f` +- `platform/android/VERSION` 与 `packages/maplibre-particle-rn/package.json` 当前均应为 `13.4.1-particle.8` +- 不得修改其他 Mac 工作树 +- 主线目标仍为 `origin/particle/main` + +先阅读项目根目录 AGENTS.md(如存在)、docs/00_项目总则、docs/00_项目规范,以及 STEP-073~085 中与 QBIN ingress、FieldProfile、发布和 iOS device-arm64 相关的最新记录。## 3. Phase 0:只读现状调查 + +在写代码前完成一次只读调查并在报告中记录结论,至少确认: +- `QbinV2Decoder`、`EncodedFieldIngress::decodeAndValidate()`、`DecodedFieldFrame`、`FieldProfileValidator` 的真实接口。 +- QBV2 source/test 在 Bazel/CMake/平台构建中的注册位置。 +- RN / Android / iOS 当前 encoded-field 入口是否只是透传 bytes,确认 QBV3 不需要新增 JS/TS 公共业务 API。 +- `.8` 版本号实际出现在哪些“当前版本”文件中;禁止全仓替换历史文档中的旧版本号。 +- 现有 Wind / Rain / Wave / Current QBIN Golden Fixture 的来源、尺寸与可复用性。 + +结论应坚持: +- V2 与 V3 只在 Encoded Field Ingress 前分流。 +- Decoder 后继续输出同一 `DecodedFieldFrame / Plane Registry`。 +- Runtime / Binding / Renderer 不感知 QBV2/QBV3。 +- 不把 Provider、Weather Service、HTTP、Manifest、缓存、React 业务逻辑放进 Native Decoder。 + +## 4. Block Geometry 冻结 + +v2.2 规范只剩 `BLOCK_WIDTH / BLOCK_HEIGHT` 未冻结,本任务必须在正式 QBV3 Decoder 固化前完成选择。 + +使用现有四类真实 Golden 数据: +- `integration/react-native/MapLibreParticleRNTestHost/assets/qbin-golden/wind/` +- `rain/` +- `wave/` +- `current/` + +优先复用 QBV2 Decoder 得到 canonical quantized integer grid + validity,再用测试/benchmark 专用 deterministic QBV3 encoder/prototype 比较 `16×16` 与 `32×32`。该 encoder 仅用于测试、fixture 和 benchmark,不得演变成 Weather Provider/生产 Writer。比较必须使用同一 predictor / ZigZag / Rice 规则,至少记录: +- encoded bytes +- block count / non-empty block count +- encode time +- entropy decode time +- total decode time +- CRC time(若可独立测) +- peak temporary bytes(若现有工具不能可靠测量,报告中明确说明,不得伪造) + +选择原则: +1. 首先保证规范一致和 bit-exact round-trip。 +2. 压缩率差异很小时,优先更低 decode/encode 开销、更少 block、实现更简单的一项。 +3. 最终必须只留下一个 production Block Geometry。 +4. production Decoder 不得同时“猜测”16×16/32×32,也不得根据解码是否成功试两遍。 +5. 测试代码可以保留两个候选参数用于 benchmark,但正式 QBV3 ABI 常量必须唯一。 + +把选择依据写入: +- `docs/30_设计/MapLibreParticle_QBIN_V3_Decoder与BlockGeometry实施决策_v1.md` + +该文档是粒子侧实施补充,不改写用户提供的 WeatherV2 v2.2 原文。 + +## 5. 正式 QBV3 Decoder + +新增清晰独立的 QBV3 decoder 文件,优先采用: +- `include/mbgl/particle/ingress/qbin_v3_decoder.hpp` +- `src/mbgl/particle/ingress/qbin_v3_decoder.cpp` + +如现有构建结构要求轻微调整可据实处理,但禁止把 V3 逻辑塞成 `qbin_v2_decoder.cpp` 内的大量 version 分支。实现必须逐项遵守 v2.2 normative 主体,重点包括: +- 112-byte Header、32-byte Descriptor、Little Endian。 +- Magic=`QBV3`、version=3;FRAME 固定 layout/projection/sampleOrder/gridRegistration/tile 字段。 +- Version 3 limits:planeCount 32、cellCount 2,097,152、file 64 MiB、decoded frame 32 MiB。 +- Header bounds finite、Web Mercator 范围、binary64 数值零禁止 -0.0。 +- Numeric dtype 只允许 INT16/UINT16/UINT8/INT8;FLOAT32 与 Numeric BITSET reject。 +- `semantic==3 ⇔ valueClass==VALIDITY ⇔ dtype==BITSET`,且只允许一个 Shared Validity Plane。 +- Validity LSB-first;tail unused high bits 必须为 0。 +- unknown fieldType(包括 0)只作为 opaque hint,不得单独 reject。 +- Numeric `scale > 0` finite,offset finite;数值为 0 时只接受 binary32 +0.0。 +- Parser 顺序保持:结构与 budget → CRC → padding/Validity → Rice decode。 +- CRC-32/ISO-HDLC,CRC field 76..79 计算时视为 0。 +- all-invalid 合法:Validity 全 0、Numeric byteLength=0、decoded raw 全 0。 +- zero-length Plane 可以共享 canonical byteOffset;只对真实非零 range 做 overlap 判断。 +- Numeric Plane byteLength 是各 non-empty block encoded bytes 之和,不含 align16 padding。 + +Rice / predictor 必须严格实现: +- Predictor 在全 Grid 连续,不在 block 边界 reset。 +- invalid cell 不消费 Rice symbol,materialize raw=0。 +- ZigZag 使用无 UB 的 widened fixed-width arithmetic。 +- Rice bitstream 与 remainder 都按 byte 内 LSB-first。 +- riceK 允许 0..31;Decoder permissive,不因非最优 k 单独 reject。 +- unary quotient、MAX_Q、residual、raw reconstruction 全部 checked;非法立即 fail-closed。 +- block padding high unused bits 必须为 0,symbolCount 必须精确。 +- Direction 使用 canonical float32(360/N)、N=floor(360/scale+0.5)、raw∈[0,N)。 +- Direction predictor 必须 normalize;half-circle tie 固定 `-N/2`。## 6. Ingress 与兼容性 + +`EncodedFieldIngress::decodeAndValidate()` 必须按 bytes 中的 magic/version 做显式 dispatch: +- `QBV2 / version=2 → QbinV2Decoder` +- `QBV3 / version=3 → QbinV3Decoder` +- 其他组合 fail-closed + +要求: +- QBV2 frozen behavior 不得改变。 +- V3 decode 成功后继续走现有 `FieldProfileValidator`。 +- Binary-valid 与 Profile-valid 继续分层;V3 Decoder 不得根据 wind/wave/rain/current 名称要求业务 Plane。 +- V2/V3 decoded Plane 的 ContentIdentity 必须继续使用项目既有 canonical identity 语义,避免 Runtime 因容器版本产生无意义 identity 漂移。 +- 如需要抽取共享 helper,可做小范围重构,但不得把公共 API 扩大到 Renderer/Binding。 + +## 7. 测试与 Fixture + +新增 C++ 定向测试,不新增或运行 Jest。 + +至少覆盖: +- Wind / Rain / Wave / Current integer + validity bit-exact round-trip。 +- INT16 / UINT16 / INT8 / UINT8。 +- all-valid、部分 invalid、all-invalid。 +- zero-length shared offset。 +- bad magic/version/endian、reserved、bounds、negative zero、descriptor order/duplicate、packing、CRC。 +- malformed Rice、unterminated unary、riceK>31、symbolCount 少/多、nonzero block padding、MAX_Q/residual/raw overflow。 +- non-optimal but otherwise-valid riceK 必须 accept。 +- fieldType=0 与 unknown fieldType 必须 accept。 +- Direction 359↔0、N-1↔0、half-circle tie、predictor wrap。 +- WEB_MERCATOR + NODE sample 公式的定位 Golden Test。 + +最终冻结 Geometry 后生成唯一正式 QBV3 Golden Fixture 集,至少覆盖四类场;实验 16/32 双候选 fixture 不得混入 production fixture 目录。## 8. 构建与验证 + +本任务在 Mac 上执行 Native/C++ 开发和精确编译验证。 + +必须: +- 运行与 QBV2/QBV3 decoder 直接相关的 C++ 定向测试。 +- 运行必要的 Mac 原生精确编译,确认新增 source/header 被真实构建目标消费。 +- 对 iOS 只验证 iPhone/iPad device arm64 路径;不得恢复 simulator slice。 +- 如执行 noisy compile/test,遵循当前 Codex 全局输出节流规则。 +- `git diff --check` 必须 PASS。 + +禁止: +- Android Gradle build +- AAR/APK 构建或安装 +- ADB +- 启动 Android App / 清理数据 +- Android 实机/虚拟机冒烟 + +Android 最终构建安装仍由用户手工执行。本任务应在报告中给出最少的用户手工验证入口,但不要代替用户执行。 + +## 9. 升级到 13.4.1-particle.9 + +以已纳入的 `.8` 为基线,将当前有效版本升级为: +`13.4.1-particle.9` + +按 `.8` 的真实发布结构更新所有“当前版本”源文件/配置,包括 Native VERSION、RN package、插件/Android 依赖引用、CHANGELOG/README 中的当前安装示例,以及 release pipeline 需要的当前版本元数据。 + +禁止: +- 全仓替换历史 STEP / 报告中的 `.1~.8`。 +- 覆盖既有 `.8` artifact、tag 或发布目录。 +- 未经用户明确要求发布外部 Maven/npm/CDN artifact。 +- 未经用户明确要求创建正式 release tag。## 10. 文档输出 + +除本任务书和用户提供设计稿外,任务必须生成/更新: +- `docs/30_设计/MapLibreParticle_QBIN_V3_Decoder与BlockGeometry实施决策_v1.md` +- `docs/50_Step记录/STEP-087_QBIN_V3解码与13.4.1-particle.9升级_v1.md` +- `docs/报告/STEP-087_QBIN_V3解码与13.4.1-particle.9升级_Codex完成报告_v1.md` + +完成报告必须写清: +- 起始 HEAD / 最终 HEAD +- 选定的 Block Geometry 与 benchmark 证据 +- 新增/修改的主要代码文件 +- QBV2 回归结果 +- QBV3 测试结果 +- Mac 原生精确编译结果 +- 未执行的 Android 手工门槛 +- 版本号变更位置 +- 仍存在的风险或未完成项 + +如同主题报告已存在,按 v2/v3 递增,不覆盖旧报告。 + +## 11. Git 收尾 + +完成规定代码、测试、版本和文档后: +1. 只提交本任务相关变更。 +2. 不提交无关 untracked 文件或其他工作树内容。 +3. commit message 使用清晰英文,例如:`feat: add QBIN V3 decoder for particle.9`。 +4. 将最终结果 push 到公开远端 `origin/particle/main`。 +5. push 失败不得声称完成。 +6. 完成报告必须记录最终 commit hash、commit message、push result、remote branch、Final HEAD。 +7. 最终再次确认本地 HEAD 与 `origin/particle/main` 一致。 + +## 12. 停止条件 + +如果实际 benchmark 无法用现有真实 fixture 对 16×16/32×32 做可信选择,或 v2.2 存在导致合法文件无法唯一解释的真实矛盾: +- 不得猜测 production Geometry。 +- 完成可独立验证的代码/调查部分后停止。 +- 在 `docs/报告/` 生成接口/规范阻塞说明,明确缺什么数据或决定。 +- 不得把支持两种 production Geometry 的“兼容 decoder”当成完成方案。 + +除此之外,应完成实现,不要因为普通工程细节反复向用户确认。 \ No newline at end of file diff --git a/docs/50_Step记录/STEP-087_QBIN_V3解码与13.4.1-particle.9升级_v1.md b/docs/50_Step记录/STEP-087_QBIN_V3解码与13.4.1-particle.9升级_v1.md new file mode 100644 index 000000000..c94d417c2 --- /dev/null +++ b/docs/50_Step记录/STEP-087_QBIN_V3解码与13.4.1-particle.9升级_v1.md @@ -0,0 +1,31 @@ +# STEP-087 QBIN V3 解码与 13.4.1-particle.9 升级 + +## 状态 + +IMPLEMENTED / MAC VALIDATION IN PROGRESS + +## 已完成 + +- 已将用户提供的《WeatherV2 QBIN V3 通用天气场二进制格式与 React 独立 Render Binding 设计 v2.2 最终冻结稿》保存到 `docs/30_设计/`,原文件内容与 SHA-256 保持一致。 +- 新增独立 `QbinV3Decoder`,生产 Block Geometry 冻结为 16×16。 +- `EncodedFieldIngress` 改为 QBV2 / QBV3 显式 magic 分流;QBV2 兼容链保留。 +- `RasterArraySource::submitEncodedField()` 改为统一走 `EncodedFieldIngress::decodeAndValidate()`。 +- CMake / Bazel Core 已注册 V3 decoder;CMake test 已注册 V3 decoder tests。 +- 新增 8 个 Current / Rain / Wave / Wind QBIN V3 Golden Fixtures、manifest 与中文校验脚本。 +- 新增 C++ 定向测试,覆盖 block boundary、Direction wrap、all-invalid、非最优 Rice-K、CRC/descriptor/validity corruption、Rice padding、残差越界、Node grid 与 ingress 分流。 +- Mac clang 定向构建并运行 8 个 V3 tests:8/8 PASS。 +- Native / RN 版本声明已统一升级到 `13.4.1-particle.9`。 + +## Block Geometry + +真实 8 帧 benchmark:16×16 数值 payload 为 2,470,419 bytes;32×32 为 2,514,288 bytes,32×32 增加约 1.7758%。32×32 的 decode 总时间仅减少约 2.67 ms / 8 帧,因此最终冻结 16×16。详见 `docs/30_设计/MapLibreParticle_QBIN_V3_Decoder与BlockGeometry实施决策_v1.md`。 + +## 当前验证 + +- `verify-qbin-v3-golden-fixtures.py`:PASS(8 files,block=16×16)。 +- QBIN V3 C++ 定向 tests:PASS 8 / FAIL 0。 +- iOS Metal `MapLibre.dynamic`:当前正在 Mac 专用 worktree 中做正式 Bazel 构建验证;此前仅因 worktree vendor submodule 未展开而在 analysis 阶段失败,已修复 submodule checkout。 + +## 下一步 + +完成 iOS Metal 正式构建结果确认后,执行 `git diff --check`、生成完成报告、提交并 push 到 `origin/particle/main`。Android Gradle / APK / ADB 不在本任务自动执行范围内。 diff --git a/docs/报告/STEP-087_QBIN_V3解码与13.4.1-particle.9升级_Codex完成报告_v1.md b/docs/报告/STEP-087_QBIN_V3解码与13.4.1-particle.9升级_Codex完成报告_v1.md new file mode 100644 index 000000000..d9c295e85 --- /dev/null +++ b/docs/报告/STEP-087_QBIN_V3解码与13.4.1-particle.9升级_Codex完成报告_v1.md @@ -0,0 +1,49 @@ +# STEP-087 QBIN V3 解码与 13.4.1-particle.9 升级完成报告 v1 + +## 1. 执行方式 + +本任务最初尝试在 Mac 上交给 Codex 执行,但 Codex CLI 返回 usage limit,未实际执行修改。后续由 ChatGPT 通过 RDC 直接在 Mac 专用工作树 `/Users/tei/dev/MapLibreParticleLab-qbv3-p9` 完成代码、测试、版本升级、文档与 Git 收尾。 + +## 2. 实施内容 + +- 基线包含 `13.4.1-particle.8` 的 Android / iOS 统一发行改动。 +- 新增 `QbinV3Decoder`,按 WeatherV2 QBIN V3 v2.2 冻结稿实现固定 Header / Descriptor、CRC32、canonical packing、VALIDITY、Predictor + ZigZag + Rice、Direction 环形残差与资源上限校验。 +- 生产 Block Geometry 固定为 16×16;不做双块尺寸猜测。 +- `EncodedFieldIngress` 显式识别 `QBV2` / `QBV3` magic;V2 兼容链保留,V3 使用新 decoder;解码后仍统一进入 `FieldProfileValidator`。 +- `RasterArraySource::submitEncodedField()` 改为统一使用 `EncodedFieldIngress::decodeAndValidate()`。 +- CMake / Bazel Core 注册 V3 decoder;CMake test 注册 V3 tests。 +- 新增 Current / Rain / Wave / Wind 共 8 个 QBIN V3 Golden Fixtures 及 manifest / 校验脚本。 +- Native / RN 当前版本声明统一升级到 `13.4.1-particle.9`。 + +## 3. Block Geometry 结果 + +C++ benchmark 使用四类真实 Golden 数据共 8 帧: + +- 16×16:2,470,419 bytes;encode 218.713 ms;decode 104.121 ms。 +- 32×32:2,514,288 bytes;encode 200.101 ms;decode 101.451 ms。 +- 32×32 payload 增加 43,869 bytes(约 +1.7758%),而 8 帧总 decode 仅减少约 2.67 ms。 +- 因网络/缓存/跨桥传输尺寸是 QBIN V3 的核心目标,最终冻结 16×16。 + +## 4. 验证结果 + +- QBIN V3 Golden Fixtures 校验:PASS,8 files,block=16×16。 +- C++ 定向测试:PASS 8 / FAIL 0。 +- Current / Rain / Wave / Wind V3 解码结果与对应 V2 canonical raw 做逐 plane / validity byte-for-byte 对比:PASS 8 / FAIL 0。 +- `git diff --check`:将在提交前执行。 +- iOS Metal `MapLibre.dynamic` 正式 Bazel 构建:__IOS_BUILD_RESULT__。 +- Android Gradle / APK / ADB:按项目规则未执行,由用户手工完成。 + +## 5. 文档与数据 + +- 原始设计:`docs/30_设计/WeatherV2_QBIN_V3通用天气场二进制格式与React独立Render_Binding设计_v2.2_最终冻结稿.md`。 +- Block Geometry 决策:`docs/30_设计/MapLibreParticle_QBIN_V3_Decoder与BlockGeometry实施决策_v1.md`。 +- Step 记录:`docs/50_Step记录/STEP-087_QBIN_V3解码与13.4.1-particle.9升级_v1.md`。 +- Golden Fixtures:`integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/`。 + +## 6. Git 收尾 + +- 实现提交:__IMPLEMENTATION_COMMIT__ +- 提交信息:__IMPLEMENTATION_MESSAGE__ +- Push:__PUSH_RESULT__ +- 远端分支:`origin/particle/main` +- Final HEAD:__FINAL_HEAD__ diff --git a/include/mbgl/particle/ingress/qbin_v2_decoder.hpp b/include/mbgl/particle/ingress/qbin_v2_decoder.hpp index daa5c980d..dfba45a00 100644 --- a/include/mbgl/particle/ingress/qbin_v2_decoder.hpp +++ b/include/mbgl/particle/ingress/qbin_v2_decoder.hpp @@ -15,19 +15,42 @@ struct EncodedFieldRuntimeMetadata { uint64_t dataVersion = 0; }; -enum class QbinV2DecodeErrorCode : uint8_t { - Accepted, InvalidRuntimeMetadata, InputTooSmall, BadMagic, UnsupportedVersion, UnsupportedLayout, - UnsupportedProjection, UnsupportedSampleOrder, UnsupportedGridRegistration, InvalidHeader, InvalidBounds, - InvalidTime, LimitExceeded, LengthMismatch, ReservedNonZero, InvalidDescriptor, - NonCanonicalDescriptorOrder, DuplicatePlaneKey, NonCanonicalPacking, NonZeroPadding, CrcMismatch, - InvalidValidity, InvalidCellPayload, InvalidFrame, +enum class QbinDecodeErrorCode : uint8_t { + Accepted, + InvalidRuntimeMetadata, + InputTooSmall, + BadMagic, + UnsupportedVersion, + UnsupportedLayout, + UnsupportedProjection, + UnsupportedSampleOrder, + UnsupportedGridRegistration, + InvalidHeader, + InvalidBounds, + InvalidTime, + LimitExceeded, + LengthMismatch, + ReservedNonZero, + InvalidDescriptor, + NonCanonicalDescriptorOrder, + DuplicatePlaneKey, + NonCanonicalPacking, + NonZeroPadding, + CrcMismatch, + InvalidValidity, + InvalidCellPayload, + InvalidFrame, + MalformedRice, + ResidualOutOfRange, }; +using QbinV2DecodeErrorCode = QbinDecodeErrorCode; + struct QbinV2DecodeResult { - QbinV2DecodeErrorCode code = QbinV2DecodeErrorCode::InvalidHeader; + QbinDecodeErrorCode code = QbinDecodeErrorCode::InvalidHeader; std::string detail; std::shared_ptr frame; - bool accepted() const { return code == QbinV2DecodeErrorCode::Accepted && static_cast(frame); } + bool accepted() const { return code == QbinDecodeErrorCode::Accepted && static_cast(frame); } }; class QbinV2Decoder final { @@ -40,7 +63,7 @@ enum class EncodedFieldIngressCode : uint8_t { Accepted, DecodeRejected, Contrac struct EncodedFieldIngressResult { EncodedFieldIngressCode code = EncodedFieldIngressCode::DecodeRejected; std::string detail; - QbinV2DecodeErrorCode decodeCode = QbinV2DecodeErrorCode::InvalidHeader; + QbinDecodeErrorCode decodeCode = QbinDecodeErrorCode::InvalidHeader; FieldProfileValidationCode contractCode = FieldProfileValidationCode::InvalidContract; std::shared_ptr frame; bool accepted() const { return code == EncodedFieldIngressCode::Accepted && static_cast(frame); } diff --git a/include/mbgl/particle/ingress/qbin_v3_decoder.hpp b/include/mbgl/particle/ingress/qbin_v3_decoder.hpp new file mode 100644 index 000000000..9d9310af0 --- /dev/null +++ b/include/mbgl/particle/ingress/qbin_v3_decoder.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include + +namespace mbgl::particle::ingress { + +using QbinV3DecodeErrorCode = QbinDecodeErrorCode; + +struct QbinV3DecodeResult { + QbinDecodeErrorCode code = QbinDecodeErrorCode::InvalidHeader; + std::string detail; + std::shared_ptr frame; + + bool accepted() const { + return code == QbinDecodeErrorCode::Accepted && static_cast(frame); + } +}; + +class QbinV3Decoder final { +public: + static constexpr uint32_t BlockWidth = 16; + static constexpr uint32_t BlockHeight = 16; + + static QbinV3DecodeResult decode(const EncodedFieldRuntimeMetadata&, std::span); +}; + +} // namespace mbgl::particle::ingress diff --git a/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/current/T0.qbin b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/current/T0.qbin new file mode 100644 index 000000000..739c85006 Binary files /dev/null and b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/current/T0.qbin differ diff --git a/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/current/T1.qbin b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/current/T1.qbin new file mode 100644 index 000000000..6b62dcead Binary files /dev/null and b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/current/T1.qbin differ diff --git a/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/manifest.json b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/manifest.json new file mode 100644 index 000000000..673d1d9da --- /dev/null +++ b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/manifest.json @@ -0,0 +1,72 @@ +{ + "format": "QBV3", + "version": 3, + "blockWidth": 16, + "blockHeight": 16, + "fixtures": [ + { + "layer": "current", + "frame": "T0", + "bytes": 104592, + "sha256": "b1b6d4a1dbb98971c3daca6b781587464e3528dd929da25196409b4b3b2bc052", + "width": 513, + "height": 513 + }, + { + "layer": "current", + "frame": "T1", + "bytes": 104112, + "sha256": "0588b3b1ed9e2d491adefa308f7d0b0707a38d773cc8d0802cded77a24c2aa24", + "width": 513, + "height": 513 + }, + { + "layer": "rain", + "frame": "T0", + "bytes": 153904, + "sha256": "567d99c6309d185184193f203f6eb7b2891572730ec9299371e6a0db0a067d20", + "width": 513, + "height": 513 + }, + { + "layer": "rain", + "frame": "T1", + "bytes": 153904, + "sha256": "8688d0373534d1c67ab4eed836e1314f14eba639594666683e6c22ff091e14fc", + "width": 513, + "height": 513 + }, + { + "layer": "wave", + "frame": "T0", + "bytes": 520688, + "sha256": "e4409ba0b09003e663dacdce8fc2ef35a1b929b5ccba8dc82e1ad0fc8c2f1202", + "width": 513, + "height": 769 + }, + { + "layer": "wave", + "frame": "T1", + "bytes": 520016, + "sha256": "4827e4c9a7917da944cdf65212cc6371b0b7276fb9ae3487625f1c539b27f178", + "width": 513, + "height": 769 + }, + { + "layer": "wind", + "frame": "T0", + "bytes": 621120, + "sha256": "e815d94c18711c70e27c570cfd89acda3a27d64646d04e0b29846404ca325979", + "width": 513, + "height": 769 + }, + { + "layer": "wind", + "frame": "T1", + "bytes": 622832, + "sha256": "bb2dbde0a8984d2fda4140cb6393744ede14fc9327663091e7afd4d2e37606da", + "width": 513, + "height": 769 + } + ] +} diff --git a/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/rain/T0.qbin b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/rain/T0.qbin new file mode 100644 index 000000000..903e5e9e7 Binary files /dev/null and b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/rain/T0.qbin differ diff --git a/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/rain/T1.qbin b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/rain/T1.qbin new file mode 100644 index 000000000..486ee0c19 Binary files /dev/null and b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/rain/T1.qbin differ diff --git a/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/wave/T0.qbin b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/wave/T0.qbin new file mode 100644 index 000000000..a59c7087e Binary files /dev/null and b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/wave/T0.qbin differ diff --git a/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/wave/T1.qbin b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/wave/T1.qbin new file mode 100644 index 000000000..dd688c8de Binary files /dev/null and b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/wave/T1.qbin differ diff --git a/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/wind/T0.qbin b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/wind/T0.qbin new file mode 100644 index 000000000..d07782034 Binary files /dev/null and b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/wind/T0.qbin differ diff --git a/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/wind/T1.qbin b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/wind/T1.qbin new file mode 100644 index 000000000..5cb9581d4 Binary files /dev/null and b/integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden/wind/T1.qbin differ diff --git a/integration/react-native/MapLibreParticleRNTestHost/plugins/withParticleNativeAndroid.js b/integration/react-native/MapLibreParticleRNTestHost/plugins/withParticleNativeAndroid.js index a051804c4..49a8150ce 100644 --- a/integration/react-native/MapLibreParticleRNTestHost/plugins/withParticleNativeAndroid.js +++ b/integration/react-native/MapLibreParticleRNTestHost/plugins/withParticleNativeAndroid.js @@ -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.8")\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.9")\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; }); }; diff --git a/integration/react-native/MapLibreParticleRNTestHost/scripts/verify-qbin-v3-golden-fixtures.py b/integration/react-native/MapLibreParticleRNTestHost/scripts/verify-qbin-v3-golden-fixtures.py new file mode 100755 index 000000000..09110a6fc --- /dev/null +++ b/integration/react-native/MapLibreParticleRNTestHost/scripts/verify-qbin-v3-golden-fixtures.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""校验仓库内冻结的 QBIN V3 Golden Fixtures。""" + +import hashlib +import json +import struct +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[4] +ASSETS = ROOT / "integration/react-native/MapLibreParticleRNTestHost/assets/qbin-v3-golden" +MANIFEST = ASSETS / "manifest.json" + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main() -> int: + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + if manifest.get("format") != "QBV3" or manifest.get("version") != 3: + raise RuntimeError("manifest 格式或版本不是 QBV3/v3") + if manifest.get("blockWidth") != 16 or manifest.get("blockHeight") != 16: + raise RuntimeError("Block Geometry 必须冻结为 16x16") + + fixtures = manifest.get("fixtures", []) + if len(fixtures) != 8: + raise RuntimeError(f"必须存在 8 个 Golden Fixture,当前为 {len(fixtures)} 个") + + for item in fixtures: + path = ASSETS / item["layer"] / f'{item["frame"]}.qbin' + data = path.read_bytes() + if data[:4] != b"QBV3" or data[4] != 3: + raise RuntimeError(f"{path}: magic/version 错误") + width, height = struct.unpack_from(" { 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.8")\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.9")\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; }); diff --git a/packages/maplibre-particle-rn/package.json b/packages/maplibre-particle-rn/package.json index 6c89dd215..632ac8f43 100644 --- a/packages/maplibre-particle-rn/package.json +++ b/packages/maplibre-particle-rn/package.json @@ -1,6 +1,6 @@ { "name": "@navsea/maplibre-particle-rn", - "version": "13.4.1-particle.8", + "version": "13.4.1-particle.9", "description": "NavSea MapLibre Particle React Native pure Field Renderer API.", "license": "BSD-2-Clause", "main": "src/index.ts", diff --git a/platform/android/VERSION b/platform/android/VERSION index e32dcf50f..08bc14bb4 100644 --- a/platform/android/VERSION +++ b/platform/android/VERSION @@ -1 +1 @@ -13.4.1-particle.8 +13.4.1-particle.9 diff --git a/src/mbgl/particle/ingress/qbin_v2_decoder.cpp b/src/mbgl/particle/ingress/qbin_v2_decoder.cpp index 8097f1c9c..06bf489ff 100644 --- a/src/mbgl/particle/ingress/qbin_v2_decoder.cpp +++ b/src/mbgl/particle/ingress/qbin_v2_decoder.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -71,5 +72,53 @@ QbinV2DecodeResult QbinV2Decoder::decode(const EncodedFieldRuntimeMetadata& meta if (!out->isStructurallyValid()) return fail(QbinV2DecodeErrorCode::InvalidFrame, "decoded frame structural validity"); return {QbinV2DecodeErrorCode::Accepted,"accepted",out}; } -EncodedFieldIngressResult EncodedFieldIngress::decodeAndValidate(const EncodedFieldRuntimeMetadata& m,const ResolvedFieldProfileContract* c,std::span b){ auto decoded=QbinV2Decoder::decode(m,b); if(!decoded.accepted()) return {EncodedFieldIngressCode::DecodeRejected,decoded.detail,decoded.code,FieldProfileValidationCode::InvalidContract,{}}; if(!c) return {EncodedFieldIngressCode::ContractMissing,"contract",decoded.code,FieldProfileValidationCode::InvalidContract,{}}; auto v=FieldProfileValidator::validate(*c,*decoded.frame); if(!v.accepted()) return {EncodedFieldIngressCode::ContractRejected,v.detail,decoded.code,v.code,{}}; return {EncodedFieldIngressCode::Accepted,"accepted",decoded.code,v.code,decoded.frame}; } +EncodedFieldIngressResult EncodedFieldIngress::decodeAndValidate(const EncodedFieldRuntimeMetadata& m, + const ResolvedFieldProfileContract* c, + std::span b) { + QbinDecodeErrorCode decodeCode = QbinDecodeErrorCode::BadMagic; + std::string detail; + std::shared_ptr frame; + + if (b.size() >= 4 && std::memcmp(b.data(), "QBV2", 4) == 0) { + auto decoded = QbinV2Decoder::decode(m, b); + decodeCode = decoded.code; + detail = std::move(decoded.detail); + frame = std::move(decoded.frame); + } else if (b.size() >= 4 && std::memcmp(b.data(), "QBV3", 4) == 0) { + auto decoded = QbinV3Decoder::decode(m, b); + decodeCode = decoded.code; + detail = std::move(decoded.detail); + frame = std::move(decoded.frame); + } else { + return {EncodedFieldIngressCode::DecodeRejected, + "QBIN magic", + QbinDecodeErrorCode::BadMagic, + FieldProfileValidationCode::InvalidContract, + {}}; + } + + if (decodeCode != QbinDecodeErrorCode::Accepted || !frame) { + return {EncodedFieldIngressCode::DecodeRejected, + std::move(detail), + decodeCode, + FieldProfileValidationCode::InvalidContract, + {}}; + } + if (!c) { + return {EncodedFieldIngressCode::ContractMissing, + "contract", + decodeCode, + FieldProfileValidationCode::InvalidContract, + {}}; + } + const auto validation = FieldProfileValidator::validate(*c, *frame); + if (!validation.accepted()) { + return {EncodedFieldIngressCode::ContractRejected, + validation.detail, + decodeCode, + validation.code, + {}}; + } + return {EncodedFieldIngressCode::Accepted, "accepted", decodeCode, validation.code, std::move(frame)}; +} } // namespace mbgl::particle::ingress diff --git a/src/mbgl/particle/ingress/qbin_v3_decoder.cpp b/src/mbgl/particle/ingress/qbin_v3_decoder.cpp new file mode 100644 index 000000000..742892406 --- /dev/null +++ b/src/mbgl/particle/ingress/qbin_v3_decoder.cpp @@ -0,0 +1,674 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mbgl::particle::ingress { +namespace { + +constexpr size_t HeaderSize = 112; +constexpr size_t DescriptorSize = 32; +constexpr size_t MaxBytes = 64u * 1024u * 1024u; +constexpr uint32_t MaxPlanes = 32; +constexpr uint32_t MaxCells = 2'097'152; +constexpr uint32_t MaxDecoded = 32u * 1024u * 1024u; +constexpr uint16_t ValiditySemantic = 3; + +uint16_t readU16(std::span b, size_t o) { + return uint16_t(b[o]) | (uint16_t(b[o + 1]) << 8); +} + +uint32_t readU32(std::span b, size_t o) { + uint32_t v = 0; + for (size_t i = 0; i < 4; ++i) v |= uint32_t(b[o + i]) << (8 * i); + return v; +} +uint64_t readU64(std::span b, size_t o) { + uint64_t v = 0; + for (size_t i = 0; i < 8; ++i) v |= uint64_t(b[o + i]) << (8 * i); + return v; +} + +int64_t readI64(std::span b, size_t o) { + const uint64_t bits = readU64(b, o); + int64_t value = 0; + std::memcpy(&value, &bits, sizeof(value)); + return value; +} + +float readF32(std::span b, size_t o) { + const uint32_t bits = readU32(b, o); + float value = 0.0f; + std::memcpy(&value, &bits, sizeof(value)); + return value; +} + +double readF64(std::span b, size_t o) { + const uint64_t bits = readU64(b, o); + double value = 0.0; + std::memcpy(&value, &bits, sizeof(value)); + return value; +} + +uint32_t floatBits(float value) { + uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + return bits; +} +uint32_t crc32(std::span bytes) { + uint32_t c = 0xffffffffu; + for (size_t i = 0; i < bytes.size(); ++i) { + const uint8_t x = (i >= 76 && i < 80) ? 0 : bytes[i]; + c ^= x; + for (int j = 0; j < 8; ++j) { + c = (c >> 1) ^ ((c & 1u) ? 0xedb88320u : 0u); + } + } + return ~c; +} + +size_t align16(size_t x) { + return (x + 15u) & ~size_t(15u); +} + +bool allZero(std::span bytes) { + for (const auto byte : bytes) { + if (byte != 0) return false; + } + return true; +} + +QbinV3DecodeResult fail(QbinDecodeErrorCode code, std::string detail) { + return {code, std::move(detail), {}}; +} + +struct Descriptor { + uint16_t semantic = 0; + uint16_t unit = 0; + uint16_t group = 0; + uint8_t dtype = 0; + uint8_t flags = 0; + uint8_t valueClass = 0; + uint8_t component = 0; + float scale = 0.0f; + float offset = 0.0f; + uint32_t scaleBits = 0; + uint32_t offsetBits = 0; + uint32_t count = 0; + uint32_t byteOffset = 0; + uint32_t byteLength = 0; + uint32_t directionN = 0; +}; + +std::string contentIdentity(const Descriptor& d, std::span raw) { + uint64_t hash = 1469598103934665603ull; + auto addByte = [&](uint8_t v) { + hash ^= v; + hash *= 1099511628211ull; + }; + auto addLE = [&](uint64_t value, size_t count) { + for (size_t i = 0; i < count; ++i) addByte(uint8_t(value >> (8 * i))); + }; + + for (const auto v : raw) addByte(v); + addLE(d.semantic, 2); + addLE(d.group, 2); + addByte(d.component); + addByte(d.dtype); + addByte(d.flags); + addByte(d.valueClass); + addLE(d.unit, 2); + addLE(d.count, 4); + addLE(d.scaleBits, 4); + addLE(d.offsetBits, 4); + + std::ostringstream out; + out << std::hex << hash; + return out.str(); +} + +std::optional directionCount(float scale) { + if (!std::isfinite(scale) || scale <= 0.0f) return std::nullopt; + const double ratioToN = 360.0 / double(scale); + if (!std::isfinite(ratioToN) || ratioToN < 1.0 || ratioToN > 65536.0) return std::nullopt; + + const uint32_t n = static_cast(std::floor(ratioToN + 0.5)); + if (n < 1 || n > 65536) return std::nullopt; + + const double ratio64 = 360.0 / double(n); + if (!std::isfinite(ratio64)) return std::nullopt; + const float canonicalScale = static_cast(ratio64); + if (floatBits(canonicalScale) != floatBits(scale)) return std::nullopt; + return n; +} + +uint64_t logicalDecodedBytes(const Descriptor& d, uint64_t cells) { + switch (d.dtype) { + case 1: + case 2: + return cells * 2u; + case 3: + case 4: + return cells; + case 6: + return (cells + 7u) / 8u; + default: + return std::numeric_limits::max(); + } +} + +bool validAt(std::span validity, uint64_t index) { + return ((validity[index >> 3] >> (index & 7u)) & 1u) != 0; +} + +std::pair dtypeRange(uint8_t dtype) { + switch (dtype) { + case 1: + return {-32768, 32767}; + case 2: + return {0, 65535}; + case 3: + return {0, 255}; + case 4: + return {-128, 127}; + default: + return {1, 0}; + } +} + +int64_t readRaw(const std::vector& raw, uint64_t index, uint8_t dtype) { + if (dtype == 1 || dtype == 2) { + const size_t o = size_t(index) * 2u; + const uint16_t bits = uint16_t(raw[o]) | (uint16_t(raw[o + 1]) << 8); + if (dtype == 2) return bits; + int16_t value = 0; + std::memcpy(&value, &bits, sizeof(value)); + return value; + } + + const uint8_t bits = raw[size_t(index)]; + if (dtype == 3) return bits; + int8_t value = 0; + std::memcpy(&value, &bits, sizeof(value)); + return value; +} + +void writeRaw(std::vector& raw, uint64_t index, uint8_t dtype, int64_t value) { + if (dtype == 1 || dtype == 2) { + uint16_t bits = 0; + if (dtype == 1) { + const int16_t signedValue = static_cast(value); + std::memcpy(&bits, &signedValue, sizeof(bits)); + } else { + bits = static_cast(value); + } + const size_t o = size_t(index) * 2u; + raw[o] = uint8_t(bits); + raw[o + 1] = uint8_t(bits >> 8); + return; + } + + if (dtype == 3) { + raw[size_t(index)] = static_cast(value); + } else { + const int8_t signedValue = static_cast(value); + uint8_t bits = 0; + std::memcpy(&bits, &signedValue, sizeof(bits)); + raw[size_t(index)] = bits; + } +} + +int64_t normalizeDirection(int64_t value, uint32_t n) { + if (value < 0) value += n; + else if (value >= int64_t(n)) value -= n; + return value; +} + +class BitReader { +public: + explicit BitReader(std::span input_) : input(input_) {} + + bool readBit(uint8_t& bit) { + if (bitPosition >= input.size() * 8u) return false; + bit = (input[bitPosition >> 3] >> (bitPosition & 7u)) & 1u; + ++bitPosition; + return true; + } + + bool readBits(uint8_t count, uint32_t& value) { + value = 0; + for (uint8_t bitIndex = 0; bitIndex < count; ++bitIndex) { + uint8_t bit = 0; + if (!readBit(bit)) return false; + value |= uint32_t(bit) << bitIndex; + } + return true; + } + + size_t position() const { return bitPosition; } + +private: + std::span input; + size_t bitPosition = 0; +}; + +struct PlaneDecode { + QbinDecodeErrorCode code = QbinDecodeErrorCode::Accepted; + std::string detail; + std::shared_ptr> raw; + + bool accepted() const { + return code == QbinDecodeErrorCode::Accepted && static_cast(raw); + } +}; + +PlaneDecode decodeNumericPlane(const Descriptor& d, + std::span encoded, + std::span validity, + uint32_t width, + uint32_t height) { + const uint64_t cells = uint64_t(width) * height; + const size_t bytesPerCell = (d.dtype == 1 || d.dtype == 2) ? 2u : 1u; + auto output = std::make_shared>(size_t(cells) * bytesPerCell, 0); + + const bool direction = d.valueClass == 2; + const auto [minRaw, maxRaw] = dtypeRange(d.dtype); + const uint64_t maxAbsResidual = direction ? uint64_t(d.directionN / 2u) + : uint64_t(2) * uint64_t(maxRaw - minRaw); + const uint64_t maxZigZag = 2u * maxAbsResidual; + + size_t cursor = 0; + for (uint32_t rowBegin = 0; rowBegin < height; rowBegin += QbinV3Decoder::BlockHeight) { + const uint32_t rowEnd = std::min(rowBegin + QbinV3Decoder::BlockHeight, height); + for (uint32_t colBegin = 0; colBegin < width; colBegin += QbinV3Decoder::BlockWidth) { + const uint32_t colEnd = std::min(colBegin + QbinV3Decoder::BlockWidth, width); + + uint32_t symbolCount = 0; + for (uint32_t row = rowBegin; row < rowEnd; ++row) { + for (uint32_t col = colBegin; col < colEnd; ++col) { + if (validAt(validity, uint64_t(row) * width + col)) ++symbolCount; + } + } + if (symbolCount == 0) continue; + + if (cursor >= encoded.size()) { + return {QbinDecodeErrorCode::MalformedRice, "missing riceK", {}}; + } + const uint8_t riceK = encoded[cursor++]; + if (riceK > 31) { + return {QbinDecodeErrorCode::MalformedRice, "riceK > 31", {}}; + } + + const uint64_t maxQ = maxZigZag >> riceK; + BitReader reader(encoded.subspan(cursor)); + uint32_t decodedSymbols = 0; + + for (uint32_t row = rowBegin; row < rowEnd; ++row) { + for (uint32_t col = colBegin; col < colEnd; ++col) { + const uint64_t index = uint64_t(row) * width + col; + if (!validAt(validity, index)) continue; + + uint64_t q = 0; + while (true) { + uint8_t bit = 0; + if (!reader.readBit(bit)) { + return {QbinDecodeErrorCode::MalformedRice, "unterminated unary", {}}; + } + if (bit != 0) break; + if (q >= maxQ) { + return {QbinDecodeErrorCode::ResidualOutOfRange, "rice quotient exceeds logical maximum", {}}; + } + ++q; + } + + uint32_t remainder = 0; + if (!reader.readBits(riceK, remainder)) { + return {QbinDecodeErrorCode::MalformedRice, "truncated rice remainder", {}}; + } + if (q > (std::numeric_limits::max() >> riceK)) { + return {QbinDecodeErrorCode::ResidualOutOfRange, "rice symbol overflow", {}}; + } + const uint64_t zigzag = (q << riceK) | remainder; + if (zigzag > maxZigZag || zigzag > std::numeric_limits::max()) { + return {QbinDecodeErrorCode::ResidualOutOfRange, "zigzag exceeds logical maximum", {}}; + } + + const int64_t residual = (zigzag & 1u) ? -int64_t(zigzag / 2u) - 1 + : int64_t(zigzag / 2u); + if (residual < -int64_t(maxAbsResidual) || residual > int64_t(maxAbsResidual)) { + return {QbinDecodeErrorCode::ResidualOutOfRange, "residual exceeds logical maximum", {}}; + } + if (direction && (d.directionN % 2u == 0u) && + residual == int64_t(d.directionN / 2u)) { + return {QbinDecodeErrorCode::ResidualOutOfRange, "non-canonical positive half-circle residual", {}}; + } + + const bool hasLeft = col > 0 && validAt(validity, index - 1); + const bool hasUp = row > 0 && validAt(validity, index - width); + const bool hasUpperLeft = row > 0 && col > 0 && validAt(validity, index - width - 1); + + int64_t predicted = 0; + if (hasLeft && hasUp && hasUpperLeft) { + predicted = readRaw(*output, index - 1, d.dtype) + + readRaw(*output, index - width, d.dtype) - + readRaw(*output, index - width - 1, d.dtype); + if (direction) predicted = normalizeDirection(predicted, d.directionN); + } else if (hasLeft) { + predicted = readRaw(*output, index - 1, d.dtype); + } else if (hasUp) { + predicted = readRaw(*output, index - width, d.dtype); + } + + int64_t reconstructed = predicted + residual; + if (direction) { + reconstructed = normalizeDirection(reconstructed, d.directionN); + if (reconstructed < 0 || reconstructed >= int64_t(d.directionN)) { + return {QbinDecodeErrorCode::InvalidCellPayload, "direction raw outside circular domain", {}}; + } + } else if (reconstructed < minRaw || reconstructed > maxRaw) { + return {QbinDecodeErrorCode::InvalidCellPayload, "numeric raw outside dtype range", {}}; + } + + writeRaw(*output, index, d.dtype, reconstructed); + ++decodedSymbols; + } + } + + if (decodedSymbols != symbolCount) { + return {QbinDecodeErrorCode::MalformedRice, "block symbol count mismatch", {}}; + } + + const size_t remainderBits = reader.position() & 7u; + if (remainderBits != 0) { + const size_t paddingBits = 8u - remainderBits; + for (size_t i = 0; i < paddingBits; ++i) { + uint8_t bit = 0; + if (!reader.readBit(bit)) { + return {QbinDecodeErrorCode::MalformedRice, "truncated block padding", {}}; + } + if (bit != 0) { + return {QbinDecodeErrorCode::MalformedRice, "nonzero block padding", {}}; + } + } + } + + const size_t blockBytes = reader.position() / 8u; + if (blockBytes > encoded.size() - cursor) { + return {QbinDecodeErrorCode::MalformedRice, "block exceeds plane", {}}; + } + cursor += blockBytes; + } + } + + if (cursor != encoded.size()) { + return {QbinDecodeErrorCode::MalformedRice, "extra encoded bytes after final block", {}}; + } + + return {QbinDecodeErrorCode::Accepted, "accepted", output}; +} + +} // namespace + +QbinV3DecodeResult QbinV3Decoder::decode(const EncodedFieldRuntimeMetadata& meta, + std::span bytes) { + if (meta.fieldID.empty() || meta.generation == 0) { + return fail(QbinDecodeErrorCode::InvalidRuntimeMetadata, "fieldID/generation"); + } + if (bytes.size() < HeaderSize) return fail(QbinDecodeErrorCode::InputTooSmall, "header"); + if (bytes.size() > MaxBytes) return fail(QbinDecodeErrorCode::LimitExceeded, "file bytes"); + if (std::memcmp(bytes.data(), "QBV3", 4) != 0) return fail(QbinDecodeErrorCode::BadMagic, "magic"); + if (bytes[4] != 3) return fail(QbinDecodeErrorCode::UnsupportedVersion, "version"); + + if (bytes[5] != 1) return fail(QbinDecodeErrorCode::UnsupportedLayout, "layout"); + if (bytes[8] != 1) return fail(QbinDecodeErrorCode::UnsupportedProjection, "projection"); + if (bytes[9] != 1) return fail(QbinDecodeErrorCode::UnsupportedSampleOrder, "sampleOrder"); + if (bytes[10] != 1) return fail(QbinDecodeErrorCode::UnsupportedGridRegistration, "gridRegistration"); + if (bytes[11] != 0 || readU16(bytes, 14) != 0 || readU16(bytes, 82) != 0 || + readU32(bytes, 92) != 0 || readU32(bytes, 96) != 0 || readU32(bytes, 100) != 0 || + readU32(bytes, 104) != 0 || readU32(bytes, 108) != 0) { + return fail(QbinDecodeErrorCode::ReservedNonZero, "header reserved"); + } + + const uint16_t planeCount = readU16(bytes, 12); + const uint32_t width = readU32(bytes, 16); + const uint32_t height = readU32(bytes, 20); + const uint32_t payloadLength = readU32(bytes, 72); + if (planeCount == 0 || planeCount > MaxPlanes || width < 2 || height < 2) { + return fail(QbinDecodeErrorCode::InvalidHeader, "planes/grid"); + } + + const uint64_t cells = uint64_t(width) * uint64_t(height); + if (cells > MaxCells) return fail(QbinDecodeErrorCode::LimitExceeded, "cell count"); + if (readU16(bytes, 80) != 0xffff || readU32(bytes, 84) != 0 || readU32(bytes, 88) != 0) { + return fail(QbinDecodeErrorCode::InvalidHeader, "FRAME tile"); + } + + const int64_t referenceTime = readI64(bytes, 24); + const int64_t validTime = readI64(bytes, 32); + constexpr uint64_t MaxSecondsForMs = std::numeric_limits::max() / 1000u; + if (referenceTime <= 0 || validTime <= 0 || + uint64_t(referenceTime) > MaxSecondsForMs || uint64_t(validTime) > MaxSecondsForMs) { + return fail(QbinDecodeErrorCode::InvalidTime, "UTC seconds"); + } + const double west = readF64(bytes, 40); + const double south = readF64(bytes, 48); + const double east = readF64(bytes, 56); + const double north = readF64(bytes, 64); + for (const size_t offset : {size_t(40), size_t(48), size_t(56), size_t(64)}) { + if (readF64(bytes, offset) == 0.0 && readU64(bytes, offset) != 0) { + return fail(QbinDecodeErrorCode::InvalidBounds, "negative-zero bound"); + } + } + if (!std::isfinite(west) || !std::isfinite(south) || !std::isfinite(east) || !std::isfinite(north) || + west < -180.0 || east > 180.0 || west >= east || + south < -85.0511287798066 || north > 85.0511287798066 || south >= north) { + return fail(QbinDecodeErrorCode::InvalidBounds, "Web Mercator bounds"); + } + + const size_t payloadOffset = HeaderSize + size_t(planeCount) * DescriptorSize; + if (payloadOffset > std::numeric_limits::max() - payloadLength || + bytes.size() != payloadOffset + payloadLength) { + return fail(QbinDecodeErrorCode::LengthMismatch, "total length"); + } + + std::vector descriptors; + descriptors.reserve(planeCount); + std::set> keys; + std::tuple previous{}; + bool first = true; + int validityIndex = -1; + size_t expectedOffset = 0; + uint64_t decodedBytes = 0; + for (uint16_t i = 0; i < planeCount; ++i) { + const size_t o = HeaderSize + size_t(i) * DescriptorSize; + Descriptor d; + d.semantic = readU16(bytes, o); + d.dtype = bytes[o + 2]; + d.flags = bytes[o + 3]; + d.scaleBits = readU32(bytes, o + 4); + d.offsetBits = readU32(bytes, o + 8); + d.scale = readF32(bytes, o + 4); + d.offset = readF32(bytes, o + 8); + d.count = readU32(bytes, o + 12); + d.byteOffset = readU32(bytes, o + 16); + d.byteLength = readU32(bytes, o + 20); + d.unit = readU16(bytes, o + 24); + d.group = readU16(bytes, o + 26); + d.valueClass = bytes[o + 28]; + d.component = bytes[o + 29]; + + if (readU16(bytes, o + 30) != 0 || (d.flags & ~1u) != 0) { + return fail(QbinDecodeErrorCode::InvalidDescriptor, "descriptor reserved/flags " + std::to_string(i)); + } + const auto order = std::tuple(d.semantic, d.group, d.component); + if (!first && order <= previous) { + return fail(QbinDecodeErrorCode::NonCanonicalDescriptorOrder, "descriptor " + std::to_string(i)); + } + first = false; + previous = order; + if (!keys.insert({d.semantic, d.group}).second) { + return fail(QbinDecodeErrorCode::DuplicatePlaneKey, "descriptor " + std::to_string(i)); + } + if (d.semantic == 0 || d.valueClass > 3 || d.component > 3 || d.count != cells) { + return fail(QbinDecodeErrorCode::InvalidDescriptor, "descriptor values " + std::to_string(i)); + } + + const bool validitySemantic = d.semantic == ValiditySemantic; + const bool validityClass = d.valueClass == 3; + const bool bitset = d.dtype == 6; + if (validitySemantic != validityClass || validityClass != bitset) { + return fail(QbinDecodeErrorCode::InvalidValidity, "VALIDITY semantic/valueClass/dtype"); + } + + if (validityClass) { + if (validityIndex != -1 || d.unit != 0 || d.group != 0 || d.component != 0 || + (d.flags & 1u) == 0 || d.scaleBits != 0x3f800000u || d.offsetBits != 0u) { + return fail(QbinDecodeErrorCode::InvalidValidity, "validity descriptor"); + } + validityIndex = i; + } else { + if (d.dtype < 1 || d.dtype > 4 || !std::isfinite(d.scale) || d.scale <= 0.0f || + !std::isfinite(d.offset) || (d.offset == 0.0f && d.offsetBits != 0u)) { + return fail(QbinDecodeErrorCode::InvalidDescriptor, "numeric descriptor " + std::to_string(i)); + } + + if (d.valueClass == 0 && d.component != 0) { + return fail(QbinDecodeErrorCode::InvalidDescriptor, "scalar component"); + } + if (d.valueClass == 1 && + (d.unit != 1 || d.component < 1 || d.component > 3 || d.group == 0)) { + return fail(QbinDecodeErrorCode::InvalidDescriptor, "velocity descriptor"); + } + if (d.valueClass == 2) { + if (d.dtype != 2 || d.unit != 4 || d.component != 0 || d.group != 0 || d.offsetBits != 0u) { + return fail(QbinDecodeErrorCode::InvalidDescriptor, "direction descriptor"); + } + const auto n = directionCount(d.scale); + if (!n) return fail(QbinDecodeErrorCode::InvalidDescriptor, "direction canonical scale"); + d.directionN = *n; + } + } + + if (d.byteOffset != expectedOffset || d.byteOffset > payloadLength || + d.byteLength > payloadLength - d.byteOffset) { + return fail(d.byteOffset != expectedOffset ? QbinDecodeErrorCode::NonCanonicalPacking + : QbinDecodeErrorCode::InvalidDescriptor, + "descriptor packing " + std::to_string(i)); + } + expectedOffset = align16(size_t(d.byteOffset) + d.byteLength); + + const uint64_t logicalBytes = logicalDecodedBytes(d, cells); + if (logicalBytes == std::numeric_limits::max() || + decodedBytes > MaxDecoded - logicalBytes) { + return fail(QbinDecodeErrorCode::LimitExceeded, "decoded bytes"); + } + decodedBytes += logicalBytes; + descriptors.push_back(d); + } + + if (validityIndex < 0) return fail(QbinDecodeErrorCode::InvalidValidity, "missing validity"); + if (expectedOffset != payloadLength) { + return fail(QbinDecodeErrorCode::NonCanonicalPacking, "payload length"); + } + + if (crc32(bytes) != readU32(bytes, 76)) { + return fail(QbinDecodeErrorCode::CrcMismatch, "file CRC32"); + } + + for (size_t i = 0; i < descriptors.size(); ++i) { + const auto& d = descriptors[i]; + const size_t end = size_t(d.byteOffset) + d.byteLength; + const size_t next = i + 1 < descriptors.size() ? descriptors[i + 1].byteOffset : payloadLength; + if (next > end && !allZero(bytes.subspan(payloadOffset + end, next - end))) { + return fail(QbinDecodeErrorCode::NonZeroPadding, "plane/tail padding"); + } + } + + const auto& validityDescriptor = descriptors[size_t(validityIndex)]; + const size_t expectedValidityBytes = size_t((cells + 7u) / 8u); + if (validityDescriptor.byteLength != expectedValidityBytes) { + return fail(QbinDecodeErrorCode::InvalidValidity, "validity byteLength"); + } + const auto validity = bytes.subspan(payloadOffset + validityDescriptor.byteOffset, + validityDescriptor.byteLength); + if ((cells & 7u) != 0) { + const uint8_t unusedMask = uint8_t(0xffu << (cells & 7u)); + if ((validity.back() & unusedMask) != 0) { + return fail(QbinDecodeErrorCode::InvalidValidity, "unused validity bits"); + } + } + + uint64_t validCount = 0; + for (uint64_t i = 0; i < cells; ++i) { + if (validAt(validity, i)) ++validCount; + } + for (size_t i = 0; i < descriptors.size(); ++i) { + if (int(i) == validityIndex) continue; + if ((validCount == 0 && descriptors[i].byteLength != 0) || + (validCount != 0 && descriptors[i].byteLength == 0)) { + return fail(QbinDecodeErrorCode::InvalidDescriptor, "numeric byteLength vs validity"); + } + } + + auto output = std::make_shared(); + output->frameContentIdentity = { + meta.fieldID, + meta.generation, + meta.dataVersion, + uint64_t(validTime) * 1000u, + }; + output->referenceTimeMs = uint64_t(referenceTime) * 1000u; + output->grid = {1, 1, 1, 1, width, height, west, south, east, north}; + + for (size_t i = 0; i < descriptors.size(); ++i) { + const auto& d = descriptors[i]; + std::shared_ptr> raw; + if (int(i) == validityIndex) { + raw = std::make_shared>(validity.begin(), validity.end()); + } else if (validCount == 0) { + const size_t logicalBytes = size_t(logicalDecodedBytes(d, cells)); + raw = std::make_shared>(logicalBytes, 0); + } else { + const auto encoded = bytes.subspan(payloadOffset + d.byteOffset, d.byteLength); + const auto decoded = decodeNumericPlane(d, encoded, validity, width, height); + if (!decoded.accepted()) return fail(decoded.code, decoded.detail); + raw = decoded.raw; + } + + DecodedFieldPlane plane{ + {d.semantic, d.group, static_cast(d.component)}, + static_cast(d.dtype), + static_cast(d.valueClass), + d.unit, + bool(d.flags & 1u), + d.count, + d.scale, + d.offset, + contentIdentity(d, *raw), + raw, + }; + + if (int(i) == validityIndex) { + output->validityContentIdentity = plane.contentIdentity; + output->validityBits = raw; + output->validityPlane = std::move(plane); + } else { + output->planes.push_back(std::move(plane)); + } + } + + if (!output->isStructurallyValid()) { + return fail(QbinDecodeErrorCode::InvalidFrame, "decoded frame structural validity"); + } + return {QbinDecodeErrorCode::Accepted, "accepted", output}; +} + +} // namespace mbgl::particle::ingress diff --git a/src/mbgl/style/sources/raster_array_source.cpp b/src/mbgl/style/sources/raster_array_source.cpp index 75f23f6ab..9aca4ebfa 100644 --- a/src/mbgl/style/sources/raster_array_source.cpp +++ b/src/mbgl/style/sources/raster_array_source.cpp @@ -428,11 +428,13 @@ RasterArrayEncodedFieldSubmitResult RasterArraySource::submitEncodedField( // checks metadata, but validate it here to make the Host ordering explicit. if (!validEncodedFieldKey(fieldKey)) return RasterArrayEncodedFieldSubmitResult::RejectedInvalidFieldKey; if (!validRuntimeMetadata(runtimeMetadata)) return RasterArrayEncodedFieldSubmitResult::RejectedDecode; - const auto decoded = particle::ingress::QbinV2Decoder::decode(runtimeMetadata, rawQbinBytes); - if (!decoded.accepted()) return RasterArrayEncodedFieldSubmitResult::RejectedDecode; - if (!particle::FieldProfileValidator::validate(contract, *decoded.frame).accepted()) + auto ingress = particle::ingress::EncodedFieldIngress::decodeAndValidate( + runtimeMetadata, &contract, rawQbinBytes); + if (ingress.code == particle::ingress::EncodedFieldIngressCode::DecodeRejected) + return RasterArrayEncodedFieldSubmitResult::RejectedDecode; + if (ingress.code != particle::ingress::EncodedFieldIngressCode::Accepted) return RasterArrayEncodedFieldSubmitResult::RejectedContract; - const auto result = mapEncodedFieldStoreResult(encodedFieldStore.submit(fieldKey, std::move(decoded.frame))); + const auto result = mapEncodedFieldStoreResult(encodedFieldStore.submit(fieldKey, std::move(ingress.frame))); if (result == RasterArrayEncodedFieldSubmitResult::AcceptedInserted || result == RasterArrayEncodedFieldSubmitResult::AcceptedReplacedRevision) refreshV4RendererState(); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 78ee3e7f0..893838b72 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -22,6 +22,7 @@ add_library( ${PROJECT_SOURCE_DIR}/test/particle/field_binding.test.cpp ${PROJECT_SOURCE_DIR}/test/particle/field_profile_contract_json.test.cpp ${PROJECT_SOURCE_DIR}/test/particle/qbin_v2_decoder.test.cpp + ${PROJECT_SOURCE_DIR}/test/particle/qbin_v3_decoder.test.cpp ${PROJECT_SOURCE_DIR}/test/particle/encoded_field_transport.test.cpp ${PROJECT_SOURCE_DIR}/test/plugin/plugin.test.cpp ${PROJECT_SOURCE_DIR}/test/renderer/image_manager.test.cpp diff --git a/test/particle/qbin_v3_decoder.test.cpp b/test/particle/qbin_v3_decoder.test.cpp new file mode 100644 index 000000000..2129ff12b --- /dev/null +++ b/test/particle/qbin_v3_decoder.test.cpp @@ -0,0 +1,618 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include + +using namespace mbgl; +namespace { + +constexpr size_t Header = 112; +constexpr size_t Descriptor = 32; + +void u16(std::vector& b, size_t o, uint16_t v) { + b[o] = uint8_t(v); + b[o + 1] = uint8_t(v >> 8); +} +void u32(std::vector& b, size_t o, uint32_t v) { + for (size_t i = 0; i < 4; ++i) b[o + i] = uint8_t(v >> (8 * i)); +} +uint32_t r32(const std::vector& b, size_t o) { + uint32_t v = 0; + for (size_t i = 0; i < 4; ++i) v |= uint32_t(b[o + i]) << (8 * i); + return v; +} +void u64(std::vector& b, size_t o, uint64_t v) { + for (size_t i = 0; i < 8; ++i) b[o + i] = uint8_t(v >> (8 * i)); +} +void i64(std::vector& b, size_t o, int64_t v) { + u64(b, o, static_cast(v)); +} +void f32(std::vector& b, size_t o, float v) { + uint32_t bits = 0; + std::memcpy(&bits, &v, sizeof(bits)); + u32(b, o, bits); +} +void f64(std::vector& b, size_t o, double v) { + uint64_t bits = 0; + std::memcpy(&bits, &v, sizeof(bits)); + u64(b, o, bits); +} +size_t a16(size_t x) { return (x + 15u) & ~size_t(15u); } +uint32_t crc(const std::vector& b) { + uint32_t c = ~0u; + for (size_t i = 0; i < b.size(); ++i) { + const uint8_t x = i >= 76 && i < 80 ? 0 : b[i]; + c ^= x; + for (int j = 0; j < 8; ++j) c = (c >> 1) ^ ((c & 1u) ? 0xedb88320u : 0); + } + return ~c; +} +void recalc(std::vector& b) { u32(b, 76, crc(b)); } +bool validAt(const std::vector& v, uint64_t i) { + return ((v[i >> 3] >> (i & 7u)) & 1u) != 0; +} +int64_t normDir(int64_t x, uint32_t n) { + if (x < 0) x += n; + else if (x >= int64_t(n)) x -= n; + return x; +} +uint64_t zigzag(int64_t r) { + return r >= 0 ? uint64_t(2 * r) : uint64_t(-2 * r - 1); +} + +struct Bits { + std::vector bytes; + size_t pos = 0; + void bit(uint8_t value) { + if ((pos & 7u) == 0) bytes.push_back(0); + if (value) bytes.back() |= uint8_t(1u << (pos & 7u)); + ++pos; + } + void bits(uint32_t value, uint8_t count) { + for (uint8_t i = 0; i < count; ++i) bit((value >> i) & 1u); + } +}; + +struct PlaneSpec { + uint16_t semantic = 1; + uint8_t dtype = 1; + uint8_t flags = 1; + float scale = 1.0f; + float offset = 0.0f; + uint16_t unit = 0; + uint16_t group = 0; + uint8_t valueClass = 0; + uint8_t component = 0; + std::vector values; +}; + +int64_t predict(const PlaneSpec& p, + const std::vector& validity, + uint32_t width, + uint32_t row, + uint32_t col, + uint32_t n) { + const uint64_t i = uint64_t(row) * width + col; + const bool left = col > 0 && validAt(validity, i - 1); + const bool up = row > 0 && validAt(validity, i - width); + const bool upperLeft = row > 0 && col > 0 && validAt(validity, i - width - 1); + int64_t predicted = 0; + if (left && up && upperLeft) { + predicted = p.values[i - 1] + p.values[i - width] - p.values[i - width - 1]; + } else if (left) { + predicted = p.values[i - 1]; + } else if (up) { + predicted = p.values[i - width]; + } + return n ? normDir(predicted, n) : predicted; +} + +std::vector encodePlane(const PlaneSpec& p, + const std::vector& validity, + uint32_t width, + uint32_t height, + std::optional forcedK = std::nullopt) { + const uint32_t n = p.valueClass == 2 + ? uint32_t(std::floor(360.0 / double(p.scale) + 0.5)) + : 0; + std::vector out; + for (uint32_t rb = 0; rb < height; rb += 16) { + for (uint32_t cb = 0; cb < width; cb += 16) { + std::vector symbols; + for (uint32_t row = rb; row < std::min(rb + 16u, height); ++row) { + for (uint32_t col = cb; col < std::min(cb + 16u, width); ++col) { + const uint64_t i = uint64_t(row) * width + col; + if (!validAt(validity, i)) continue; + const int64_t predicted = predict(p, validity, width, row, col, n); + int64_t residual = p.values[i] - predicted; + if (n) { + int64_t m = residual; + if (m < 0) m += n; + residual = 2 * m > int64_t(n) ? m - n + : 2 * m < int64_t(n) ? m + : -int64_t(n / 2); + } + symbols.push_back(zigzag(residual)); + } + } + if (symbols.empty()) continue; + + uint8_t bestK = forcedK.value_or(0); + if (!forcedK) { + uint64_t bestBits = ~uint64_t(0); + for (uint8_t k = 0; k <= 31; ++k) { + uint64_t bits = 0; + for (const auto z : symbols) bits += (z >> k) + 1u + k; + if (bits < bestBits) { + bestBits = bits; + bestK = k; + } + } + } + + out.push_back(bestK); + Bits writer; + for (const auto z : symbols) { + const uint64_t q = z >> bestK; + for (uint64_t j = 0; j < q; ++j) writer.bit(0); + writer.bit(1); + if (bestK) writer.bits(uint32_t(z & ((uint64_t(1) << bestK) - 1u)), bestK); + } + out.insert(out.end(), writer.bytes.begin(), writer.bytes.end()); + } + } + return out; +} + +std::vector rawBytes(const PlaneSpec& p, const std::vector& validity) { + const size_t stride = p.dtype == 1 || p.dtype == 2 ? 2u : 1u; + std::vector out(p.values.size() * stride, 0); + for (size_t i = 0; i < p.values.size(); ++i) { + if (!validAt(validity, i)) continue; + if (stride == 2) { + const uint16_t v = static_cast(p.values[i]); + out[i * 2] = uint8_t(v); + out[i * 2 + 1] = uint8_t(v >> 8); + } else { + out[i] = static_cast(p.values[i]); + } + } + return out; +} + +std::vector fixture(uint32_t width, + uint32_t height, + std::vector validity, + std::vector planes, + std::optional forcedK = std::nullopt, + uint16_t fieldType = 999) { + const uint64_t cells = uint64_t(width) * height; + const uint16_t count = uint16_t(planes.size() + 1); + const size_t payloadOffset = Header + size_t(count) * Descriptor; + + struct Encoded { PlaneSpec p; std::vector bytes; }; + std::vector encoded; + for (auto& p : planes) encoded.push_back({p, encodePlane(p, validity, width, height, forcedK)}); + + size_t payloadLength = 0; + for (const auto& e : encoded) payloadLength = a16(payloadLength + e.bytes.size()); + payloadLength = a16(payloadLength + validity.size()); + std::vector b(payloadOffset + payloadLength, 0); + std::memcpy(b.data(), "QBV3", 4); + b[4] = 3; + b[5] = 1; + u16(b, 6, fieldType); + b[8] = b[9] = b[10] = 1; + u16(b, 12, count); + u32(b, 16, width); + u32(b, 20, height); + i64(b, 24, 100); + i64(b, 32, 90); + f64(b, 40, -10); + f64(b, 48, -10); + f64(b, 56, 10); + f64(b, 64, 10); + u32(b, 72, payloadLength); + u16(b, 80, 0xffff); + + size_t off = 0; + for (size_t i = 0; i < encoded.size(); ++i) { + const auto& e = encoded[i]; + const size_t d = Header + i * Descriptor; + u16(b, d, e.p.semantic); + b[d + 2] = e.p.dtype; + b[d + 3] = e.p.flags; + f32(b, d + 4, e.p.scale); + f32(b, d + 8, e.p.offset); + u32(b, d + 12, cells); + u32(b, d + 16, off); + u32(b, d + 20, e.bytes.size()); + u16(b, d + 24, e.p.unit); + u16(b, d + 26, e.p.group); + b[d + 28] = e.p.valueClass; + b[d + 29] = e.p.component; + std::copy(e.bytes.begin(), e.bytes.end(), b.begin() + payloadOffset + off); + off = a16(off + e.bytes.size()); + } + + const size_t d = Header + encoded.size() * Descriptor; + u16(b, d, 3); + b[d + 2] = 6; + b[d + 3] = 1; + f32(b, d + 4, 1.0f); + f32(b, d + 8, 0.0f); + u32(b, d + 12, cells); + u32(b, d + 16, off); + u32(b, d + 20, validity.size()); + b[d + 28] = 3; + std::copy(validity.begin(), validity.end(), b.begin() + payloadOffset + off); + recalc(b); + return b; +} + +std::vector fixtureV2(uint32_t width, + uint32_t height, + const std::vector& validity, + const std::vector& planes, + uint16_t fieldType = 999) { + const uint64_t cells = uint64_t(width) * height; + const uint16_t count = uint16_t(planes.size() + 1); + const size_t payloadOffset = Header + size_t(count) * Descriptor; + + struct RawPlane { + PlaneSpec p; + std::vector bytes; + }; + std::vector raws; + for (const auto& p : planes) raws.push_back({p, rawBytes(p, validity)}); + + size_t payloadLength = 0; + for (const auto& r : raws) payloadLength = a16(payloadLength + r.bytes.size()); + payloadLength = a16(payloadLength + validity.size()); + + std::vector b(payloadOffset + payloadLength, 0); + std::memcpy(b.data(), "QBV2", 4); + b[4] = 2; + b[5] = 1; + u16(b, 6, fieldType); + b[8] = b[9] = b[10] = 1; + u16(b, 12, count); + u32(b, 16, width); + u32(b, 20, height); + i64(b, 24, 100); + i64(b, 32, 90); + f64(b, 40, -10); + f64(b, 48, -10); + f64(b, 56, 10); + f64(b, 64, 10); + u32(b, 72, payloadLength); + u16(b, 80, 0xffff); + + size_t off = 0; + for (size_t i = 0; i < raws.size(); ++i) { + const auto& r = raws[i]; + const size_t d = Header + i * Descriptor; + u16(b, d, r.p.semantic); + b[d + 2] = r.p.dtype; + b[d + 3] = r.p.flags; + f32(b, d + 4, r.p.scale); + f32(b, d + 8, r.p.offset); + u32(b, d + 12, cells); + u32(b, d + 16, off); + u32(b, d + 20, r.bytes.size()); + u16(b, d + 24, r.p.unit); + u16(b, d + 26, r.p.group); + b[d + 28] = r.p.valueClass; + b[d + 29] = r.p.component; + std::copy(r.bytes.begin(), r.bytes.end(), b.begin() + payloadOffset + off); + off = a16(off + r.bytes.size()); + } + + const size_t d = Header + raws.size() * Descriptor; + u16(b, d, 3); + b[d + 2] = 6; + b[d + 3] = 1; + f32(b, d + 4, 1.0f); + f32(b, d + 8, 0.0f); + u32(b, d + 12, cells); + u32(b, d + 16, off); + u32(b, d + 20, validity.size()); + b[d + 28] = 3; + std::copy(validity.begin(), validity.end(), b.begin() + payloadOffset + off); + recalc(b); + return b; +} + +std::vector validityBits(uint32_t cells, bool allValid = true) { + std::vector v((cells + 7u) / 8u, allValid ? 0xffu : 0u); + if (allValid && (cells & 7u)) v.back() = uint8_t((1u << (cells & 7u)) - 1u); + return v; +} + +particle::ingress::EncodedFieldRuntimeMetadata meta() { + return {"qbv3-test", 1, 1}; +} + +using Code = particle::ingress::QbinDecodeErrorCode; + +} // namespace + +TEST(ParticleQbinV3Decoder, DecodesAcrossBlockBoundaryAndDirectionWrap) { + constexpr uint32_t width = 17, height = 17, cells = width * height; + auto validity = validityBits(cells); + validity[3] &= uint8_t(~(1u << 1)); // one invalid cell + std::vector scalar(cells), direction(cells); + for (uint32_t r = 0; r < height; ++r) for (uint32_t c = 0; c < width; ++c) { + const size_t i = r * width + c; + scalar[i] = int64_t(r) * 40 - int64_t(c) * 7; + direction[i] = (350 + int64_t(c) * 3 + int64_t(r) * 11) % 360; + } + PlaneSpec a{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, scalar}; + PlaneSpec d{2, 2, 1, 1.0f, 0.0f, 4, 0, 2, 0, direction}; + auto bytes = fixture(width, height, validity, {a, d}); + auto result = particle::ingress::QbinV3Decoder::decode(meta(), bytes); + ASSERT_TRUE(result.accepted()) << result.detail; + ASSERT_EQ(result.frame->planes.size(), 2u); + EXPECT_EQ(*result.frame->planes[0].storage, rawBytes(a, validity)); + EXPECT_EQ(*result.frame->planes[1].storage, rawBytes(d, validity)); + EXPECT_EQ(*result.frame->validityBits, validity); +} + +TEST(ParticleQbinV3Decoder, AcceptsAllInvalidWithSharedZeroLengthOffsets) { + constexpr uint32_t width = 2, height = 2, cells = 4; + auto validity = validityBits(cells, false); + PlaneSpec a{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {1,2,3,4}}; + PlaneSpec d{2, 2, 1, 1.0f, 0.0f, 4, 0, 2, 0, {0,90,180,270}}; + auto bytes = fixture(width, height, validity, {a, d}); + const size_t d0 = Header, d1 = Header + Descriptor, dv = Header + 2 * Descriptor; + EXPECT_EQ(r32(bytes, d0 + 16), 0u); + EXPECT_EQ(r32(bytes, d1 + 16), 0u); + EXPECT_EQ(r32(bytes, dv + 16), 0u); + auto result = particle::ingress::QbinV3Decoder::decode(meta(), bytes); + ASSERT_TRUE(result.accepted()) << result.detail; + EXPECT_EQ(*result.frame->planes[0].storage, std::vector(cells * 2, 0)); + EXPECT_EQ(*result.frame->planes[1].storage, std::vector(cells * 2, 0)); +} + +TEST(ParticleQbinV3Decoder, AcceptsNonOptimalRiceK) { + auto validity = validityBits(4); + PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {0,0,0,0}}; + auto bytes = fixture(2, 2, validity, {p}, uint8_t(5)); + auto result = particle::ingress::QbinV3Decoder::decode(meta(), bytes); + EXPECT_TRUE(result.accepted()) << result.detail; +} + + +TEST(ParticleQbinV3Decoder, RejectsCrcDescriptorAndValidityCorruption) { + auto validity = validityBits(4); + PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {1,2,3,4}}; + + auto bytes = fixture(2, 2, validity, {p}); + bytes[6] ^= 1; + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::CrcMismatch); + + bytes = fixture(2, 2, validity, {p}); + bytes[Header + 2] = 5; + recalc(bytes); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::InvalidDescriptor); + + bytes = fixture(2, 2, validity, {p}); + const size_t validityDescriptor = Header + Descriptor; + bytes[validityDescriptor + 2] = 3; + recalc(bytes); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::InvalidValidity); +} + +TEST(ParticleQbinV3Decoder, RejectsMalformedRiceAndNonzeroBlockPadding) { + auto validity = validityBits(4); + PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {0,0,0,0}}; + + auto bytes = fixture(2, 2, validity, {p}); + const size_t payload = Header + 2 * Descriptor; + bytes[payload] = 32; + recalc(bytes); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::MalformedRice); + + auto oneValid = validityBits(4, false); + oneValid[0] = 0x01; + bytes = fixture(2, 2, oneValid, {p}, uint8_t(0)); + bytes[payload + 1] |= 0x80; + recalc(bytes); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::MalformedRice); +} + +TEST(ParticleQbinV3Decoder, RejectsResidualOutsideLogicalRange) { + auto validity = validityBits(4); + PlaneSpec p{1, 3, 1, 1.0f, 0.0f, 77, 0, 0, 0, {0,0,0,0}}; + auto bytes = fixture(2, 2, validity, {p}, uint8_t(31)); + const size_t payload = Header + 2 * Descriptor; + ASSERT_GT(bytes.size(), payload + 4); + bytes[payload + 4] |= 0x80; + recalc(bytes); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::ResidualOutOfRange); +} + +TEST(ParticleQbinV3Decoder, PreservesNodeRegisteredGridGeometry) { + auto validity = validityBits(9); + PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, std::vector(9, 0)}; + auto bytes = fixture(3, 3, validity, {p}); + auto result = particle::ingress::QbinV3Decoder::decode(meta(), bytes); + ASSERT_TRUE(result.accepted()) << result.detail; + + const auto& g = result.frame->grid; + EXPECT_EQ(g.width, 3u); + EXPECT_EQ(g.height, 3u); + EXPECT_DOUBLE_EQ(g.west, -10.0); + EXPECT_DOUBLE_EQ(g.south, -10.0); + EXPECT_DOUBLE_EQ(g.east, 10.0); + EXPECT_DOUBLE_EQ(g.north, 10.0); + EXPECT_DOUBLE_EQ(g.west + (g.east - g.west) * 1.0 / (g.width - 1), 0.0); + EXPECT_DOUBLE_EQ(g.south + (g.north - g.south) * 1.0 / (g.height - 1), 0.0); +} + +TEST(ParticleEncodedFieldIngress, DispatchesQbinV3AndRejectsUnknownMagic) { + auto validity = validityBits(4); + PlaneSpec u{1, 1, 1, 1.0f, 0.0f, 1, 1, 1, 1, {1,2,3,4}}; + PlaneSpec v{2, 1, 1, 1.0f, 0.0f, 1, 1, 1, 2, {5,6,7,8}}; + auto bytes = fixture(2, 2, validity, {u, v}); + + particle::ResolvedFieldProfileContract contract; + contract.profileID = "qbv3.wind.test"; + contract.profileVersion = 1; + contract.requiredPlanes = { + {1, particle::FieldValueClass::VelocityComponent, 1, 1, particle::FieldComponent::UEast, true}, + {2, particle::FieldValueClass::VelocityComponent, 1, 1, particle::FieldComponent::VNorth, true}, + {3, particle::FieldValueClass::Validity, 0, 0, particle::FieldComponent::None, true}, + }; + + auto accepted = particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(), &contract, bytes); + EXPECT_TRUE(accepted.accepted()) << accepted.detail; + + bytes[0] = 'X'; + recalc(bytes); + auto rejected = particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(), &contract, bytes); + EXPECT_EQ(rejected.code, particle::ingress::EncodedFieldIngressCode::DecodeRejected); + EXPECT_EQ(rejected.decodeCode, Code::BadMagic); +} + + +TEST(ParticleQbinV3Decoder, CoversAllCompressedIntegerDtypes) { + auto validity = validityBits(4); + PlaneSpec u8{1, 3, 1, 0.5f, 1.0f, 77, 0, 0, 0, {0, 255, 128, 1}}; + PlaneSpec i8{2, 4, 1, 2.0f, -5.0f, 78, 0, 0, 0, {-128, 127, -1, 0}}; + auto bytes = fixture(2, 2, validity, {u8, i8}); + auto result = particle::ingress::QbinV3Decoder::decode(meta(), bytes); + ASSERT_TRUE(result.accepted()) << result.detail; + ASSERT_EQ(result.frame->planes.size(), 2u); + EXPECT_EQ(*result.frame->planes[0].storage, rawBytes(u8, validity)); + EXPECT_EQ(*result.frame->planes[1].storage, rawBytes(i8, validity)); +} + +TEST(ParticleQbinV3Decoder, AcceptsOpaqueFieldTypeZeroAndUnknown) { + auto validity = validityBits(4); + PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {1, 2, 3, 4}}; + EXPECT_TRUE(particle::ingress::QbinV3Decoder::decode(meta(), fixture(2, 2, validity, {p}, std::nullopt, 0)).accepted()); + EXPECT_TRUE(particle::ingress::QbinV3Decoder::decode(meta(), fixture(2, 2, validity, {p}, std::nullopt, 65535)).accepted()); +} + +TEST(ParticleQbinV3Decoder, RejectsHeaderVersionEndianReservedBoundsAndNegativeZero) { + auto validity = validityBits(4); + PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {1, 2, 3, 4}}; + + auto bytes = fixture(2, 2, validity, {p}); + bytes[0] = 'X'; + recalc(bytes); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::BadMagic); + + bytes = fixture(2, 2, validity, {p}); + bytes[4] = 2; + recalc(bytes); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::UnsupportedVersion); + + bytes = fixture(2, 2, validity, {p}); + bytes[11] = 1; + recalc(bytes); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::ReservedNonZero); + + bytes = fixture(2, 2, validity, {p}); + bytes[16] = 0; + bytes[17] = 0; + bytes[18] = 0; + bytes[19] = 2; + recalc(bytes); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::LimitExceeded); + + bytes = fixture(2, 2, validity, {p}); + f64(bytes, 40, 11.0); + recalc(bytes); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::InvalidBounds); + + bytes = fixture(2, 2, validity, {p}); + u64(bytes, 40, 0x8000000000000000ull); + recalc(bytes); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::InvalidBounds); +} + +TEST(ParticleQbinV3Decoder, RejectsDescriptorOrderDuplicateAndPacking) { + auto validity = validityBits(4); + PlaneSpec u{1, 1, 1, 1.0f, 0.0f, 1, 1, 1, 1, {1, 2, 3, 4}}; + PlaneSpec v{2, 1, 1, 1.0f, 0.0f, 1, 1, 1, 2, {5, 6, 7, 8}}; + + auto bytes = fixture(2, 2, validity, {u, v}); + u16(bytes, Header, 2); + u16(bytes, Header + Descriptor, 1); + recalc(bytes); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::NonCanonicalDescriptorOrder); + + bytes = fixture(2, 2, validity, {u, v}); + u16(bytes, Header + Descriptor, 1); + recalc(bytes); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::DuplicatePlaneKey); + + bytes = fixture(2, 2, validity, {u, v}); + u32(bytes, Header + Descriptor + 16, 0); + recalc(bytes); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::NonCanonicalPacking); +} + +TEST(ParticleQbinV3Decoder, RejectsUnterminatedUnaryExtraEncodedByteAndRawOverflow) { + auto validity = validityBits(4); + PlaneSpec zero{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {0, 0, 0, 0}}; + const size_t payload = Header + 2 * Descriptor; + + auto bytes = fixture(2, 2, validity, {zero}, uint8_t(0)); + bytes[payload + 1] = 0; + recalc(bytes); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::MalformedRice); + + bytes = fixture(2, 2, validity, {zero}, uint8_t(0)); + const uint32_t length = r32(bytes, Header + 20); + ASSERT_LT(payload + length, bytes.size()); + u32(bytes, Header + 20, length + 1); + bytes[payload + length] = 0; + recalc(bytes); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::MalformedRice); + + PlaneSpec overflow{1, 3, 1, 1.0f, 0.0f, 77, 0, 0, 0, {300, 0, 0, 0}}; + bytes = fixture(2, 2, validity, {overflow}); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::InvalidCellPayload); +} + +TEST(ParticleQbinV3Decoder, EnforcesDirectionHalfCircleNegativeTie) { + auto validity = validityBits(4); + PlaneSpec direction{1, 2, 1, 90.0f, 0.0f, 4, 0, 2, 0, {0, 2, 0, 2}}; + auto bytes = fixture(2, 2, validity, {direction}, uint8_t(0)); + auto canonical = particle::ingress::QbinV3Decoder::decode(meta(), bytes); + ASSERT_TRUE(canonical.accepted()) << canonical.detail; + + const size_t payload = Header + 2 * Descriptor; + ASSERT_EQ(r32(bytes, Header + 20), 2u); + // canonical bits are 0x71: 0, -2, 0, 0. Replace the -2 symbol + // with the forbidden +2 half-circle representation (z=4), yielding 0xe1. + bytes[payload + 1] = 0xe1; + recalc(bytes); + EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::ResidualOutOfRange); +} + +TEST(ParticleQbinV3Decoder, ContentIdentityMatchesQbinV2CanonicalFrame) { + auto validity = validityBits(4); + PlaneSpec u{1, 1, 1, 0.25f, 0.0f, 1, 1, 1, 1, {1, -2, 3, -4}}; + PlaneSpec v{2, 1, 1, 0.25f, 0.0f, 1, 1, 1, 2, {5, 6, -7, 8}}; + auto v2 = particle::ingress::QbinV2Decoder::decode(meta(), fixtureV2(2, 2, validity, {u, v})); + auto v3 = particle::ingress::QbinV3Decoder::decode(meta(), fixture(2, 2, validity, {u, v})); + ASSERT_TRUE(v2.accepted()) << v2.detail; + ASSERT_TRUE(v3.accepted()) << v3.detail; + ASSERT_EQ(v2.frame->planes.size(), v3.frame->planes.size()); + for (size_t i = 0; i < v2.frame->planes.size(); ++i) { + EXPECT_EQ(*v2.frame->planes[i].storage, *v3.frame->planes[i].storage); + EXPECT_EQ(v2.frame->planes[i].contentIdentity, v3.frame->planes[i].contentIdentity); + } + EXPECT_EQ(*v2.frame->validityBits, *v3.frame->validityBits); + EXPECT_EQ(v2.frame->validityContentIdentity, v3.frame->validityContentIdentity); +} diff --git a/tools/qbin_v3_block_benchmark.cpp b/tools/qbin_v3_block_benchmark.cpp new file mode 100644 index 000000000..15745621e --- /dev/null +++ b/tools/qbin_v3_block_benchmark.cpp @@ -0,0 +1,279 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using Clock = std::chrono::steady_clock; + +uint16_t u16(const std::vector& b, size_t o) { + return uint16_t(b[o]) | uint16_t(b[o + 1]) << 8; +} +uint32_t u32(const std::vector& b, size_t o) { + uint32_t v = 0; + for (size_t i = 0; i < 4; ++i) v |= uint32_t(b[o + i]) << (8 * i); + return v; +} +float f32(const std::vector& b, size_t o) { + uint32_t bits = u32(b, o); + float v = 0; + std::memcpy(&v, &bits, 4); + return v; +} + +struct Desc { + uint16_t semantic = 0; + uint8_t dtype = 0; + float scale = 1; + uint32_t off = 0; + uint32_t len = 0; + uint8_t valueClass = 0; +}; +struct Plane { + Desc d; + std::vector raw; +}; +struct Frame { + uint32_t w = 0, h = 0; + std::vector validity; + std::vector planes; +}; + +bool validAt(const std::vector& v, uint64_t i) { + return ((v[i >> 3] >> (i & 7u)) & 1u) != 0; +} +std::vector readFile(const fs::path& p) { + std::ifstream in(p, std::ios::binary); + if (!in) throw std::runtime_error("open failed: " + p.string()); + return {std::istreambuf_iterator(in), std::istreambuf_iterator()}; +} + +Frame parseV2(const fs::path& p) { + const auto b = readFile(p); + if (b.size() < 112 || std::memcmp(b.data(), "QBV2", 4) != 0) throw std::runtime_error("not QBV2"); + Frame f; + const uint16_t n = u16(b, 12); + f.w = u32(b, 16); + f.h = u32(b, 20); + const uint64_t cells = uint64_t(f.w) * f.h; + const size_t payload = 112 + size_t(n) * 32; + std::vector ds; + for (uint16_t i = 0; i < n; ++i) { + const size_t o = 112 + size_t(i) * 32; + ds.push_back({u16(b,o), b[o+2], f32(b,o+4), u32(b,o+16), u32(b,o+20), b[o+28]}); + } + const auto it = std::find_if(ds.begin(), ds.end(), [](const Desc& d){ return d.dtype == 6; }); + if (it == ds.end()) throw std::runtime_error("missing validity"); + f.validity.assign(b.begin()+payload+it->off, b.begin()+payload+it->off+it->len); + + for (const auto& d : ds) { + if (d.dtype == 6) continue; + Plane p0; + p0.d = d; + p0.raw.resize(cells); + const uint8_t* raw = b.data() + payload + d.off; + for (uint64_t i=0;i= int64_t(n)) x -= n; + return x; +} +uint64_t zigzag(int64_t r) { return r >= 0 ? uint64_t(2*r) : uint64_t(-2*r-1); } +int64_t unzigzag(uint64_t z) { return (z&1u) ? -int64_t(z/2)-1 : int64_t(z/2); } + +int64_t predictor(const Frame& f, const Plane& p, const std::vector& raw, + uint32_t row, uint32_t col, uint32_t n) { + const uint64_t i=uint64_t(row)*f.w+col; + const bool l=col>0 && validAt(f.validity,i-1); + const bool u=row>0 && validAt(f.validity,i-f.w); + const bool ul=row>0 && col>0 && validAt(f.validity,i-f.w-1); + int64_t pred=0; + if (l&&u&&ul) pred=raw[i-1]+raw[i-f.w]-raw[i-f.w-1]; + else if(l) pred=raw[i-1]; + else if(u) pred=raw[i-f.w]; + if (n) pred=normDir(pred,n); + return pred; +} +int64_t residualFor(const Frame& f, const Plane& p, uint32_t row, uint32_t col, uint32_t n) { + const uint64_t i=uint64_t(row)*f.w+col; + const int64_t pred=predictor(f,p,p.raw,row,col,n); + int64_t r=p.raw[i]-pred; + if (!n) return r; + int64_t m=r; + if(m<0)m+=n; + if(2*m>n)return m-n; + if(2*m b; + size_t pos=0; + void bit(uint8_t v) { + if ((pos & 7u)==0) b.push_back(0); + if(v) b.back() |= uint8_t(1u << (pos & 7u)); + ++pos; + } + void bits(uint32_t v,uint8_t n){ for(uint8_t i=0;i>i)&1u); } +}; +struct EncodedPlane { std::vector bytes; uint64_t blocks=0, nonempty=0; }; + +EncodedPlane encodePlane(const Frame& f,const Plane& p,uint32_t block) { + EncodedPlane out; + const uint32_t n = p.d.valueClass==2 ? uint32_t(std::floor(360.0/double(p.d.scale)+0.5)) : 0; + for(uint32_t rb=0;rb z; + for(uint32_t r=rb;r>k)+1u+k; + if(bits>bestK; + for(uint64_t j=0;j=bytes*8) throw std::runtime_error("truncated"); return (p[pos>>3]>>(pos++&7u))&1u; } + uint32_t bitsN(uint8_t n){ uint32_t v=0; for(uint8_t i=0;i decodePlane(const Frame& f,const Plane& p,const EncodedPlane& enc,uint32_t block) { + std::vector raw(uint64_t(f.w)*f.h,0); + size_t cursor=0; + const uint32_t n=p.d.valueClass==2 ? uint32_t(std::floor(360.0/double(p.d.scale)+0.5)) : 0; + for(uint32_t rb=0;rb=enc.bytes.size()) throw std::runtime_error("missing k"); + const uint8_t k=enc.bytes[cursor++]; + if(k>31) throw std::runtime_error("bad k"); + BitReader br{enc.bytes.data()+cursor,enc.bytes.size()-cursor,0}; + for(uint32_t r=rb;r encoded(frame.planes.size()); + constexpr int EncodeRepeats=3, DecodeRepeats=8; + + auto start=Clock::now(); + for(int rep=0;rep(mid-start).count()/EncodeRepeats; + totals.decodeMs += std::chrono::duration(end-mid).count()/DecodeRepeats; +} +int main(int argc,char** argv) { + try { + const fs::path root=argc>1 ? fs::path(argv[1]) + : fs::path("integration/react-native/MapLibreParticleRNTestHost/assets/qbin-golden"); + std::vector files; + for(const auto& e:fs::recursive_directory_iterator(root)) + if(e.is_regular_file() && e.path().extension()==".qbin") files.push_back(e.path()); + std::sort(files.begin(),files.end()); + if(files.empty()) throw std::runtime_error("no qbin fixtures"); + + for(uint32_t block:{16u,32u}) { + Totals total; + std::cout<<"BLOCK="<