feat: add provider-neutral particle field adapter contract
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-04 11:26:14 +08:00
parent f095447678
commit ce1a148f27
12 changed files with 371 additions and 143 deletions

View File

@@ -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

View File

@@ -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。

View File

@@ -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`

View File

@@ -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 语义不在本阶段实现。

View File

@@ -0,0 +1,95 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
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<float> velocity;
std::vector<uint8_t> 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<ParticleFieldSnapshot> 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<char>&, uint64_t generation);
};
} // namespace mbgl::particle

View File

@@ -0,0 +1,78 @@
#include <mbgl/particle/field/field_data_adapter.hpp>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <utility>
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<size_t>(width) * height * 2 &&
valid.size() == static_cast<size_t>(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<char>& 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<uint64_t>(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<size_t>(cells) * 2);
std::memcpy(field.velocity.data(), bytes.data(), bytes.size());
field.valid.resize(static_cast<size_t>(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

View File

@@ -107,26 +107,6 @@ std::string sha256(const std::vector<char>& 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<size_t>(width) * height * 2 ||
valid.size() != static_cast<size_t>(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<char>& 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<float>(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<float>(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<size_t>(field.width) * field.height * 2 * sizeof(float);
descriptor.dataVersion = member(metadata, "dataVersion").GetUint64();
descriptor.byteLength = static_cast<uint64_t>(descriptor.width) * descriptor.height * 2 * sizeof(float);
descriptor.fieldSha256 = member(integrity, "fieldSha256").GetString();
const size_t expected = static_cast<size_t>(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<size_t>(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) {

View File

@@ -1,76 +1,21 @@
#pragma once
#include <mbgl/particle/field/field_data_adapter.hpp>
#include <cstdint>
#include <string>
#include <vector>
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<float> velocity;
std::vector<uint8_t> 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<char>& 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);
};

View File

@@ -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;
}

View File

@@ -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>(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;
}

View File

@@ -22,6 +22,7 @@
#include <mbgl/style/sources/raster_source.hpp>
#include <mbgl/style/sources/vector_source.hpp>
#include <mbgl/style/style.hpp>
#include <mbgl/particle/field/field_data_adapter.hpp>
#include <mbgl/renderer/sources/render_geojson_source.hpp>
#include <mbgl/renderer/sources/render_raster_array_source.hpp>
@@ -53,6 +54,7 @@
#include <gmock/gmock.h>
#include <optional>
#include <cstring>
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<char> 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<char> 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);
}

View File

@@ -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。