feat: add RN binary wind field transport
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
This commit is contained in:
41
docs/50_Step记录/STEP-061_Binary_UV_Field_Transport_v1.md
Normal file
41
docs/50_Step记录/STEP-061_Binary_UV_Field_Transport_v1.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# STEP-061 Binary U/V Field Transport
|
||||
|
||||
- 状态:`进行中 / WAITING_RDC_REVIEW`
|
||||
- 起始 HEAD:`e3c36880960ddb74abeafefe5c5662e1a61b46dc`
|
||||
- 分支:`particle/main`
|
||||
|
||||
## 目标
|
||||
|
||||
以既有 `WeatherRuntime.submitWindField(metadataJson, byte[])` 为唯一 Core ingress,完成 RN Test Host 的 canonical U/V 原始二进制 file-reference transport,并保留 Core 对 metadata、byteLength、Timeline 和 Runtime Field Window 的 fail-closed 所有权。
|
||||
|
||||
## 已完成
|
||||
|
||||
- [x] 调查确认 HEAD 与 `origin/particle/main` 一致;既有 Java/JNI/Core Host ingress 可复用。
|
||||
- [x] TurboModule 增加 `clearWindFields(reactTag)` 与 `submitWindField(reactTag, metadataJson, payloadFileUri)`。
|
||||
- [x] Kotlin 只读取受限的本地 `file://` URI 或绝对路径为原始 `byte[]`,再调用既有 WeatherRuntime;相对路径、非本地、空、不可读、`IOException` 及超过 64 MiB 文件均 fail-closed,metadata 上限按 UTF-8 bytes 计算。
|
||||
- [x] Test Host 用 Expo FileSystem 56.0.11 写入两个 4×3 `float32-le/interleaved-uv` canonical WIND/GLOBAL frame;`DataView.setFloat32(..., true)` 明确 little-endian。
|
||||
- [x] Test Host 初始/每次提交均为 PENDING,严格断言 invalid/T0/T1/Native state 依次为 `REJECTED_ADAPTER`/`ACCEPTED_PENDING_INITIAL_WINDOW`/`INITIAL_WINDOW_READY`/`READY`;不符即 ERROR。
|
||||
- [x] Runtime UI 区分 Core evidence 与 producer identity;MIX 标为 Core-owned/not exposed,最终 T0/T1 真值留给既有 Android logcat 事件验证。
|
||||
- [x] 坐标已准备为唯一 `13.4.1-step061`(`platform/android/VERSION`、plugin、bridge dependency、README);未覆盖 `step060f`,本轮未 build/publish。
|
||||
- [x] 本轮修正后的 `npm run typecheck`、TurboModule Codegen(输出至 `/tmp`)、plugin `node --check`、禁止 transport 审计与 `git diff --check` 通过。
|
||||
|
||||
## 允许修改
|
||||
|
||||
RN Test Host、weather-runtime-bridge、其 README/package 直接依赖,以及本 Step/完成报告/开发记录。
|
||||
|
||||
## 禁止修改
|
||||
|
||||
不修改 NavSea、provider/network/download/cache 业务、Particle/Core Timeline 数据模型或视觉参数;不提交 build 产物;不加入或运行 Jest。
|
||||
|
||||
## 当前结果
|
||||
|
||||
RDC NEEDS FIX 的 UI、I/O 与版本坐标修正已实现并通过本轮静态验证。Runtime snapshot 当前只公开 State/Playback/FPS;`INITIAL_WINDOW_READY + READY` 是 Core evidence,界面中的 generation/time 明确只是 producer identity,MIX 继续由 Core 独占且未新增观测 API。
|
||||
|
||||
## 未完成/阻塞
|
||||
|
||||
- [ ] RDC 对真实 diff 审查。
|
||||
- [ ] 审查通过并获后续明确指令后,在指定 `particle-codex:step061` tmux window 构建并 Maven Local publish 唯一 `13.4.1-step061` AAR,再继续 Android Gradle/APK 和实机端到端验收;本次按任务要求未执行。
|
||||
|
||||
## 下一步
|
||||
|
||||
停在工作区,等待 RDC 审查后再进行任务书限定的端到端流程。
|
||||
83
docs/报告/STEP-061_Binary_UV_Field_Transport_Codex完成报告_v1.md
Normal file
83
docs/报告/STEP-061_Binary_UV_Field_Transport_Codex完成报告_v1.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# STEP-061 Binary U/V Field Transport Codex完成报告 v1
|
||||
|
||||
日期:2026-09-09
|
||||
结论:`IMPLEMENTED / WAITING_RDC_REVIEW`
|
||||
|
||||
## 调查结论与真实路径
|
||||
|
||||
开工前确认 `particle/main` 的 HEAD `e3c36880960ddb74abeafefe5c5662e1a61b46dc` 与 `origin/particle/main` 一致。STEP-053/057 已有唯一正式 ingress:
|
||||
|
||||
`RN Test Host → WeatherRuntimeBridge → WeatherRuntime.submitWindField(metadataJson, byte[]) → MapLibreMap.submitRasterArraySourceHostField → JNI → RasterArraySource::submitHostField → LocalParticleFieldFeed → ParticleFieldDataAdapter → Core Timeline/Runtime Field Window`。
|
||||
|
||||
因此本次没有新建 RasterArray、Descriptor、Snapshot、Timeline 或 Adapter,也没有改动 Core。
|
||||
|
||||
## 修改清单
|
||||
|
||||
- `integration/react-native/MapLibreParticleRNTestHost/App.tsx`:新增固定 binary fixture 提交按钮与可见证据。
|
||||
- `integration/react-native/MapLibreParticleRNTestHost/src/canonicalWindFixture.ts`:新增确定性 4×3 T0/T1/invalid canonical WIND fixture。
|
||||
- `integration/react-native/MapLibreParticleRNTestHost/modules/weather-runtime-bridge/src/NativeWeatherRuntimeBridge.ts`、`src/index.ts`:新增稳定 `clearWindFields` 与 `submitWindField(metadataJson, payloadFileUri)` contract/result type。
|
||||
- `integration/react-native/MapLibreParticleRNTestHost/modules/weather-runtime-bridge/android/src/main/java/.../WeatherRuntimeBridgeModule.kt`:本地文件读取、大小保护与既有 WeatherRuntime 调用。
|
||||
- `integration/react-native/MapLibreParticleRNTestHost/package.json`、`package-lock.json`:将现有实际安装的 `expo-file-system@56.0.11` 提升为 Test Host 直接依赖。
|
||||
- `integration/react-native/MapLibreParticleRNTestHost/README.md`、本 Step 记录与开发记录:补充 transport contract/状态。
|
||||
- `platform/android/VERSION`、`integration/react-native/MapLibreParticleRNTestHost/plugins/withParticleNativeAndroid.js`、bridge `android/build.gradle`:只将待发布坐标统一为 `13.4.1-step061`,不改 Core 行为。
|
||||
|
||||
## RDC NEEDS FIX 修正
|
||||
|
||||
- App 初始状态和每次提交开始都为 PENDING;仅当 invalid/T0/T1/Native state 严格分别等于 `REJECTED_ADAPTER`、`ACCEPTED_PENDING_INITIAL_WINDOW`、`INITIAL_WINDOW_READY`、`READY` 时才显示 READY。任一不符立即显示 ERROR。
|
||||
- Runtime Field Window 文案改为 `Core evidence: INITIAL_WINDOW_READY + runtime READY`,另行标注 producer identity;MIX 为 `Core-owned / not exposed`。Android 最终 T0/T1 真值继续由既有 `HOST_FIELD_SNAPSHOT_READY` 与 `HOST_FIELD_INITIAL_WINDOW_READY` logcat 事件验证,不新增 Core getter。
|
||||
- Kotlin metadata 长度按 UTF-8 bytes 检查;无 URI scheme 的路径必须 absolute,并显式捕获 `IOException` 为 `PAYLOAD_FILE_UNREADABLE`。读取相关其他运行时异常同样 fail-closed。
|
||||
- 版本坐标统一为新的 `13.4.1-step061`;`13.4.1-step060f` 保持冻结。本轮依照明确限制未实际 build AAR 或 Maven publish。
|
||||
|
||||
## Binary transport
|
||||
|
||||
JS 以 `ArrayBuffer + DataView.setFloat32(offset, value, true)` 明确写入 `float32-le`,每个 row-major north-west grid cell 连续写入 U(eastward)再 V(northward),layout 为 `interleaved-uv`。Expo `File.write(Uint8Array)` 将 raw bytes 写入 App cache。跨 TurboModule 的仅有 `metadataJson + file:// URI/path`,不传 `number[]`、`Array<number>`、payload JSON、base64 或 hex。
|
||||
|
||||
Kotlin 仅接受 `file://` 或绝对本地路径,拒绝其他 scheme 和相对路径;metadata 限制按 UTF-8 bytes 检查。检查文件存在、可读、非空及不超过 64 MiB 后读取 raw `byte[]`,显式捕获 `IOException`,直接调用既有 `WeatherRuntime.submitWindField(metadataJson, payload)`。文件错误不会提交空数据或伪造成功。metadata/payload 的 schema、grid、bounds、layout、encoding、byteLength、generation、validTime 等最终校验继续完全由 `LocalParticleFieldFeed`/`ParticleFieldDataAdapter` fail-closed。
|
||||
|
||||
## RN 验收证据与预期
|
||||
|
||||
`Submit binary WIND T0/T1` 先调用既有 `clearWindFields()`,再提交:
|
||||
|
||||
1. 声明正常 byteLength、实际少一个 byte 的 INVALID:预期 `REJECTED_ADAPTER`。
|
||||
2. generation=1、validTime=T0 的 frame:预期 `ACCEPTED_PENDING_INITIAL_WINDOW`。
|
||||
3. generation=2、validTime>T0 的兼容 frame:预期 `INITIAL_WINDOW_READY`。
|
||||
|
||||
UI 初始与每轮开始均显示 PENDING;只有四个严格断言全部通过才显示 `READY: FILE_REF / FLOAT32_LE / INTERLEAVED_UV`。`Core evidence: INITIAL_WINDOW_READY + runtime READY` 是已建立 initial window 的证据;generation/time 单列为 producer identity,不计算或持有 T0/T1/MIX runtime 状态;MIX 仍为 Core-owned/not exposed。最终 Android T0/T1 真值由既有 `HOST_FIELD_SNAPSHOT_READY` / `HOST_FIELD_INITIAL_WINDOW_READY` logcat 验证,本任务未新增 Core getter。
|
||||
|
||||
## 实际静态验证
|
||||
|
||||
- PASS(本轮修正后):RN Test Host `npm run typecheck`(build-output-throttle)。
|
||||
- PASS(本轮修正后):RN 0.85.2 Codegen 输出至 `/tmp/step061-weather-runtime-codegen`;Java abstract methods 仍为 `clearWindFields(double, Promise)` 与 `submitWindField(double, String, String, Promise)`,与 Kotlin override 一致;未提交生成物。
|
||||
- PASS(本轮修正后):`node --check plugins/withParticleNativeAndroid.js`。
|
||||
- PASS(本轮修正后):禁止 transport 审计未发现 base64、hex、number-array 或 raw-payload-JSON;`git diff --check` 通过。
|
||||
|
||||
## 未执行与范围
|
||||
|
||||
未执行 `make apackage`、AAR build/publish、Windows Maven Local、Windows APK、Android Gradle APK、ADB、设备/端到端验收,也未加入、运行 Jest。下一阶段需构建并 Maven Local publish 唯一 `13.4.1-step061` AAR,绝不覆盖冻结的 step060f;本轮 Codex 任务书限制在 RDC 源码审查前不得构建/发布 AAR。未修改任何 MapLibre Core、JNI、WeatherRuntime、NavSea、provider/network/vendor/download/cache 或粒子视觉代码。
|
||||
|
||||
## 工作区状态
|
||||
|
||||
本次未 commit、未 push。最终 `git status --short` 与本任务有关的范围为:
|
||||
|
||||
```text
|
||||
M integration/react-native/MapLibreParticleRNTestHost/App.tsx
|
||||
M integration/react-native/MapLibreParticleRNTestHost/README.md
|
||||
M integration/react-native/MapLibreParticleRNTestHost/modules/weather-runtime-bridge/android/src/main/java/.../WeatherRuntimeBridgeModule.kt
|
||||
M integration/react-native/MapLibreParticleRNTestHost/modules/weather-runtime-bridge/src/NativeWeatherRuntimeBridge.ts
|
||||
M integration/react-native/MapLibreParticleRNTestHost/modules/weather-runtime-bridge/src/index.ts
|
||||
M integration/react-native/MapLibreParticleRNTestHost/modules/weather-runtime-bridge/android/build.gradle
|
||||
M integration/react-native/MapLibreParticleRNTestHost/package-lock.json
|
||||
M integration/react-native/MapLibreParticleRNTestHost/package.json
|
||||
M integration/react-native/MapLibreParticleRNTestHost/plugins/withParticleNativeAndroid.js
|
||||
M platform/android/VERSION
|
||||
M 粒子开发记录.md
|
||||
?? integration/react-native/MapLibreParticleRNTestHost/src/canonicalWindFixture.ts
|
||||
?? docs/50_Step记录/STEP-061_Binary_UV_Field_Transport_v1.md
|
||||
?? docs/报告/STEP-061_Binary_UV_Field_Transport_Codex完成报告_v1.md
|
||||
```
|
||||
|
||||
开始前已存在大量历史 untracked 文档、`STEP-046-e48fa087-review.diff`、Test Host `android/` 生成目录及 bridge `android/build/`;均未触碰。`.codex-build-logs/` 为验证工具生成的未跟踪日志目录,未纳入提交。
|
||||
|
||||
## 停止条件
|
||||
|
||||
已停在工作区,等待 ChatGPT 通过 RDC 审查真实 diff;审查前不继续 AAR 或 Android 端到端流程。
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View, findNodeHandle } from "react-native";
|
||||
import { Map } from "@maplibre/maplibre-react-native";
|
||||
import { WeatherRuntimeBridge, type RuntimeSnapshot, type TargetFps } from "@maplibre-particle/weather-runtime-bridge";
|
||||
import { writeCanonicalWindFixture } from "./src/canonicalWindFixture";
|
||||
import { weatherTestStyle } from "./src/weatherStyle";
|
||||
|
||||
const initialSnapshot: RuntimeSnapshot = { state: "WAITING_FOR_FIELDS", playbackState: "PAUSE", targetFps: "AUTO", disposed: false };
|
||||
@@ -11,6 +12,11 @@ export default function App() {
|
||||
const [snapshot, setSnapshot] = useState<RuntimeSnapshot>(initialSnapshot);
|
||||
const [result, setResult] = useState("Style is loading");
|
||||
const [opacity, setOpacity] = useState("0.62");
|
||||
const [binaryTransport, setBinaryTransport] = useState("PENDING: submit fixture to validate FILE_REF / FLOAT32_LE / INTERLEAVED_UV");
|
||||
const [invalidProbe, setInvalidProbe] = useState("PENDING");
|
||||
const [t0Submit, setT0Submit] = useState("PENDING");
|
||||
const [t1Submit, setT1Submit] = useState("PENDING");
|
||||
const [fieldWindow, setFieldWindow] = useState("TIMELINE_NOT_READY");
|
||||
|
||||
const attach = useCallback(async (tag: number) => {
|
||||
try { setSnapshot(await WeatherRuntimeBridge.attach(tag)); setReactTag(tag); setResult("WeatherRuntime attached"); }
|
||||
@@ -34,6 +40,39 @@ export default function App() {
|
||||
catch (error) { setResult(`${name}: ${String(error)}`); }
|
||||
}, [reactTag]);
|
||||
|
||||
const submitBinaryFixture = useCallback(async () => {
|
||||
if (reactTag == null) return setResult("MAP_NOT_READY: wait for style ready");
|
||||
try {
|
||||
setBinaryTransport("PENDING: clearing fields and validating FILE_REF / FLOAT32_LE / INTERLEAVED_UV");
|
||||
setInvalidProbe("PENDING");
|
||||
setT0Submit("PENDING");
|
||||
setT1Submit("PENDING");
|
||||
setFieldWindow("PENDING: awaiting Core initial window");
|
||||
const clear = await WeatherRuntimeBridge.clearWindFields(reactTag);
|
||||
if (clear !== "ACCEPTED") throw new Error(`clearWindFields: ${clear}`);
|
||||
const [t0, t1, invalid] = writeCanonicalWindFixture();
|
||||
const rejected = await WeatherRuntimeBridge.submitWindField(reactTag, invalid.metadataJson, invalid.payloadFileUri);
|
||||
setInvalidProbe(rejected);
|
||||
if (rejected !== "REJECTED_ADAPTER") throw new Error(`invalid probe expected REJECTED_ADAPTER, got ${rejected}`);
|
||||
const t0Result = await WeatherRuntimeBridge.submitWindField(reactTag, t0.metadataJson, t0.payloadFileUri);
|
||||
setT0Submit(t0Result);
|
||||
if (t0Result !== "ACCEPTED_PENDING_INITIAL_WINDOW") throw new Error(`T0 expected ACCEPTED_PENDING_INITIAL_WINDOW, got ${t0Result}`);
|
||||
const t1Result = await WeatherRuntimeBridge.submitWindField(reactTag, t1.metadataJson, t1.payloadFileUri);
|
||||
setT1Submit(t1Result);
|
||||
if (t1Result !== "INITIAL_WINDOW_READY") throw new Error(`T1 expected INITIAL_WINDOW_READY, got ${t1Result}`);
|
||||
const native = await WeatherRuntimeBridge.getState(reactTag);
|
||||
setSnapshot(native);
|
||||
if (native.state !== "READY") throw new Error(`Native WeatherRuntime expected READY, got ${native.state}`);
|
||||
setFieldWindow(`Core evidence: INITIAL_WINDOW_READY + runtime READY; producer identity: T0=${t0.generation}@${t0.validTime}, T1=${t1.generation}@${t1.validTime}; MIX: Core-owned / not exposed`);
|
||||
setBinaryTransport("READY: FILE_REF / FLOAT32_LE / INTERLEAVED_UV");
|
||||
setResult(`Binary field submit: invalid=${rejected}, T0=${t0Result}, T1=${t1Result}`);
|
||||
} catch (error) {
|
||||
setBinaryTransport(`ERROR: ${String(error)}`);
|
||||
setResult(`Binary field submit: ${String(error)}`);
|
||||
setFieldWindow("TIMELINE_NOT_READY");
|
||||
}
|
||||
}, [reactTag]);
|
||||
|
||||
const buttons = useMemo(() => [
|
||||
["getState", (tag: number) => WeatherRuntimeBridge.getState(tag)],
|
||||
["enable", (tag: number) => WeatherRuntimeBridge.setWeatherEnabled(tag, true)],
|
||||
@@ -49,7 +88,13 @@ export default function App() {
|
||||
<ScrollView style={styles.controls} contentContainerStyle={styles.controlContent}>
|
||||
<Text>reactTag: {reactTag ?? "not attached"}</Text>
|
||||
<Text>runtime: {snapshot.state} / {snapshot.playbackState} / {snapshot.targetFps}</Text>
|
||||
<Text selectable>Binary Transport: {binaryTransport}</Text>
|
||||
<Text selectable>Invalid Probe: {invalidProbe}</Text>
|
||||
<Text selectable>T0 Submit Result: {t0Submit}</Text>
|
||||
<Text selectable>T1 Submit Result: {t1Submit}</Text>
|
||||
<Text selectable>Runtime Field Window: {fieldWindow}</Text>
|
||||
<Text selectable>{result}</Text>
|
||||
<Button title="Submit binary WIND T0/T1" onPress={() => void submitBinaryFixture()} />
|
||||
{buttons.map(([label, operation]) => <Button key={label} title={label} onPress={() => void command(label, operation)} />)}
|
||||
<TextInput value={opacity} onChangeText={setOpacity} keyboardType="decimal-pad" style={styles.input} />
|
||||
<Button title="weather opacity" onPress={() => void command("weather opacity", (tag) => WeatherRuntimeBridge.setWeatherColorOpacity(tag, Number(opacity)))} />
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
## 版本与首次 prebuild 边界
|
||||
|
||||
- package manager 为 npm(`packageManager: npm@10.9.4`),锁文件为 `package-lock.json`;当前环境的 `npm ci --dry-run --ignore-scripts` 无须 `--legacy-peer-deps` 即可解析。
|
||||
- package 明确固定 React Native `0.85.2`、React `19.2.3`、Expo `56.0.4`、TypeScript `5.9.3`、MLRN `11.3.6`;`app.json` 明确请求 New Architecture。Hermes 的最终启用状态必须在首次 Android prebuild 后检查生成工程。
|
||||
- package 明确固定 React Native `0.85.2`、React `19.2.3`、Expo `56.0.4`、`expo-file-system` `56.0.11`、TypeScript `5.9.3`、MLRN `11.3.6`;`app.json` 明确请求 New Architecture。Hermes 的最终启用状态必须在首次 Android prebuild 后检查生成工程。
|
||||
- AGP `8.5.0`、Gradle `8.14.3`、Kotlin `2.1.20`、NDK `27.1.12297006`、JDK 17、minSdk 24、compile/targetSdk 36 是本 Test Host 的**人工验收配置目标**,不是尚未生成 Android 工程的已验证结果。首次 prebuild 后必须以实际 Gradle wrapper、AGP/Kotlin plugin、SDK/NDK 与 Hermes/New Architecture 配置为准核对。
|
||||
- MLRN 的 root Gradle extras 固定 `org.maplibre.reactnative.nativeVersion=13.4.1-step060f` 与 `nativeVariant=opengl`;这是 STEP-060F WeatherColor opacity 修复的唯一临时版本,避免与先前 Maven Local artifact 缓存混淆。
|
||||
- MLRN 的 root Gradle extras 固定 `org.maplibre.reactnative.nativeVersion=13.4.1-step061` 与 `nativeVariant=opengl`。STEP-061 必须构建并 Maven Local publish 这个唯一的新坐标,禁止覆盖已经冻结的 `13.4.1-step060f`;本轮 RDC 修复仅推进坐标和文档,未构建或发布。
|
||||
|
||||
## Custom SDK 来源(手工执行)
|
||||
|
||||
@@ -17,7 +17,7 @@ Gate C 封存环境负责生成 custom AAR;Test Host 只负责 Maven Local han
|
||||
cd /mnt/sda1/MapLibreParticleLab/platform/android
|
||||
# 仅当 checkout 已变化,或下方已核对 AAR 的 SHA-256 不一致时才需要:
|
||||
RENDERER=opengl BUILDTYPE=Release make apackage
|
||||
# 当前 Gate C 已生成并核对的 AAR 可直接发布,不需要为 STEP-060C 重建:
|
||||
# STEP-061 必须先按当前 checkout 重建,且只能发布新的 `13.4.1-step061` 坐标:
|
||||
./gradlew :MapLibreAndroid:publishOpenglreleasePublicationToMavenLocal --console=plain
|
||||
cd /mnt/sda1/MapLibreParticleLab/integration/react-native/MapLibreParticleRNTestHost
|
||||
npm ci
|
||||
@@ -37,7 +37,13 @@ Style JSON 明确创建 background、host-field `RasterArraySource`、`WeatherCo
|
||||
|
||||
页面 style ready 后显示 reactTag/runtime;Phase A 按钮为 `getState`、enable、WeatherColor visible、particle visible、FPS 30。Phase B 还通过 bridge 暴露 epoch-ms forecast seek、PLAY/PAUSE、速度、stops、独立 particle/trail RGBA、粒子视觉参数和 WeatherColor opacity。JS 只校验小参数,并不保存另一套 runtime 状态;snapshot 来自 Native。
|
||||
|
||||
当前**不包含 Binary Field**,不传 `number[]`、JSON 数组或 base64 field。3.01 MiB canonical field ingress 留给 STEP-061。
|
||||
## STEP-061 Binary U/V Field transport
|
||||
|
||||
正式 Binary ingress 为 `metadataJson + payloadFileUri/path`。Test Host 使用 Expo FileSystem `File.write(Uint8Array)` 将 raw bytes 写入 app cache;TurboModule 只接收 metadata 和本地 `file://` URI(也接受绝对本地路径),Kotlin 对可读性与 64 MiB 上限 fail-closed 后读取原始 `byte[]`,并且只调用既有 `WeatherRuntime.submitWindField(metadataJson, byte[])`。没有 `number[]`/`Array<number>`、payload JSON、base64、hex 或网络下载层。
|
||||
|
||||
`Submit binary WIND T0/T1` 固定生产 4×3 的两帧 `WIND/GLOBAL` canonical field:north-west origin、row-major、`interleaved-uv`、`float32-le`,并以 `DataView.setFloat32(..., true)` 显式写入 U=eastward、V=northward。初始状态与每轮开始均为 `PENDING`,绝不预先显示 READY。按钮先通过既有 `clearWindFields()` 清空 Source-owned Timeline,再依序提交一个声明字节数与文件实际字节数不一致的 invalid probe、T0、T1,并严格断言 Core 结果依次为 `REJECTED_ADAPTER`、`ACCEPTED_PENDING_INITIAL_WINDOW`、`INITIAL_WINDOW_READY`,最后 Native `WeatherRuntime.state` 必须为 `READY`;任意不符均显示 `ERROR`。
|
||||
|
||||
界面严格区分 `Core evidence: INITIAL_WINDOW_READY + runtime READY` 与 producer 已提交的 T0/T1 generation/time identity;后者不是 Native/Core runtime window。MIX 明确为 `Core-owned / not exposed`。Android 最终 T0/T1 真值由既有 logcat `HOST_FIELD_SNAPSHOT_READY` 与 `HOST_FIELD_INITIAL_WINDOW_READY` 验证,本 Step 不新增 Core getter。
|
||||
|
||||
启动入口是 `App.tsx`。`app.json` 已固定 `orientation: landscape`;下一次 Android prebuild/build 应生成 `MainActivity android:screenOrientation="landscape"`。Android build、安装及设备操作必须由用户手工执行,详见 `docs/60_测试/STEP-060_RNTestHost_Android人工编译与验收_v1.md`。
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
dependencies {
|
||||
implementation "com.facebook.react:react-android"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:2.1.20"
|
||||
implementation "org.maplibre.gl:android-sdk-opengl:13.4.1-step060f"
|
||||
implementation "org.maplibre.gl:android-sdk-opengl:13.4.1-step061"
|
||||
compileOnly project(":maplibre_maplibre-react-native")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.maplibre.particle.rntesthost.weather
|
||||
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
@@ -15,6 +16,10 @@ import org.maplibre.android.maps.MapLibreMap
|
||||
import org.maplibre.android.maps.Style
|
||||
import org.maplibre.android.maps.WeatherRuntime
|
||||
import org.maplibre.reactnative.components.mapview.MLRNMapView
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* A low-frequency, per-view bridge. It resolves the Fabric view for each call and never stores a
|
||||
@@ -58,6 +63,22 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) :
|
||||
}
|
||||
|
||||
@ReactMethod override fun getState(reactTag: Double, promise: Promise) = withRuntime(reactTag, promise) { runtime -> promise.resolve(snapshot(runtime)) }
|
||||
@ReactMethod override fun clearWindFields(reactTag: Double, promise: Promise) = command(reactTag, promise) { it.clearWindFields() }
|
||||
|
||||
/** Binary ingress is a local file reference, never a JS number array or encoded string. */
|
||||
@ReactMethod
|
||||
override fun submitWindField(reactTag: Double, metadataJson: String, payloadFileUri: String, promise: Promise) {
|
||||
if (metadataJson.isEmpty() || metadataJson.toByteArray(Charsets.UTF_8).size > MAX_METADATA_UTF8_BYTES) return reject(promise, "INVALID_ARGUMENT", "metadataJson UTF-8 payload must be non-empty and at most $MAX_METADATA_UTF8_BYTES bytes")
|
||||
val payload = try { readLocalPayload(payloadFileUri) }
|
||||
catch (error: IllegalArgumentException) { return reject(promise, "PAYLOAD_FILE_UNREADABLE", error.message ?: "payload file is unreadable") }
|
||||
catch (error: IOException) { return reject(promise, "PAYLOAD_FILE_UNREADABLE", "payload file could not be read: ${error.message ?: "I/O error"}") }
|
||||
catch (error: RuntimeException) { return reject(promise, "PAYLOAD_FILE_UNREADABLE", "payload file could not be read") }
|
||||
if (payload.isEmpty()) return reject(promise, "PAYLOAD_FILE_UNREADABLE", "payload file is empty")
|
||||
withRuntime(reactTag, promise) { runtime ->
|
||||
try { promise.resolve(runtime.submitWindField(metadataJson, payload).name) }
|
||||
catch (error: IllegalStateException) { reject(promise, "NATIVE_REJECTED", error.message ?: "WeatherRuntime rejected the field") }
|
||||
}
|
||||
}
|
||||
@ReactMethod override fun setWeatherEnabled(reactTag: Double, enabled: Boolean, promise: Promise) = command(reactTag, promise) { it.setWeatherEnabled(enabled) }
|
||||
@ReactMethod override fun setWeatherColorVisible(reactTag: Double, visible: Boolean, promise: Promise) = command(reactTag, promise) { it.setWeatherColorVisible(visible) }
|
||||
@ReactMethod override fun setParticleVisible(reactTag: Double, visible: Boolean, promise: Promise) = command(reactTag, promise) { it.setParticleVisible(visible) }
|
||||
@@ -123,6 +144,27 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) :
|
||||
val value = values.firstOrNull { it.name == raw } ?: return reject(promise, "INVALID_ARGUMENT", "unsupported enum value: $raw")
|
||||
command(reactTag, promise) { fn(it, value) }
|
||||
}
|
||||
private fun readLocalPayload(reference: String): ByteArray {
|
||||
if (reference.isBlank()) throw IllegalArgumentException("payload file reference is empty")
|
||||
val uri = Uri.parse(reference)
|
||||
if (uri.scheme != null && uri.scheme != "file") throw IllegalArgumentException("payload file reference must use file:// or an absolute local path")
|
||||
val file = if (uri.scheme == "file") File(uri.path ?: throw IllegalArgumentException("payload file URI has no path")) else File(reference).also {
|
||||
if (!it.isAbsolute) throw IllegalArgumentException("payload path without a URI scheme must be absolute")
|
||||
}
|
||||
if (!file.isFile || !file.canRead()) throw IllegalArgumentException("payload file is not a readable local file")
|
||||
if (file.length() <= 0L || file.length() > MAX_PAYLOAD_BYTES) throw IllegalArgumentException("payload file size must be in 1..$MAX_PAYLOAD_BYTES bytes")
|
||||
return file.inputStream().use(::readBounded)
|
||||
}
|
||||
private fun readBounded(input: InputStream): ByteArray {
|
||||
val output = ByteArrayOutputStream(); val buffer = ByteArray(DEFAULT_READ_BUFFER_BYTES)
|
||||
while (true) {
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
if (output.size().toLong() + count > MAX_PAYLOAD_BYTES) throw IllegalArgumentException("payload file exceeds $MAX_PAYLOAD_BYTES bytes")
|
||||
output.write(buffer, 0, count)
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
private fun withRuntime(reactTag: Double, promise: Promise, fn: (WeatherRuntime) -> Unit) = withMap(reactTag, promise) { map ->
|
||||
val runtime = map.weatherRuntime
|
||||
if (runtime == null || runtime.isDisposed) reject(promise, "RUNTIME_DISPOSED", "WeatherRuntime is not attached to this map") else fn(runtime)
|
||||
@@ -176,5 +218,8 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) :
|
||||
}
|
||||
companion object {
|
||||
const val NAME = "WeatherRuntimeBridge"
|
||||
private const val MAX_METADATA_UTF8_BYTES = 1_048_576
|
||||
private const val MAX_PAYLOAD_BYTES = 64L * 1024L * 1024L
|
||||
private const val DEFAULT_READ_BUFFER_BYTES = 16 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ type RuntimeState = "DISABLED" | "WAITING_FOR_FIELDS" | "READY" | "ERROR";
|
||||
type PlaybackState = "PLAY" | "PAUSE";
|
||||
type TargetFps = "AUTO" | "FPS_30" | "FPS_60";
|
||||
type CommandResult = "ACCEPTED" | "REJECTED";
|
||||
type FieldSubmitResult = "REJECTED_SOURCE_MODE" | "REJECTED_ADAPTER" | "REJECTED_INCOMPATIBLE" | "REJECTED_NON_MONOTONIC" | "REJECTED_CAPACITY" | "ACCEPTED_PENDING_INITIAL_WINDOW" | "INITIAL_WINDOW_READY" | "ACCEPTED_NEXT_SNAPSHOT" | "DUPLICATE" | "UNKNOWN";
|
||||
type ColorStop = { speedMps: number; red: number; green: number; blue: number; alpha: number };
|
||||
type RuntimeSnapshot = { state: RuntimeState; playbackState: PlaybackState; targetFps: TargetFps; disposed: boolean };
|
||||
|
||||
@@ -12,6 +13,8 @@ export interface Spec extends TurboModule {
|
||||
attach(reactTag: number): Promise<RuntimeSnapshot>;
|
||||
detach(reactTag: number): Promise<void>;
|
||||
getState(reactTag: number): Promise<RuntimeSnapshot>;
|
||||
clearWindFields(reactTag: number): Promise<CommandResult>;
|
||||
submitWindField(reactTag: number, metadataJson: string, payloadFileUri: string): Promise<FieldSubmitResult>;
|
||||
setWeatherEnabled(reactTag: number, enabled: boolean): Promise<CommandResult>;
|
||||
setWeatherColorVisible(reactTag: number, visible: boolean): Promise<CommandResult>;
|
||||
setParticleVisible(reactTag: number, visible: boolean): Promise<CommandResult>;
|
||||
|
||||
@@ -3,6 +3,7 @@ export type RuntimeState = "DISABLED" | "WAITING_FOR_FIELDS" | "READY" | "ERROR"
|
||||
export type PlaybackState = "PLAY" | "PAUSE";
|
||||
export type TargetFps = "AUTO" | "FPS_30" | "FPS_60";
|
||||
export type CommandResult = "ACCEPTED" | "REJECTED";
|
||||
export type FieldSubmitResult = "REJECTED_SOURCE_MODE" | "REJECTED_ADAPTER" | "REJECTED_INCOMPATIBLE" | "REJECTED_NON_MONOTONIC" | "REJECTED_CAPACITY" | "ACCEPTED_PENDING_INITIAL_WINDOW" | "INITIAL_WINDOW_READY" | "ACCEPTED_NEXT_SNAPSHOT" | "DUPLICATE" | "UNKNOWN";
|
||||
export type ColorStop = Readonly<{ speedMps: number; red: number; green: number; blue: number; alpha: number }>;
|
||||
export type RuntimeSnapshot = Readonly<{ state: RuntimeState; playbackState: PlaybackState; targetFps: TargetFps; disposed: boolean }>;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"@maplibre-particle/weather-runtime-bridge": "file:./modules/weather-runtime-bridge",
|
||||
"@maplibre/maplibre-react-native": "11.3.6",
|
||||
"expo": "56.0.4",
|
||||
"expo-file-system": "56.0.11",
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.85.2"
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@maplibre-particle/weather-runtime-bridge": "file:./modules/weather-runtime-bridge",
|
||||
"@maplibre/maplibre-react-native": "11.3.6",
|
||||
"expo": "56.0.4",
|
||||
"expo-file-system": "56.0.11",
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.85.2"
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ const marker = "// MapLibreParticleRNTestHost custom OpenGL 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.mavenLocal()\n p.repositories.withType(org.gradle.api.artifacts.repositories.MavenArtifactRepository).configureEach { repo ->\n def repoUrl = repo.url.toString()\n if (repoUrl.contains(\"repo.maven.apache.org/maven2\") || repoUrl.contains(\"repo1.maven.org/maven2\")) {\n repo.content { excludeModule(\"org.maplibre.gl\", \"android-sdk-opengl\") }\n }\n }\n }\n}\nrootProject.ext.set(\"org.maplibre.reactnative.nativeVersion\", \"13.4.1-step060f\")\nrootProject.ext.set(\"org.maplibre.reactnative.nativeVariant\", \"opengl\")\n`;
|
||||
project.modResults.contents += `\n${marker}\ngradle.projectsEvaluated {\n allprojects { p ->\n p.repositories.mavenLocal()\n p.repositories.withType(org.gradle.api.artifacts.repositories.MavenArtifactRepository).configureEach { repo ->\n def repoUrl = repo.url.toString()\n if (repoUrl.contains(\"repo.maven.apache.org/maven2\") || repoUrl.contains(\"repo1.maven.org/maven2\")) {\n repo.content { excludeModule(\"org.maplibre.gl\", \"android-sdk-opengl\") }\n }\n }\n }\n}\nrootProject.ext.set(\"org.maplibre.reactnative.nativeVersion\", \"13.4.1-step061\")\nrootProject.ext.set(\"org.maplibre.reactnative.nativeVariant\", \"opengl\")\n`;
|
||||
return project;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { File, Paths } from "expo-file-system";
|
||||
|
||||
export type CanonicalWindFrame = Readonly<{
|
||||
label: "T0" | "T1" | "INVALID";
|
||||
metadataJson: string;
|
||||
payloadFileUri: string;
|
||||
generation: number;
|
||||
validTime: number;
|
||||
}>;
|
||||
|
||||
const width = 4;
|
||||
const height = 3;
|
||||
const bytesPerCell = 2 * Float32Array.BYTES_PER_ELEMENT;
|
||||
const validTimeT0 = 1767225600000;
|
||||
const validTimeT1 = validTimeT0 + 3_600_000;
|
||||
|
||||
function metadata(label: "T0" | "T1" | "INVALID", generation: number, validTime: number, byteLength: number) {
|
||||
return JSON.stringify({
|
||||
schema: "maplibre-particle-field/v1", fieldId: `rn-step061-wind-${label.toLowerCase()}`,
|
||||
fieldType: "WIND", displayDomain: "GLOBAL", generation, validTime, dataVersion: 1,
|
||||
bounds: { west: -180, south: -85, east: 180, north: 85 }, grid: { width, height },
|
||||
vector: { layout: "interleaved-uv", encoding: "float32-le", noData: -9999 }, payload: { byteLength },
|
||||
});
|
||||
}
|
||||
|
||||
function interleavedUvBytes(frameOffset: number) {
|
||||
const bytes = new ArrayBuffer(width * height * bytesPerCell);
|
||||
const view = new DataView(bytes);
|
||||
for (let cell = 0; cell < width * height; cell += 1) {
|
||||
const offset = cell * bytesPerCell;
|
||||
// Canonical order is U=eastward followed by V=northward. `true` freezes LE storage.
|
||||
view.setFloat32(offset, 4 + frameOffset + cell * 0.1, true);
|
||||
view.setFloat32(offset + Float32Array.BYTES_PER_ELEMENT, -1 + frameOffset * 0.25, true);
|
||||
}
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
function writeFrame(label: "T0" | "T1" | "INVALID", generation: number, validTime: number, bytes: Uint8Array, declaredByteLength = bytes.byteLength): CanonicalWindFrame {
|
||||
const file = new File(Paths.cache, `maplibre-step061-${label.toLowerCase()}.uvf`);
|
||||
file.write(bytes);
|
||||
return { label, metadataJson: metadata(label, generation, validTime, declaredByteLength), payloadFileUri: file.uri, generation, validTime };
|
||||
}
|
||||
|
||||
/** Produces independent canonical frames; the Core timeline alone selects T0/T1/MIX. */
|
||||
export function writeCanonicalWindFixture(): readonly [CanonicalWindFrame, CanonicalWindFrame, CanonicalWindFrame] {
|
||||
const t0Bytes = interleavedUvBytes(0);
|
||||
const t1Bytes = interleavedUvBytes(1);
|
||||
// Deliberately declare the normal byte length while writing one byte fewer: Core must reject it.
|
||||
const invalidBytes = t0Bytes.slice(0, t0Bytes.byteLength - 1);
|
||||
return [
|
||||
writeFrame("T0", 1, validTimeT0, t0Bytes),
|
||||
writeFrame("T1", 2, validTimeT1, t1Bytes),
|
||||
writeFrame("INVALID", 3, validTimeT1 + 3_600_000, invalidBytes, t0Bytes.byteLength),
|
||||
];
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
13.4.1-step060f
|
||||
13.4.1-step061
|
||||
|
||||
15
粒子开发记录.md
15
粒子开发记录.md
@@ -554,3 +554,18 @@ WeatherColorLayer 的 `ParticleGpuValidationUBO` designated initializer 补齐 `
|
||||
- Android 实测横屏、双 Metro reverse、`WeatherRuntime attached` 均通过;完整验证 UI 的 `weather opacity` 返回 `ACCEPTED`。
|
||||
- 当前 App PID 日志无 `WEATHER_OBJECTS_NOT_READY`、`UNSUPPORTED_PROPERTY`、FATAL/JNI/SIGSEGV hard-gate 错误;无 Binary Field 时 `TIMELINE_NOT_READY` 继续按预期 fail-closed。
|
||||
- STEP-060 正式 `CLOSED / PASS`;后续可进入 STEP-061 Binary U/V Field Transport。
|
||||
|
||||
# 2026-09-08 — STEP-061 Binary U/V Field Transport(IMPLEMENTED / WAITING_RDC_REVIEW)
|
||||
|
||||
- RN Test Host 固定以 Expo FileSystem `File.write(Uint8Array)` 写 raw canonical `float32-le/interleaved-uv` U/V bytes,并经 TurboModule 的 `metadataJson + payloadFileUri` 交给 Kotlin;没有 number-array、payload JSON、base64 或 hex transport。
|
||||
- Bridge 限制本地 `file://`/绝对路径、可读非空和 64 MiB payload,再调用既有 `WeatherRuntime.submitWindField(metadataJson, byte[])`;Core Adapter/Timeline/Runtime Window 未修改,仍是唯一验证与状态所有者。
|
||||
- UI 提供 invalid byteLength probe(预期 `REJECTED_ADAPTER`)、T0(预期 `ACCEPTED_PENDING_INITIAL_WINDOW`)、T1(预期 `INITIAL_WINDOW_READY`)及 Native READY state evidence;JS 不管理 T0/T1/MIX runtime state。
|
||||
- `npm run typecheck`、RN 0.85.2 Codegen(仅 `/tmp` 生成)和 `git diff --check` 已通过;未执行 AAR/Windows/APK/ADB/设备/Jest,等待 RDC 审查真实 diff。
|
||||
|
||||
# 2026-09-09 — STEP-061 RDC NEEDS FIX 修正(IMPLEMENTED / WAITING_RDC_REVIEW)
|
||||
|
||||
- Binary UI 改为初始/提交时 PENDING,仅在 invalid/T0/T1/Native state 严格为 `REJECTED_ADAPTER`、`ACCEPTED_PENDING_INITIAL_WINDOW`、`INITIAL_WINDOW_READY`、`READY` 后显示 READY;任一不符 fail-closed 显示 ERROR。
|
||||
- Runtime 文案严格分离 Core evidence(`INITIAL_WINDOW_READY + runtime READY`)和 producer identity;MIX 为 Core-owned/not exposed,Android 最终 T0/T1 仍由既有 HOST_FIELD logcat 事件验证。
|
||||
- Kotlin 显式捕获 IOException、拒绝无 scheme 相对路径,metadata 上限按 UTF-8 bytes 计算。AAR 坐标已准备为唯一 `13.4.1-step061`,不覆盖冻结 step060f;本轮按限制未 build/publish,待 RDC 后续指令。
|
||||
|
||||
- 本轮 `npm run typecheck`、RN Codegen(仅 `/tmp`)、plugin `node --check`、禁止 transport 审计与 `git diff --check` 均通过;继续停在 Codex prompt 等待 RDC。
|
||||
|
||||
Reference in New Issue
Block a user