diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index 077218c..739b12d 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -32,10 +32,10 @@ authored version can be deleted. | Q3 | paint order for the six screens | βœ… answered | **decoded**: a `u16` layer key at `+0x0A` of each `T8aD` sprite header, stable-sorted with declaration index; unkeyed elements get an implied key. Confirmed on 5 measured orders + `EXTRAS` vs a capture. One residual: the **tie-break** is unknown and bites on one element of the title β€” [`structures/ui-paint-order-key.md`](../re/structures/ui-paint-order-key.md) | | Q4 | button β†’ GamePart | 🟑 mostly answered | **measured** which screen each button opens (4 of 5; `NEW GAME` untested β€” it hangs). The **GamePart id is a name match** onto the decoded id table, not a measurement β€” [`menu-navigation-semantics.md`](../re/menu-navigation-semantics.md) | | Q5 | navigation semantics | βœ… answered | **measured**: initial focus varies boot to boot (2Γ— `TUTORIAL`, 1Γ— `NEW GAME`); ⬆⬇ one step, **wraps both ends**; β¬…βž‘ do nothing; β’· returns to the parent **with focus restored**; β’· on the main menu β†’ title; β’· on the title β†’ nothing β€” [`menu-navigation-semantics.md`](../re/menu-navigation-semantics.md) | -| Q6 | boot sequence + what drives it | 🟑 partial | order observed, and the **attract cycle is timed**: ~8–10 s idle on the title β†’ fade to black β†’ ~85 s of video β†’ title again, plate and all. The driver is still not decoded | +| Q6 | boot sequence + what drives it | 🟑 partial | order observed and the attract cycle timed: ~8–10 s idle β†’ fade to black β†’ **`ADV.wmv` in full (137 s)** β†’ title again. The **movie manifest** supplies the boot-side asset order (slot key = role); the code that decides to advance is still not decoded | | Q7 | transitions | βœ… answered | a **fade through black**, drawn by the screen's own last-painting `.prm` quad. Fade-in ramp is **decoded** from its keyframes; the ~0.4 s fade-out is **measured** (not in the file) β€” [`screen-transitions.md`](../re/screen-transitions.md) | | Q8 | menu audio bindings | ❔ open | cue table complete, event binding is not | -| Q9 | video binding + playback rules | 🟑 partial | `ADV.wmv` is the boot intro; new-game intro unidentified | +| Q9 | video binding + playback rules | βœ… answered | **decoded** from the movie manifest: `ADVERTISE_MOVIE`β†’`ADV.wmv` (boot intro *and* attract are one asset), `MS00A`β†’`S00A.wmv` is the new-game intro, `STAFF_ROLL`β†’the credits reel. 🟑 skippability unsettled β€” [`movie-binding.md`](../re/movie-binding.md) | | Q10 | music-bank sub-wave roles (intro+loop?) | βœ… answered | **two stems of one performance, played together** β€” sample-synchronous, equal duration, 32/32 banks. **Concatenating is wrong.** Not a seamless loop either β€” [`structures/bgm-two-stems.md`](../re/structures/bgm-two-stems.md) | | S1 | Ready Room go/no-go | βœ… **no-go** | it is 2D and enumerates fine (60 builds), but `GP_READY_ROOM.pak` holds **briefing/tactical-map** content, not the six-button Ready Room menu β€” [`ready-room-probe.md`](../re/ready-room-probe.md) | @@ -125,6 +125,20 @@ authored version can be deleted. brightness: the incoming screen's own element animations dominate it and run much longer than the quad. +* **The movie manifest names every boot-side video by role.** `dat/tables.pak` + entry `0x5b983a08`: `LOGO1`–`LOGO4` β†’ `logo1.wmv`–`logo4.wmv` (**not on the + disc** β€” this is why the splash is a screen), **`ADVERTISE_MOVIE` β†’ `ADV.wmv`**, + `STAFF_ROLL` β†’ `SYLPH_HD720p_8M-CBR_2ch.wmv`, **`MS00A` β†’ `S00A.wmv`** (the + new-game intro, 93.9 s, with subtitle + `VOICE_S00A` + a text overlay), + `MS01A` β†’ `S01A.wmv`. βœ… decoded. + **The boot intro and the attract movie are the SAME asset** β€” there is no + separate boot slot, and 15 of 19 captured attract frames match `ADV.wmv` with a + monotonically advancing playhead ending at its full 137 s. One video, not two. + βœ… The attract movie **plays to its end**; nothing cuts it short. + 🟑 **Whether a movie is skippable is unsettled** β€” the corpus says β’Ά skips one + every time, while the boot harness deliberately never taps during a movie + because it breaks the title. Do not rely on either until it is retested. + * **The GamePart id table** β€” 29 entries at `.rdata 0x820A1630`, confirmed by the executable's own registration strings. βœ… This is the screen vocabulary; which button reaches which entry is Q4 and is *not* part of it. diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index cd058b7..0ee6506 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -152,6 +152,7 @@ files, which is how the same ground got covered twice. | [`ui-quad-class-foothold.md`](ui-quad-class-foothold.md) | The guest's UI quad class β€” a foothold found from the capture's vertex layout | 🟑 PROBABLE for the identification below (it is a static read, but | | [`menu-navigation-semantics.md`](menu-navigation-semantics.md) | The title menu β€” how it moves, and where each button goes | βœ… measured: wraps both ends, β’· restores focus, β¬…βž‘ inert; 4 of 5 destinations driven. 🟑 GamePart id is a name match, ❔ `NEW GAME` untested | | [`screen-transitions.md`](screen-transitions.md) | Between two screens β€” a fade through black, and where its timing lives | βœ… the fade quad's keyframe group is decoded (disc-wide: per-pak all-or-nothing; `GP_TITLE` = the 6 screens, not the 6 overlays); the ~0.4 s fade-OUT is measured, not on the disc | +| [`movie-binding.md`](movie-binding.md) | Which movie plays where β€” boot intro, attract loop, new-game intro | βœ… decoded from the movie manifest (`ADVERTISE_MOVIE`β†’`ADV.wmv`, `MS00A`β†’`S00A.wmv`); attract identity confirmed independently by frame matching; 🟑 skippability unsettled | | [`ready-room-probe.md`](ready-room-probe.md) | S1 β€” the Ready Room probe: no-go, and not for the reason expected | βœ… it is 2D and enumerates (60 builds), but the pak is briefing/tactical-map content; and `kind == 0x3002` finds 0 buttons there | | [`ui-title-build-map.md`](ui-title-build-map.md) | Which `GP_TITLE` build is which screen state | βœ… CONFIRMED for title / `PRESS β’Ά` / main menu / `EXTRAS` against live captures; the archive is 8 screens Γ— EN/JP, and "6/8/9 are submenus" is withdrawn | | [`ui-title-paint-order-capture.md`](ui-title-paint-order-capture.md) | The title screen's paint order, measured from the guest's draw submissions | βœ… CONFIRMED β€” the order in which the running game paints the title | diff --git a/docs/re/METHOD.md b/docs/re/METHOD.md index 9c206db..c2df6e1 100644 --- a/docs/re/METHOD.md +++ b/docs/re/METHOD.md @@ -160,3 +160,12 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the counts β€” they pin each wave's join exactly, so a straddle can be realigned rather than thrown away. 4 of 32 BGM banks look like counterexamples until this is applied. +* **Do not time a video whose start you did not see.** An attract cycle sampled + from partway through measured "β‰ˆ85 s", which was used to argue it was not the + 137 s `ADV.wmv`. It was `ADV.wmv`; the sampling began 39 s in. Identify a video + by **content** β€” match frames against the candidates and check the matched + playhead advances monotonically β€” before reasoning about its duration. +* **A near-black frame has no signature.** In frame-matching, low-contrast frames + drive every correlation toward zero and the ranking becomes noise; one control + frame scored 0.000 and another tied the wrong movie at 0.98. Read a dark + frame's runner-up as *no evidence*, not as evidence for the runner-up. diff --git a/docs/re/REFUTED.md b/docs/re/REFUTED.md index 9c70711..bf8b2f7 100644 --- a/docs/re/REFUTED.md +++ b/docs/re/REFUTED.md @@ -35,6 +35,14 @@ neighbourhood, not just the line. ## Screens, classes and RTTI +* "the attract movie runs ~85 s, so it is not `ADV.wmv` (137 s)" β†’ **mine, and + wrong.** Sampling began 39 s into the movie, so what was timed was its tail. + The attract movie **is** `ADV.wmv`, played in full. + [`movie-binding.md`](movie-binding.md) +* "the boot intro and the attract movie are different videos" β†’ one asset, one + manifest slot (`ADVERTISE_MOVIE`). +* "the new-game intro is unidentified" β†’ `MS00A` β†’ `S00A.wmv`, decoded from the + movie manifest. * "`GP_READY_ROOM.pak` holds the Ready Room screen" β†’ its 317 distinct element names contain **none** of the six visible labels; it is the briefing / tactical-map content behind the `BRIEFINGS` item. diff --git a/docs/re/data/attract-frame-match.txt b/docs/re/data/attract-frame-match.txt new file mode 100644 index 0000000..6ba0c98 --- /dev/null +++ b/docs/re/data/attract-frame-match.txt @@ -0,0 +1,33 @@ +Frame-signature match of captured frames against five candidate movies. +32x18 normalised grayscale, movie frames cropped to the 675/720 the game +surface shows, 1 fps sampling, best correlation over the whole movie. + +CONTROL - frames captured during the BOOT movie, known to be ADV.wmv: + p2.png -> ADV @116s 0.644 (runner-up S13A 0.438) + p3.png -> ADV @121s 0.994 (runner-up S03A 0.650) + p4.png -> S13A @ 35s 0.984 (runner-up ADV 0.926) + p5.png -> ADV @129s 0.933 (runner-up S13A 0.809) + p6.png -> ADV @134s 0.992 (runner-up S13A 0.499) + p7.png -> S15B @ 88s 0.000 (runner-up S13A 0.000) + p8.png -> S13A @ 65s 0.576 (runner-up ADV 0.534) + +TEST - frames captured during the ATTRACT loop, 5 s apart: + attract01.png -> ADV @ 39s 0.990 (runner-up S13A 0.487) + attract02.png -> ADV @ 45s 0.961 (runner-up S13A 0.411) + attract03.png -> S03A @ 37s 0.731 (runner-up ADV 0.422) + attract04.png -> ADV @ 56s 0.955 (runner-up S03A 0.532) + attract05.png -> ADV @ 62s 0.934 (runner-up S15B 0.245) + attract06.png -> S15B @ 65s 0.885 (runner-up ADV 0.743) + attract07.png -> ADV @ 75s 0.961 (runner-up S03A 0.355) + attract08.png -> ADV @ 80s 0.938 (runner-up S03A 0.753) + attract09.png -> ADV @ 85s 0.974 (runner-up S03A 0.484) + attract10.png -> ADV @ 92s 0.892 (runner-up S13A 0.754) + attract11.png -> S03A @ 24s 0.606 (runner-up ADV 0.588) + attract12.png -> ADV @102s 0.994 (runner-up S03A 0.374) + attract13.png -> ADV @108s 0.977 (runner-up S13A 0.496) + attract14.png -> ADV @113s 0.836 (runner-up S06B 0.464) + attract15.png -> ADV @119s 0.958 (runner-up S15B 0.424) + attract16.png -> S13A @ 32s 0.996 (runner-up ADV 0.980) + attract17.png -> ADV @131s 0.865 (runner-up S13A 0.769) + attract18.png -> ADV @137s 0.993 (runner-up S13A 0.502) + attract19.png -> S13A @ 65s 0.551 (runner-up ADV 0.534) diff --git a/docs/re/movie-binding.md b/docs/re/movie-binding.md new file mode 100644 index 0000000..9710b6d --- /dev/null +++ b/docs/re/movie-binding.md @@ -0,0 +1,99 @@ +# Which movie plays where β€” the boot intro, the attract loop, the new-game intro + +**Status:** βœ… `CONFIRMED`. The bindings are **decoded** from the movie manifest, +and the attract movie is independently **measured** from captured frames. One +part β€” whether playback is skippable β€” is 🟑 and the corpus contradicts itself +about it; that is written down rather than resolved by assertion. + +Answers [MISSION Q9](../port/MISSION.md), and contributes to Q6. + +## The manifest names the boot-side slots β€” decoded + +The movie manifest (`dat/tables.pak` entry `0x5b983a08`, schema `0x067025b9`, +[`movie_manifest.rs`](../../crates/sylpheed-formats/src/movie_manifest.rs)) is an +array of slots whose **key is the semantic role**, with a parallel array of value +records in the same order. Its first eight slots are the whole boot-side flow: + +| slot key | movie | also bound | +|---|---|---| +| `LOGO1` … `LOGO4` | `logo1.wmv` … `logo4.wmv` | β€” | +| **`ADVERTISE_MOVIE`** | **`ADV.wmv`** | `VOICE_ADV` | +| `STAFF_ROLL` | `SYLPH_HD720p_8M-CBR_2ch.wmv` | subtitle table | +| **`MS00A`** | **`S00A.wmv`** | `SUBTITLE_S00A`, `VOICE_S00A`, a `pwterop_s01a.prt` overlay | +| `MS01A` | `S01A.wmv` | `SUBTITLE_S01A`, `VOICE_S01A` | + +The alignment is not in doubt here even though the manifest has valueless slots +elsewhere: `logo1..4` zip 1:1 onto `LOGO1..4`, and `MS00A`β†’`S00A.wmv`, +`MS01A`β†’`S01A.wmv` are the manifest's own anchor rule. + +**The `logo1`–`logo4` slots are bound to `.wmv` files that are not on the disc** β€” +already established, and the reason the developer splash is a screen, not a video. + +## βœ… The boot intro and the attract movie are the same asset + +There is **no separate boot-intro slot**. `ADV.wmv` is bound to +`ADVERTISE_MOVIE` β€” it *is* the advertise movie, and the boot simply plays it +first. For the port that means one video, not two. + +### Measured independently, from captured frames + +Frames captured 5 s apart during an attract cycle were matched against five +candidate movies by frame signature (32Γ—18 normalised grayscale, movie frames +cropped to the 675 of 720 rows the game surface shows, 1 fps sampling). +`tools/re-capture/frame_match.py`; full table in +[`data/attract-frame-match.txt`](data/attract-frame-match.txt). + +**15 of 19 attract frames matched `ADV.wmv`, and the matched timestamps advance +monotonically at the sampling rate** β€” 39, 45, 56, 62, 75, 80, 85, 92, 102, 108, +113, 119, 131, 137 s β€” ending at 137 s, which is `ADV.wmv`'s full length, after +which the title returned. That is not a similarity score, it is a playhead. + +**The control was run first and behaves the same way.** Frames captured during +the *boot* movie β€” known to be `ADV.wmv` β€” matched `ADV` at 116, 121, 129, 134 s. +Its failure mode is worth stating: on near-black frames the correlation collapses +(one control frame scored 0.000, another tied `S13A` 0.984 against `ADV` 0.926). +Those are exactly the 4 attract frames that did not match. **A dark frame carries +no signature; it is not evidence for the runner-up.** + +### πŸ”΄ This corrects an earlier claim of mine + +Two iterations ago I recorded the attract movie as "β‰ˆ85 s, so probably not +`ADV.wmv`'s 137 s". That was wrong, and the mistake was arithmetic on an +unobserved start: my sampling began when the movie was already **39 s in**, so +what I timed was the *tail* of it, not the whole thing. + +## βœ… The new-game intro is `S00A.wmv` + +Slot `MS00A` β€” the prologue story intro, 93.87 s, with its own subtitle table, +`VOICE_S00A`, and a text overlay. Decoded from the manifest, **not** measured: +β’Ά on `NEW GAME` hangs the emulator +([`menu-navigation-semantics.md`](menu-navigation-semantics.md)), so this +binding has not been watched happening. + +## 🟑 Skippable β€” the corpus contradicts itself, and I did not settle it + +* [`canary-scripted-input-traps.md`](canary-scripted-input-traps.md) states + plainly that "β’Ά during a movie skips the movie, every time". +* But `boot_menu.sh` / `skip_intro.sh` as they stand **deliberately do not tap + during a movie**, logging `movie … -> waiting it out (tapping breaks the + title)`, and the same document records run G: 88 β’Ά presses through the boot + left a permanent black screen. + +Both cannot be the whole story. **The test**: boot, tap β’Ά exactly once well +inside the movie, and record whether the title arrives early. One boot, and it +was not run this iteration. + +## βœ… What ends the attract movie + +Nothing intervenes: it **plays to its end**. The last matched frame is `ADV` at +137 s β€” the file's full duration β€” and the title is back on the next sample. So +the attract cycle is: title, idle ~8–10 s, fade to black, `ADV.wmv` in full, back +to the title (which redraws `PRESS β’Ά BUTTON`). + +## What this contributes to Q6 + +The manifest is **data the game reads** to sequence the boot side: the slot keys +are in play order and the key *is* the role. That is not the whole driver β€” it +says what plays, not what decides to advance β€” but it is the first file-side +piece of Q6's second half, and it means the boot-side asset order does not have +to be authored. diff --git a/tools/re-capture/frame_match.py b/tools/re-capture/frame_match.py new file mode 100644 index 0000000..13e6523 --- /dev/null +++ b/tools/re-capture/frame_match.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Match a captured game frame against candidate .wmv files by frame signature. + +Static + previously-captured frames. See docs/re/movie-binding.md.""" +import numpy as np, subprocess, sys, os +from PIL import Image +W,H=32,18 +def sig_img(path, crop_bottom=False): + im=Image.open(path).convert("L") + if crop_bottom: # movie frame -> what the capture can see + w,h=im.size; im=im.crop((0,0,w,int(h*675/720))) + a=np.asarray(im.resize((W,H)),dtype=np.float32).ravel() + return (a-a.mean())/(a.std()+1e-6) +def movie_sigs(path, fps=1.0): + out=f"/tmp/q9/fr_{os.path.basename(path).replace('.wmv','')}" + os.makedirs(out, exist_ok=True) + if not os.listdir(out): + subprocess.run(["ffmpeg","-v","error","-i",path,"-vf",f"fps={fps},scale=160:90", + "-y",f"{out}/f%05d.png"],check=True) + fs=sorted(os.listdir(out)) + return [(int(f[1:6]), sig_img(os.path.join(out,f), crop_bottom=True)) for f in fs]