From 2dcc6e9710a4263eda433247fb87b7a5699269b4 Mon Sep 17 00:00:00 2001 From: ReaperOfVeriod Date: Sun, 11 Oct 2026 02:15:33 +0200 Subject: [PATCH] Parse the sound bank container framing A level's sound extent is two regions: an `SBlk` descriptor table and the sample data that follows it. Four u32 at the head describe the layout, and the invariant that establishes it is sblk_offset + sblk_size == daw_offset, which holds on all 27 levels. That is what pins the field meanings; the numbers alone are ambiguous about which of them is the offset. DAW is a format tag inside the SBlk header, not a region signature. It occurs exactly once in the entire extent, at sblk_offset + 12, naming the sample format -- the sample data itself starts at daw_offset with no signature at all, roughly 19 KB away on level 0. Reading it as a region magic is the obvious reading and it is wrong. daw_size never reaches the end of the file. The tail is positive on all 27 levels, 232 to 1,848 bytes, so the field describes less than the region it names; the test asserts that direction rather than inventing a size that fits. 523,720 SBlk and 37,649,008 DAW bytes across the 27 levels. The sample format inside DAW is not decoded. It looks like VAG/ADPCM from the nibble packing, and it has no bearing on drawing anything, so it stays open rather than getting a half-answer. --- crates/gc-extract/tests/disc.rs | 66 +++++++++ crates/gc-formats/src/lib.rs | 1 + crates/gc-formats/src/sound_bank.rs | 202 ++++++++++++++++++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 crates/gc-formats/src/sound_bank.rs diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index f904d10..e76064d 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -1393,3 +1393,69 @@ fn occlusion_grids_parse_on_every_level() { widest unexplained tail {widest_slack} bytes" ); } + +/// The sound extent framing. +/// +/// The invariant here is `sblk_offset + sblk_size == daw_offset`, which holds +/// on all 27 levels and pins the meaning of the four head fields. `DAW` never +/// reaches the end of the file; the tail is positive on every level, so the +/// size field is not the whole region and this test asserts the direction +/// rather than a size. +#[test] +fn sound_bank_framing_holds_on_every_level() { + require_disc!(); + let data = std::env::var_os("GC_EXTRACTED") + .map(PathBuf::from) + .unwrap_or_else(|| { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + PathBuf::from(home).join("Documents/gc-extracted") + }); + if !data.exists() { + eprintln!("skipped: unpack the disc first"); + return; + } + + use gc_formats::sound_bank::parse_sound_bank; + let mut levels = 0usize; + let mut sblk_bytes = 0usize; + let mut daw_bytes = 0usize; + let (mut narrowest, mut widest) = (usize::MAX, 0usize); + + for n in 0..27 { + let raw = match std::fs::read(data.join(format!("level{n}")).join("sound_bank.bin")) { + Ok(v) => v, + Err(_) => continue, + }; + let s = parse_sound_bank(&raw).unwrap_or_else(|e| panic!("level{n}: sound bank: {e}")); + + assert_eq!(s.header.version, 3, "level{n}: sound bank version"); + assert_eq!(s.header.unknown_4, 2, "level{n}: sound bank unknown_4"); + assert_eq!( + s.header.sblk_offset, 24, + "level{n}: SBlk sits at 24 on every level" + ); + assert!( + s.header.sblk_offset + s.header.sblk_size == s.header.daw_offset, + "level{n}: SBlk must run into DAW with no gap" + ); + assert!(s.has_sblk_magic(), "level{n}: SBlk magic"); + assert!(s.has_daw_tag(), "level{n}: DAW tag at SBlk+12"); + assert_eq!(s.sblk_version(), Some(1), "level{n}: SBlk version"); + // The tail is always in the same direction: the extent is longer than + // the DAW region it describes. + narrowest = narrowest.min(s.daw_tail); + widest = widest.max(s.daw_tail); + + sblk_bytes += s.sblk.len(); + daw_bytes += s.daw.len(); + levels += 1; + } + + assert_eq!(levels, 27, "expected all 27 levels, got {levels}"); + assert!(sblk_bytes > 300_000, "only {sblk_bytes} SBlk bytes"); + assert!(daw_bytes > 20_000_000, "only {daw_bytes} DAW bytes"); + eprintln!( + "sound_bank: {sblk_bytes} SBlk + {daw_bytes} DAW bytes across {levels} levels; \ + unexplained tail {narrowest}..{widest} bytes" + ); +} diff --git a/crates/gc-formats/src/lib.rs b/crates/gc-formats/src/lib.rs index 5f895e9..b139b27 100644 --- a/crates/gc-formats/src/lib.rs +++ b/crates/gc-formats/src/lib.rs @@ -5,6 +5,7 @@ pub mod level_core; pub mod level_data; pub mod level_wad; pub mod occlusion; +pub mod sound_bank; pub mod texture; pub mod tfrag; pub mod tfrag_light; diff --git a/crates/gc-formats/src/sound_bank.rs b/crates/gc-formats/src/sound_bank.rs new file mode 100644 index 0000000..ccc44b4 --- /dev/null +++ b/crates/gc-formats/src/sound_bank.rs @@ -0,0 +1,202 @@ +//! The level's sound bank: container framing. +//! +//! Spec: `docs/formats/sound-bank.md`. +//! +//! A level's sound extent holds two regions. `SBlk` is a table of short sound +//! descriptors; the sample data follows at `daw_offset`. The framing is four +//! `u32` at the head, and the invariant that establishes it is simple: +//! **`sblk_offset + sblk_size == daw_offset` on every level**. +//! +//! `DAW` is a format tag inside the `SBlk` header, not a region signature. It +//! appears exactly once in the whole extent, at `sblk_offset + 12`, nowhere +//! near the sample data it names. +//! +//! The audio sample format inside `DAW` is not decoded here. That is a +//! separate body of work with no bearing on drawing anything, and the framing +//! is the part every other format's "what is in this level" questions depend +//! on. + +use crate::buf::{Buf, Error, ErrorKind, Result}; + +/// Offset of the `SBlk` signature on every level measured. +pub const SBLK_SIGNATURE_AT: usize = 24; +/// The head of a sound extent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SoundBankHeader { + /// Always 3 on all 27 levels. + pub version: u32, + /// Always 2 on all 27 levels. + pub unknown_4: u32, + /// Byte offset of the `SBlk` region; 24 on every level. + pub sblk_offset: u32, + /// Size of the `SBlk` region. + pub sblk_size: u32, + /// Byte offset of the `DAW` region. + pub daw_offset: u32, + /// Size of the `DAW` region. + /// + /// This never reaches the end of the file: the extent is 232 to 1,848 bytes + /// longer than `daw_offset + daw_size` on every level, and always longer + /// rather than shorter. Whatever follows is not accounted for by this field. + pub daw_size: u32, +} + +/// The two regions of a sound extent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SoundBank<'a> { + pub header: SoundBankHeader, + /// The `SBlk` descriptor table, including its leading `SBlk` magic. + pub sblk: &'a [u8], + /// The `DAW` sample region, including its leading `DAW` magic. + pub daw: &'a [u8], + /// Bytes after `daw_offset + daw_size`. + pub daw_tail: usize, +} + +impl SoundBankHeader { + /// Parse the 0x18-byte head. + pub fn parse(bytes: &[u8]) -> Result { + let mut b = Buf::new(bytes); + Ok(SoundBankHeader { + version: b.u32("sound bank version")?, + unknown_4: b.u32("sound bank unknown_4")?, + sblk_offset: b.u32("SBlk offset")?, + sblk_size: b.u32("SBlk size")?, + daw_offset: b.u32("DAW offset")?, + daw_size: b.u32("DAW size")?, + }) + } +} + +/// Split a sound extent into its two regions. +/// +/// Bounds are checked before slicing; a level whose head points outside its own +/// extent is an error, not a panic. +pub fn parse_sound_bank(bytes: &[u8]) -> Result> { + let header = SoundBankHeader::parse(bytes)?; + let sblk_end = header + .sblk_offset + .checked_add(header.sblk_size) + .filter(|&e| e <= bytes.len() as u32) + .ok_or(Error { + offset: header.sblk_offset as usize, + context: "SBlk region runs past the extent", + kind: ErrorKind::OutOfBounds { + needed: header.sblk_size as usize, + available: bytes.len(), + }, + })? as usize; + let daw_end = header + .daw_offset + .checked_add(header.daw_size) + .filter(|&e| e <= bytes.len() as u32) + .ok_or(Error { + offset: header.daw_offset as usize, + context: "DAW region runs past the extent", + kind: ErrorKind::OutOfBounds { + needed: header.daw_size as usize, + available: bytes.len(), + }, + })? as usize; + let sblk = &bytes[header.sblk_offset as usize..sblk_end]; + let daw = &bytes[header.daw_offset as usize..daw_end]; + Ok(SoundBank { + header, + sblk, + daw, + daw_tail: bytes.len().saturating_sub(daw_end), + }) +} + +impl SoundBank<'_> { + /// The `SBlk` magic, as measured. + pub fn has_sblk_magic(&self) -> bool { + self.sblk.starts_with(b"SBlk") + } + + /// The `DAW` tag, which lives inside the `SBlk` header rather than at the + /// start of the sample data. + /// + /// Worth being explicit about because it reads like a region signature: + /// `DAW` occurs exactly once in the entire extent, at `sblk_offset + 12`, + /// naming the sample format. The sample data itself begins at `daw_offset` + /// with no signature at all, and the two are nowhere near each other. + pub fn has_daw_tag(&self) -> bool { + self.sblk.get(12..15) == Some(b"DAW") + } + + /// `SBlk` version, at `+4` past its signature; 1 on every level. + pub fn sblk_version(&self) -> Option { + self.sblk + .get(4..8) + .and_then(|b| b.try_into().ok()) + .map(u32::from_le_bytes) + } + + /// `SBlk` entry count, at `+8` past its signature. + pub fn sblk_entries(&self) -> Option { + self.sblk + .get(8..12) + .and_then(|b| b.try_into().ok()) + .map(u32::from_le_bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A bank whose `SBlk` region runs straight into its `DAW` region. + fn bytes() -> Vec { + let mut v = vec![0u8; 0x100]; + v[0x00..0x04].copy_from_slice(&3u32.to_le_bytes()); + v[0x04..0x08].copy_from_slice(&2u32.to_le_bytes()); + v[0x08..0x0c].copy_from_slice(&24u32.to_le_bytes()); + v[0x10..0x14].copy_from_slice(&64u32.to_le_bytes()); + // sblk_size chosen so 24 + size == 64. + v[0x0c..0x10].copy_from_slice(&40u32.to_le_bytes()); + v[0x14..0x18].copy_from_slice(&32u32.to_le_bytes()); + v[24..28].copy_from_slice(b"SBlk"); + v[28..32].copy_from_slice(&1u32.to_le_bytes()); + v[32..36].copy_from_slice(&4u32.to_le_bytes()); + v[36..39].copy_from_slice(b"DAW"); + v + } + + #[test] + fn splits_the_two_regions() { + let b = bytes(); + let s = parse_sound_bank(&b).unwrap(); + assert_eq!(s.sblk.len(), 40); + assert_eq!(s.daw.len(), 32); + assert!(s.has_sblk_magic()); + assert!(s.has_daw_tag()); + assert_eq!(s.sblk_version(), Some(1)); + assert_eq!(s.sblk_entries(), Some(4)); + assert_eq!(s.daw_tail, 0x100 - 96); + } + + #[test] + fn the_regions_tile_the_head() { + let b = bytes(); + let s = parse_sound_bank(&b).unwrap(); + assert_eq!( + s.header.sblk_offset + s.header.sblk_size, + s.header.daw_offset + ); + } + + #[test] + fn a_short_or_bogus_extent_is_an_error_not_a_panic() { + assert!(parse_sound_bank(&[]).is_err()); + assert!(parse_sound_bank(&[0u8; 0x10]).is_err()); + // A DAW offset past the end. + let mut b = bytes(); + b[0x10..0x14].copy_from_slice(&9_000u32.to_le_bytes()); + assert!(parse_sound_bank(&b).is_err()); + // A DAW size that runs off the end. + let mut b = bytes(); + b[0x14..0x18].copy_from_slice(&9_000u32.to_le_bytes()); + assert!(parse_sound_bank(&b).is_err()); + } +}