fix: restore particle timeline readiness after field adapter
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,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 综合验收。
|
||||
45
docs/报告/STEP-049_Timeline回归修复_Codex完成报告_v1.md
Normal file
45
docs/报告/STEP-049_Timeline回归修复_Codex完成报告_v1.md
Normal 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、未提交。
|
||||
@@ -79,6 +79,8 @@ enum class ParticleFieldAdapterError : uint8_t {
|
||||
DecodeValidationFailure,
|
||||
};
|
||||
|
||||
const char* particleFieldAdapterErrorName(ParticleFieldAdapterError);
|
||||
|
||||
struct ParticleFieldAdapterResult {
|
||||
std::optional<ParticleFieldSnapshot> snapshot;
|
||||
ParticleFieldAdapterError error = ParticleFieldAdapterError::None;
|
||||
|
||||
@@ -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"}"
|
||||
}
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 验收。
|
||||
|
||||
Reference in New Issue
Block a user