re: the menu BGM loops at 61.93 s, and the game never reaches the fade

240 s parked on the main menu, reached by using the XMA probe log as the screen
oracle instead of video -- the route the previous iteration wrote down. Menu in
26.8 s against never-in-378 s for the video rig, guest at 0.92x, capture at
0.08 % silence against the recipe page's own best of 0.31 %. BGM_103's contexts
verify the screen and no ADV context appears afterwards, so the attract loop
never took over.

Three results, two instruments.

NO SEAM: zero runs >= 0.3 s below median-18 dB in 232 s. The port's 3.4 s
near-silence is a property of its authored loop, not of the game.

NOT THE WAVE LENGTH: autocorrelation r at 87.750 s is -0.009 on four independent
windows; the top lag is 61.909 s with a 2x harmonic. Estimator controls recover
87.750 and 60.000 exactly.

61.93 s, INDEPENDENTLY: locating 30 s slices of the capture inside the decoded
summed waves shows playback advancing exactly +5.00 s per 5 s and wrapping at
61.93, from three wraps. Control: slices cut from the wave itself at 10/45/70 s
are found at 10.00/45.00/70.00. Two points mis-lock where the slice straddles a
wrap and they carry the two lowest scores in the table.

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 -- exactly 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.

Also corrects my own '8 of 10 three-chunk regions start mid-stream'. The port
counts 25 three-chunk regions; it is right that both numbers cannot describe the
same set. My audit run was CUT SHORT -- the committed file ends mid-list with no
summary line -- so that was a ratio over an unknown fraction of the population,
and the claim that the defect is specific to multichannel regions is now
unsupported. The ADV verification and the fix's own sweep are unaffected; that
sweep ran to completion and printed its totals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
This commit is contained in:
sylph-decoder
2026-08-30 09:34:42 +00:00
parent 57035a700b
commit fa832fad1e
6 changed files with 347 additions and 5 deletions

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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.330.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.

View File

@@ -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`

View File

@@ -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()