diff --git a/crates/sylpheed-formats/examples/bank_streams.rs b/crates/sylpheed-formats/examples/bank_streams.rs new file mode 100644 index 00000000..40d6c8e2 --- /dev/null +++ b/crates/sylpheed-formats/examples/bank_streams.rs @@ -0,0 +1,40 @@ +//! List a sound bank's streams and their declared rates. +//! +//! cargo run -p sylpheed-formats --example bank_streams -- 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 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}"), + } + } +} diff --git a/crates/sylpheed-formats/examples/find_stream_by_size.rs b/crates/sylpheed-formats/examples/find_stream_by_size.rs new file mode 100644 index 00000000..7a58ad89 --- /dev/null +++ b/crates/sylpheed-formats/examples/find_stream_by_size.rs @@ -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 -- … +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 …"); + let wanted: Vec = 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 = 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(®istry); + let name_of: std::collections::HashMap = + 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)"); +} diff --git a/crates/sylpheed-formats/examples/name_from_hash.rs b/crates/sylpheed-formats/examples/name_from_hash.rs new file mode 100644 index 00000000..11168461 --- /dev/null +++ b/crates/sylpheed-formats/examples/name_from_hash.rs @@ -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 -- … +use sylpheed_formats::hash::name_hash; + +fn main() { + let wanted: Vec = 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"); +} diff --git a/docs/re/bgm-102-decoded-during-boot.md b/docs/re/bgm-102-decoded-during-boot.md new file mode 100644 index 00000000..a9186642 --- /dev/null +++ b/docs/re/bgm-102-decoded-during-boot.md @@ -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 %).