diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index e6ef4aa..bbe61c5 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -3484,6 +3484,86 @@ fn tie_vertex_slots_0_to_2_are_shared_positions() { ); } +/// The `SBlk` head's constant fields, and the one field that is not a count. +/// +/// The first twelve bytes are identical on all 27 levels. The word at `+0x14` +/// varies, with its low sixteen bits always zero. It tracks the body length at +/// ~100 bytes per unit, which is the shape of a record count -- and is not one: +/// the ratio runs 91.2..110.8 and no stride makes the relation exact on all 27. +/// Both halves are asserted so the near-miss cannot quietly harden into a name. +#[test] +fn sound_bank_sblk_head_is_constant_apart_from_one_word() { + 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, SBlkHeader}; + + let mut values: Vec = Vec::new(); + let mut bodies: Vec = Vec::new(); + let mut levels = 0usize; + for lv in 0..27usize { + let Ok(bytes) = std::fs::read(data.join(format!("level{lv}/sound_bank.bin"))) else { + continue; + }; + let bank = parse_sound_bank(&bytes).expect("sound bank"); + let h = SBlkHeader::parse(bank.sblk).expect("SBlk head"); + assert!( + h.constants_hold(), + "level {lv}: SBlk constants moved: {h:?}" + ); + values.push(h.field_14); + bodies.push(bank.sblk.len() - 24); + levels += 1; + } + assert_eq!(levels, 27, "only {levels} sound banks parsed"); + + for &v in &values { + assert_eq!(v & 0xffff, 0, "field_14 gained low bits: {v:#x}"); + assert!( + (0x92..=0x103).contains(&(v >> 16)), + "field_14 moved: {v:#x}" + ); + } + // correlated with body length, but not a count at any fixed stride + let ratios: Vec = bodies + .iter() + .zip(&values) + .map(|(b, &v)| *b as f64 / (v >> 16) as f64) + .collect(); + let lo = ratios.iter().cloned().fold(f64::MAX, f64::min); + let hi = ratios.iter().cloned().fold(0.0f64, f64::max); + let exact = (0..=80).any(|k| { + let rems: Vec = bodies + .iter() + .zip(&values) + .map(|(b, &v)| *b as i64 - k * (v >> 16) as i64) + .collect(); + rems.windows(2).all(|w| w[0] == w[1]) + }); + eprintln!( + "SBlk head: constants hold on 27/27; field_14 in 0x0092..0x0103 with low 16 bits 0; \ + body/word ratio {lo:.1}..{hi:.1}; any stride making it exact: {exact}" + ); + assert!( + lo > 85.0 && hi < 115.0, + "body/word ratio left its band: {lo:.1}..{hi:.1}" + ); + assert!( + !exact, + "field_14 now satisfies a fixed stride -- revisit the record size" + ); +} + #[test] fn tie_vertex_element_is_16_bytes_with_fixed_texcoords() { require_disc!(); diff --git a/crates/gc-formats/src/sound_bank.rs b/crates/gc-formats/src/sound_bank.rs index ccc44b4..c185e1c 100644 --- a/crates/gc-formats/src/sound_bank.rs +++ b/crates/gc-formats/src/sound_bank.rs @@ -68,6 +68,74 @@ impl SoundBankHeader { } } +/// The 24-byte head that opens the `SBlk` region. +/// +/// Measured across all 27 levels: the first twelve bytes are **identical +/// everywhere** -- `"SBlk"`, then `1`, then `4`, then `"DAW\0"` -- and the word +/// at `+0x10` is zero on every level. The word at `+0x14` is the only part that +/// varies, and it varies per level with its **low sixteen bits zero in all 27**, +/// leaving values `0x0092..=0x0103` (146..=259). +/// +/// That word tracks the length of the `SBlk` body at close to 100 bytes per +/// unit, but it is **not** a record count with a fixed stride: the ratio runs +/// 91.2 to 110.8 across the 27 levels, and no stride in 0..80 makes +/// `body = stride * word + constant` hold exactly on all of them. So it is +/// correlated with the body length and is not identified. Recorded rather than +/// named, because "a count that nearly works" is exactly the claim that later +/// gets asserted by someone who did not run this test. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SBlkHeader { + /// Always `"SBlk"`. + pub magic: [u8; 4], + /// Always `1`. + pub field_4: u32, + /// Always `4`. + pub field_8: u32, + /// Always `"DAW\0"`. + pub format_tag: [u8; 4], + /// Always `0`. + pub field_10: u32, + /// Varies per level; low sixteen bits always zero, `0x0092..=0x0103`. + pub field_14: u32, +} + +impl SBlkHeader { + /// Parse the 24-byte head. + pub fn parse(bytes: &[u8]) -> Result { + // Read in byte order. The struct literal names `format_tag` after two + // words, but a `let` bound for it would consume bytes 4..8 and shift + // every field after it -- which it did, once. + let mut b = Buf::new(bytes); + let magic: [u8; 4] = b.read_bytes(4, "SBlk magic")?.try_into().expect("4 bytes"); + let field_4 = b.u32("SBlk field_4")?; + let field_8 = b.u32("SBlk field_8")?; + let format_tag: [u8; 4] = b + .read_bytes(4, "SBlk format tag")? + .try_into() + .expect("4 bytes"); + Ok(SBlkHeader { + magic, + field_4, + field_8, + format_tag, + field_10: b.u32("SBlk field_10")?, + field_14: b.u32("SBlk field_14")?, + }) + } + + /// True when every constant this header carries holds. + /// + /// Each is universal on all 27 levels. They are tested individually so a + /// real deviation says which one moved. + pub fn constants_hold(&self) -> bool { + &self.magic == b"SBlk" + && self.field_4 == 1 + && self.field_8 == 4 + && &self.format_tag == b"DAW\0" + && self.field_10 == 0 + } +} + /// Split a sound extent into its two regions. /// /// Bounds are checked before slicing; a level whose head points outside its own diff --git a/docs/formats/core-data-regions.md b/docs/formats/core-data-regions.md index e9a9997..7f88d98 100644 --- a/docs/formats/core-data-regions.md +++ b/docs/formats/core-data-regions.md @@ -2142,3 +2142,45 @@ field means anything. The test's power came from `i16[0..2]` being the only slot with tens of thousands of distinct values. **A passing result from a method that has run out of discriminative power is not a passing result**, and the 1x rows are recorded so the table is not later mistaken for nine confirmations. + +## The `SBlk` head: four constants, one unidentified word + +The sound extent's 24-byte head was parsed but not characterised. Across all 27 +levels the `SBlk` region opens identically for its first twelve bytes: + + +0x00 "SBlk" + +0x04 1 + +0x08 4 + +0x0c "DAW\0" + +0x10 0 + +0x14 varies + +`+0x14` is the only part that differs between levels, and it differs with its +**low sixteen bits zero on all 27**, leaving `0x0092..=0x0103` -- 146 to 259. + +That is the shape of a record count, and it tracks the `SBlk` body length at +close to 100 bytes per unit. It is not one: + + body / field_14 91.2 .. 110.8 across the 27 levels + any stride 0..80 making body = stride * word + constant exactly: no + +A near-miss at 100 bytes/record is exactly the kind of thing that hardens into a +name the next time somebody glances at it, so the test asserts the negative +alongside the positive: if a stride ever makes the relation exact on all 27, the +test fails and asks for the record size to be revisited. + +Asserted in `sound_bank_sblk_head_is_constant_apart_from_one_word`, which checks +the four constants individually so a real deviation names which moved, the zero +low half, the value range, and the absence of a fixed stride. + +The `DAW` region begins with sixteen zero bytes and then block-structured data +with 16-byte periodicity and slowly varying nibbles, consistent with block +compressed audio. It is not decoded. + +Two notes on how this was measured. Reading the level 0 head directly from a +scratch script gave a field order that the parser disagreed with, because the +constructor bound `format_tag` with a `let` *before* the two words that precede +it in the file -- consuming bytes 4..8 and shifting everything after. The struct +literal named the fields in the right order; the statements did not. It is fixed +with a comment saying so, since "read the `let`s in file order" is the kind of +thing that gets undone. diff --git a/docs/roadmap.md b/docs/roadmap.md index 460de62..b71b71d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -228,7 +228,11 @@ Phase 1 hands over the extents; this phase works out what each one contains. with the WAD LZ77 decoder, bit order and header length ruled out" note was wrong about the cause: the decoder was right and the input was not what it looked like. Corrected in `plan/level-assets.md`. -- [~] `sound_bank` — container framing parsed (`SoundBankHeader::parse`, +- [~] `sound_bank` — the `SBlk` head is now characterised: `"SBlk", 1, 4, + "DAW\0", 0` constant on 27/27, and `+0x14` is the only varying word, with + its low 16 bits always zero (`0x0092..=0x0103`). It tracks body length at + ~100 bytes/unit and is **not** a count -- ratio 91.2..110.8, and no stride + makes it exact. The absence of a stride is asserted too. Container framing parsed (`SoundBankHeader::parse`, `parse_sound_bank`); `SBlk` and `DAW` bounds verified on 27/27. The **record fields inside `SBlk` are not decoded**, and `DAW` shows no detectable stride, consistent with ADPCM. See the note under