diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index 70e20afd..3e3177ce 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -175,7 +175,10 @@ authored version can be deleted. (`0x0ec0 + 4096 = 0x1ec0`) — the bank is a packed run of whole 2 048-byte packets with no delimiters, which is why nothing could be scanned for. ⚠️ The order is **not** cue-id order, so the index must be observed per cue, not - counted. ❔ Decoding a located slice to PCM has not been attempted. + counted. ✅ **The slices decode**: 0.533 s, 0.344 s and 1.016 s of mono 48 kHz + audio with the attack-and-decay shape of UI blips, via + `tools/re-capture/slb_extract_wave.py` — whose wrapper reproduces a known-good + `BGM_001` decode to the same 173.808875 s, so it is verified, not assumed. * **The boot sequence is not data-driven — the port authors it.** ❔ Four places were checked and the order is in none: `config.ini`'s `[SYSTEM]` is empty, the @@ -256,7 +259,6 @@ here until 2026-08-28 and is now settled.) | | what | why it is stuck | |---|---|---| | 🟡 | **cue NAME → event binding** (Q8) | the event→**wave** binding is now measured; that the cursor's wave is the cue *named* `SE_UI_CURSOR` is still read off the authors' identifiers | -| ❔ | **decoding a located `Static.slb` slice to PCM** (Q8) | offsets and packet counts are in hand; the XMA1 `RIFF` wrapper for a mono slice was not built | | 🟡 | **the paint-order tie-break** (Q3) | eight candidates refuted; costs one element's blend on one screen | | 🟡 | **GamePart ids behind the buttons** (Q4) | the *screens* are measured; the ids are a name match onto the executable's class names | | ❔ | **the boot transitions in code** (Q6) | bounded as code-not-data, not proven. `sub_821C6458` — the substantial function in `GamePart_Title`'s neighbourhood — has **not been read** | diff --git a/docs/re/data/se-cue-runtime-offsets.txt b/docs/re/data/se-cue-runtime-offsets.txt index d2bd2ec8..027fc721 100644 --- a/docs/re/data/se-cue-runtime-offsets.txt +++ b/docs/re/data/se-cue-runtime-offsets.txt @@ -17,3 +17,15 @@ Simultaneously at the main menu, two STEREO 48 kHz streams were decoding: 1893 packets / 3 876 864 B and 1919 packets / 3 930 112 B. See structures/bgm-two-stems.md -- this is the runtime observation that the two stems of a music bank play at the same time. + +DECODED 2026-08-28 with tools/re-capture/slb_extract_wave.py + ffmpeg's xma1: + + event offset packets decoded rms peak envelope shape + ---------------- -------- ------- --------- ----- ----- ------------------------ + d-pad move 0x1ec0 4 0.533 s 2085 29813 sharp attack, monotonic decay + B / back 0x0ec0 2 0.344 s 2985 16973 attack, peak in frame 2 + (pre-input cue) 0x5d6c0 6 1.016 s 4327 32767 peaks in frame 3, long decay + +CONTROL: the same synthesized RIFF wrapper applied to BGM_001.slb's first wave +(offset 14336, stereo) decodes to 173.808875 s -- identical to the duration +obtained from that bank's own on-disc RIFF header. The wrapper is correct. diff --git a/docs/re/menu-audio-cues.md b/docs/re/menu-audio-cues.md index 70be63c3..351eb255 100644 --- a/docs/re/menu-audio-cues.md +++ b/docs/re/menu-audio-cues.md @@ -182,5 +182,37 @@ So the index cannot be derived by counting; it has to be observed per cue. * 🟡 **still a name match** — that the cursor's wave *is the cue called* `SE_UI_CURSOR`. The event→wave binding is measured; the event→*name* binding is still read off the authors' identifiers. -* ❔ **not done** — decoding a located slice to PCM. The offsets and packet counts - are here; building the XMA1 `RIFF` around a mono slice was not attempted. +* ✅ **decoded to PCM** — see below. The cues play. + +### ✅ The waves decode — artifact, not assertion + +[`tools/re-capture/slb_extract_wave.py`](../../tools/re-capture/slb_extract_wave.py) +wraps a `(bank, offset, packet count, channels, rate)` slice in a synthesized +XMA1 `RIFF` for ffmpeg's `xma1` decoder: + +``` +slb_extract_wave.py Static.slb 0x1ec0 4 # -> Static_0x1ec0.riff -> 0.533 s +``` + +| event | offset | packets | decodes to | RMS | peak | envelope | +|---|---|---|---|---|---|---| +| d-pad move | `0x1ec0` | 4 | **0.533 s** | 2 085 | 29 813 | sharp attack, monotonic decay | +| Ⓑ back | `0x0ec0` | 2 | **0.344 s** | 2 985 | 16 973 | attack, peak in frame 2 | +| pre-input cue | `0x5d6c0` | 6 | **1.016 s** | 4 327 | 32 767 | peaks in frame 3, long decay | + +All three are mono 48 kHz, audible from sample 0, and have the percussive +attack-and-decay shape of UI blips — not silence, not noise. + +**Control, run first:** the *same* synthesized wrapper applied to `BGM_001.slb`'s +first wave decodes to **173.808875 s** — identical to the duration obtained from +that bank's own on-disc `RIFF` header in +[`structures/bgm-two-stems.md`](structures/bgm-two-stems.md). The wrapper is not +approximately right; it reproduces a known-good decode exactly. + +The bitrates are consistent too: 8 192 B / 0.533 s ≈ 15.4 kB/s, 4 096 / 0.344 ≈ +11.9 kB/s, 12 288 / 1.016 ≈ 12.1 kB/s — mono at roughly half the stereo BGM rate. + +🟡 **The decoded audio is deliberately not committed.** Three commands regenerate +it from the disc, and the corpus keeps measurements and tooling rather than +extracted game audio. The offsets, packet counts and this tool are the +deliverable. diff --git a/tools/re-capture/slb_extract_wave.py b/tools/re-capture/slb_extract_wave.py new file mode 100755 index 00000000..21404bcf --- /dev/null +++ b/tools/re-capture/slb_extract_wave.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Extract one wave out of a `.slb` bank as a standalone XMA1 RIFF. + +For banks with no internal delimiters -- `Static.slb`, which is a packed run of +whole 2048-byte XMA packets -- a wave is defined only by (offset, packet count). +Those come from the running game: launch Canary with `--xma_param_probe=true`, +trigger the sound, and the log prints the stream's packet count and first 32 +bytes; search those bytes in the bank to get the offset. +See docs/re/menu-audio-cues.md. + + slb_extract_wave.py [channels] [rate] [out.riff] + slb_extract_wave.py Static.slb 0x1ec0 4 # the d-pad cursor cue + +Decode with: ffmpeg -i out.riff out.wav +""" +import os, struct, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +exec(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), + "slb_segment_phase.py")).read().split("def cmd_phases")[0]) + +XMA1_PACKET = 2048 + +def synth_fmt(channels, mask, rate): + """The 32-byte XMAWAVEFORMAT ffmpeg's `xma1` decoder wants.""" + f = b"fmt " + struct.pack(" 3 else 1 + rate = int(a[4]) if len(a) > 4 else 48000 + out = a[5] if len(a) > 5 else f"{name.split('.')[0]}_{off:#x}.riff" + disc = os.environ.get("SYLPHEED_DISC", "/work/sylph_extract") + pak = Pak(os.path.join(disc, "dat", "sound.pak")) + i = pak.find(name) + if i is None: + print(f"{name}: not in sound.pak"); return 2 + b = pak.read(i) + data = b[off:off + pkts * XMA1_PACKET] + if len(data) < pkts * XMA1_PACKET: + print(f"warning: short read -- {len(data)} of {pkts * XMA1_PACKET} bytes") + open(out, "wb").write(riff(synth_fmt(ch, 1 if ch == 1 else 2, rate), data)) + print(f"{out}: {len(data)} bytes, {pkts} packets, {ch}ch {rate}Hz") + return 0 + +if __name__ == "__main__": + raise SystemExit(main())