diff --git a/CMakeLists.txt b/CMakeLists.txt index e7a72cb6c..f194b9b8d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -212,10 +212,12 @@ list(APPEND SRC_FILES ${PROJECT_SOURCE_DIR}/src/mbgl/renderer/layers/collision_layer_tweaker.hpp ${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/offline_fixture_particle_adapter.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/offline_fixture_particle_adapter.hpp + ${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_data_adapter.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/shaders/shader_program_base.cpp ) list(APPEND INCLUDE_FILES + ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_data_adapter.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor_ref.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/actor/aspiring_actor.hpp diff --git a/docs/50_Step记录/STEP-049_Provider-neutral_Field_Data_Adapter与Runtime输入契约_v1.md b/docs/50_Step记录/STEP-049_Provider-neutral_Field_Data_Adapter与Runtime输入契约_v1.md new file mode 100644 index 000000000..e7346dec7 --- /dev/null +++ b/docs/50_Step记录/STEP-049_Provider-neutral_Field_Data_Adapter与Runtime输入契约_v1.md @@ -0,0 +1,40 @@ +# STEP-049 Provider-neutral Field Data Adapter 与 Runtime 输入契约 + +- 状态:进行中(等待 Android 综合验收) +- 目标:建立不包含 provider/network/vendor 语义的 C++ Field descriptor、payload adapter 与 immutable Snapshot 输入边界,并复用既有 Runtime Field Window。 +- 基线:`f0954476783b60c6ad4fdde7229657abed2e70f6`,`particle/main`,与 `origin/particle/main` 一致。 + +## 实际实现结构 + +新增 `ParticleFieldDescriptor`、`ParticleFieldSnapshot`、`ParticleFieldDataAdapter` 与 `ParticleFieldAdapterResult`,位于 `include/mbgl/particle/field/field_data_adapter.hpp` / `src/mbgl/particle/field/field_data_adapter.cpp`。descriptor 仅描述 field identity、类型/域、有效时间、网格 bounds、U/V canonical layout、format、stride/byte length、no-data、版本和可选 checksum;不包含 URL、token、provider 或网络对象。 + +`ParticleFieldDataAdapter::decode()` 校验 descriptor、`interleaved-uv` + `float32-le`、尺寸/stride、payload 长度和 finite data,生成拥有自有 `std::vector` 的 Snapshot。`OfflineFixtureParticleAdapter` 仍保留 `fixture-v1` schema、fixture whitelist、metadata 与 checksum 校验,但最终通过正式 adapter 输出 Snapshot;因此 fixture 不是 production adapter 的限制。 + +## 错误与接线 + +结构化错误包括 `InvalidDescriptor`、`UnsupportedFormat`、`DimensionMismatch`、`PayloadSizeMismatch`、`InvalidTimestampOrIdentity`、`NonFiniteData` 和 `DecodeValidationFailure`,错误码与 message 分离。`RasterArraySource` 继续只调用 `updateRuntimeFieldWindow()`;没有第二套 T0/T1 状态机,`OLD_T1 → NEW_T0`、正常只上传 NEW_T1、Duplicate NOOP、MIX property-only 和 fail-closed 语义未改。 + +Renderer 仅将日志字段从 fixture identity 改为 neutral `fieldID`,未引入 provider/network/vendor、decode、Timeline 或缓存业务。Timeline、Playback、WeatherColorLayer、Android JNI 和 GPU simulation 未改。 + +## 验证与待验收 + +- Linux OpenGL `mbgl-test-runner` 构建:PASS。 +- 定向测试:`Source.ParticleFieldDataAdapter*`、`Source.RasterArray*` 共 11 项,PASS。 +- `git diff --check`:PASS。 +- Android Gradle/APK/ADB/设备测试:按任务要求未执行,等待用户一次综合验收,覆盖 EAST/NORTH/VORTEX、Playback、neutral valid/invalid、payload mismatch、reuse、MIX、no black screen。 +- Jest:未新增、未执行。 + +## 必做项 + +- [x] neutral descriptor + immutable Snapshot +- [x] C++ Data Adapter 与 structured error +- [x] fixture wrapper 复用正式 adapter +- [x] RasterArraySource 继续复用 Runtime Field Window +- [x] 未新增 Provider Framework / network / vendor 逻辑 +- [x] Linux 定向构建与测试 +- [x] Step、开发记录与完成报告 +- [ ] Android 综合验收与 `CLOSED / PASS` + +## 阻塞与下一步 + +当前无代码阻塞;正式关闭依赖用户执行任务书规定的一次 Android 综合验收。Codex 不执行 Android build/install/ADB。 diff --git a/docs/报告/STEP-048_下一阶段规划调查_Codex完成报告_v1.md b/docs/报告/STEP-048_下一阶段规划调查_Codex完成报告_v1.md index 10ba452fa..6906831da 100644 --- a/docs/报告/STEP-048_下一阶段规划调查_Codex完成报告_v1.md +++ b/docs/报告/STEP-048_下一阶段规划调查_Codex完成报告_v1.md @@ -22,3 +22,11 @@ Gitea API 与 Git remote 均确认调查基线为 `46dceb5cb6110c94393b7413465f8 ## 输出与收尾 生成:本调查报告及本完成报告。未修改功能代码、测试、Android、Renderer 或 build 文件;未执行 Android Gradle、ADB、Jest。`git diff --check` 通过后仅提交两份调查文档并 push `origin/particle/main`。 + +## Git 收尾补充 + +- commit hash:`f0954476783b60c6ad4fdde7229657abed2e70f6` +- commit message:`docs: investigate next particle runtime stage` +- push result:`SUCCESS` +- remote branch:`origin/particle/main` +- Final HEAD:`f0954476783b60c6ad4fdde7229657abed2e70f6` diff --git a/docs/报告/STEP-049_Provider-neutral_Field_Data_Adapter与Runtime输入契约_Codex完成报告_v1.md b/docs/报告/STEP-049_Provider-neutral_Field_Data_Adapter与Runtime输入契约_Codex完成报告_v1.md new file mode 100644 index 000000000..c48a63147 --- /dev/null +++ b/docs/报告/STEP-049_Provider-neutral_Field_Data_Adapter与Runtime输入契约_Codex完成报告_v1.md @@ -0,0 +1,41 @@ +# STEP-049 Provider-neutral Field Data Adapter 与 Runtime 输入契约 — Codex 完成报告 + +## 结果 + +已完成 C++ provider-neutral Field descriptor、payload adapter、structured error 和 immutable Snapshot 接线。状态:`IMPLEMENTED / WAITING FOR ANDROID VALIDATION`;不能在用户 Android 验收前宣称 `CLOSED / PASS`。 + +## Git 基线与修改 + +- 起始 HEAD:`f0954476783b60c6ad4fdde7229657abed2e70f6` +- branch:`particle/main` +- origin 起始 HEAD:`f0954476783b60c6ad4fdde7229657abed2e70f6` +- 实际修改:`CMakeLists.txt`、Field adapter 新增头/源文件、fixture adapter、`RasterArraySource`、validation renderer 日志、`test/style/source.test.cpp`、STEP-048 报告、STEP-049 Step 记录、粒子开发记录。 +- 未修改/未 stage:工作区已有的无关 untracked 任务文件与 `STEP-046-e48fa087-review.diff`。 + +## 设计结论 + +`ParticleFieldDescriptor` 是最小 canonical 输入契约,包含 field identity、类型/显示域、valid time、网格 bounds、dimensions、layout/encoding、stride、byte length、no-data、data version 和可选 checksum。`ParticleFieldDataAdapter::decode()` 只接收 descriptor、bytes、runtime generation,校验后生成自持有 vectors 的 Snapshot;不持有 Android、Renderer、Timeline、provider 或 network 对象。 + +`OfflineFixtureParticleAdapter` 继续负责 fixture-v1 metadata/whitelist/checksum,并调用正式 adapter。`RasterArraySource` 仍复用唯一的 `updateRuntimeFieldWindow()`;没有改变 T0/T1、Timeline、Playback、MIX、GPU reuse 或 Renderer 业务边界。 + +## 验证 + +- C++ / Core / OpenGL:`cmake --build build-linux-opengl --target mbgl-test-runner -j3`,PASS。 +- 定向已有测试:`./mbgl-test-runner --gtest_filter='Source.ParticleFieldDataAdapter*:Source.RasterArray*'`,11/11 PASS。 +- `git diff --check`:PASS。 +- Android Gradle、APK copy/install、ADB、设备:未执行(任务明确由用户承担)。 +- Jest:未新增、未执行。 + +## Git 收尾 + +- commit hash:`3c9c58b57` +- commit message:`feat: add provider-neutral particle field adapter contract` +- push result:待执行 +- remote branch:`origin/particle/main` +- Final HEAD:`3c9c58b57`(push 前) + +## 未完成项与风险 + +- 用户尚未执行 Android 一次综合验收,因此 EAST/NORTH/VORTEX、Playback、neutral valid/invalid、fail-closed、reuse、MIX 与 no-black-screen 的设备结果待补。 +- 当前 adapter canonical format 仍只支持 `interleaved-uv` + `float32-le`,这是本阶段现有 Runtime 的明确边界,不是 provider schema。 +- `ParticleFieldSnapshot::isValid()` 继续按当前 Wind/Global renderer 语义校验;Wave/Tide/Current 的 Ocean-only 语义不在本阶段实现。 diff --git a/include/mbgl/particle/field/field_data_adapter.hpp b/include/mbgl/particle/field/field_data_adapter.hpp new file mode 100644 index 000000000..1a60378ba --- /dev/null +++ b/include/mbgl/particle/field/field_data_adapter.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include +#include + +namespace mbgl::particle { + +// Provider-neutral description of the canonical field bytes consumed by Core. +// Provider names, URLs and transport metadata deliberately do not cross this boundary. +struct ParticleFieldDescriptor { + std::string fieldID; + std::string fieldType; + std::string displayDomain; + uint64_t validTime = 0; + uint32_t width = 0; + uint32_t height = 0; + double west = -180.0; + double south = -85.0; + double east = 180.0; + double north = 85.0; + float noData = -9999.0f; + uint64_t dataVersion = 0; + uint64_t byteLength = 0; + uint32_t stride = 0; + std::string layout = "interleaved-uv"; + std::string encoding = "float32-le"; + std::string fieldSha256; +}; + +struct ParticleFieldSnapshot { + std::string fieldID; + std::string fieldType; + std::string displayDomain; + uint64_t validTime = 0; + uint32_t width = 0; + uint32_t height = 0; + double west = -180.0; + double south = -85.0; + double east = 180.0; + double north = 85.0; + float noData = -9999.0f; + uint64_t dataVersion = 0; + uint64_t generation = 0; + std::string fieldSha256; + std::vector velocity; + std::vector valid; + + bool isValid() const; + bool isTimeInterpolationCompatibleWith(const ParticleFieldSnapshot&) const; +}; + +inline bool fieldIdentityChanged(const ParticleFieldSnapshot& current, const ParticleFieldSnapshot& next) { + return current.generation != next.generation || current.fieldID != next.fieldID || + current.dataVersion != next.dataVersion; +} + +inline bool sameFieldGpuContent(const ParticleFieldSnapshot& current, const ParticleFieldSnapshot& next) { + return current.fieldID == next.fieldID && current.dataVersion == next.dataVersion && + current.fieldSha256 == next.fieldSha256 && current.isTimeInterpolationCompatibleWith(next); +} + +struct ParticleFieldTimeWindow { + ParticleFieldSnapshot t0; + ParticleFieldSnapshot t1; + + bool isValid() const { return t0.isValid() && t1.isValid() && t0.isTimeInterpolationCompatibleWith(t1); } +}; + +enum class ParticleFieldAdapterError : uint8_t { + None, + InvalidDescriptor, + UnsupportedFormat, + DimensionMismatch, + PayloadSizeMismatch, + InvalidTimestampOrIdentity, + NonFiniteData, + DecodeValidationFailure, +}; + +struct ParticleFieldAdapterResult { + std::optional snapshot; + ParticleFieldAdapterError error = ParticleFieldAdapterError::None; + std::string message; + + explicit operator bool() const { return snapshot.has_value(); } +}; + +class ParticleFieldDataAdapter final { +public: + static ParticleFieldAdapterResult decode(const ParticleFieldDescriptor&, const std::vector&, uint64_t generation); +}; + +} // namespace mbgl::particle diff --git a/src/mbgl/particle/field/field_data_adapter.cpp b/src/mbgl/particle/field/field_data_adapter.cpp new file mode 100644 index 000000000..bd518908a --- /dev/null +++ b/src/mbgl/particle/field/field_data_adapter.cpp @@ -0,0 +1,78 @@ +#include + +#include +#include +#include +#include + +namespace mbgl::particle { +namespace { +ParticleFieldAdapterResult reject(ParticleFieldAdapterError error, std::string message) { + return {.snapshot = std::nullopt, .error = error, .message = std::move(message)}; +} +} // namespace + +bool ParticleFieldSnapshot::isValid() const { + return !fieldID.empty() && fieldType == "WIND" && displayDomain == "GLOBAL" && width != 0 && height != 0 && + west < east && south < north && velocity.size() == static_cast(width) * height * 2 && + valid.size() == static_cast(width) * height && + std::all_of(velocity.begin(), velocity.end(), [](float value) { return std::isfinite(value); }); +} + +bool ParticleFieldSnapshot::isTimeInterpolationCompatibleWith(const ParticleFieldSnapshot& other) const { + return width == other.width && height == other.height && west == other.west && south == other.south && + east == other.east && north == other.north && noData == other.noData && fieldType == other.fieldType && + displayDomain == other.displayDomain; +} + +ParticleFieldAdapterResult ParticleFieldDataAdapter::decode(const ParticleFieldDescriptor& descriptor, + const std::vector& bytes, + const uint64_t generation) { + if (descriptor.fieldID.empty() || descriptor.fieldType.empty() || descriptor.displayDomain.empty() || generation == 0) { + return reject(ParticleFieldAdapterError::InvalidTimestampOrIdentity, "field identity or generation is invalid"); + } + if (descriptor.width == 0 || descriptor.height == 0 || !(descriptor.west < descriptor.east) || + !(descriptor.south < descriptor.north) || !std::isfinite(descriptor.noData)) { + return reject(ParticleFieldAdapterError::InvalidDescriptor, "field dimensions, bounds or noData are invalid"); + } + if (descriptor.layout != "interleaved-uv" || descriptor.encoding != "float32-le") { + return reject(ParticleFieldAdapterError::UnsupportedFormat, "only interleaved-uv float32-le is supported"); + } + const uint64_t cells = static_cast(descriptor.width) * descriptor.height; + const uint64_t expected = cells * 2 * sizeof(float); + if (descriptor.stride != 0 && descriptor.stride != 2 * sizeof(float)) { + return reject(ParticleFieldAdapterError::DimensionMismatch, "field stride does not match interleaved UV"); + } + if (descriptor.byteLength != expected || bytes.size() != expected) { + return reject(ParticleFieldAdapterError::PayloadSizeMismatch, "field payload length does not match descriptor"); + } + ParticleFieldSnapshot field; + field.fieldID = descriptor.fieldID; + field.fieldType = descriptor.fieldType; + field.displayDomain = descriptor.displayDomain; + field.validTime = descriptor.validTime; + field.width = descriptor.width; + field.height = descriptor.height; + field.west = descriptor.west; + field.south = descriptor.south; + field.east = descriptor.east; + field.north = descriptor.north; + field.noData = descriptor.noData; + field.dataVersion = descriptor.dataVersion; + field.generation = generation; + field.fieldSha256 = descriptor.fieldSha256; + field.velocity.resize(static_cast(cells) * 2); + std::memcpy(field.velocity.data(), bytes.data(), bytes.size()); + field.valid.resize(static_cast(cells)); + for (size_t cell = 0; cell < field.valid.size(); ++cell) { + const float u = field.velocity[cell * 2]; + const float v = field.velocity[cell * 2 + 1]; + if (!std::isfinite(u) || !std::isfinite(v)) return reject(ParticleFieldAdapterError::NonFiniteData, "field contains non-finite data"); + field.valid[cell] = (u == field.noData || v == field.noData) ? 0 : 1; + } + if (field.fieldType != "WIND" || field.displayDomain != "GLOBAL" || !field.isValid()) { + return reject(ParticleFieldAdapterError::DecodeValidationFailure, "decoded field is invalid"); + } + return {.snapshot = std::move(field), .error = ParticleFieldAdapterError::None, .message = {}}; +} +} // namespace mbgl::particle diff --git a/src/mbgl/particle/field/offline_fixture_particle_adapter.cpp b/src/mbgl/particle/field/offline_fixture_particle_adapter.cpp index 2e7667ad5..d90c8f6f9 100644 --- a/src/mbgl/particle/field/offline_fixture_particle_adapter.cpp +++ b/src/mbgl/particle/field/offline_fixture_particle_adapter.cpp @@ -107,26 +107,6 @@ std::string sha256(const std::vector& input) { } // namespace -bool ParticleFieldSnapshot::isValid() const { - if (fixtureID.empty() || fieldType != "WIND" || displayDomain != "GLOBAL" || width == 0 || height == 0 || - !(west < east) || !(south < north) || velocity.size() != static_cast(width) * height * 2 || - valid.size() != static_cast(width) * height) { - return false; - } - for (const float component : velocity) { - if (!std::isfinite(component)) { - return false; - } - } - return true; -} - -bool ParticleFieldSnapshot::isTimeInterpolationCompatibleWith(const ParticleFieldSnapshot& other) const { - return width == other.width && height == other.height && west == other.west && south == other.south && - east == other.east && north == other.north && noData == other.noData && fieldType == other.fieldType && - displayDomain == other.displayDomain; -} - ParticleFieldSnapshot OfflineFixtureParticleAdapter::load(const std::string& fixtureID, const std::string& metadataText, const std::vector& bytes, @@ -173,45 +153,35 @@ ParticleFieldSnapshot OfflineFixtureParticleAdapter::load(const std::string& fix std::string(vector["file"].GetString()) != "field.f32") { throw std::invalid_argument("unsupported fixture v1 layout: " + fixtureID); } - ParticleFieldSnapshot field; - field.fixtureID = fixtureID; - field.fieldType = member(metadata, "fieldType").GetString(); - field.displayDomain = member(metadata, "displayDomain").GetString(); - field.width = grid["width"].GetUint(); - field.height = grid["height"].GetUint(); - field.west = bounds["west"].GetDouble(); - field.south = bounds["south"].GetDouble(); - field.east = bounds["east"].GetDouble(); - field.north = bounds["north"].GetDouble(); - field.noData = static_cast(vector["noData"].GetDouble()); - if (!std::isfinite(field.noData)) { + ParticleFieldDescriptor descriptor; + descriptor.fieldID = fixtureID; + descriptor.fieldType = member(metadata, "fieldType").GetString(); + descriptor.displayDomain = member(metadata, "displayDomain").GetString(); + descriptor.width = grid["width"].GetUint(); + descriptor.height = grid["height"].GetUint(); + descriptor.west = bounds["west"].GetDouble(); + descriptor.south = bounds["south"].GetDouble(); + descriptor.east = bounds["east"].GetDouble(); + descriptor.north = bounds["north"].GetDouble(); + descriptor.noData = static_cast(vector["noData"].GetDouble()); + if (!std::isfinite(descriptor.noData)) { throw std::invalid_argument("non-finite fixture noData: " + fixtureID); } - field.dataVersion = member(metadata, "dataVersion").GetUint64(); - field.generation = generation; - const size_t expected = static_cast(field.width) * field.height * 2 * sizeof(float); + descriptor.dataVersion = member(metadata, "dataVersion").GetUint64(); + descriptor.byteLength = static_cast(descriptor.width) * descriptor.height * 2 * sizeof(float); + descriptor.fieldSha256 = member(integrity, "fieldSha256").GetString(); + const size_t expected = static_cast(descriptor.byteLength); if (bytes.size() != expected || member(integrity, "fieldByteLength").GetUint64() != expected) { throw std::invalid_argument("fixture field length mismatch: " + fixtureID); } - field.fieldSha256 = member(integrity, "fieldSha256").GetString(); - if (sha256(bytes) != field.fieldSha256) { + if (sha256(bytes) != descriptor.fieldSha256) { throw std::invalid_argument("fixture field checksum mismatch: " + fixtureID); } - field.velocity.resize(expected / sizeof(float)); - std::memcpy(field.velocity.data(), bytes.data(), expected); - field.valid.resize(static_cast(field.width) * field.height); - for (size_t cell = 0; cell < field.valid.size(); ++cell) { - const float u = field.velocity[cell * 2]; - const float v = field.velocity[cell * 2 + 1]; - if (!std::isfinite(u) || !std::isfinite(v)) { - throw std::invalid_argument("non-finite fixture velocity: " + fixtureID); - } - field.valid[cell] = (u == field.noData || v == field.noData) ? 0 : 1; + auto result = ParticleFieldDataAdapter::decode(descriptor, bytes, generation); + if (!result) { + throw std::invalid_argument("field adapter rejected fixture: " + result.message); } - if (!field.isValid()) { - throw std::invalid_argument("invalid offline particle fixture: " + fixtureID); - } - return field; + return std::move(*result.snapshot); } ParticleFieldSnapshot OfflineFixtureParticleAdapter::load(const std::string& fixtureID, const uint64_t generation) { diff --git a/src/mbgl/particle/field/offline_fixture_particle_adapter.hpp b/src/mbgl/particle/field/offline_fixture_particle_adapter.hpp index eb5ea26c6..ce2ecb53f 100644 --- a/src/mbgl/particle/field/offline_fixture_particle_adapter.hpp +++ b/src/mbgl/particle/field/offline_fixture_particle_adapter.hpp @@ -1,76 +1,21 @@ #pragma once +#include + #include #include #include namespace mbgl::particle { -// Immutable, backend-neutral Field snapshot used by the first offline particle -// implementation. The renderer owns only a GPU representation of this value. -struct ParticleFieldSnapshot { - std::string fixtureID; - std::string fieldType; - std::string displayDomain; - uint32_t width = 0; - uint32_t height = 0; - double west = -180.0; - double south = -85.0; - double east = 180.0; - double north = 85.0; - float noData = -9999.0f; - uint64_t dataVersion = 0; - uint64_t generation = 0; - // Verified integrity.fieldSha256 identifies immutable GPU-upload content - // across source reloads; generation remains a separate runtime revision. - std::string fieldSha256; - // Row-major, little-endian contract decoded as interleaved canonical - // U-east/V-north values. A noData component makes the cell invalid. - std::vector velocity; - std::vector valid; - - bool isValid() const; - // Two snapshots can be interpolated only when their geographic sampling - // contract is identical. Fixture identity/version intentionally do not - // participate: they identify different values in the same grid. - bool isTimeInterpolationCompatibleWith(const ParticleFieldSnapshot&) const; -}; - -// A snapshot revision identifies a runtime load, while fixtureID and -// dataVersion identify its declared content. Keep all three checks at the GPU -// upload boundary so a recreated source cannot silently reuse an old field. -inline bool fieldIdentityChanged(const ParticleFieldSnapshot& current, const ParticleFieldSnapshot& next) { - return current.generation != next.generation || current.fixtureID != next.fixtureID || - current.dataVersion != next.dataVersion; -} - -inline bool sameFieldGpuContent(const ParticleFieldSnapshot& current, const ParticleFieldSnapshot& next) { - return current.fixtureID == next.fixtureID && current.dataVersion == next.dataVersion && - current.fieldSha256 == next.fieldSha256 && current.isTimeInterpolationCompatibleWith(next); -} - -// A formal source time window. T0/T1 remain independent immutable snapshots; -// the renderer uploads and samples them independently on the GPU. -struct ParticleFieldTimeWindow { - ParticleFieldSnapshot t0; - ParticleFieldSnapshot t1; - - bool isValid() const { return t0.isValid() && t1.isValid() && t0.isTimeInterpolationCompatibleWith(t1); } -}; - -// Phase-one OfflineFixtureParticleAdapter. Its public contract deliberately -// contains no Android/UI/GPU objects, allowing the same decoded snapshot to be -// used by future Android Vulkan and iOS renderers. +// Validation-only wrapper for the frozen offline fixture-v1 metadata schema. +// The resulting snapshot is always produced by ParticleFieldDataAdapter. class OfflineFixtureParticleAdapter final { public: - // Generic loader boundary used by FileSource-backed platform inputs. It - // intentionally receives bytes only: no Android asset API crosses Core. static ParticleFieldSnapshot load(const std::string& fixtureID, const std::string& metadata, const std::vector& fieldBytes, uint64_t generation); - - // Linux fixture smoke-test helper. Android must use the byte loader above. static ParticleFieldSnapshot load(const std::string& fixtureID, uint64_t generation); }; diff --git a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp index 761ef8017..9703abb99 100644 --- a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp +++ b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp @@ -513,7 +513,7 @@ public: Log::Info(Event::Render, "ParticleRender: stateSeed=" + std::string(seedState ? "INITIALIZE" : "CONTINUE") + " generation=" + std::to_string(resources->generation) + " state=" + slotName(readSlot) + - "->" + slotName(writeSlot) + " fixture=" + resources->field.fixtureID); + "->" + slotName(writeSlot) + " field=" + resources->field.fieldID); } } @@ -734,7 +734,7 @@ void RenderRasterParticleLayer::evaluate(const PropertyEvaluationParameters&) { Log::Info(Event::Render, "ParticleRender: state=" + std::string(settings.active ? "ACTIVE" : "STATIC") + " maskMode=" + std::to_string(settings.maskMode) + " id=" + getID() + " fixture=" + - (formalSource && sourceField ? sourceField->fixtureID : fixtureID(settings.fixtureMode)) + + (formalSource && sourceField ? sourceField->fieldID : fixtureID(settings.fixtureMode)) + " layerIndex=" + std::to_string(getLayerIndex()) + " timeMix=" + std::to_string(settings.timeMix) + " particleCount=" + std::to_string(settings.particleCount) + " lineWidth=" + std::to_string(settings.lineWidth) + " streakLength=" + std::to_string(settings.particleLength) + @@ -951,27 +951,27 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, " fullParticleDraw=true resourceGeneration=" + std::to_string(resources->generation)); Log::Info( Event::Render, - "ParticleField: fixture loaded id=" + resources->field.fixtureID + " type=" + resources->field.fieldType + + "ParticleField: field loaded id=" + resources->field.fieldID + " type=" + resources->field.fieldType + " bounds=[" + std::to_string(resources->field.west) + ',' + std::to_string(resources->field.east) + "]x[" + std::to_string(resources->field.south) + ',' + std::to_string(resources->field.north) + "] grid=" + std::to_string(resources->field.width) + 'x' + std::to_string(resources->field.height) + " dataVersion=" + std::to_string(resources->field.dataVersion) + " fieldGeneration=" + std::to_string(resources->fieldGeneration)); Log::Info(Event::Render, - "ParticleField: fixture validated id=" + resources->field.fixtureID + + "ParticleField: field validated id=" + resources->field.fieldID + " noData=" + std::to_string(resources->field.noData) + " velocityCount=" + std::to_string(resources->field.velocity.size())); Log::Info(Event::Render, - "ParticleField: created id=" + resources->field.fixtureID + + "ParticleField: created id=" + resources->field.fieldID + " generation=" + std::to_string(resources->fieldGeneration)); Log::Info(Event::Render, - "ParticleGPU: field upload reason=RESOURCE_RECREATE fixture=" + resources->field.fixtureID + + "ParticleGPU: field upload reason=RESOURCE_RECREATE field=" + resources->field.fieldID + " fieldGeneration=" + std::to_string(resources->fieldGeneration) + " gpuFieldGeneration=" + std::to_string(resources->gpuFieldGeneration) + " resourceGeneration=" + std::to_string(resources->generation)); Log::Info(Event::Render, - "ParticleGPU: time window T0=" + resources->field.fixtureID + "@" + - std::to_string(resources->field.dataVersion) + " T1=" + resources->nextField.fixtureID + "@" + + "ParticleGPU: time window T0=" + resources->field.fieldID + "@" + + std::to_string(resources->field.dataVersion) + " T1=" + resources->nextField.fieldID + "@" + std::to_string(resources->nextField.dataVersion) + " uploads=" + std::to_string(resources->fieldUploadCount)); Log::Info( @@ -1126,8 +1126,8 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, resources->fixtureMode = fixtureMode; if (!windowOverlap) resources->resetSequence("FIELD_NO_OVERLAP"); Log::Info(Event::Render, - "ParticleGPU: time window advance T0=" + oldT0.fixtureID + " T1=" + oldT1.fixtureID + - " -> T0=" + resources->field.fixtureID + " T1=" + resources->nextField.fixtureID + + "ParticleGPU: time window advance T0=" + oldT0.fieldID + " T1=" + oldT1.fieldID + + " -> T0=" + resources->field.fieldID + " T1=" + resources->nextField.fieldID + " reuse=" + (windowOverlap ? "true" : "false") + " uploads=" + std::to_string(resources->fieldUploadCount) + " simulationRebuild=" + (windowOverlap ? "false" : "true") + " trailCleared=" + (windowOverlap ? "false" : "true")); @@ -1145,7 +1145,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, if (!sourceTimeline || std::abs(resources->timeMix * 4.0f - std::round(resources->timeMix * 4.0f)) < 0.01f) { Log::Info(Event::Render, "ParticleGPU: event=TIME_MIX_UPDATE value=" + std::to_string(resources->timeMix) + - " T0=" + resources->field.fixtureID + " T1=" + resources->nextField.fixtureID + + " T0=" + resources->field.fieldID + " T1=" + resources->nextField.fieldID + " renderLayerInstanceId=" + std::to_string(resources->renderLayerInstanceId) + " resourceGeneration=" + std::to_string(resources->generation) + " fieldUploadCount=" + std::to_string(resources->fieldUploadCount) + @@ -1182,7 +1182,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, Log::Info(Event::Render, "ParticleRender: lifecycle transition=" + std::string(active ? "ACTIVE" : "STATIC") + " resourceGeneration=" + std::to_string(resources->generation) + " fixture=" + - resources->field.fixtureID + " simulationFrame=" + std::to_string(resources->committedFrame) + + resources->field.fieldID + " simulationFrame=" + std::to_string(resources->committedFrame) + " stateRead=" + slotName(resources->stateRead) + " trailRead=" + slotName(resources->trailRead)); resources->lifecycleModeKnown = true; resources->lifecycleActive = active; @@ -1316,7 +1316,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, resources->pending = true; Log::Info(Event::Render, "ParticleRender: STATIC bootstrap scheduled resourceGeneration=" + - std::to_string(resources->generation) + " fixture=" + resources->field.fixtureID + + std::to_string(resources->generation) + " field=" + resources->field.fieldID + " simulationFrame=" + std::to_string(resources->committedFrame)); return; } diff --git a/src/mbgl/style/sources/raster_array_source.cpp b/src/mbgl/style/sources/raster_array_source.cpp index 537b8fe48..5b1782f08 100644 --- a/src/mbgl/style/sources/raster_array_source.cpp +++ b/src/mbgl/style/sources/raster_array_source.cpp @@ -78,18 +78,18 @@ RasterArrayTimelineFrame RasterArrayTimelineRuntime::tick() { state = RasterArrayPlaybackState::Waiting; Log::Warning(Event::Render, std::string("ParticleTimeline: event=TIMELINE_REJECTED reason=") + - (invalid ? "INVALID_NEXT" : "GAP_HOLD") + " t1=" + entries[t0 + 1].field->fixtureID); + (invalid ? "INVALID_NEXT" : "GAP_HOLD") + " t1=" + entries[t0 + 1].field->fieldID); return frameLocked(); } if (!moveToLocked(t0 + 1)) { mix = 1.0f; state = RasterArrayPlaybackState::Ended; - Log::Info(Event::Render, "ParticleTimeline: event=END t1=" + entries[t0 + 1].field->fixtureID); + Log::Info(Event::Render, "ParticleTimeline: event=END t1=" + entries[t0 + 1].field->fieldID); return frameLocked(); } Log::Info(Event::Render, - "ParticleTimeline: event=WINDOW_BOUNDARY oldT1=" + entries[t0].field->fixtureID + - " newT1=" + entries[t0 + 1].field->fixtureID + " result=APPLIED reuse=OLD_T1_TO_NEW_T0"); + "ParticleTimeline: event=WINDOW_BOUNDARY oldT1=" + entries[t0].field->fieldID + + " newT1=" + entries[t0 + 1].field->fieldID + " result=APPLIED reuse=OLD_T1_TO_NEW_T0"); Log::Info(Event::Render, "ParticleTimeline: event=TIMELINE_CACHE_HIT index=" + std::to_string(t0 + 1) + " cacheSize=" + std::to_string(entries.size()) + " capacity=" + std::to_string(cacheCapacity_)); @@ -332,7 +332,7 @@ void RasterArraySource::tryLoad() { " dataVersion=" + std::to_string(loadedField.dataVersion)); if (impl().nextField) { Log::Info(Event::Render, - "ParticleField: source loaded next fixtureId=" + impl().nextField->fixtureID + + "ParticleField: source loaded next fieldId=" + impl().nextField->fieldID + " generation=" + std::to_string(impl().nextField->generation) + " dataVersion=" + std::to_string(impl().nextField->dataVersion)); } @@ -351,7 +351,7 @@ void RasterArraySource::tryLoadRuntimeField(const size_t index) { candidate.snapshot = particle::OfflineFixtureParticleAdapter::load( candidate.descriptor.fixtureID, *candidate.metadata, *candidate.fieldBytes, 0); Log::Info(Event::Render, - "ParticleField: runtime candidate ready fixtureId=" + candidate.snapshot->fixtureID + + "ParticleField: runtime candidate ready fieldId=" + candidate.snapshot->fieldID + " dataVersion=" + std::to_string(candidate.snapshot->dataVersion)); tryInitializeTimeline(); } catch (const std::exception& error) { @@ -414,7 +414,7 @@ RasterArrayRuntimeFieldUpdateResult RasterArraySource::updateRuntimeFieldWindow( if (particle::sameFieldGpuContent(*impl().nextField, newT1)) { Log::Info(Event::Render, "ParticleField: event=FIELD_WINDOW_UPDATE_APPLIED result=NOOP sourceId=" + getID() + - " T0=" + impl().field->fixtureID + " T1=" + impl().nextField->fixtureID); + " T0=" + impl().field->fieldID + " T1=" + impl().nextField->fieldID); return RasterArrayRuntimeFieldUpdateResult::Noop; } if (newT1.generation <= impl().nextField->generation) { @@ -422,20 +422,20 @@ RasterArrayRuntimeFieldUpdateResult RasterArraySource::updateRuntimeFieldWindow( "NON_MONOTONIC_GENERATION"); } - const auto oldT0 = impl().field->fixtureID; - const auto oldT1 = impl().nextField->fixtureID; + const auto oldT0 = impl().field->fieldID; + const auto oldT1 = impl().nextField->fieldID; Log::Info(Event::Render, "ParticleField: event=FIELD_WINDOW_UPDATE_REQUEST sourceId=" + getID() + " sequence=" + std::to_string(runtimeUpdateSequence + 1) + " oldT0=" + oldT0 + " oldT1=" + oldT1 + - " newT1=" + newT1.fixtureID + " generation=" + std::to_string(newT1.generation)); + " newT1=" + newT1.fieldID + " generation=" + std::to_string(newT1.generation)); auto newT0 = *impl().nextField; baseImpl = makeMutable(impl(), std::move(newT0), std::move(newT1)); ++runtimeUpdateSequence; observer->onSourceChanged(*this); Log::Info(Event::Render, "ParticleField: event=FIELD_WINDOW_UPDATE_APPLIED result=APPLIED sourceId=" + getID() + - " sequence=" + std::to_string(runtimeUpdateSequence) + " newT0=" + impl().field->fixtureID + - " newT1=" + impl().nextField->fixtureID + + " sequence=" + std::to_string(runtimeUpdateSequence) + " newT0=" + impl().field->fieldID + + " newT1=" + impl().nextField->fieldID + " generation=" + std::to_string(impl().nextField->generation)); return RasterArrayRuntimeFieldUpdateResult::Applied; } diff --git a/test/style/source.test.cpp b/test/style/source.test.cpp index e753f9321..1370170bb 100644 --- a/test/style/source.test.cpp +++ b/test/style/source.test.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -53,6 +54,7 @@ #include #include +#include namespace { @@ -231,6 +233,46 @@ TEST(Source, RasterArrayGpuContentIdentityExcludesRuntimeGeneration) { EXPECT_FALSE(particle::sameFieldGpuContent(east, changed)); } +TEST(Source, ParticleFieldDataAdapterAcceptsNeutralDescriptor) { + particle::ParticleFieldDescriptor descriptor; + descriptor.fieldID = "weather-service-wind-20260904T0000Z"; + descriptor.fieldType = "WIND"; + descriptor.displayDomain = "GLOBAL"; + descriptor.width = 1; + descriptor.height = 1; + descriptor.byteLength = 2 * sizeof(float); + float values[] = {1.0f, 2.0f}; + std::vector bytes(sizeof(values)); + std::memcpy(bytes.data(), values, sizeof(values)); + + const auto result = particle::ParticleFieldDataAdapter::decode(descriptor, bytes, 1); + ASSERT_TRUE(result); + ASSERT_TRUE(result.snapshot); + EXPECT_EQ(result.snapshot->fieldID, descriptor.fieldID); + EXPECT_EQ(result.error, particle::ParticleFieldAdapterError::None); +} + +TEST(Source, ParticleFieldDataAdapterReturnsStructuredValidationErrors) { + particle::ParticleFieldDescriptor descriptor; + descriptor.fieldID = "field"; + descriptor.fieldType = "WIND"; + descriptor.displayDomain = "GLOBAL"; + descriptor.width = 2; + descriptor.height = 1; + descriptor.byteLength = 4 * sizeof(float); + + std::vector bytes(3 * sizeof(float)); + auto result = particle::ParticleFieldDataAdapter::decode(descriptor, bytes, 1); + EXPECT_FALSE(result); + EXPECT_EQ(result.error, particle::ParticleFieldAdapterError::PayloadSizeMismatch); + + descriptor.byteLength = bytes.size(); + descriptor.encoding = "provider-packed"; + result = particle::ParticleFieldDataAdapter::decode(descriptor, bytes, 1); + EXPECT_FALSE(result); + EXPECT_EQ(result.error, particle::ParticleFieldAdapterError::UnsupportedFormat); +} + TEST(Source, RasterArrayGpuReuseConsumesNewRevision) { const auto oldT1 = particle::OfflineFixtureParticleAdapter::load("uniform-north", 100); auto wantedNewT0 = oldT1; @@ -306,9 +348,9 @@ TEST(Source, RasterArrayRuntimeFieldWindowAdvancesWithoutReplacingTheSource) { const auto nodata = particle::OfflineFixtureParticleAdapter::load("nodata", vortex.generation + 1); EXPECT_EQ(source.updateRuntimeFieldWindow(vortex), style::RasterArrayRuntimeFieldUpdateResult::Applied); - EXPECT_EQ(source.impl().field->fixtureID, "uniform-north"); + EXPECT_EQ(source.impl().field->fieldID, "uniform-north"); EXPECT_EQ(source.impl().field->generation, initialT1Generation); - EXPECT_EQ(source.impl().nextField->fixtureID, "vortex"); + EXPECT_EQ(source.impl().nextField->fieldID, "vortex"); EXPECT_EQ(source.impl().nextField->generation, vortex.generation); EXPECT_EQ(sourceChangedCount, 2u); @@ -317,18 +359,18 @@ TEST(Source, RasterArrayRuntimeFieldWindowAdvancesWithoutReplacingTheSource) { invalid.velocity.clear(); EXPECT_EQ(source.updateRuntimeFieldWindow(invalid), style::RasterArrayRuntimeFieldUpdateResult::RejectedInvalidSnapshot); - EXPECT_EQ(source.impl().field->fixtureID, "uniform-north"); - EXPECT_EQ(source.impl().nextField->fixtureID, "vortex"); + EXPECT_EQ(source.impl().field->fieldID, "uniform-north"); + EXPECT_EQ(source.impl().nextField->fieldID, "vortex"); EXPECT_EQ(source.updateRuntimeFieldWindow(nodata), style::RasterArrayRuntimeFieldUpdateResult::Applied); - EXPECT_EQ(source.impl().field->fixtureID, "vortex"); - EXPECT_EQ(source.impl().nextField->fixtureID, "nodata"); + EXPECT_EQ(source.impl().field->fieldID, "vortex"); + EXPECT_EQ(source.impl().nextField->fieldID, "nodata"); EXPECT_EQ(sourceChangedCount, 3u); EXPECT_NE(source.impl().field->generation, initialT0Generation); EXPECT_EQ(source.repeatRuntimeFieldWindow(), style::RasterArrayRuntimeFieldUpdateResult::Noop); - EXPECT_EQ(source.impl().field->fixtureID, "vortex"); - EXPECT_EQ(source.impl().nextField->fixtureID, "nodata"); + EXPECT_EQ(source.impl().field->fieldID, "vortex"); + EXPECT_EQ(source.impl().nextField->fieldID, "nodata"); EXPECT_EQ(sourceChangedCount, 3u); } diff --git a/粒子开发记录.md b/粒子开发记录.md index 66da9a488..af6f9927e 100644 --- a/粒子开发记录.md +++ b/粒子开发记录.md @@ -348,3 +348,10 @@ - Android 最终验收在 `1343929474796b1313489ee9fecbf23b949580a7` 功能基线上全部 PASS,STEP-047 正式 `CLOSED / PASS`。 - Playback tick、Pause/Resume、0.5x/1x/2x、Seek/Step、Gap/Invalid fail-closed 与 Recover、Preload/Cache、`OLD_T1 → NEW_T0` reuse、仅上传 NEW_T1、无正常 reset/clear trail、VIS/MAP/MORE 与紧凑 UI 均已验证。 - snapshots=6/cacheCapacity=8 下没有 CACHE_EVICT 是正常容量现象。状态栏对自动 MIX/WAITING/INVALID 的展示记录为非阻塞 Validation UI observability 技术债;TimeStats 上限信息为 Android RenderEngine 环境噪音。此次仅文档收尾,未修改功能代码。 +## 2026-09-04 — STEP-049 Provider-neutral C++ Field Data Adapter 与 Runtime 输入契约 + +- 新增 `ParticleFieldDescriptor`、`ParticleFieldDataAdapter`、structured error result 与 provider-neutral `ParticleFieldSnapshot`;descriptor/payload 边界不携带 provider、URL、token、network 或 Renderer 对象。 +- `OfflineFixtureParticleAdapter` 保留 fixture-v1 whitelist、metadata/checksum 校验,但最终调用正式 Data Adapter;EAST/NORTH/VORTEX/NODATA validation fixture 继续工作,fixture identity 改为 neutral `fieldID`。 +- `RasterArraySource` 继续复用唯一 `updateRuntimeFieldWindow()`;没有第二套 T0/T1、Timeline、Playback、GPU upload policy 或 Renderer 业务语义。`OLD_T1 → NEW_T0`、NEW_T1-only、Duplicate NOOP、MIX property-only 和 fail-closed 保持。 +- Linux OpenGL `mbgl-test-runner` 构建通过,adapter + RasterArray 定向 11 项测试通过,`git diff --check` 通过;未执行 Android Gradle/APK/ADB/设备/Jest。 +- 状态:`IMPLEMENTED / WAITING FOR ANDROID VALIDATION`,等待用户一次 Android 综合验收后再 docs-only closeout。