re: the two unexplained XMA streams are BGM_102, and BGM_103 s sizes survive

Closes the open question left by the take-2 audio capture, where the probe
logged five streams on one boot when only ADV s three were accounted for.

Both unexplained sizes are whole packet counts, 562 and 620. Searching every
inter-descriptor span of the voice stream and every sound.pak entry large enough
finds zero hits in the voice stream and ONE entry carrying both -- hash
9799c546, which candidate enumeration recovers as BGM_102.slb, two streams of
1150976 and 1269760 B. One entry holding both sizes is the two-stem shape rather
than two coincidental matches. So the boot s five streams were ADV s three voice
streams plus one music bank s two stems, and nothing is unaccounted for.

What it does not establish is which screen it belongs to. The window ran from
launch to t=253 s with the title arriving at 262, so BGM_102 was decoded
somewhere inside a launch-to-just-before-title window -- but the probe fires on
first decode and its lines carry a thread id rather than a timestamp, so a title
BGM decoded moments before the title appears is equally consistent with the
evidence. Cue 1103 is already the main menu, which makes 1102 as the title at
least suggestive. The settling experiment is written down and not done.

Refutation attempt on HANDOFF s BGM_103 wave sizes: exact match on both
(3876864 / 3930112). The claim survives unchanged.

Also a third route to two-stems-of-identical-duration, from the XMA1 header
alone now that PsuedoBytesPerSec is read correctly: BGM_102 37.487/37.487,
BGM_103 87.750/87.749, BGM_001 173.821/173.821. The one apparent disagreement
resolves in the corpus s favour -- BGM_001 reads 173.821 here against the port s
decoded 167.663, a gap of 6.158 s, and HANDOFF already records 6.15 s of trailing
silence after its fade-out. Declared duration covers the encoded stream including
that silence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
This commit is contained in:
sylph-decoder
2026-08-29 16:16:13 +00:00
parent b047788d37
commit 05d278173d
4 changed files with 299 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
//! List a sound bank's streams and their declared rates.
//!
//! cargo run -p sylpheed-formats --example bank_streams -- <disc> BGM_102.slb …
use sylpheed_formats::media::{self, DirectorySource};
use sylpheed_formats::{hash::name_hash, slb};
fn main() {
let mut a = std::env::args().skip(1);
let disc = a.next().expect("usage: bank_streams <disc> NAME.slb…");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
for name in a {
let h = name_hash(&name);
match media::read_sound_bank(&src, h) {
Ok(bytes) => {
let riffs = slb::to_xma_riffs(&bytes);
println!(
"{name} (hash {h:08x}, {} B on disc) header {:?} -> {} stream(s)",
bytes.len(),
slb::bank_header_len(&bytes),
riffs.len()
);
for (i, r) in riffs.iter().enumerate() {
let rate = if r.len() >= 0x28 {
u32::from_le_bytes(r[0x20..0x24].try_into().unwrap())
} else {
0
};
let payload = r.len() - 60;
println!(
" stream {i}: payload {payload} B ({} packets) declared {rate} B/s \
=> {:.3} s",
payload / 2048,
if rate > 0 { payload as f64 / rate as f64 } else { 0.0 }
);
}
}
Err(e) => println!("{name}: {e}"),
}
}
}

View File

@@ -0,0 +1,123 @@
//! Which cue owns an XMA stream of a given payload size?
//!
//! A boot with `--xma_param_probe` logs each decoded stream's `byte_size`. Three
//! of the five on the take-2 `ADV` boot are that movie's own streams; two —
//! 1 150 976 and 1 269 760 B — belong to something unidentified. The probe gives
//! a size and nothing else, so the disc has to be asked which cue has a stream
//! that long.
//!
//! Searches every inter-descriptor span of the continuous voice stream, and
//! every `sound.pak` entry, for a stream whose payload matches.
//!
//! cargo run -p sylpheed-formats --example find_stream_by_size -- <disc> <bytes>…
use sylpheed_formats::media::{DirectorySource, DiscSource};
use sylpheed_formats::{slb, PakArchive};
const DESC_MARK: u32 = 0x11;
const DESC_REPEAT: usize = 0x800;
const ID_MAX: u32 = 0x1_0000;
fn all_descriptors(buf: &[u8]) -> Vec<(usize, u32)> {
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
let mut out = Vec::new();
if buf.len() < DESC_REPEAT + 8 {
return out;
}
let end = buf.len() - (DESC_REPEAT + 4);
let mut o = 0;
while o <= end {
let id = be(o);
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
out.push((o, id));
}
o += 4;
}
out
}
fn main() {
let mut args = std::env::args().skip(1);
let disc = args.next().expect("usage: find_stream_by_size <disc> <bytes>…");
let wanted: Vec<usize> = args.filter_map(|a| a.parse().ok()).collect();
assert!(!wanted.is_empty(), "give at least one payload size");
// A `to_xma_riffs` chunk is the payload plus a 60-byte RIFF wrapper.
let want_riff: Vec<usize> = wanted.iter().map(|w| w + 60).collect();
println!("looking for payloads {wanted:?} (riff sizes {want_riff:?})\n");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
// --- 1. the continuous movie-voice stream, span by span
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let marker = "eng\\Movie\\VOICE_ADV.slb";
let registry = tpak
.entries()
.iter()
.find_map(|e| {
tpak.read(e)
.ok()
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
})
.expect("registry");
let ids = sylpheed_formats::movie_voice::registry_voice_ids(&registry);
let name_of: std::collections::HashMap<u32, String> =
ids.iter().map(|(n, &i)| (i, n.clone())).collect();
let win_start: u64 = 421_739_888 & !3;
let buf = src
.read_segment_range("dat/sound", win_start, 116_300_000)
.expect("window");
let descs = all_descriptors(&buf);
println!("voice stream: {} descriptors", descs.len());
let mut hits = 0;
for w in descs.windows(2) {
let (a, b) = (w[0].0, w[1].0);
if b <= a || b - a < 4096 {
continue;
}
for (i, r) in slb::to_xma_riffs(&buf[a..b]).iter().enumerate() {
if want_riff.contains(&r.len()) {
let name = name_of
.get(&w[1].1)
.cloned()
.unwrap_or_else(|| format!("id{}", w[1].1));
println!(
" ✅ cue {name} (id {}) stream {i}: payload {} B",
w[1].1,
r.len() - 60
);
hits += 1;
}
}
}
println!(" {hits} hit(s) in the voice stream\n");
// --- 2. every sound.pak entry
let stoc = src.read_file("dat/sound.pak").expect("sound.pak toc");
let entries = PakArchive::parse_toc(&stoc).expect("toc");
println!("sound.pak: {} entries", entries.len());
let mut phits = 0;
let mut scanned = 0usize;
for e in &entries {
// Only entries big enough to hold the target.
let need = wanted.iter().copied().min().unwrap_or(0) as u32;
if e.comp_size < need {
continue;
}
let Ok(bytes) = src.read_segment_range("dat/sound", e.offset as u64, e.comp_size as usize)
else {
continue;
};
scanned += 1;
for (i, r) in slb::to_xma_riffs(&bytes).iter().enumerate() {
if want_riff.contains(&r.len()) {
println!(
" ✅ sound.pak entry hash {:08x} offset {} size {} — stream {i}: payload {} B",
e.name_hash,
e.offset,
e.comp_size,
r.len() - 60
);
phits += 1;
}
}
}
println!(" scanned {scanned} entries large enough; {phits} hit(s)");
}

View File

@@ -0,0 +1,46 @@
//! Recover a `sound.pak` TOC name from its hash by generating candidates.
//!
//! The hash is a Barrett-reduction over the uppercased path, so it cannot be
//! inverted — but the naming is regular enough to enumerate. Tries the shapes
//! this disc actually uses for sound entries.
//!
//! cargo run -p sylpheed-formats --example name_from_hash -- <hex-hash>…
use sylpheed_formats::hash::name_hash;
fn main() {
let wanted: Vec<u32> = std::env::args()
.skip(1)
.filter_map(|a| u32::from_str_radix(a.trim_start_matches("0x"), 16).ok())
.collect();
assert!(!wanted.is_empty(), "give hashes in hex");
let langs = ["eng", "jpn", ""];
let dirs = ["", "Movie", "etc", "Voice", "Sound", "BGM", "bgm", "se", "SE"];
let mut tried = 0usize;
let mut check = |name: String, tried: &mut usize| {
*tried += 1;
let h = name_hash(&name);
if wanted.contains(&h) {
println!("{h:08x} {name}");
}
};
for l in langs {
for d in dirs {
let pre = match (l.is_empty(), d.is_empty()) {
(true, true) => String::new(),
(true, false) => format!("{d}\\"),
(false, true) => format!("{l}\\"),
(false, false) => format!("{l}\\{d}\\"),
};
for stem in ["BGM", "bgm", "JNGL", "jngl", "SE", "Static", "VOICE"] {
for n in 0..1200u32 {
check(format!("{pre}{stem}_{n:03}.slb"), &mut tried);
check(format!("{pre}{stem}{n:03}.slb"), &mut tried);
}
}
for bare in ["Static.slb", "static.slb", "SE.slb", "BGM.slb"] {
check(format!("{pre}{bare}"), &mut tried);
}
}
}
println!("tried {tried} candidate names");
}

View File

@@ -0,0 +1,90 @@
# ✅ The two unexplained XMA streams are `BGM_102.slb` — and the corpus's `BGM_103` sizes survive a check
**Classification: decoded** for the identification (the bank, plus a disc-wide
search); **measured** for the fact that it was decoded during a boot.
Closes the ❔ left by the take-2 audio capture, where
[`audio-capture-channel-map-trap.md`](audio-capture-channel-map-trap.md) recorded
that `--xma_param_probe` logged **five** distinct streams on one boot when only
`ADV`'s three were accounted for.
## The identification
The probe gives a `byte_size` and nothing else, so the disc was asked which cue
owns a stream that long. Both unexplained sizes are whole packet counts —
1 150 976 = 562 packets, 1 269 760 = 620 — and
`--example find_stream_by_size` searched every inter-descriptor span of the
continuous voice stream **and** every `sound.pak` entry large enough:
| | |
|---|---|
| hits in the movie-voice stream | **0** |
| hits in `sound.pak` | one entry carrying **both**: hash `9799c546` |
One entry holding both sizes is the two-stem shape, not a coincidence of two
separate matches. The hash recovers by candidate enumeration
(`--example name_from_hash`) to **`BGM_102.slb`**.
```
BGM_102.slb 2 445 760 B on disc, header 10 240 -> 2 streams
stream 0: 1 150 976 B (562 packets) declared 30 703 B/s => 37.487 s
stream 1: 1 269 760 B (620 packets) declared 33 872 B/s => 37.487 s
```
✅ So the boot's five streams were **`ADV`'s three voice streams plus one music
bank's two stems**, and nothing is unaccounted for.
## 🟡 What it does NOT establish: which screen it belongs to
The capture window ran from process launch to **t = 253 s**, and its screen log
reads movie/attract throughout, with the title arriving at t = 262 s — *after*
the recording ended. So `BGM_102` was decoded somewhere inside a
launch-to-just-before-title window.
⚠️ **That is not enough to call it the attract music.** The probe fires on *first
decode* and its log lines carry a thread id, not a timestamp, so nothing here
says *when* in those 253 s it started — and a title BGM being decoded moments
before the title appears is exactly as consistent. The numbering makes that a
live hypothesis rather than a remote one: the corpus already has the **main
menu** on cue **1103**`BGM_103`, so **1102** sitting one below it is at least
suggestive of the title.
**The experiment that would settle it** is cheap and is not done: put a
wall-clock timestamp on the probe line (or bound the run so it stops before the
title) and compare against the screen log the capture already produces.
## 🟢 Refutation attempt — HANDOFF's `BGM_103` wave sizes. It SURVIVED.
HANDOFF asserts the menu's music is `BGM_103` partly on *"`BGM_103.slb`'s two
declared waves (3 876 864 / 3 930 112 B)"*. Read off the disc:
```
BGM_103.slb 7 841 292 B, header 10 240 -> 2 streams
stream 0: 3 876 864 B declared 44 181 B/s => 87.750 s
stream 1: 3 930 112 B declared 44 788 B/s => 87.749 s
```
**Exact, both.** The claim stands unchanged.
## ✅ And a third route to "two stems of identical duration"
[`bgm-two-stems`](structures/bgm-two-stems.md) established equal duration by
decoding. The XMA1 `PsuedoBytesPerSec` fix
([`voice-region-leading-chunk.md`](structures/voice-region-leading-chunk.md))
gives the same answer from the header alone, on three banks:
| bank | stem 0 | stem 1 |
|---|---|---|
| `BGM_102` | 37.487 s | 37.487 s |
| `BGM_103` | 87.750 s | 87.749 s |
| `BGM_001` | 173.821 s | 173.821 s |
⚠️ **And the one apparent disagreement resolves in the corpus's favour.**
`BGM_001` reads **173.821 s** here, against the **167.663 s** the port measured by
decoding. The gap is **6.158 s** — and HANDOFF already records that `BGM_001`
"fades out at 167.663 s and is followed by **6.15 s of silence**". The declared
duration covers the encoded stream *including* its trailing silence; the decoded
figure is where the audio stops. The two are consistent, and neither is wrong.
That is a genuine cross-check of the declared-rate method against an independent
decode, and it is the second one (the first was `ADV`, agreeing to 0.02 %).