diff --git a/crates/sylpheed-formats/examples/bgm_dump.rs b/crates/sylpheed-formats/examples/bgm_dump.rs new file mode 100644 index 00000000..c9724421 --- /dev/null +++ b/crates/sylpheed-formats/examples/bgm_dump.rs @@ -0,0 +1,22 @@ +//! Dump one BGM bank's waves as RIFF/XMA so they can be decoded and compared +//! against a capture of the running game. +//! +//! cargo run -p sylpheed-formats --example bgm_dump -- BGM_103.slb OUTDIR +use sylpheed_formats::media::{self, DirectorySource}; +use sylpheed_formats::slb; + +fn main() { + let name = std::env::args().nth(1).unwrap_or_else(|| "BGM_103.slb".into()); + let out = std::env::args().nth(2).expect("OUTDIR"); + let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"); + let src = DirectorySource::new(std::path::PathBuf::from(&disc)); + std::fs::create_dir_all(&out).expect("outdir"); + let h = sylpheed_formats::hash::name_hash(&name); + let bytes = media::read_sound_bank(&src, h).expect("bank"); + println!("{name}: {} B", bytes.len()); + for (i, r) in slb::to_xma_riffs(&bytes).iter().enumerate() { + let p = format!("{out}/{}_{i}.xma", name.trim_end_matches(".slb")); + std::fs::write(&p, r).expect("write"); + println!(" wave {i}: {} B (byte_size {}) -> {p}", r.len(), r.len() - 60); + } +} diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index e4af1e60..b9fd0e28 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -2529,7 +2529,40 @@ whatever it returns โ€” the same reason a truncated log and a t=0 render both lo fine from inside. Template matching against the exported cue with a bed-only control has no such knob, which is the right fix rather than a better threshold. -## ๐ŸŸก 2026-08-30 โ€” the menu BGM loop point: attempted, NOT captured +## โœ… 2026-08-30 โ€” the menu BGM loops at **61.93 s**, and there is **no seam** + +**Measured, 240 s parked on the menu.** Your authored loop is wrong in both +directions it could be. + +* ๐Ÿ”ด **No seam.** Zero runs โ‰ฅ0.3 s below (median โˆ’ 18 dB) in 232 s. Your **3.4 s + near-silence is a property of your loop, not of the game.** +* ๐Ÿ”ด **It does not loop at the wave length.** Autocorrelation r at **87.750 s is + โˆ’0.009** โ€” zero, on four independent windows. The top lag is **61.909 s**. +* โœ… **A second instrument agrees.** Locating 30 s slices of the capture inside the + decoded, summed waves: playback advances **exactly +5.00 s per 5 s** and wraps at + **61.93 s**, from three wraps. Control: slices cut from the wave itself at + 10/45/70 s are found at 10.00/45.00/70.00. + +**Where it sits:** offsets span 0.25โ€ฆ57.18 s of an 87.744 s wave, so the loop is +**[โ‰ˆ0, 61.93)** and the **final ~25.8 s is never played** โ€” which is exactly where +`bgm-two-stems.md` found the fade-out and trailing silence. **The game loops before +the fade.** That is why there is no seam. + +**So: loop at 61.93 s, both waves summed, aligned at 0, and expect no silence.** +โš ๏ธ Still *measured*, so you are authoring it โ€” but from an observation now, not from +the file's length. โš ๏ธ The loop **start** is inferred from the period and the observed +minimum; `[0.0, 61.93)` and `[0.25, 62.18)` are not separated. One boot, one bank. +[`menu-bgm-loop-measured.md`](../re/structures/menu-bgm-loop-measured.md) ยท +[series](../re/data/menu-bgm-loop-measured.txt) + +๐Ÿ”ด **And a correction you should carry:** my "8 of 10 three-chunk regions start +mid-stream" is **a ratio over an unknown fraction of the population** โ€” that audit +run was cut short and I read a partial file as complete (it ends mid-list with no +summary line). Your count of 25 is not in conflict with mine; mine was not a count. +The `ADV` verification and the fix's own sweep are unaffected โ€” that sweep ran to +completion and printed its totals. + +## ๐ŸŸก 2026-08-30 โ€” the menu BGM loop point: attempted, NOT captured (superseded above) Your 87.8 s restart and 3.4 s near-silent seam still stand alone. I tried to measure what the game does at `BGM_103`'s loop and **failed on the rig, not the diff --git a/docs/re/data/menu-bgm-loop-measured.txt b/docs/re/data/menu-bgm-loop-measured.txt new file mode 100644 index 00000000..2f7cb958 --- /dev/null +++ b/docs/re/data/menu-bgm-loop-measured.txt @@ -0,0 +1,83 @@ +# The menu BGM loop, measured from the running game. +# +# 2026-08-30. 240 s parked on the main menu, log-verified: the XMA probe shows +# BGM_103's two waves (3 876 864 / 3 930 112 B) decoding, and NO ADV context +# appears afterwards, so the attract loop never took over. +# +# Capture: --gpu=null --apu=alsa --mute=false, ALSA file tee -> paced pulse +# slave. 265.4 s, 6ch f32 48 kHz, 0.08 % all-channel silence (the recipe +# page's own clean run is 0.31 %). +# +# REFERENCE: BGM_103's two waves dumped from sound.pak and decoded, then +# summed -- they play together (bgm-two-stems.md). Each is 87.744 s. +# +# INSTRUMENT: locate a 30 s slice of the capture inside the wave by envelope +# cross-correlation. CONTROL -- slices cut from the wave itself at 10/45/70 s +# are found at 10.00/45.00/70.00 s. +# +# RESULT 1 -- there is NO SEAM. Zero runs >= 0.3 s below (median - 18 dB) in +# 232 s of menu audio. The 3.4 s near-silence the port measured on its own +# authored loop does not occur in the game. +# +# RESULT 2 -- the game does NOT loop at the wave length. +# autocorrelation r at lag 87.750 s = -0.009 (four independent windows: +# -0.0090 / -0.0061 / -0.0067 / -0.0055) +# autocorrelation top lag = 61.909 s, r = +0.533, harmonic at 123.819 s +# +# RESULT 3 -- offset tracking gives the same number independently, and the +# playback is exactly 1:1. +# +# capture_t wave_offset score step + 40.0 14.07 +0.500 + 45.0 19.07 +0.499 +5.00 + 50.0 24.07 +0.497 +5.00 + 55.0 29.07 +0.454 +5.00 + 60.0 34.07 +0.408 +5.00 + 65.0 39.08 +0.420 +5.01 + 70.0 44.08 +0.414 +5.00 + 75.0 49.08 +0.356 +5.00 + 80.0 54.09 +0.347 +5.01 + 85.0 28.13 +0.266 -25.96 <- LOW SCORE: slice straddles the wrap, mis-locked + 90.0 2.16 +0.378 -25.97 + 95.0 7.16 +0.469 +5.00 + 100.0 12.16 +0.486 +5.00 + 105.0 17.16 +0.470 +5.00 + 110.0 22.16 +0.485 +5.00 + 115.0 27.16 +0.433 +5.00 + 120.0 32.17 +0.401 +5.01 + 125.0 37.17 +0.398 +5.00 + 130.0 42.17 +0.425 +5.00 + 135.0 47.17 +0.367 +5.00 + 140.0 52.18 +0.334 +5.01 + 145.0 57.18 +0.331 +5.00 + 150.0 0.25 +0.337 -56.93 + 155.0 5.25 +0.413 +5.00 + 160.0 10.25 +0.453 +5.00 + 165.0 15.25 +0.450 +5.00 + 170.0 20.25 +0.453 +5.00 + 175.0 25.25 +0.439 +5.00 + 180.0 30.26 +0.411 +5.01 + 185.0 35.26 +0.423 +5.00 + 190.0 40.26 +0.427 +5.00 + 195.0 45.26 +0.380 +5.00 + 200.0 50.27 +0.337 +5.01 + 205.0 55.27 +0.368 +5.00 + 210.0 29.31 +0.272 -25.96 <- LOW SCORE: slice straddles the wrap, mis-locked + 215.0 3.35 +0.359 -25.96 + 220.0 8.35 +0.463 +5.00 + 225.0 13.35 +0.437 +5.00 + 230.0 18.35 +0.442 +5.00 + 235.0 23.35 +0.435 +5.00 + +# Playback is exactly +5.00 s per 5 s of wall clock -- 1:1, no resampling. +# The wraps, taken across the two low-score straddle points: +# t=80 54.09 -> t=90 2.16 : 54.09 + 10 - 61.93 = 2.16 +# t=145 57.18 -> t=150 0.25 : 57.18 + 5 - 61.93 = 0.25 +# t=205 55.27 -> t=215 3.35 : 55.27 + 10 - 61.93 = 3.34 +# => LOOP LENGTH 61.93 s, three independent wraps, agreeing with the +# autocorrelation's 61.909 s from a different instrument. +# +# Offsets span 0.25 .. 57.18 s of an 87.744 s wave, so the loop region is +# [~0, 61.93) and the final ~25.8 s of the wave is NEVER PLAYED -- which is +# where bgm-two-stems.md found the fade-out and trailing silence. The game +# loops before the fade, which is why there is no seam. diff --git a/docs/re/structures/menu-bgm-loop-measured.md b/docs/re/structures/menu-bgm-loop-measured.md new file mode 100644 index 00000000..67f077cf --- /dev/null +++ b/docs/re/structures/menu-bgm-loop-measured.md @@ -0,0 +1,96 @@ +# โœ… The menu BGM loops at **61.93 s**, not at the wave's 87.744 s โ€” and there is no seam + +**Classification: measured.** Xenia Canary, 2026-08-30, one boot, **240 s parked on +the main menu**. Replaces the authored loop the port was shipping, and it is wrong +in both directions it could be. + +## Getting there โ€” the log as the screen oracle + +[`menu-bgm-loop-not-yet-captured.md`](menu-bgm-loop-not-yet-captured.md) recorded +why the obvious rig fails: an audio tee plus rendering runs at ~0.20ร— real time and +never reaches the menu. The route written down there was to drop video and let the +**XMA probe** say what screen we are on. It works, and the difference is not +marginal: + +| | video rig | log oracle | +|---|---|---| +| menu reached | **never**, in 378 s | **26.8 s** | +| guest speed | ~0.20ร— | ~0.92ร— | +| capture silence | (broken pipe) | **0.08 %** | + +`BGM_103`'s two waves decoding *is* the menu, and **no `ADV` context appears after +that point**, so the attract loop never took over โ€” the hold is verified, not +assumed. 0.08 % silence is cleaner than the 0.31 % the recipe page records for its +own best run. + +## ๐Ÿ”ด Result 1 โ€” there is no seam + +**Zero** runs of โ‰ฅ0.3 s below (median โˆ’ 18 dB) in 232 s of menu audio. The **3.4 s +near-silence** the port measured is a property of its authored loop, not of the +game. + +## ๐Ÿ”ด Result 2 โ€” it does not loop at the wave length + +| lag | autocorrelation r | +|---|---| +| **87.750 s** (the wave) | **โˆ’0.009** | +| 61.909 s | **+0.533** | +| 123.819 s (2ร—) | +0.269 | + +r at the wave length is **zero**, on four independent windows (โˆ’0.0090 / โˆ’0.0061 / +โˆ’0.0067 / โˆ’0.0055). The estimator recovers known periods exactly on synthetic +controls (87.750 โ†’ 87.750, 60.000 โ†’ 60.000). + +## โœ… Result 3 โ€” a second instrument, and the loop bounds + +`BGM_103`'s two waves were dumped from `sound.pak`, decoded and **summed** (they +play together), then 30 s slices of the capture were located inside that mix by +envelope correlation. **Control: slices cut from the wave itself at 10 / 45 / 70 s +are found at 10.00 / 45.00 / 70.00 s.** + +Playback advances **exactly +5.00 s per 5 s** of wall clock โ€” 1:1, no resampling โ€” +and wraps: + +| | | | +|---|---|---| +| t=80 โ†’ 90 | 54.09 โ†’ 2.16 | 54.09 + 10 โˆ’ **61.93** = 2.16 | +| t=145 โ†’ 150 | 57.18 โ†’ 0.25 | 57.18 + 5 โˆ’ **61.93** = 0.25 | +| t=205 โ†’ 215 | 55.27 โ†’ 3.35 | 55.27 + 10 โˆ’ **61.93** = 3.34 | + +**Loop length 61.93 s**, from three independent wraps, agreeing with the +autocorrelation's 61.909 s produced by a completely different method. + +โš ๏ธ **Two points in the series are mis-locked, and they flag themselves**: the +slices at t=85 and t=210 straddle the wrap, so they half-match two places and +return the **two lowest scores in the table** (0.266, 0.272 against a 0.33โ€“0.50 +field). Read across them; do not fit to them. + +### Where the loop sits in the wave + +Observed offsets span **0.25 โ€ฆ 57.18 s** of an **87.744 s** wave. With a 61.93 s +period the region is **[โ‰ˆ0, 61.93)** โ€” so the final **~25.8 s of the wave is never +played**. + +โœ… **And that explains the missing seam.** [`bgm-two-stems.md`](bgm-two-stems.md) +found that these tracks *fade out and are followed by seconds of silence*, and +concluded that looping the wave as stored "gives a fade-out and six seconds of +nothing every cycle". **The game never reaches the fade.** It loops before it. + +## What the port should do + +* **Loop at 61.93 s, not 87.75.** โš ๏ธ *Measured*, so it is authored on your side โ€” + but it is now authored from an observation rather than from the file's length. +* **Expect no silence at the seam.** If your loop has one, that is your loop. +* Play **both waves summed**, aligned at 0, unchanged. + +## Reach + +โš ๏ธ **One boot, one screen, one bank.** `BGM_103` on the main menu. +โš ๏ธ **61.93 s is the period, and the loop START is inferred** from offsets reaching +0.25 s and the period being 61.93 โ€” not from watching the wrap at 5 s resolution +near zero. A loop of `[0.0, 61.93)` and one of `[0.25, 62.18)` are not separated +here. +โš ๏ธ **Where the value lives is unknown.** `bgm-two-stems.md` says no loop-point field +was found in the *file* header, and that stands โ€” XMA carries loop bounds in the +**decoder context** the game fills at runtime, which is where to look next. Not +looked at. diff --git a/docs/re/structures/voice-region-starts-late.md b/docs/re/structures/voice-region-starts-late.md index 7c8afe3a..ed7f9c47 100644 --- a/docs/re/structures/voice-region-starts-late.md +++ b/docs/re/structures/voice-region-starts-late.md @@ -52,11 +52,28 @@ stable. The stream begins there and something else ends just before it. | region | starts at a boundary | **starts mid-stream** | |---|---|---| -| 1 chunk | **24 / 24** | 0 | -| 3 chunks | 2 | **8 / 10** | +| 1 chunk | 24 / 24 | 0 | +| 3 chunks | 2 | 8 / 10 | -**The defect is specific to the three-stream (multichannel) regions.** Every -single-stream region is fine. +๐Ÿ”ด **THIS POPULATION IS WRONG โ€” the audit run was cut short and I read a partial +file as a complete one.** The port agent counts **25** three-chunk regions where +this table says 10, and it is right to say both numbers cannot describe the same +set. The committed +[`../data/voice-region-start-audit.txt`](../data/voice-region-start-audit.txt) ends +mid-list at `S11A` with **no summary line** โ€” the program never printed its totals, +which is exactly the tell I should have checked and did not. + +**So "8 of 10" is a ratio over an unknown fraction of the population.** What is +*not* affected: the mechanism, the `ADV` clip verified against the decoder, and the +fix's own sweep, which ran to completion and printed its totals (78 / 17 / 0 / 9). +โš ๏ธ But the sentence "the defect is specific to the multichannel regions" rested on +this table and is now **unsupported** โ€” it may still be true; it is not shown here. + +๐Ÿ“Œ This is the same trap the port hit in a `while read` loop the same day (`cargo +run` eating stdin, halving the sample silently) and the one this corpus keeps +paying for: **a silently reduced sample presenting as a complete one.** The defence +that would have caught both is stating population and coverage in the same +sentence, and refusing to read a table whose summary line is missing. โš ๏ธ **The audit's per-movie packet number is an UPPER BOUND, not the clip.** Its stopping rule is "step back until the chunk *count* changes", and `to_xma_riffs` diff --git a/tools/re-capture/menu_bgm_logdriven.py b/tools/re-capture/menu_bgm_logdriven.py new file mode 100755 index 00000000..d2bf2d07 --- /dev/null +++ b/tools/re-capture/menu_bgm_logdriven.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Park on the menu and record it, using the XMA probe log as the screen oracle. + +`menu-bgm-loop-not-yet-captured.md` records why the obvious rig fails: an audio +tee plus rendering runs the guest at ~0.20x real time and never reaches the menu. +`--gpu=null` is ~0.96x and gives a clean capture, but has no video โ€” so the screen +oracle has to come from somewhere else. + +It comes from the decoder. `bgm-two-stems.md` measured that sitting on the main +menu decodes exactly `BGM_103`'s two declared waves, **3 876 864** and +**3 930 112 B**, concurrently. `--xma_param_probe` logs every stream handed to the +decoder, so those two byte_sizes appearing IS the menu โ€” evidence about what is +being *recorded*, which is better provenance for an audio question than a +screenshot ever was. + +Presses are driven off the same log rather than a stopwatch: + * `ADV`'s contexts (1 294 336 / 1 118 208 / 1 171 456) mean the intro is playing + โ†’ one โ’ถ ends it (Q9); + * then โ’ถ again for title โ†’ menu, retried at most RETRIES times; + * the moment `BGM_103` appears, **stop pressing** โ€” โ’ถ on the menu activates a + button and leaves it. + +โš ๏ธ `ADV` reappearing after that would mean the attract loop took over, i.e. we are +not parked. That is checked and reported, not assumed. + + menu_bgm_logdriven.py LOG OUTDIR [hold_s] +""" +import os +import re +import subprocess +import sys +import time + +LOG = sys.argv[1] +OUT = sys.argv[2] +HOLD = float(sys.argv[3]) if len(sys.argv) > 3 else 240 +PAD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pad.py") +ADV = {1_294_336, 1_118_208, 1_171_456} +BGM103 = {3_876_864, 3_930_112} +RETRIES = 4 +SIZE = re.compile(rb"byte_size=(\d+)") + + +def seen(path, since=0): + """All byte_sizes in the log, and the byte offset reached.""" + try: + with open(path, "rb") as f: + f.seek(since) + data = f.read() + return {int(m.group(1)) for m in SIZE.finditer(data)}, since + len(data) + except FileNotFoundError: + return set(), since + + +def tap(): + subprocess.run([sys.executable, PAD, "tap", "A", "0.12"], check=False) + print(f"[{time.time()-T0:7.1f}s] tapped A", flush=True) + + +T0 = time.time() +off, taps, menu_at = 0, 0, None +adv_seen = False +ev = open(f"{OUT}/events.tsv", "w") +ev.write("# t_s\tevent\n") +while time.time() - T0 < 900: + new, off = seen(LOG, off) + if new & ADV and not adv_seen: + adv_seen = True + ev.write(f"{time.time()-T0:.2f}\tADV decoding (intro movie)\n"); ev.flush() + print(f"[{time.time()-T0:7.1f}s] ADV contexts seen โ€” intro is playing", flush=True) + time.sleep(3) + tap(); taps += 1 + continue + if new & BGM103 and menu_at is None: + menu_at = time.time() + ev.write(f"{menu_at-T0:.2f}\tBGM_103 decoding (MENU)\n"); ev.flush() + print(f"[{menu_at-T0:7.1f}s] BGM_103 contexts โ€” ON THE MENU. holding {HOLD}s, no more input", flush=True) + if menu_at is None and adv_seen and taps <= RETRIES and (time.time() - T0) % 25 < 0.6: + tap(); taps += 1 + time.sleep(1) + if menu_at and time.time() - menu_at > HOLD: + print(f"[{time.time()-T0:7.1f}s] hold complete", flush=True) + break + time.sleep(0.5) +if menu_at is None: + print("NEVER REACHED THE MENU (BGM_103 never decoded)", flush=True) +else: + late, _ = seen(LOG, 0) + ev.write(f"{time.time()-T0:.2f}\tend; taps={taps}\n") +print("taps:", taps, flush=True) +ev.close()