From ae03d8bfd2813eb5e65074db16018e37c7cff1c4 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Sun, 13 Sep 2026 14:43:51 +0800 Subject: [PATCH] feat: wire V4 QBIN host API into RasterArraySource --- ..._V4_CoreHostAPI与RasterArraySource接线_v1.md | 34 ++++ ...I与RasterArraySource接线_Codex完成报告_v1.md | 28 +++ .../style/sources/raster_array_source.hpp | 45 +++++ .../style/sources/raster_array_source.cpp | 96 ++++++++++ test/CMakeLists.txt | 1 + test/source/raster_array_v4_host_api.test.cpp | 169 ++++++++++++++++++ 粒子开发记录.md | 6 + 7 files changed, 379 insertions(+) create mode 100644 docs/50_Step记录/STEP-076_QBIN2_V4_CoreHostAPI与RasterArraySource接线_v1.md create mode 100644 docs/报告/STEP-076_QBIN2_V4_CoreHostAPI与RasterArraySource接线_Codex完成报告_v1.md create mode 100644 test/source/raster_array_v4_host_api.test.cpp diff --git a/docs/50_Step记录/STEP-076_QBIN2_V4_CoreHostAPI与RasterArraySource接线_v1.md b/docs/50_Step记录/STEP-076_QBIN2_V4_CoreHostAPI与RasterArraySource接线_v1.md new file mode 100644 index 000000000..bf5adc20c --- /dev/null +++ b/docs/50_Step记录/STEP-076_QBIN2_V4_CoreHostAPI与RasterArraySource接线_v1.md @@ -0,0 +1,34 @@ +# STEP-076 — QBIN2 V4 Core Host API 与 RasterArraySource 接线 v1 + +状态:完成 + +日期:2026-09-13 + +## 目标与基线 + +- 开始 HEAD:`b148fb6a0c6711182194f6778d80c8f8a60eb037`。 +- 任务前 STEP-062 两处 dirty、历史 untracked 和 build 输出保持原样,未暂存、覆盖或提交。 +- 本 Step 仅在 C++ Core / style source 接入 QBIN V4;V3 timeline、renderer、JNI/Java/RN/Test Host 均不变。 + +## 完成项 + +- [x] `RasterArraySource` 新增隔离 V4 owner 状态:`EncodedFieldStore`、`ForecastClock`、可选 Color/Particle binding;与 V3 `RasterArrayTimelineRuntime` 生命周期和数据完全分离。 +- [x] 新增 `submitEncodedField`,严格按 fieldKey/runtime metadata → `QbinV2Decoder::decode` → `FieldProfileValidator::validate` → `EncodedFieldStore::submit` 顺序执行;失败在 Store 提交前返回,结果稳定区分 insert/replace/duplicate/decode/contract/key/identity/stale/incompatible/capacity。 +- [x] 新增 binding、requested forecast time、playback state/speed、clock advance、独立 color/particle resolve、单 field / all field clear API;没有 Host 可写 T0/T1/MIX。 +- [x] binding 只保存 Host intent;resolve 使用既有 `BindingProjectionResolver` fail-closed。clear 保留 binding intent,且绝不调用 V3 `clearHostFields()`。 +- [x] 新增离线 QBIN deterministic suite:submission reject 后旧 window 可继续 resolve;wind `00/01/02` 与 wave `00/03/06` 各自解析 window/mix,clock in-window advance 只改变 mix 不改变 content identity,pause 和 HALF/NORMAL/DOUBLE 语义覆盖;clear/re-submit 生命周期覆盖。 + +## 验证 + +- 先通过全局 `build-output-throttle` 配置,再真实执行 `cmake --build build-linux-opengl --target mbgl-test-runner -j3`。构建环境的默认 ccache 位于只读目录,故仅将本次 ccache 临时目录置于 `/tmp`;重建成功。 +- `Source.RasterArrayV4HostApi*`:3/3,0 failure。 +- `ParticleFieldBinding.*`:6/6,0 failure。 +- `ParticleFieldRuntime.*:ParticleFieldProfileValidator.*`:11/11,0 failure。 +- `ParticleQbinV2Decoder.*:ParticleEncodedFieldIngress.*`:24/24,0 failure。 +- `Source.ParticleFieldDataAdapter*:Source.RasterArrayTimeline*:Source.HostField*`:11/11,0 failure。 +- `git diff --check`:PASS。 + +## 边界、阻塞与下一步 + +- 未执行 Android Gradle/APK/ADB/Jest、iOS/Metal;未修改 Android/JNI/RN、Renderer、shader、Test Host;未使用真实服务器 QBIN,也未新增网络/缓存/gzip 逻辑。 +- 无实现阻塞。本 Step 到 C++ Host API 为止,按任务书停止,不进入 Android/JNI/RN bridge。 diff --git a/docs/报告/STEP-076_QBIN2_V4_CoreHostAPI与RasterArraySource接线_Codex完成报告_v1.md b/docs/报告/STEP-076_QBIN2_V4_CoreHostAPI与RasterArraySource接线_Codex完成报告_v1.md new file mode 100644 index 000000000..e42abf523 --- /dev/null +++ b/docs/报告/STEP-076_QBIN2_V4_CoreHostAPI与RasterArraySource接线_Codex完成报告_v1.md @@ -0,0 +1,28 @@ +# STEP-076 QBIN2 V4 Core Host API 与 RasterArraySource 接线 Codex 完成报告 v1 + +## 结果 + +已完成 V4 QBIN Host API 到 `RasterArraySource` 的隔离接线。raw QBIN bytes 经 metadata/key 检查、decode、contract validate 后才进入按 `fieldKey` 独立维护的 `EncodedFieldStore`。Color 与 Particle binding 保持独立 intent,并通过同一 `ForecastClock` 在 resolve 时产生各自 window/mix;本 Step 没有让 renderer 消费 V4 projection,也没有改变 V3 host/timeline 行为。 + +## 开始基线与修改文件 + +- 开始 HEAD:`b148fb6a0c6711182194f6778d80c8f8a60eb037`。 +- 修改:`include/mbgl/style/sources/raster_array_source.hpp`、`src/mbgl/style/sources/raster_array_source.cpp`、`test/CMakeLists.txt`、`test/source/raster_array_v4_host_api.test.cpp`、本 Step、本文报告和 `粒子开发记录.md`。 +- 历史 STEP-062 dirty/untracked/build 输出未暂存或提交。 + +## 验证结果 + +| Filter | Running | Failure | +| --- | ---: | ---: | +| `Source.RasterArrayV4HostApi*` | 3 | 0 | +| `ParticleFieldBinding.*` | 6 | 0 | +| `ParticleFieldRuntime.*:ParticleFieldProfileValidator.*` | 11 | 0 | +| `ParticleQbinV2Decoder.*:ParticleEncodedFieldIngress.*` | 24 | 0 | +| `Source.ParticleFieldDataAdapter*:Source.RasterArrayTimeline*:Source.HostField*` | 11 | 0 | + +真实 Linux OpenGL `mbgl-test-runner` 已重建;所有命令经全局 build-output-throttle,`git diff --check` PASS。未执行 Android Gradle/APK/ADB/Jest、iOS/Metal。 + +## Git 收尾 + +- scoped commit、push result、`origin/particle/main` 远端和 Final HEAD 以本次提交/推送的实际输出为准,未混入任务前内容。 +- 未完成/阻塞:无;按任务边界停止,不进入 Android/JNI/RN bridge。 diff --git a/include/mbgl/style/sources/raster_array_source.hpp b/include/mbgl/style/sources/raster_array_source.hpp index 9d20b5328..cea0b8d00 100644 --- a/include/mbgl/style/sources/raster_array_source.hpp +++ b/include/mbgl/style/sources/raster_array_source.hpp @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include @@ -142,6 +144,24 @@ enum class RasterArrayHostFieldSubmitResult : int8_t { const char* rasterArrayHostFieldSubmitResultName(RasterArrayHostFieldSubmitResult); +// Stable outcome for the isolated QBIN V4 Host ingress. This is deliberately +// separate from the legacy V3 host-field result above: V4 does not populate a +// renderer-facing T0/T1 timeline. +enum class RasterArrayEncodedFieldSubmitResult : int8_t { + RejectedDecode = -1, + RejectedContract = -2, + RejectedInvalidFieldKey = -3, + RejectedIdentityConflict = -4, + RejectedStaleRevision = -5, + RejectedIncompatible = -6, + RejectedCapacity = -7, + AcceptedInserted = 1, + AcceptedReplacedRevision = 2, + Duplicate = 3, +}; + +const char* rasterArrayEncodedFieldSubmitResultName(RasterArrayEncodedFieldSubmitResult); + class RasterArraySource final : public Source { public: RasterArraySource(std::string id, @@ -181,6 +201,25 @@ public: // Clears producer-owned Host ingress state while retaining the Source and // its Style layers for a subsequent T0/T1 submission. bool clearHostFields(); + + // V4 QBIN ingress is a parallel, Core-only owner. It retains per-field + // series and a requested forecast clock but never writes V3 T0/T1/MIX or + // renderer snapshots. + RasterArrayEncodedFieldSubmitResult submitEncodedField( + const std::string& fieldKey, + const particle::ingress::EncodedFieldRuntimeMetadata& runtimeMetadata, + const particle::ResolvedFieldProfileContract& contract, + const std::vector& rawQbinBytes); + void setColorBinding(particle::ColorBinding); + void setParticleBinding(particle::ParticleBinding); + bool setRequestedForecastTime(uint64_t timestampMs); + void setForecastPlaybackState(particle::ForecastPlaybackState); + bool setForecastPlaybackSpeed(float); + bool advanceForecastClock(uint64_t elapsedMs); + particle::ColorProjectionResult resolveColorProjection() const; + particle::ParticleProjectionResult resolveParticleProjection() const; + bool clearEncodedField(const std::string& fieldKey); + void clearAllEncodedFields(); // Producer-facing timestamp seek. Core alone selects T0/T1 and MIX. bool seekRuntimeTimelineToTimestamp(uint64_t timestamp); // Low-frequency JNI commands for the Source-owned validation timeline. @@ -234,6 +273,12 @@ private: uint64_t runtimeUpdateSequence = 0; std::shared_ptr timeline; RasterArrayTargetFps targetFps = RasterArrayTargetFps::Auto; + // Deliberately independent from `timeline`: V4 bindings retain Host intent + // across clear operations and resolve their own field-keyed windows. + particle::EncodedFieldStore encodedFieldStore; + particle::ForecastClock forecastClock; + std::optional colorBinding; + std::optional particleBinding; mapbox::base::WeakPtrFactory weakFactory{this}; }; diff --git a/src/mbgl/style/sources/raster_array_source.cpp b/src/mbgl/style/sources/raster_array_source.cpp index d77a669ab..17b73763a 100644 --- a/src/mbgl/style/sources/raster_array_source.cpp +++ b/src/mbgl/style/sources/raster_array_source.cpp @@ -11,6 +11,7 @@ #include #include +#include #include namespace mbgl::style { @@ -22,8 +23,56 @@ namespace { // same source ID, and that must always invalidate the prior GPU field. std::atomic_uint64_t nextRasterArrayFieldGeneration{0}; +bool validEncodedFieldKey(const std::string& fieldKey) { + if (fieldKey.empty() || fieldKey.size() > 128) return false; + return std::all_of(fieldKey.begin(), fieldKey.end(), [](const unsigned char value) { + return std::isalnum(value) || value == '.' || value == '_' || value == '-' || value == ':'; + }); +} + +bool validRuntimeMetadata(const particle::ingress::EncodedFieldRuntimeMetadata& metadata) { + return !metadata.fieldID.empty() && metadata.fieldID.size() <= 128 && metadata.generation != 0; +} + +RasterArrayEncodedFieldSubmitResult mapEncodedFieldStoreResult(const particle::FieldSeriesSubmitResult result) { + switch (result) { + case particle::FieldSeriesSubmitResult::Inserted: return RasterArrayEncodedFieldSubmitResult::AcceptedInserted; + case particle::FieldSeriesSubmitResult::ReplacedRevision: + return RasterArrayEncodedFieldSubmitResult::AcceptedReplacedRevision; + case particle::FieldSeriesSubmitResult::Duplicate: return RasterArrayEncodedFieldSubmitResult::Duplicate; + case particle::FieldSeriesSubmitResult::RejectedInvalidFieldKey: + return RasterArrayEncodedFieldSubmitResult::RejectedInvalidFieldKey; + case particle::FieldSeriesSubmitResult::RejectedIdentityConflict: + return RasterArrayEncodedFieldSubmitResult::RejectedIdentityConflict; + case particle::FieldSeriesSubmitResult::RejectedStaleRevision: + return RasterArrayEncodedFieldSubmitResult::RejectedStaleRevision; + case particle::FieldSeriesSubmitResult::RejectedIncompatibleFrame: + case particle::FieldSeriesSubmitResult::RejectedInvalidFrame: + return RasterArrayEncodedFieldSubmitResult::RejectedIncompatible; + case particle::FieldSeriesSubmitResult::RejectedCapacity: + return RasterArrayEncodedFieldSubmitResult::RejectedCapacity; + } + return RasterArrayEncodedFieldSubmitResult::RejectedIncompatible; +} + } // namespace +const char* rasterArrayEncodedFieldSubmitResultName(const RasterArrayEncodedFieldSubmitResult result) { + switch (result) { + case RasterArrayEncodedFieldSubmitResult::AcceptedInserted: return "AcceptedInserted"; + case RasterArrayEncodedFieldSubmitResult::AcceptedReplacedRevision: return "AcceptedReplacedRevision"; + case RasterArrayEncodedFieldSubmitResult::Duplicate: return "Duplicate"; + case RasterArrayEncodedFieldSubmitResult::RejectedDecode: return "RejectedDecode"; + case RasterArrayEncodedFieldSubmitResult::RejectedContract: return "RejectedContract"; + case RasterArrayEncodedFieldSubmitResult::RejectedInvalidFieldKey: return "RejectedInvalidFieldKey"; + case RasterArrayEncodedFieldSubmitResult::RejectedIdentityConflict: return "RejectedIdentityConflict"; + case RasterArrayEncodedFieldSubmitResult::RejectedStaleRevision: return "RejectedStaleRevision"; + case RasterArrayEncodedFieldSubmitResult::RejectedIncompatible: return "RejectedIncompatible"; + case RasterArrayEncodedFieldSubmitResult::RejectedCapacity: return "RejectedCapacity"; + } + return "RejectedIncompatible"; +} + RasterArrayTimelineRuntime::RasterArrayTimelineRuntime(std::vector entries_) : entries(std::move(entries_)) { if (entries.size() < 2) { @@ -307,6 +356,53 @@ RasterArraySource::~RasterArraySource() { weakFactory.invalidateWeakPtrs(); } +RasterArrayEncodedFieldSubmitResult RasterArraySource::submitEncodedField( + const std::string& fieldKey, + const particle::ingress::EncodedFieldRuntimeMetadata& runtimeMetadata, + const particle::ResolvedFieldProfileContract& contract, + const std::vector& rawQbinBytes) { + // Keep every failure before Store::submit side-effect-free. Decoder also + // 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()) + return RasterArrayEncodedFieldSubmitResult::RejectedContract; + return mapEncodedFieldStoreResult(encodedFieldStore.submit(fieldKey, std::move(decoded.frame))); +} + +void RasterArraySource::setColorBinding(particle::ColorBinding binding) { colorBinding = std::move(binding); } + +void RasterArraySource::setParticleBinding(particle::ParticleBinding binding) { particleBinding = std::move(binding); } + +bool RasterArraySource::setRequestedForecastTime(const uint64_t timestampMs) { + return forecastClock.setRequestedForecastTime(timestampMs); +} + +void RasterArraySource::setForecastPlaybackState(const particle::ForecastPlaybackState state) { + state == particle::ForecastPlaybackState::Playing ? forecastClock.play() : forecastClock.pause(); +} + +bool RasterArraySource::setForecastPlaybackSpeed(const float value) { return forecastClock.setPlaybackSpeed(value); } + +bool RasterArraySource::advanceForecastClock(const uint64_t elapsedMs) { return forecastClock.advance(elapsedMs); } + +particle::ColorProjectionResult RasterArraySource::resolveColorProjection() const { + if (!colorBinding) return {particle::BindingProjectionError::InvalidBinding, {}}; + return particle::BindingProjectionResolver::resolve(encodedFieldStore, *colorBinding, forecastClock.requestedForecastTime()); +} + +particle::ParticleProjectionResult RasterArraySource::resolveParticleProjection() const { + if (!particleBinding) return {particle::BindingProjectionError::InvalidBinding, {}}; + return particle::BindingProjectionResolver::resolve(encodedFieldStore, *particleBinding, + forecastClock.requestedForecastTime()); +} + +bool RasterArraySource::clearEncodedField(const std::string& fieldKey) { return encodedFieldStore.clear(fieldKey); } + +void RasterArraySource::clearAllEncodedFields() { encodedFieldStore.clearAll(); } + const RasterArraySource::Impl& RasterArraySource::impl() const { return static_cast(*baseImpl); } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 226a3c9e2..11894e941 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -65,6 +65,7 @@ add_library( ${PROJECT_SOURCE_DIR}/test/style/properties.test.cpp ${PROJECT_SOURCE_DIR}/test/style/property_expression.test.cpp ${PROJECT_SOURCE_DIR}/test/style/source.test.cpp + ${PROJECT_SOURCE_DIR}/test/source/raster_array_v4_host_api.test.cpp ${PROJECT_SOURCE_DIR}/test/style/style.test.cpp ${PROJECT_SOURCE_DIR}/test/style/style_image.test.cpp ${PROJECT_SOURCE_DIR}/test/style/style_layer.test.cpp diff --git a/test/source/raster_array_v4_host_api.test.cpp b/test/source/raster_array_v4_host_api.test.cpp new file mode 100644 index 000000000..4eaab0cae --- /dev/null +++ b/test/source/raster_array_v4_host_api.test.cpp @@ -0,0 +1,169 @@ +#include +#include + +#include + +#include +#include +#include + +using namespace mbgl; + +namespace { + +constexpr size_t Header = 112; +constexpr size_t Descriptor = 32; +constexpr uint64_t Base = 1'700'000'000'000ULL; +constexpr uint64_t Hour = 3'600'000ULL; + +void u16(std::vector& bytes, size_t offset, uint16_t value) { + bytes[offset] = uint8_t(value); bytes[offset + 1] = uint8_t(value >> 8); +} +void u32(std::vector& bytes, size_t offset, uint32_t value) { + for (size_t i = 0; i < 4; ++i) bytes[offset + i] = uint8_t(value >> (8 * i)); +} +void u64(std::vector& bytes, size_t offset, uint64_t value) { + for (size_t i = 0; i < 8; ++i) bytes[offset + i] = uint8_t(value >> (8 * i)); +} +void i64(std::vector& bytes, size_t offset, int64_t value) { u64(bytes, offset, static_cast(value)); } +void f32(std::vector& bytes, size_t offset, float value) { + uint32_t bits; std::memcpy(&bits, &value, sizeof(bits)); u32(bytes, offset, bits); +} +void f64(std::vector& bytes, size_t offset, double value) { + uint64_t bits; std::memcpy(&bits, &value, sizeof(bits)); u64(bytes, offset, bits); +} +size_t aligned16(size_t value) { return (value + 15) & ~size_t(15); } +uint32_t crc(const std::vector& bytes) { + uint32_t result = ~0u; + for (size_t i = 0; i < bytes.size(); ++i) { + uint8_t value = i >= 76 && i < 80 ? 0 : bytes[i]; result ^= value; + for (int bit = 0; bit < 8; ++bit) result = (result >> 1) ^ ((result & 1) ? 0xedb88320u : 0); + } + return ~result; +} + +struct Plane { + uint16_t semantic, unit, group; + uint8_t type, valueClass, component; + std::vector bytes; +}; + +std::vector qbin(uint64_t validTime, bool scalar = false) { + std::vector planes; + if (scalar) planes.push_back({99, 77, 0, 3, 0, 0, {1, 2, 3, 4}}); + else { + planes.push_back({1, 1, 1, 1, 1, 1, {1, 0, 2, 0, 3, 0, 4, 0}}); + planes.push_back({2, 1, 1, 1, 1, 2, {5, 0, 6, 0, 7, 0, 8, 0}}); + } + planes.push_back({3, 0, 0, 6, 3, 0, {15}}); + std::sort(planes.begin(), planes.end(), [](const Plane& a, const Plane& b) { return a.semantic < b.semantic; }); + size_t payloadLength = 0; + for (const auto& plane : planes) payloadLength = aligned16(payloadLength + plane.bytes.size()); + std::vector bytes(Header + planes.size() * Descriptor + payloadLength); + std::memcpy(bytes.data(), "QBV2", 4); bytes[4] = 2; bytes[5] = 1; u16(bytes, 6, 999); + bytes[8] = bytes[9] = bytes[10] = 1; u16(bytes, 12, planes.size()); u32(bytes, 16, 2); u32(bytes, 20, 2); + i64(bytes, 24, static_cast(validTime / 1000)); i64(bytes, 32, static_cast(validTime / 1000)); + f64(bytes, 40, -10); f64(bytes, 48, -10); f64(bytes, 56, 10); f64(bytes, 64, 10); u32(bytes, 72, payloadLength); + u16(bytes, 80, 0xffff); + size_t payloadOffset = 0; + for (size_t i = 0; i < planes.size(); ++i) { + const auto& plane = planes[i]; const size_t descriptor = Header + i * Descriptor; + u16(bytes, descriptor, plane.semantic); bytes[descriptor + 2] = plane.type; bytes[descriptor + 3] = 1; + f32(bytes, descriptor + 4, 1); f32(bytes, descriptor + 8, 0); u32(bytes, descriptor + 12, 4); + u32(bytes, descriptor + 16, payloadOffset); u32(bytes, descriptor + 20, plane.bytes.size()); + u16(bytes, descriptor + 24, plane.unit); u16(bytes, descriptor + 26, plane.group); + bytes[descriptor + 28] = plane.valueClass; bytes[descriptor + 29] = plane.component; + std::copy(plane.bytes.begin(), plane.bytes.end(), bytes.begin() + Header + planes.size() * Descriptor + payloadOffset); + payloadOffset = aligned16(payloadOffset + plane.bytes.size()); + } + u32(bytes, 76, crc(bytes)); return bytes; +} + +particle::ingress::EncodedFieldRuntimeMetadata metadata(const char* id, uint64_t generation, uint64_t version = 1) { + return {id, generation, version}; +} +particle::ResolvedFieldProfileContract windContract() { + particle::ResolvedFieldProfileContract result; result.profileID = "test.wind"; result.profileVersion = 1; + result.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}}; + return result; +} +particle::ResolvedFieldProfileContract waveContract() { + particle::ResolvedFieldProfileContract result; result.profileID = "test.wave"; result.profileVersion = 1; + result.requiredPlanes = {{3, particle::FieldValueClass::Validity, 0, 0, particle::FieldComponent::None, true}, + {99, particle::FieldValueClass::Scalar, 77, 0, particle::FieldComponent::None, true}}; + return result; +} +void submit(style::RasterArraySource& source, const char* key, const char* id, uint64_t time, uint64_t generation, + bool scalar = false) { + EXPECT_EQ(source.submitEncodedField(key, metadata(id, generation), scalar ? waveContract() : windContract(), qbin(time, scalar)), + style::RasterArrayEncodedFieldSubmitResult::AcceptedInserted); +} + +} // namespace + +TEST(Source, RasterArrayV4HostApiRejectsWithoutPartialMutationAndMapsStoreResults) { + style::RasterArraySource source{"v4"}; const auto first = qbin(Base); + EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 1), windContract(), first), + style::RasterArrayEncodedFieldSubmitResult::AcceptedInserted); + EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 1), windContract(), first), + style::RasterArrayEncodedFieldSubmitResult::Duplicate); + EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 2, 2), windContract(), first), + style::RasterArrayEncodedFieldSubmitResult::AcceptedReplacedRevision); + EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 1), windContract(), first), + style::RasterArrayEncodedFieldSubmitResult::RejectedStaleRevision); + auto corrupt = first; corrupt[0] = 'X'; + EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 3), windContract(), corrupt), + style::RasterArrayEncodedFieldSubmitResult::RejectedDecode); + EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 3), waveContract(), first), + style::RasterArrayEncodedFieldSubmitResult::RejectedContract); + EXPECT_EQ(source.submitEncodedField("", metadata("wind", 3), windContract(), first), + style::RasterArrayEncodedFieldSubmitResult::RejectedInvalidFieldKey); + submit(source, "wind", "wind", Base + Hour, 3); + source.setParticleBinding({particle::ParticleBindingMode::VectorComponents, "wind", + {1, 1, particle::FieldComponent::UEast}, {2, 1, particle::FieldComponent::VNorth}, {}, 1}); + ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2)); + EXPECT_TRUE(source.resolveParticleProjection().accepted()); +} + +TEST(Source, RasterArrayV4HostApiResolvesIndependentBindingsFromOneForecastClock) { + style::RasterArraySource source{"v4"}; + submit(source, "wind", "wind", Base, 1); submit(source, "wind", "wind", Base + Hour, 2); + submit(source, "wind", "wind", Base + 2 * Hour, 3); + submit(source, "wave", "wave", Base, 1, true); submit(source, "wave", "wave", Base + 3 * Hour, 2, true); + submit(source, "wave", "wave", Base + 6 * Hour, 3, true); + source.setColorBinding({"wave", {99, 0, particle::FieldComponent::None}, particle::FieldValueClass::Scalar, 77}); + source.setParticleBinding({particle::ParticleBindingMode::VectorComponents, "wind", + {1, 1, particle::FieldComponent::UEast}, {2, 1, particle::FieldComponent::VNorth}, {}, 1}); + ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour + Hour / 2)); + const auto color = source.resolveColorProjection(); const auto particles = source.resolveParticleProjection(); + ASSERT_TRUE(color.accepted()); ASSERT_TRUE(particles.accepted()); + EXPECT_FLOAT_EQ(color.projection->sampleState.mix, 0.5f); + EXPECT_FLOAT_EQ(particles.projection->sampleState.mix, 0.5f); + EXPECT_NE(color.projection->contentIdentity.t1Coordinate.validTimeMs, particles.projection->contentIdentity.t1Coordinate.validTimeMs); + const auto identity = particles.projection->contentIdentity; + source.setForecastPlaybackState(particle::ForecastPlaybackState::Playing); + ASSERT_TRUE(source.advanceForecastClock(10)); const auto advanced = source.resolveParticleProjection(); + ASSERT_TRUE(advanced.accepted()); EXPECT_NE(advanced.projection->sampleState.mix, particles.projection->sampleState.mix); + EXPECT_EQ(advanced.projection->contentIdentity, identity); + source.setForecastPlaybackState(particle::ForecastPlaybackState::Paused); + EXPECT_FALSE(source.advanceForecastClock(10)); EXPECT_TRUE(source.setForecastPlaybackSpeed(0.5f)); + EXPECT_TRUE(source.setForecastPlaybackSpeed(1.0f)); EXPECT_TRUE(source.setForecastPlaybackSpeed(2.0f)); +} + +TEST(Source, RasterArrayV4HostApiClearRetainsBindingsAndNeverTouchesV3) { + style::RasterArraySource source{"v4"}; + submit(source, "wind", "wind", Base, 1); submit(source, "wind", "wind", Base + Hour, 2); + submit(source, "wave", "wave", Base, 1, true); submit(source, "wave", "wave", Base + 3 * Hour, 2, true); + source.setColorBinding({"wave", {99, 0, particle::FieldComponent::None}, particle::FieldValueClass::Scalar, 77}); + source.setParticleBinding({particle::ParticleBindingMode::VectorComponents, "wind", + {1, 1, particle::FieldComponent::UEast}, {2, 1, particle::FieldComponent::VNorth}, {}, 1}); + ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour)); ASSERT_TRUE(source.resolveColorProjection().accepted()); + EXPECT_TRUE(source.clearEncodedField("wave")); EXPECT_TRUE(source.resolveParticleProjection().accepted()); + EXPECT_EQ(source.resolveColorProjection().error, particle::BindingProjectionError::FieldSeriesMissing); + submit(source, "wave", "wave", Base, 1, true); submit(source, "wave", "wave", Base + 3 * Hour, 2, true); + EXPECT_TRUE(source.resolveColorProjection().accepted()); source.clearAllEncodedFields(); + EXPECT_EQ(source.resolveColorProjection().error, particle::BindingProjectionError::FieldSeriesMissing); + EXPECT_EQ(source.resolveParticleProjection().error, particle::BindingProjectionError::FieldSeriesMissing); +} diff --git a/粒子开发记录.md b/粒子开发记录.md index bc2578d6b..e2adb8c85 100644 --- a/粒子开发记录.md +++ b/粒子开发记录.md @@ -754,3 +754,9 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2` - 新增两组离线回归:Vector 的 inactive direction 与 Direction 的 inactive U/V 都不改变 binding/resolved projection identity;各自 active selector 变更仍 binding 不同并 `PlaneMissing` fail-closed。 - Linux OpenGL runner 真实重建 PASS;Binding 6/6、Runtime/Profile 11/11、QBIN/Ingress 24/24、Source 11/11,均 0 failure;`git diff --check` PASS。Android Gradle/APK/ADB/Jest、iOS/Metal 未执行;STEP-062 dirty 与历史 untracked/build 输出未纳入。 - scoped implementation `2645bd725f63c4da12ab48a0cc11ff034c9d9878`(`fix: close STEP-075 binding selector identity audit`)已成功 push:`653e6f2f1..2645bd725 particle/main -> particle/main`,remote 为 `origin/particle/main`。 + +### 2026-09-13 STEP-076 QBIN2 V4 Core Host API 与 RasterArraySource 接线 + +- `RasterArraySource` 新增完全隔离的 V4 QBIN Host API:submission 严格执行 key/runtime metadata → QBIN decoder → Profile validator → per-field Store,稳定返回 insert/replace/duplicate 与各类 fail-closed rejection;失败不留下 partial Store mutation。 +- V4 owner 独立持有 `EncodedFieldStore`、`ForecastClock`、Color/Particle binding;每个 binding 在 resolve 时按自己的 fieldKey 获得 window/mix。没有 Host T0/T1/MIX API,没有切换 renderer,也未改动 V3 timeline。 +- 离线 Source V4 suite 3/3、Binding 6/6、Runtime/Profile 11/11、QBIN/Ingress 24/24、V3 Source 11/11,均为 0 failure;Linux runner 真实重建、`git diff --check` PASS。Android Gradle/APK/ADB/Jest、iOS/Metal 未执行;STEP-062 dirty 与既有 untracked/build 输出未纳入。