fix: complete STEP-047 playback validation controls
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-04 07:39:01 +08:00
parent 49b2e8aebd
commit 0609199cce
6 changed files with 110 additions and 27 deletions

View File

@@ -0,0 +1,24 @@
# STEP-047 Runtime Timeline 与 Playback(集中修复)
状态:IMPLEMENTED / WAITING FOR ANDROID RE-VALIDATION
## Android Validation Round 1
Round 1 结论:PARTIAL / NEEDS FIX。
已通过:Core Timeline READY、自动 MIX、自动 window advance、`OLD_T1 → NEW_T0` reuse、紧凑控制台、MAP/MORE 基础回归、VIS Core property 调用链。
失败或无法充分验证:Pause/Resume UX、速度可视性、Seek 状态语义、GAP/INVALID/RECOVER 入口、播放中 VIS、COLOR 可达性、Preload/Cache 可观测性。
## 本轮修复
- Validation 逻辑时间窗口从 4 秒扩展到 40 秒,6 snapshot 总时长约 200 秒;Core clock 不使用 Android timer。
- PLAYING→PAUSED 和 PAUSED→PLAYING 保留 cursor;END 时 PLAY 明确从 start restart;END/WAITING 拒绝日志明确。
- PLAYING 的非 END seek 保持 PLAYING;PAUSED seek 保持 PAUSED;Seek END 进入 ENDED;日志记录状态、cursor、目标窗口和 mix。
- TIME 菜单直接提供 NORMAL、GAP、INVALID NEXT、RECOVER NEXT;GAP/INVALID 在边界 fail-closed,recover 恢复此前播放/暂停语义。
- VIS 把 COLOR 放到第二项;Validation 调节步长扩大,状态行即时显示 W/L/O/T/F/C;状态行同时显示 Timeline state/speed。
- 预加载仍是 Source 的有界 CPU snapshot cache(容量 8,绝不预上传未来 GPU Field);READY 输出 TIMELINE_PRELOAD,窗口推进输出 TIMELINE_CACHE_HIT。
## 不变项
自动 MIX、GPU T0/T1 window、`OLD_T1 → NEW_T0` reuse、只上传新 T1、无 Style reload/simulation reset/trail clear 的正常推进路径,及 STEP-046 visual property-only 均未改动。

View File

@@ -0,0 +1,9 @@
# 40-06 / STEP-047 集中修复完成报告
状态:IMPLEMENTED / WAITING FOR ANDROID RE-VALIDATION
第一轮 Android 验收为 `PARTIAL / NEEDS FIX`,历史结论保留于 STEP-047 v2。此次修复将 Core logical window 拉长至约 200 秒;暂停/恢复、END restart、seek 状态保持、GAP/INVALID/RECOVER 与可见 speed/state 行为均已在 Core/Validation 层修正。
Source 仍是 Timeline 和 CPU preload cache 权威;Renderer 继续只消费当前 T0/T1/mix。CACHE/PRELOAD 日志来自真实 immutable Source entries,而非未来 GPU 上传。自动边界仍走既有 overlap GPU reuse。
未执行 Android Gradle、ADB、Jest 或设备测试;等待一次完整 Android Round 2。

View File

@@ -77,6 +77,7 @@ private:
RasterArrayPlaybackState state = RasterArrayPlaybackState::Ready;
bool gap = false;
bool invalid = false;
RasterArrayPlaybackState stateBeforeWait = RasterArrayPlaybackState::Paused;
uint64_t windowRevision = 1;
std::chrono::steady_clock::time_point lastTick{};
const size_t cacheCapacity_ = 8;

View File

@@ -34,6 +34,9 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private var timeWindowMode = false
private var styleLoadEpoch = 0L
private var styleReady = false
// Display mirror only; Core remains the playback state authority.
private var timelineState = "READY"
private var timelineSpeed = "1x"
override fun onCreate(savedInstanceState: Bundle?) {
active = savedInstanceState?.getBoolean(STATE_ACTIVE, true) ?: true
@@ -111,6 +114,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
timeWindowMode = true
fixtureMode = FIXTURE_UNIFORM_EAST
timeMix = 0.0f
timelineState = "READY"
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "TIME_WINDOW")
}
findViewById<Button>(R.id.validationMix0).setOnClickListener { setTimeMix(0.0f) }
@@ -130,16 +134,16 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
if (this::maplibreMap.isInitialized) duplicateRuntimeFieldWindow()
}
findViewById<Button>(R.id.validationVisualDefault).setOnClickListener { resetVisualDefaults() }
findViewById<Button>(R.id.validationVisualSizeDown).setOnClickListener { setVisualFloat("raster-particle-line-width", lineWidth - 0.5f) { lineWidth = it.coerceIn(1.0f, 8.0f) } }
findViewById<Button>(R.id.validationVisualSizeUp).setOnClickListener { setVisualFloat("raster-particle-line-width", lineWidth + 0.5f) { lineWidth = it.coerceIn(1.0f, 8.0f) } }
findViewById<Button>(R.id.validationVisualLengthDown).setOnClickListener { setVisualFloat("raster-particle-particle-length", particleLength - 0.004f) { particleLength = it.coerceIn(0.002f, 0.080f) } }
findViewById<Button>(R.id.validationVisualLengthUp).setOnClickListener { setVisualFloat("raster-particle-particle-length", particleLength + 0.004f) { particleLength = it.coerceIn(0.002f, 0.080f) } }
findViewById<Button>(R.id.validationVisualOpacityDown).setOnClickListener { setVisualFloat("raster-particle-particle-opacity", particleOpacity - 0.1f) { particleOpacity = it.coerceIn(0.0f, 1.0f) } }
findViewById<Button>(R.id.validationVisualOpacityUp).setOnClickListener { setVisualFloat("raster-particle-particle-opacity", particleOpacity + 0.1f) { particleOpacity = it.coerceIn(0.0f, 1.0f) } }
findViewById<Button>(R.id.validationVisualTrailDown).setOnClickListener { setVisualFloat("raster-particle-trail-opacity", trailOpacity - 0.1f) { trailOpacity = it.coerceIn(0.0f, 1.0f) } }
findViewById<Button>(R.id.validationVisualTrailUp).setOnClickListener { setVisualFloat("raster-particle-trail-opacity", trailOpacity + 0.1f) { trailOpacity = it.coerceIn(0.0f, 1.0f) } }
findViewById<Button>(R.id.validationVisualFadeDown).setOnClickListener { setVisualFloat("raster-particle-trail-fade", trailFade - 0.05f) { trailFade = it.coerceIn(0.0f, 0.995f) } }
findViewById<Button>(R.id.validationVisualFadeUp).setOnClickListener { setVisualFloat("raster-particle-trail-fade", trailFade + 0.05f) { trailFade = it.coerceIn(0.0f, 0.995f) } }
findViewById<Button>(R.id.validationVisualSizeDown).setOnClickListener { setVisualFloat("raster-particle-line-width", lineWidth - 1.0f) { lineWidth = it.coerceIn(1.0f, 8.0f) } }
findViewById<Button>(R.id.validationVisualSizeUp).setOnClickListener { setVisualFloat("raster-particle-line-width", lineWidth + 1.0f) { lineWidth = it.coerceIn(1.0f, 8.0f) } }
findViewById<Button>(R.id.validationVisualLengthDown).setOnClickListener { setVisualFloat("raster-particle-particle-length", particleLength - 0.012f) { particleLength = it.coerceIn(0.002f, 0.080f) } }
findViewById<Button>(R.id.validationVisualLengthUp).setOnClickListener { setVisualFloat("raster-particle-particle-length", particleLength + 0.012f) { particleLength = it.coerceIn(0.002f, 0.080f) } }
findViewById<Button>(R.id.validationVisualOpacityDown).setOnClickListener { setVisualFloat("raster-particle-particle-opacity", particleOpacity - 0.25f) { particleOpacity = it.coerceIn(0.0f, 1.0f) } }
findViewById<Button>(R.id.validationVisualOpacityUp).setOnClickListener { setVisualFloat("raster-particle-particle-opacity", particleOpacity + 0.25f) { particleOpacity = it.coerceIn(0.0f, 1.0f) } }
findViewById<Button>(R.id.validationVisualTrailDown).setOnClickListener { setVisualFloat("raster-particle-trail-opacity", trailOpacity - 0.25f) { trailOpacity = it.coerceIn(0.0f, 1.0f) } }
findViewById<Button>(R.id.validationVisualTrailUp).setOnClickListener { setVisualFloat("raster-particle-trail-opacity", trailOpacity + 0.25f) { trailOpacity = it.coerceIn(0.0f, 1.0f) } }
findViewById<Button>(R.id.validationVisualFadeDown).setOnClickListener { setVisualFloat("raster-particle-trail-fade", trailFade - 0.12f) { trailFade = it.coerceIn(0.0f, 0.995f) } }
findViewById<Button>(R.id.validationVisualFadeUp).setOnClickListener { setVisualFloat("raster-particle-trail-fade", trailFade + 0.12f) { trailFade = it.coerceIn(0.0f, 0.995f) } }
findViewById<Button>(R.id.validationVisualColor).setOnClickListener { setSpeedColor(!speedColorEnabled) }
findViewById<Button>(R.id.validationDateline).setOnClickListener {
maplibreMap.cameraPosition = CameraPosition.Builder().target(LatLng(0.0, 179.0)).zoom(2.0).build()
@@ -150,7 +154,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
Log.i(TAG, "ParticleValidation30_03: WORLD_WRAP preset center=0,0 zoom=0")
}
findViewById<Button>(R.id.validationMenuTime).setOnClickListener { showTimeMenu(it) }
findViewById<Button>(R.id.validationMenuVisual).setOnClickListener { showMenu(it, "VIS", intArrayOf(R.id.validationVisualDefault, R.id.validationVisualSizeDown, R.id.validationVisualSizeUp, R.id.validationVisualLengthDown, R.id.validationVisualLengthUp, R.id.validationVisualOpacityDown, R.id.validationVisualOpacityUp, R.id.validationVisualTrailDown, R.id.validationVisualTrailUp, R.id.validationVisualFadeDown, R.id.validationVisualFadeUp, R.id.validationVisualColor)) }
findViewById<Button>(R.id.validationMenuVisual).setOnClickListener { showMenu(it, "VIS", intArrayOf(R.id.validationVisualDefault, R.id.validationVisualColor, R.id.validationVisualSizeDown, R.id.validationVisualSizeUp, R.id.validationVisualLengthDown, R.id.validationVisualLengthUp, R.id.validationVisualOpacityDown, R.id.validationVisualOpacityUp, R.id.validationVisualTrailDown, R.id.validationVisualTrailUp, R.id.validationVisualFadeDown, R.id.validationVisualFadeUp)) }
findViewById<Button>(R.id.validationMenuCount).setOnClickListener { showMenu(it, "COUNT", intArrayOf(R.id.validationParticle256, R.id.validationParticle1024, R.id.validationParticle4096, R.id.validationParticle16384, R.id.validationParticle65536, R.id.validationParticle262144, R.id.validationParticle1048576)) }
findViewById<Button>(R.id.validationMenuMap).setOnClickListener { showMenu(it, "MAP", intArrayOf(R.id.validationGlobal, R.id.validationOceanA, R.id.validationOceanB, R.id.validationDateline, R.id.validationWorldWrap)) }
findViewById<Button>(R.id.validationMenuMore).setOnClickListener { showMenu(it, "MORE", intArrayOf(R.id.validationActive, R.id.validationStatic, R.id.validationDisable, R.id.validationEnable, R.id.validationUniformEast, R.id.validationUniformNorth, R.id.validationVortex, R.id.validationAdvanceWindow, R.id.validationDuplicateWindow)) }
@@ -166,7 +170,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private fun showTimeMenu(anchor: android.view.View) {
PopupMenu(this, anchor).apply {
val labels = arrayOf("LOAD TIMELINE", "PLAY", "PAUSE", "SPEED 0.5x", "SPEED 1x", "SPEED 2x", "STEP PREV", "STEP NEXT", "SEEK START", "SEEK MID", "SEEK END", "INJECT GAP", "INJECT INVALID", "RECOVER")
val labels = arrayOf("SCENARIO NORMAL", "PLAY / RESTART", "PAUSE", "SPEED 0.5x", "SPEED 1x", "SPEED 2x", "STEP PREV", "STEP NEXT", "SEEK START", "SEEK MID", "SEEK END", "SCENARIO GAP", "SCENARIO INVALID NEXT", "RECOVER NEXT")
labels.forEachIndexed { index, label -> menu.add(0, index, index, label) }
setOnMenuItemClickListener { item ->
when (item.itemId) {
@@ -194,7 +198,12 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
if (!styleReady || !enabled || !timeWindowMode) { Log.i(TAG, "ParticleTimeline: event=REJECTED reason=STYLE_NOT_READY"); return }
if (!maplibreMap.controlRasterArraySourceTimeline(PARTICLE_SOURCE_ID, command, value)) {
Log.i(TAG, "ParticleTimeline: event=REJECTED command=$command")
} else Log.i(TAG, "ParticleTimeline: event=COMMAND command=$command value=$value")
} else {
timelineState = when (command) { 0 -> "PLAYING"; 1 -> "PAUSED"; 6, 7 -> "WAITING"; 8 -> "PAUSED"; else -> timelineState }
if (command == 2) timelineSpeed = "${value}x"
status.text = statusText()
Log.i(TAG, "ParticleTimeline: event=COMMAND command=$command value=$value")
}
}
private fun actionLabel(id: Int): String = when (id) {
@@ -371,7 +380,8 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
} else {
"EAST"
}
return "$mask · $runtime · $fields · 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"}"
val playback = if (timeWindowMode) " · $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"}"
}
private fun validationStyle(active: Boolean, enabled: Boolean): String {

View File

@@ -69,10 +69,11 @@ RasterArrayTimelineFrame RasterArrayTimelineRuntime::tick() {
while (mix >= 1.0f) {
if (gap || invalid) {
mix = 1.0f;
stateBeforeWait = state;
state = RasterArrayPlaybackState::Waiting;
Log::Warning(Event::Render,
std::string("ParticleTimeline: event=") + (invalid ? "INVALID_WAITING" : "GAP_WAITING") +
" t1=" + entries[t0 + 1].field->fixtureID);
std::string("ParticleTimeline: event=TIMELINE_REJECTED reason=") +
(invalid ? "INVALID_NEXT" : "GAP_HOLD") + " t1=" + entries[t0 + 1].field->fixtureID);
return frameLocked();
}
if (!moveToLocked(t0 + 1)) {
@@ -84,44 +85,70 @@ RasterArrayTimelineFrame RasterArrayTimelineRuntime::tick() {
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");
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_));
}
return frameLocked();
}
bool RasterArrayTimelineRuntime::play() {
std::lock_guard<std::mutex> lock(mutex);
if (state == RasterArrayPlaybackState::Waiting || state == RasterArrayPlaybackState::Ended) return false;
if (state == RasterArrayPlaybackState::Waiting) {
Log::Warning(Event::Render, "ParticleTimeline: event=PLAYBACK_REJECTED reason=WAITING");
return false;
}
if (state == RasterArrayPlaybackState::Ended) {
t0 = 0;
mix = 0.0f;
++windowRevision;
Log::Info(Event::Render, "ParticleTimeline: event=PLAYBACK_PLAY reason=RESTART_FROM_END");
}
state = RasterArrayPlaybackState::Playing;
lastTick = std::chrono::steady_clock::now();
Log::Info(Event::Render, "ParticleTimeline: event=PLAY speed=" + std::to_string(speed));
Log::Info(Event::Render,
"ParticleTimeline: event=PLAYBACK_PLAY speed=" + std::to_string(speed) + " cursor=" + std::to_string(t0));
return true;
}
bool RasterArrayTimelineRuntime::pause() {
std::lock_guard<std::mutex> lock(mutex);
if (state == RasterArrayPlaybackState::Waiting || state == RasterArrayPlaybackState::Ended) return false;
if (state == RasterArrayPlaybackState::Waiting || state == RasterArrayPlaybackState::Ended) {
Log::Warning(Event::Render, "ParticleTimeline: event=PLAYBACK_REJECTED reason=ENDED_OR_WAITING");
return false;
}
state = RasterArrayPlaybackState::Paused;
Log::Info(Event::Render, "ParticleTimeline: event=PAUSE");
Log::Info(Event::Render, "ParticleTimeline: event=PLAYBACK_PAUSE cursor=" + std::to_string(t0));
return true;
}
bool RasterArrayTimelineRuntime::setSpeed(const float value) {
if (value != 0.5f && value != 1.0f && value != 2.0f) return false;
std::lock_guard<std::mutex> lock(mutex);
const auto oldSpeed = speed;
speed = value;
Log::Info(Event::Render, "ParticleTimeline: event=SPEED value=" + std::to_string(speed));
Log::Info(Event::Render,
"ParticleTimeline: event=PLAYBACK_SPEED oldSpeed=" + std::to_string(oldSpeed) +
" newSpeed=" + std::to_string(speed) + " cursor=" + std::to_string(t0));
return true;
}
bool RasterArrayTimelineRuntime::seek(const size_t index) {
std::lock_guard<std::mutex> lock(mutex);
if (index >= entries.size()) return false;
const auto fromState = state;
const auto fromCursor = t0;
if (index + 1 == entries.size()) {
t0 = index - 1;
mix = 1.0f;
state = RasterArrayPlaybackState::Ended;
} else {
moveToLocked(index);
state = RasterArrayPlaybackState::Paused;
state = fromState == RasterArrayPlaybackState::Playing ? RasterArrayPlaybackState::Playing
: RasterArrayPlaybackState::Paused;
}
Log::Info(Event::Render, "ParticleTimeline: event=SEEK index=" + std::to_string(index));
Log::Info(Event::Render,
"ParticleTimeline: event=PLAYBACK_SEEK fromState=" + std::to_string(static_cast<int>(fromState)) +
" toState=" + std::to_string(static_cast<int>(state)) + " fromCursor=" + std::to_string(fromCursor) +
" toCursor=" + std::to_string(t0) + " targetIndex=" + std::to_string(index) + " t0Index=" +
std::to_string(t0) + " t1Index=" + std::to_string(t0 + 1) + " mix=" + std::to_string(mix));
return true;
}
bool RasterArrayTimelineRuntime::step(const int direction) {
@@ -149,8 +176,8 @@ bool RasterArrayTimelineRuntime::injectInvalid() {
bool RasterArrayTimelineRuntime::recover() {
std::lock_guard<std::mutex> lock(mutex);
gap = invalid = false;
if (state == RasterArrayPlaybackState::Waiting) state = RasterArrayPlaybackState::Paused;
Log::Info(Event::Render, "ParticleTimeline: event=RECOVERED");
if (state == RasterArrayPlaybackState::Waiting) state = stateBeforeWait;
Log::Info(Event::Render, "ParticleTimeline: event=RECOVERED state=" + std::to_string(static_cast<int>(state)));
return true;
}
@@ -332,13 +359,13 @@ void RasterArraySource::tryInitializeTimeline() {
std::vector<RasterArrayTimelineRuntime::Entry> entries;
entries.reserve(runtimeFields.size() + 2);
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(*impl().field), 0});
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(*impl().nextField), 4000});
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(*impl().nextField), 40000});
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)),
8000 + static_cast<uint64_t>(index) * 4000});
80000 + static_cast<uint64_t>(index) * 40000});
}
try {
timeline = std::make_shared<RasterArrayTimelineRuntime>(std::move(entries));
@@ -348,6 +375,12 @@ void RasterArraySource::tryInitializeTimeline() {
Log::Info(Event::Render,
"ParticleTimeline: event=READY snapshots=" + std::to_string(runtimeFields.size() + 2) +
" cacheCapacity=" + std::to_string(timeline->cacheCapacity()));
for (size_t index = 0; index < runtimeFields.size() + 2; ++index) {
Log::Info(Event::Render,
"ParticleTimeline: event=TIMELINE_PRELOAD timelineId=" + getID() +
" index=" + std::to_string(index) + " cacheSize=" + std::to_string(runtimeFields.size() + 2) +
" capacity=" + std::to_string(timeline->cacheCapacity()));
}
observer->onSourceChanged(*this);
} catch (const std::exception& error) {
observer->onSourceError(*this, std::make_exception_ptr(std::runtime_error(error.what())));

View File

@@ -331,3 +331,9 @@
- RenderRasterParticleLayer 只读取 Source Core 给出的当前 T0/T1/mix;窗口 revision 变化继续进入既有 Field identity/GPU reuse 路径,自动边界维持 `OLD_T1 -> NEW_T0` 的重用语义。正常 timeline MIX 不需要 Style reload。
- Validation UI 收敛为 `TIME | VIS | COUNT | MAP | MORE`;原有动作均由菜单保留,TIME 提供 load/play/pause/speed/step/seek/gap/invalid/recover。
- 已加 Source timeline 单元覆盖并运行 Linux/OpenGL Core 精确构建;未运行 Android Gradle、ADB、实体机或 Jest。状态仍为 `IMPLEMENTED / WAITING FOR ANDROID VALIDATION`,等待 Android x86_64 逐项验收。
## 2026-09-04 — 40-06 / STEP-047 Android Round 1 集中修复
- Round 1 为 `PARTIAL / NEEDS FIX`:主链、自动 MIX/window reuse 和紧凑 UI 通过,但 timeline 过短、END/seek 语义、异常入口、VIS 可达性与 cache 可观测性不足。
- Core validation timeline 改为约 200 秒;END PLAY restart、PLAYING/PAUSED seek 状态保持、GAP/INVALID fail-closed 和 RECOVER 保留此前状态。TIME 直接暴露 NORMAL/GAP/INVALID/RECOVER,VIS 将 COLOR 前置并使用明显的 validation 步长。
- 仍保持 Source 有界 CPU snapshot preload、GPU 仅 T0/T1、相邻窗口 reuse 与 STEP-046 visual property-only。状态为 `IMPLEMENTED / WAITING FOR ANDROID RE-VALIDATION`。