From dbbcae1df28c43f8318846951c884266b04a7fa0 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 19:51:57 +0000 Subject: [PATCH] docs: the timeline lands on `rest` -- except on six elements, where the game agrees with the timeline P2's gate is the buttons sliding in, and `tools/screen-strip` renders the strip that shows it. But the useful result came out of checking where the animation settles. On 8 of 12 screens the settled timeline is BYTE-IDENTICAL to the declared `rest` pose -- the port walks the keyframes with an authored time unit and arrives, to the pixel, where the pinned decoders independently say the screen rests. On `main_menu` the two differ in exactly one region, 400x470 at (440,108): the bounding box of `ptframe1` and `ptframe2` and nothing else. `rest` puts both at their first keyframe, off-position and transparent. The capture of the running game shows them -- the bright circuit bracket around the menu. Cropping the same region from the capture and from both renders puts the ring and its elbow trace in the timeline render pixel-aligned with the game's, and absent from the rest render. Geometry, so it does not depend on the capture's gamma or on its having been taken with NEW GAME focused. `ui_layout::rest_plateau` excludes a trailing run of identical keyframes because it is normally the exit. On an element with NO exit animation the trailing run IS the hold. The condition that identifies these exactly, with no false positives here, is "the final untimed keyframe has the same pose as the last timed one" -- six elements, and `rest()` misses all six. Filed in BLOCKED.md for the RE agent: the decoders are pinned and are not this port's to fix, and `sylpheed-cli screen render` is missing the bracket too. Worth saying plainly what this does to P1: the port and the reference agreed on `main_menu` to 3/255 and BOTH were missing two elements the game draws. Two renderers reading one field through one decoder agreeing is not evidence the field is right. BLOCKED.md had already said that about the pivot; here it bit. The title is NOT settled and P2 does not claim it. `rest` and the timeline disagree there by 142-247/255, the only live title capture composites the PRESS A plate over build 4 so it cannot be diffed against the title alone, and both of the port's modes draw a cyan glow slab the game does not have -- a third problem, P3's. Recorded as an open question rather than resolved by tuning. Also reconciled against the RE agent's new work: Q8 is answered -- the SE waves are located in `Static.slb` (move/confirm/back), which unblocks P6's audio; and the title's transitions are a lookup by NAME, giving P3/P5 the game's own screen vocabulary as candidate `goto` targets, marked as the name match it is. --- docs/BLOCKED.md | 54 +++++++++++++++++++- docs/DECISIONS.md | 119 ++++++++++++++++++++++++++++++++++++++++++++ docs/FORMAT.md | 16 ++++++ tools/screen-strip | 52 +++++++++++++++++++ tools/verify-screen | 17 +++++-- 5 files changed, 251 insertions(+), 7 deletions(-) create mode 100755 tools/screen-strip diff --git a/docs/BLOCKED.md b/docs/BLOCKED.md index 97a20f90..28654480 100644 --- a/docs/BLOCKED.md +++ b/docs/BLOCKED.md @@ -9,7 +9,7 @@ a decoded one a month from now. Where a milestone can proceed with a placeholder the placeholder goes in `authored/` with a `why` naming the question it stands in for, so it is deleted rather than forgotten when the answer arrives. -Last reconciled against HANDOFF.md on **2026-08-28**, at `/reborn` HEAD `bc6354d`. +Last reconciled against HANDOFF.md on **2026-08-28**, at `/reborn` HEAD `8b4965f`. (`/reborn` is mounted read-only, so `git -C /reborn pull` fails by design; the mount is refreshed outside this container and HEAD is read, not fetched.) @@ -17,7 +17,7 @@ mount is refreshed outside this container and HEAD is read, not fetched.) | Milestone | Needs | HANDOFF | State | |---|---|---|---| -| P6 audio | which cue fires on move / confirm / back | Q8 | ❔ open. The cue table is complete; the **event binding is not**. P6 cannot bind a sound to a keypress without inventing it. | +| ~~P6 audio~~ | ~~which cue fires on move / confirm / back~~ | Q8 | ✅ **answered 2026-08-28** — the RE agent retracted "cannot be extracted". The waves are located in `Static.slb` by playing them: **move `0x1ec0`** (8 192 B, 0.533 s), **confirm `0x5d6c0`** (12 288 B, 1.016 s), **back `0x0ec0`** (4 096 B, 0.344 s), and ⬅➡ play nothing. Move and back reproduce across two boots. 🟡 that the cursor's wave is the cue *named* `SE_UI_CURSOR` is still a name match, and Ⓐ's wave is not separated between `SE_UI_DECIDE` and `SE_UI_SUB_WIN_OPN`. P6 can now export real audio; the exporter has to grow an SE path. | | P6 audio | which BGM the menu plays | Q10 | ❔ **not on the disc.** All 32 banks are named `BGM_001`…`BGM_109` with no semantic name anywhere. The port is choosing a track, and that choice is authored. | | P6 looping | where a menu loop restarts | Q10 | ❔ `BGM_001` fades out at 167.663 s into 6.15 s of silence, and no loop-point field has been identified. A menu loop is authored. | | P4/P7 video | whether Ⓐ skips a movie | Q9 | 🟡 unsettled — the corpus says Ⓐ skips every time, the boot harness never taps during a movie because it breaks the title. P4 can play the movie; it cannot yet say what a button press does during one. | @@ -34,6 +34,18 @@ measured), Q9 (`ADVERTISE_MOVIE` → `ADV.wmv` is boot intro *and* attract; `MS0 `S00A.wmv` is the new-game intro), Q10 (a bank is two stems played **together** — do not concatenate), S1 (Ready Room: no-go). +Also newly available, and useful to P3/P5 when they author the flow: the title +part's transitions are a **lookup by name**, and the game's own screen +vocabulary includes `TITLE_SCREEN`, `TITLE_MENU`, `LOADING`, `DIFFICULTY`, +`EXTRA_MENU`, `TUTORIAL_MENU`. Three of those are corroborated by measurements +taken before the function was opened (`DIFFICULTY` is what `NEW GAME` opens, +`EXTRA_MENU` is `EXTRAS`, `TUTORIAL_MENU` the lesson list). 🟡 **Candidate, not +decoded** — the RE agent is explicit that the strings are what the call sites +*reference*, not proven arguments, and the same list mixes in `TEXT_FONT` and +`GAMMA_RGB`. So `authored/flow.json` may use these as `goto` names — which is +better than inventing names — but must mark them as a name match, not a +measurement. + Three of those are **measured**, not decoded, and so are authored here rather than exported: @@ -45,9 +57,47 @@ than exported: ## Questions this port has raised +### Does a keyframe group loop, or hold its last pose? + +Raised at P2 and **unsettled**. The port holds the last timed keyframe, which is +right for an entry animation (the main menu settles at t=80, 1.33 s) and is +proven on the screen P2 gates. The **title** runs to t=269 — 4.48 s — and there +the port's settled pose and the decoders' `rest` disagree badly (max 142/255). + +What is known: no element's alpha reverses direction anywhere in this export, so +nothing pulses, which removes the obvious reason to expect a loop without +disproving one. What would settle it: **a capture of build 4 alone**. The one +live title capture composites the `PRESS Ⓐ` plate (build 2) over it, so it +cannot be diffed against the title by itself. + +⚠️ Independently, **both** of the port's modes draw a washed-out cyan glow over +the title logo that the running game does not have. That is a third problem and +it is P3's; it is noted here so nobody reads the loop question as its cause. + Not blocking anything today; raised because the port found them and a guess here would be believed later. +### `rest_plateau` misfires on elements with no exit animation + +**This one is a decoder bug, not a question**, and it is the highest-value item +on this page for the RE agent. `ui_layout::rest_plateau` excludes a run of +identical keyframes that ends the group, on the grounds that it is the exit. For +an element that **has no exit animation** the trailing run *is* the hold, and the +rule falls back to an earlier run — for a slide-in, the invisible pre-roll. + +The condition that identifies the affected elements exactly, with no false +positives across this export, is: **the final untimed keyframe has the same pose +as the last timed one.** Six elements match; `rest()` misses all six. + +`ptframe1` and `ptframe2` on the main menu are the visible case, and +`docs/re/captures/main-menu-oracle.png` settles it — the game draws the circuit +bracket that `rest` calls invisible. `sylpheed-cli screen render` is missing it +too, so this is not only a port concern. + +The port needs nothing here: it derives the arrived pose from the keyframes and +does not use `rest`. Filed because `rest()` is used elsewhere and because a +capture already proves it. + ### Does the game sample a scaled sprite at the pixel corner or the pixel centre? Found at P1, by the only screen it could have been found on. `title_jp`'s diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 13f4c5f8..cfb13fa2 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -272,3 +272,122 @@ Both renderers skip them. The bundle declares them and carries **zero** RATC children for either, so there is no texture on the disc to export — this is a property of the disc, not a gap in the exporter, and `ScreenView` reports it as `no sprite in the export` rather than dropping it silently. + +--- + +## P2 — keyframe animation, 2026-08-28 + +### The time unit is authored, in one file, and says loudly that it is not on the disc + +`authored/timing.json`. HANDOFF Q1 is answered — linear ramp, 2 units per +rendered frame, 1 unit = 1/60 s — but that conversion is **measured off the +running game**, not read from a file, which is exactly the case the +derived/authored split exists for. It is expressed as +`keyframe_units_per_second: 60` rather than seconds-per-unit so the value is +exact instead of a repeating decimal, and it carries the two independent lines +that support it. `t` stays raw everywhere in `export/`; seconds appear only +where this file is applied, which is one line of `boot.gd`. + +`exit_ramp_seconds` is deliberately **null**. See below. + +### The timeline stops at the last *timed* keyframe, and never plays the exit + +The last keyframe of every group carries **no `t`** — the disc has no time slot +there. Across this export that final frame is an *exit* pose: for 116 of 134 +elements it differs from the last timed keyframe **in alpha only** (a fade-out), +for 12 it is the loading splash's scale-and-slide exit, and for 6 it is +identical (no exit animation at all). + +So the group is `pre-roll → ramp in → hold → [exit]`, and the port plays it up to +the hold and stops. Playing into the exit would mean **inventing how long the +ramp takes**, because the disc does not say. That duration is the screen +transition — HANDOFF Q7 measured it at ~0.4 s — and it belongs to P3, with its +own evidence. This is why `exit_ramp_seconds` is null rather than 0.4: P2 has no +business holding it. + +### The interpolation is checked by where it lands, not by inspection + +For **8 of the 12** screens the settled timeline is **byte-identical** to the +`--pose=rest` render. That is the useful assertion: the port walks the keyframes +with an authored time unit and arrives, to the pixel, at the pose the pinned +decoders independently identify as the resting one. `tools/screen-strip` reports +this per screen, so a change to the interpolation that drifts by one unit shows +up as a diff rather than as nothing. + +The four that differ do so for two distinct reasons, below. + +## `rest` misidentifies six elements, and the running game says so + +On `main_menu`, the settled timeline and `rest` differ in exactly one region: +**400×470 at (440,108)** — the bounding box of `ptframe1` and `ptframe2`, and +nothing else on the screen. + +`rest` puts both at their **first** keyframe: off-position and fully +transparent. The keyframes say they slide (620,108)→(440,108) and (403,267)→ +(583,267) while fading 0x00→0xff, and then hold that pose for their last three +keyframes including the untimed one. + +`/reborn/docs/re/captures/main-menu-oracle.png`, a capture of the running game, +**shows them**: the bright circuit-frame bracket around the menu, with a ring at +the bottom right. Cropping the same 250×180 region from the capture and from +both renders puts the ring and its elbow trace in the port's timeline render +**pixel-aligned with the game's**, and absent from the `rest` render. That is +geometry, not luminance, so it does not depend on the capture's gamma or on the +fact that it was taken with `NEW GAME` focused. + +### Why the decoders get it wrong, precisely + +`ui_layout::rest_plateau` excludes a run of identical keyframes that **ends the +group**, because that run is normally the exit — the comment cites the pause +menu, where taking the trailing run erased the word PAUSE. That exclusion is +right in general and wrong for an element with **no exit animation**, where the +trailing run *is* the hold. The rule then falls back to an earlier run, which +for a slide-in is the invisible pre-roll. + +The condition that identifies the affected elements exactly, with no false +positives in this export, is: + +> the final untimed keyframe has the **same pose** as the last timed keyframe + +Six elements match it and `rest` misses all six: `ptframe1`/`ptframe2` on +`main_menu` and `main_menu_jp`, and `pteff02` on `title` and `title_jp`. This is +a **finding for the RE agent** about `sylpheed-formats`, not something this port +fixes: the decoders are pinned and must not be reimplemented here. The port +simply does not use `rest` — it derives the arrived pose from the keyframes, +which needs no heuristic — and `verify-screen` still asks for `--pose=rest` so +that renderer-vs-renderer diffing compares like with like. + +Note what this says about P1: the port and the reference renderer **agreed** on +`main_menu` to 3/255, and both were missing two elements the game draws. Two +renderers reading the same field through the same decoder agreeing is not +evidence that the field is right. `docs/BLOCKED.md` had already said that about +the pivot; here it bit for real. + +## The title is not settled, and P2 does not claim it + +`title` and `title_jp` differ between the two modes by much more (max 142 and +247), and there the disagreement is **not** the six-element bug alone. `rest` +picks a mid-timeline hold for several glows (`pteff01`, `ptlogoall_eff`, +`ptlogoall_eff2`, `ptlogo_back2eff5`) where the timeline runs on to a much +brighter pose. + +I could not settle which is right, and did not try to make the numbers agree: + +* No element's alpha ever reverses direction anywhere in this export, so the + title's 4.48 s timeline is a slow one-way ramp, not a pulse — which removes the + obvious reason to expect a loop, but does not prove there is none. +* The only live title capture composites the **`PRESS Ⓐ` plate (build 2) over + the title (build 4)**, so it cannot be diffed against build 4 alone. Mean + luminance is oracle 64.1, `rest` 62.8, timeline 80.0 — which looks like it + favours `rest`, except that the plate *adds* brightness and `rest` is carrying + a 25 % black dim quad (`pteff02`) that is itself one of the six misidentified + elements. The comparison is confounded in both directions and settles nothing. +* **Both modes are visibly wrong anyway.** Side by side with the capture, the + port draws a washed-out cyan glow slab across the logo that the running game + does not have — in `rest` mode too. That is a third problem, independent of + this one, and it is P3's. + +So: the timeline is the default because it is derived from the disc's own +keyframes with one measured constant and no heuristic, and because it is proven +right on the screen this milestone gates. On the title it is **unverified**, and +P3 should not assume P2 settled it. diff --git a/docs/FORMAT.md b/docs/FORMAT.md index 91ce0115..446ebd9d 100644 --- a/docs/FORMAT.md +++ b/docs/FORMAT.md @@ -187,6 +187,22 @@ keyframes with an identical pose that does not end the group. Neither the first nor the last keyframe, and not the longest-dwell frame either: a long gap after keyframe *k* means the screen spends that time *arriving at* `k+1`. +> ⚠️ **`rest` is a heuristic over the keyframes, and it misfires.** The rule +> excludes a run that ends the group, because that run is usually the exit. On +> an element with **no exit animation** the trailing run *is* the hold, and the +> rule then falls back to an earlier run — usually the invisible pre-roll. Six +> elements in this export are affected, and the condition that identifies them +> exactly is *"the final untimed keyframe has the same pose as the last timed +> one"*: `ptframe1`/`ptframe2` on both main menus, and `pteff02` on both titles. +> A live capture of the running main menu shows `ptframe1`/`ptframe2` on screen; +> `rest` says they are invisible. +> +> A consumer that wants the pose after arrival should therefore take **the last +> timed keyframe**, not `rest`. `rest` is kept in the format because it is what +> the pinned decoders say and removing it would hide the disagreement — see +> `docs/DECISIONS.md`. The format is unchanged at **v2**: no field changed +> meaning, this is a warning about one of them. + **`paint_order`** is back-to-front, as declaration indices, and is a permutation of them. It is the stable sort by `layer`. See `unresolved: paint_order_ties`. diff --git a/tools/screen-strip b/tools/screen-strip new file mode 100755 index 00000000..86bda871 --- /dev/null +++ b/tools/screen-strip @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Render one screen at several points on its timeline and montage them -- the +# P2 gate artifact, and the way to eyeball any animation question later. +# +# tools/screen-strip main_menu # a default spread +# tools/screen-strip main_menu 0.45 0.6 0.9 1.35 # explicit seconds +# +# Also writes .rest.png and .settled.png and reports where they +# differ. That difference is the interesting number: the timeline is expected to +# land EXACTLY on the declared resting pose for every element whose `rest` the +# decoders identify correctly, so a clean run shows a difference confined to the +# elements we know it misses, and nothing else. A difference anywhere else means +# the interpolation is wrong. +set -euo pipefail +cd "${PROJECT_DIR:-/work}" + +name="${1:?usage: screen-strip SCREEN [SECONDS...]}"; shift +times=("$@") +[ ${#times[@]} -eq 0 ] && times=(0.45 0.52 0.57 0.62 0.67 0.75 0.90 1.35) +OUT="${OUT:-${TMPDIR:-/tmp}/screen-strip}" +export DISPLAY="${DISPLAY:-:97}" +mkdir -p "$OUT" +[ -d port/.godot ] || godot --headless --path port --import >/dev/null 2>&1 + +shot() { # shot + local out="${!#}" + godot --path port --resolution 1280x720 -- "--screen=$name" "${@:1:$#-1}" \ + "--capture=$out" >"$OUT/$name.log" 2>&1 +} + +labelled=() +for t in "${times[@]}"; do + shot "--time=$t" "$OUT/$name.t$t.png" + convert "$OUT/$name.t$t.png" -resize 320x180 -bordercolor gray30 -border 1 \ + -background black -fill white -pointsize 13 label:"t = ${t}s" \ + -gravity center -append "$OUT/$name.lab$t.png" + labelled+=("$OUT/$name.lab$t.png") +done +montage "${labelled[@]}" -tile 4x -geometry +4+4 -background black "$OUT/$name.strip.png" + +shot --pose=rest "$OUT/$name.rest.png" +shot --time=99 "$OUT/$name.settled.png" +convert "$OUT/$name.settled.png" "$OUT/$name.rest.png" -compose difference -composite "$OUT/$name.d.png" +max=$(convert "$OUT/$name.d.png" -format "%[fx:maxima*255]" info:) +convert "$OUT/$name.d.png" -colorspace Gray -threshold 0 "$OUT/$name.m.png" +if [ "${max%.*}" = "0" ]; then + box="(identical)" +else + box=$(convert "$OUT/$name.m.png" -trim -format "%wx%h%X%Y" info: 2>/dev/null) +fi +echo "$name: strip -> $OUT/$name.strip.png" +echo "$name: settled timeline vs declared rest -- max ${max}/255, differing region $box" diff --git a/tools/verify-screen b/tools/verify-screen index 94d9c3e9..3fd2181e 100755 --- a/tools/verify-screen +++ b/tools/verify-screen @@ -18,10 +18,17 @@ # * `--black` because Godot clears to black and the screen carries its own # background. The CLI's default dim slate stands in for a 3D scene behind an # in-mission screen, which is not this screen. -# * `--primitives --animated` because the port draws every element at its -# resting pose, and those two flags are what make the CLI draw the same set. -# `--focus` is NOT passed: nothing is focused at rest (HANDOFF Q5 measured -# initial focus as unstable boot to boot, so choosing one is P5's decision). +# * `--primitives --animated` because those are what make the CLI draw the same +# element set. `--focus` is NOT passed: nothing is focused at rest (HANDOFF +# Q5 measured initial focus as unstable boot to boot, so choosing one is +# P5's decision). +# * `--pose=rest` on the Godot side. Since P2 the port's DEFAULT is to play the +# timeline, and the settled timeline is deliberately NOT what `rest` says -- +# the export's `rest` misses `ptframe1`/`ptframe2` on the main menu, and the +# running game shows them (docs/DECISIONS.md). Both renderers read `rest` +# through the same decoder, so asking for it here keeps this a test of the +# PORT against the reference. It is not the test of whether `rest` is right; +# that one is the oracle capture, and the port already departs from it. # # A difference here is not automatically the port's fault. Say which renderer is # wrong and why -- do not tune until they match. @@ -59,7 +66,7 @@ print(json.load(open("export/"+f))["source"]["build"])' "$name") --build "$build" --black --primitives --animated >/dev/null godot --path port --resolution 1280x720 -- \ - "--screen=$name" "--capture=$OUT/$name.godot.png" >"$OUT/$name.log" 2>&1 + "--screen=$name" --pose=rest "--capture=$OUT/$name.godot.png" >"$OUT/$name.log" 2>&1 convert "$OUT/$name.godot.png" "$OUT/$name.ref.png" \ -compose difference -composite -colorspace Gray -auto-level "$OUT/$name.diff.png"