fix: restore particle timeline readiness after field adapter
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-04 13:43:30 +08:00
parent dcedbe7394
commit a418e50a01
9 changed files with 170 additions and 8 deletions

View File

@@ -0,0 +1,32 @@
# STEP-049 Provider-neutral Field Data Adapter 与 Runtime 输入契约 — Timeline 回归修复
- 状态:进行中(等待 Android 重新验收)
- Starting HEAD:`dcedbe7394e74b80d91f00315a9956dfaea35229`
- 分支:`particle/main`
## Android 回归与根因
STEP-049 后 Android 的 Runtime Window 和 Renderer 正常,但 Timeline 没有 READY。失败链路定位为 `RasterArraySource::tryLoadRuntimeField()` 调用 fixture wrapper 时传入 generation `0`;正式 `ParticleFieldDataAdapter` 正确拒绝 `generation == 0`。Runtime Window 初始 field 使用已分配的全局 generation,因此未受影响。Timeline 原本计划在 `tryInitializeTimeline()` 中才给 candidate 分配 generation,顺序晚于 adapter 校验。
## 修复点与语义
candidate 在 metadata/payload 完整后、调用正式 adapter 前分配 `++nextRasterArrayFieldGeneration`,因此每个进入 Core 的 Snapshot 都满足非空 identity 与 `generation > 0`。Timeline 构建阶段不再重复改写 generation。没有放宽 adapter contract,没有新增 adapter/Timeline/Window,也没有改变 Timeline 状态机、Runtime Field Window、Renderer 或 Android Timeline ownership。
低频 rejection 路径补充 error code、fieldId、generation、validTime 和 candidate index 上下文,不输出 payload。
## 测试
新增 `RasterArrayTimelineCandidatesReachReadyThroughFieldAdapter`,实际加载 EAST/NORTH 初始窗口和 VORTEX candidate,覆盖 fixture wrapper → formal adapter → candidate preload → Timeline READY,并断言 candidate identity/generation 合法、PLAY 成功。原有 generation=0/invalid descriptor 结构化拒绝测试继续通过。
- Linux OpenGL `mbgl-test-runner`:PASS
- 定向测试:12/12 PASS
- `git diff --check`:PASS
- Android/APK/ADB/Jest:未执行
## Validation UI
仅修改 Android Validation UI 展示:Window 与 Timeline 分离显示,Timeline 使用 `TL NOT READY`、`TL READY`、`TL PLAY`、`TL PAUSE`、`TL WAIT`、`TL INVALID`,MIX 保持显示。未修改 Core 状态机。
## 待复验与阻塞
代码无已知阻塞。Android 尚未复验,当前只能标记 `IMPLEMENTED / WAITING_ANDROID_REVALIDATION`;用户需重新构建一次 APK 并完成 Timeline READY、PLAY、跨 boundary、Pause/Resume、GAP、INVALID NEXT 综合验收。

View File

@@ -0,0 +1,45 @@
# STEP-049 Timeline 回归修复 — Codex 完成报告
## 状态
`IMPLEMENTED / WAITING_ANDROID_REVALIDATION`。本报告不宣称 `CLOSED / PASS`。
## 基线与根因
- Starting HEAD:`dcedbe7394e74b80d91f00315a9956dfaea35229`
- 实际远端 `origin/particle/main` 起始 HEAD:`dcedbe7394e74b80d91f00315a9956dfaea35229`
- 根因:`tryLoadRuntimeField()` 以 generation `0` 调用正式 adapter;adapter 合法拒绝,Timeline candidate 在更晚的 `tryInitializeTimeline()` 才分配 generation。Runtime Window 使用已分配 generation,故出现 Timeline 失败而 Window 正常。
- 原 STEP-049 11/11 未捕获:测试覆盖了直接 adapter、手工 Timeline 和 Runtime Window,但没有覆盖真实 Source 异步 candidate preload → adapter → Timeline READY 顺序。
## 实际修改
- `src/mbgl/style/sources/raster_array_source.cpp`:candidate decode 前分配全局 generation,移除 Timeline 阶段重复赋值;增强低频 adapter rejection 日志。
- `src/mbgl/particle/field/field_data_adapter.cpp`、`.hpp` 与 fixture wrapper:保留正式 contract 和 generation 非零校验,提供 error code 名称用于日志。
- `test/style/source.test.cpp`:新增真实 candidate → adapter → Timeline READY 回归测试。
- `platform/android/MapLibreAndroidTestApp/.../ParticleCoreLayerValidationActivity.kt`:仅整理 Window/Timeline 状态可见性。
- `docs/50_Step记录/STEP-049..._v2.md`、本报告、`粒子开发记录.md`。
未修改 Runtime Field Window、Duplicate、Renderer simulation、Timeline Core 状态机或 Android per-frame JNI;未新增兼容层。
## 验证
- C++/Core/OpenGL:`cmake --build build-linux-opengl --target mbgl-test-runner -j3`,PASS。
- 定向测试:`Source.RasterArrayTimelineCandidatesReachReadyThroughFieldAdapter:Source.ParticleFieldDataAdapter*:Source.RasterArray*`,12/12 PASS。
- 合法 candidate 通过正式 adapter 并进入 Timeline READY:PASS。
- generation=0 / invalid descriptor 结构化拒绝:PASS。
- `git diff --check`:PASS。
- Android Gradle/APK/copy/install/ADB/设备:未执行。
- Jest:未新增、未执行。
## Android 待复验
需验证 `ParticleTimeline: event=READY`、PLAY 不再 `TIMELINE_NOT_READY`、MIX/cursor 推进、跨至少两个 Window boundary、Pause/Resume、GAP WAIT、INVALID NEXT、fail-closed、Renderer 持续和 `simulationRebuild=false`/`trailCleared=false`。
## Git 收尾
- commit hash:待提交
- commit message:`fix: restore particle timeline readiness after field adapter`
- push result:待执行
- remote branch:`origin/particle/main`
- Final HEAD:待提交
- 无关 untracked 文件:保留,未修改、未 stage、未提交。

View File

@@ -79,6 +79,8 @@ enum class ParticleFieldAdapterError : uint8_t {
DecodeValidationFailure,
};
const char* particleFieldAdapterErrorName(ParticleFieldAdapterError);
struct ParticleFieldAdapterResult {
std::optional<ParticleFieldSnapshot> snapshot;
ParticleFieldAdapterError error = ParticleFieldAdapterError::None;

View File

@@ -35,7 +35,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private var styleLoadEpoch = 0L
private var styleReady = false
// Display mirror only; Core remains the playback state authority.
private var timelineState = "READY"
private var timelineState = "NOT READY"
private var timelineSpeed = "1x"
override fun onCreate(savedInstanceState: Bundle?) {
@@ -114,7 +114,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
timeWindowMode = true
fixtureMode = FIXTURE_UNIFORM_EAST
timeMix = 0.0f
timelineState = "READY"
timelineState = "NOT READY"
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "TIME_WINDOW")
}
findViewById<Button>(R.id.validationMix0).setOnClickListener { setTimeMix(0.0f) }
@@ -199,7 +199,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
if (!maplibreMap.controlRasterArraySourceTimeline(PARTICLE_SOURCE_ID, command, value)) {
Log.i(TAG, "ParticleTimeline: event=REJECTED command=$command")
} else {
timelineState = when (command) { 0 -> "PLAYING"; 1 -> "PAUSED"; 6, 7 -> "WAITING"; 8 -> "PAUSED"; else -> timelineState }
timelineState = when (command) { 0 -> "PLAY"; 1 -> "PAUSE"; 6 -> "WAIT"; 7 -> "INVALID"; 8 -> "PAUSE"; else -> timelineState }
if (command == 2) timelineSpeed = "${value}x"
status.text = statusText()
Log.i(TAG, "ParticleTimeline: event=COMMAND command=$command value=$value")
@@ -219,7 +219,8 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
Log.i(TAG, "ParticleGPU: event=STYLE_LOAD epoch=$styleLoadEpoch reason=$reason")
maplibreMap.setStyle(Style.Builder().fromJson(validationStyle(active, enabled))) {
styleReady = true
status.text = state
if (timeWindowMode) timelineState = "READY"
status.text = statusText()
}
}
@@ -380,7 +381,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
} else {
"EAST"
}
val playback = if (timeWindowMode) " · $timelineState · $timelineSpeed" else ""
val playback = if (timeWindowMode) " · TL $timelineState · $timelineSpeed" else ""
return "$mask · $runtime · $fields$playback · MIX ${(timeMix * 100).toInt()}% · $particleCount · W ${"%.1f".format(lineWidth)} L ${"%.3f".format(particleLength)} O ${(particleOpacity * 100).toInt()} T ${(trailOpacity * 100).toInt()} F ${(trailFade * 100).toInt()} C ${if (speedColorEnabled) "ON" else "OFF"}"
}

View File

@@ -12,6 +12,20 @@ ParticleFieldAdapterResult reject(ParticleFieldAdapterError error, std::string m
}
} // namespace
const char* particleFieldAdapterErrorName(const ParticleFieldAdapterError error) {
switch (error) {
case ParticleFieldAdapterError::None: return "NONE";
case ParticleFieldAdapterError::InvalidDescriptor: return "INVALID_DESCRIPTOR";
case ParticleFieldAdapterError::UnsupportedFormat: return "UNSUPPORTED_FORMAT";
case ParticleFieldAdapterError::DimensionMismatch: return "DIMENSION_MISMATCH";
case ParticleFieldAdapterError::PayloadSizeMismatch: return "PAYLOAD_SIZE_MISMATCH";
case ParticleFieldAdapterError::InvalidTimestampOrIdentity: return "INVALID_TIMESTAMP_OR_IDENTITY";
case ParticleFieldAdapterError::NonFiniteData: return "NON_FINITE_DATA";
case ParticleFieldAdapterError::DecodeValidationFailure: return "DECODE_VALIDATION_FAILURE";
}
return "UNKNOWN";
}
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 &&

View File

@@ -179,7 +179,10 @@ ParticleFieldSnapshot OfflineFixtureParticleAdapter::load(const std::string& fix
}
auto result = ParticleFieldDataAdapter::decode(descriptor, bytes, generation);
if (!result) {
throw std::invalid_argument("field adapter rejected fixture: " + result.message);
throw std::invalid_argument("field adapter rejected fixture errorCode=" +
std::string(particleFieldAdapterErrorName(result.error)) +
" fieldId=" + descriptor.fieldID + " generation=" + std::to_string(generation) +
" validTime=" + std::to_string(descriptor.validTime) + " " + result.message);
}
return std::move(*result.snapshot);
}

View File

@@ -340,6 +340,8 @@ void RasterArraySource::tryLoad() {
observer->onSourceChanged(*this);
tryInitializeTimeline();
} catch (const std::exception& error) {
Log::Warning(Event::Render, "ParticleField: event=ADAPTER_REJECTED sourceId=" + getID() +
" error=" + error.what());
observer->onSourceError(*this, std::make_exception_ptr(std::runtime_error(error.what())));
}
}
@@ -348,13 +350,23 @@ void RasterArraySource::tryLoadRuntimeField(const size_t index) {
auto& candidate = runtimeFields.at(index);
if (candidate.snapshot || !candidate.metadata || !candidate.fieldBytes) return;
try {
// A candidate is already a Core-owned runtime snapshot when it leaves
// the adapter. Do not defer the required revision assignment until
// timeline construction; that made valid preload data fail closed.
candidate.snapshot = particle::OfflineFixtureParticleAdapter::load(
candidate.descriptor.fixtureID, *candidate.metadata, *candidate.fieldBytes, 0);
candidate.descriptor.fixtureID, *candidate.metadata, *candidate.fieldBytes,
++nextRasterArrayFieldGeneration);
Log::Info(Event::Render,
"ParticleField: runtime candidate ready fieldId=" + candidate.snapshot->fieldID +
" dataVersion=" + std::to_string(candidate.snapshot->dataVersion));
tryInitializeTimeline();
} catch (const std::exception& error) {
Log::Warning(Event::Render, "ParticleField: event=ADAPTER_REJECTED sourceId=" + getID() +
" candidateIndex=" + std::to_string(index) + " fieldId=" +
candidate.descriptor.fixtureID + " generation=" +
std::to_string(candidate.snapshot ? candidate.snapshot->generation : 0) +
" validTime=" + std::to_string(candidate.snapshot ? candidate.snapshot->validTime : 0) +
" error=" + error.what());
observer->onSourceError(*this, std::make_exception_ptr(std::runtime_error(error.what())));
}
}
@@ -368,7 +380,6 @@ void RasterArraySource::tryInitializeTimeline() {
for (size_t index = 0; index < runtimeFields.size(); ++index) {
if (!runtimeFields[index].snapshot) return;
auto field = *runtimeFields[index].snapshot;
field.generation = ++nextRasterArrayFieldGeneration;
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(std::move(field)),
80000 + static_cast<uint64_t>(index) * 40000});
}

View File

@@ -273,6 +273,53 @@ TEST(Source, ParticleFieldDataAdapterReturnsStructuredValidationErrors) {
EXPECT_EQ(result.error, particle::ParticleFieldAdapterError::UnsupportedFormat);
}
TEST(Source, RasterArrayTimelineCandidatesReachReadyThroughFieldAdapter) {
SourceTest test;
const auto eastMetadata = fixtureBytes("uniform-east", "metadata.json");
const auto eastField = fixtureBytes("uniform-east", "field.f32");
const auto northMetadata = fixtureBytes("uniform-north", "metadata.json");
const auto northField = fixtureBytes("uniform-north", "field.f32");
const auto vortexMetadata = fixtureBytes("vortex", "metadata.json");
const auto vortexField = fixtureBytes("vortex", "field.f32");
test.fileSource->sourceResponse = [&](const Resource& resource) -> Response {
Response response;
const std::string* text = nullptr;
const std::string* bytes = nullptr;
if (resource.url == "east-metadata") text = &eastMetadata;
if (resource.url == "east-field") bytes = &eastField;
if (resource.url == "north-metadata") text = &northMetadata;
if (resource.url == "north-field") bytes = &northField;
if (resource.url == "vortex-metadata") text = &vortexMetadata;
if (resource.url == "vortex-field") bytes = &vortexField;
if (text) response.data = std::make_unique<std::string>(*text);
if (bytes) response.data = std::make_unique<std::string>(*bytes);
if (!response.data) ADD_FAILURE() << "Unexpected Timeline resource: " << resource.url;
return response;
};
style::RasterArraySource source{
"source", "uniform-east", "east-metadata", "east-field", "uniform-north", "north-metadata", "north-field",
{{"vortex", "vortex-metadata", "vortex-field", 0}}};
source.setObserver(&test.styleObserver);
test.styleObserver.sourceChanged = [&](Source&) {
if (source.impl().timeline) test.end();
};
source.loadDescription(*test.fileSource);
test.run();
ASSERT_TRUE(source.impl().timeline);
const auto frame = source.impl().timeline->frame();
ASSERT_TRUE(frame.t0);
ASSERT_TRUE(frame.t1);
EXPECT_EQ(frame.state, style::RasterArrayPlaybackState::Ready);
EXPECT_EQ(frame.t0Index, 0u);
EXPECT_TRUE(frame.t0->isValid());
EXPECT_TRUE(frame.t1->isValid());
EXPECT_FALSE(frame.t1->fieldID.empty());
EXPECT_GT(frame.t1->generation, 0u);
EXPECT_TRUE(source.impl().timeline->play());
}
TEST(Source, RasterArrayGpuReuseConsumesNewRevision) {
const auto oldT1 = particle::OfflineFixtureParticleAdapter::load("uniform-north", 100);
auto wantedNewT0 = oldT1;

View File

@@ -355,3 +355,10 @@
- `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。
## 2026-09-04 — STEP-049 Timeline 回归修复
- 根因是 Timeline candidate 在 `tryLoadRuntimeField()` 以 generation=0 进入正式 Field Adapter;adapter 的非零 generation 校验正确拒绝,Runtime Window 因使用已分配 generation 仍正常。
- 修复为 candidate decode 前分配 Core 全局 runtime generation,Timeline 构建不再重复改写;未放宽 contract,未新增 adapter、Timeline 或 Window。
- 新增真实 Source candidate preload → formal adapter → Timeline READY 回归测试,并保留 generation=0/invalid descriptor 负向校验;Linux OpenGL 构建通过,定向测试 12/12 PASS。
- Validation UI 仅明确显示 `TL NOT READY/READY/PLAY/PAUSE/WAIT/INVALID`,与 Window 状态分离;未执行 Android Gradle/APK/ADB/Jest。
- 状态:`IMPLEMENTED / WAITING_ANDROID_REVALIDATION`,等待用户重新构建 APK 并完成一次综合 Timeline 验收。