feat: wire V4 QBIN host API into RasterArraySource
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:
@@ -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。
|
||||
@@ -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。
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <mbgl/particle/field/field_binding.hpp>
|
||||
#include <mbgl/particle/field/offline_fixture_particle_adapter.hpp>
|
||||
#include <mbgl/particle/ingress/qbin_v2_decoder.hpp>
|
||||
#include <mbgl/style/source.hpp>
|
||||
|
||||
#include <chrono>
|
||||
@@ -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<uint8_t>& 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<RasterArrayTimelineRuntime> 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<particle::ColorBinding> colorBinding;
|
||||
std::optional<particle::ParticleBinding> particleBinding;
|
||||
mapbox::base::WeakPtrFactory<Source> weakFactory{this};
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
#include <atomic>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
|
||||
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<Entry> 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<uint8_t>& 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<const Impl&>(*baseImpl);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
169
test/source/raster_array_v4_host_api.test.cpp
Normal file
169
test/source/raster_array_v4_host_api.test.cpp
Normal file
@@ -0,0 +1,169 @@
|
||||
#include <mbgl/particle/ingress/qbin_v2_decoder.hpp>
|
||||
#include <mbgl/style/sources/raster_array_source.hpp>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
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<uint8_t>& bytes, size_t offset, uint16_t value) {
|
||||
bytes[offset] = uint8_t(value); bytes[offset + 1] = uint8_t(value >> 8);
|
||||
}
|
||||
void u32(std::vector<uint8_t>& 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<uint8_t>& 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<uint8_t>& bytes, size_t offset, int64_t value) { u64(bytes, offset, static_cast<uint64_t>(value)); }
|
||||
void f32(std::vector<uint8_t>& bytes, size_t offset, float value) {
|
||||
uint32_t bits; std::memcpy(&bits, &value, sizeof(bits)); u32(bytes, offset, bits);
|
||||
}
|
||||
void f64(std::vector<uint8_t>& 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<uint8_t>& 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<uint8_t> bytes;
|
||||
};
|
||||
|
||||
std::vector<uint8_t> qbin(uint64_t validTime, bool scalar = false) {
|
||||
std::vector<Plane> 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<uint8_t> 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<int64_t>(validTime / 1000)); i64(bytes, 32, static_cast<int64_t>(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);
|
||||
}
|
||||
@@ -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 输出未纳入。
|
||||
|
||||
Reference in New Issue
Block a user