diff --git a/docs/BLOCKED.md b/docs/BLOCKED.md index 97a20f9..2865448 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 13f4c5f..cfb13fa 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 91ce011..446ebd9 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 0000000..86bda87 --- /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 94d9c3e..3fd2181 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"