Files
Going-Commando/crates/gc-formats/src/chunks.rs
T
ReaperOfVeriod c2af0528e3 Correct the chunk loader finding and decode a record field
The loader note said every record gains a 02 00 prefix in RAM. It does not:
the word is the record's own leading field, already on the disc at every
record. What the loader changes is the pointer -- it addresses the word, two
bytes earlier than the disc's table -- and that pointer shift is what made the
bytes look inserted.

Measured, in order:

- the two bytes before a payload are identical in core_data and in RAM, record
  for record: 02 00 on level 0's 90-record chunk at 0x100b302, 08 00 on the
  level's first chunk. Nothing is inserted;
- the RAM table's low 24 bits are the payload's minus 2, consistently, so the
  record is [leading u16][two index bytes][payload] and the loaded table
  addresses the leading word. That is also why the index bytes read as +2 from
  the pointer -- the same +2 the earlier RAM result quotes;
- the word is a field, not a pad: uniform across a chunk's records on 1,439 of
  1,509 chunks (None on the 70 that vary, reported rather than averaged),
  fixed within all 71 chunk types, and only ever 1, 2, 4, 6, 8 or 10 -- 554
  chunks carry 2, 520 carry 4.

Two readings of the value were measured and disproved, so they are recorded as
disproved rather than left as candidates: it is not an entry count (the payload
divided by it leaves 57 distinct fractional "sizes", and 162 chunks do not
divide at all), and it is not the number of distinct leading bytes (agrees on 0
of 1,506 chunks).

Chunk gains `prefix: Option<u16>`, and the disc test asserts the uniformity,
the per-type fixity and the value set. The scratch example that produced the
per-type class-ID table is dropped now that the same scan is a committed test,
which also lands here: moby class IDs sit in one body slot per chunk type, with
the slot varying by type and the distinct >= 4 filter load-bearing -- without it
the top slots are constants that read as 305x because any value below the moby
count counts as a hit.
2026-10-12 06:00:29 +02:00

506 lines
20 KiB
Rust

//! The post-shrub chunk container.
//!
//! Spec: `docs/formats/core-data-regions.md`.
//!
//! This is the container that fills the region after the last shrub class body
//! and before the constant trailer. It is *not* established to be moby_anim;
//! a 49 x 368-byte table inside it turned out to be byte-identical on all 27
//! levels, which is evidence against that identification rather than for it.
//!
//! The framing is established. Each chunk is:
//!
//! ```text
//! +0x00 16 bytes, last byte 0xff (opaque, differs per chunk)
//! +0x10 10 zero bytes
//! +0x1a N tagged offsets, top byte 0xf0
//! ```
//!
//! and each tagged offset is `0xF0000000 | low24`. Resolving a record at
//! `chunk_start + low` yields records that share a field layout: 17-21% of
//! byte positions are constant across a chunk's own records, on every level.
//! That is what distinguishes this from a coincidental byte pattern.
//!
//! The stride between records is a property of the chunk, not of the region.
//! Level 0 alone contains chunks at 148, 340, 368, 288, 320, 236, 272, 384,
//! 116, 160, 250, 580 and 576. Because the strides differ, a region-wide
//! autocorrelation averages them to the noise floor -- which is why an
//! earlier claim of a "368-byte period" for the region as a whole was wrong.
//! 368 is the stride of one chunk.
//!
//! Fields are *not* named. The 16-byte header is opaque, the `0xf0` tag is
//! unexplained, and it is not known which fields are an index, a length and
//! an offset. What this parser provides is bounds-checked chunk and record
//! extraction, which is what the format analysis actually established.
use crate::buf::{Buf, ErrorKind, Result};
/// Bytes in a chunk's opaque leading header.
pub const CHUNK_HEADER_BYTES: usize = 16;
/// Zero bytes between the header and the offset table.
pub const CHUNK_GAP_BYTES: usize = 10;
/// The value every tagged offset's top byte carries, on all 27 levels.
pub const CHUNK_TAG: u8 = 0xf0;
/// Bytes between a chunk record's leading word and the payload the table
/// points at.
///
/// A record is `[leading u16][two index bytes][payload]`, and the disc's
/// offset table points at the *payload*. The loaded table in RAM points two
/// bytes earlier, at the leading word -- the payload's index bytes are then at
/// `+2` from the pointer -- so the game's record starts here and the disc's
/// table is two bytes late. See [`Chunk::prefix`].
pub const RECORD_PREFIX_BYTES: usize = 2;
/// The two bytes immediately preceding every chunk region on the disc.
///
/// The last class body in a level -- shrub where the level has shrub, tie
/// where it does not -- ends with this constant pair before the chunk
/// container begins. It is *not* a unique pattern: the pair occurs 25 to 103
/// times in a 5 to 17 MB lump, so on its own it means nothing. What locates
/// the boundary is the pair sitting exactly in front of a chunk signature
/// (see [`chunk_region_start`]).
///
/// Not interpreted. Whether it is a terminator, an end count or a tag is not
/// established; what is established is that it is constant across all 27
/// levels and sits at this one place.
/// Alignment of the first byte of a chunk record.
///
/// The leading byte is not decoded -- what it indexes is not established, and
/// naming it would be a guess. What *is* established is that it is a small
/// bucket index, not data: within a chunk type it takes 16 discrete values
/// (0x00, 0x10, ... 0xf0) with near-uniform counts, so it reads as an index into
/// groups rather than a running offset.
///
/// **The alignment step is per type, not global.** Across all 27 levels, over
/// 1,509 chunks and 34,491 records:
///
/// - the leading byte is a multiple of **8** on 34,432 of 34,491;
/// - a multiple of **16** on only 33,349 -- the 1,142-record difference falls in
/// **seven** types, four of which step their buckets by 8 (0x00, 0x08, 0x10,
/// ...) instead of 16;
/// - and 59 records in three types break even the 8-byte rule, which is where the
/// variable-length type lives (0x00f2ef4275, records 76 to 104 bytes).
///
/// So [`record_is_aligned`] is checked at 8, not 16, and a type that steps by 16
/// is not distinguishable from one that steps by 8 without looking at the type.
pub const CHUNK_RECORD_ALIGNMENT: usize = 8;
/// True when a record's leading byte is a multiple of [`CHUNK_RECORD_ALIGNMENT`].
///
/// Measured over all 27 levels: 34,432 of 34,491 records, with the exceptions
/// confined to the seven types above.
pub fn record_is_aligned(record: &[u8]) -> bool {
match record.first() {
Some(&b) => (b as usize) % CHUNK_RECORD_ALIGNMENT == 0,
None => false,
}
}
pub const CHUNK_REGION_MARKER: [u8; 2] = [0xf6, 0x7c];
/// A chunk header plus the records its offsets address.
///
/// `stride` is the distance between the first two resolved offsets. Every
/// offset after the first is `stride` from its predecessor within a few bytes
/// on the measured levels, but the parser takes each offset literally rather
/// than assuming a uniform stride, because the deltas are not exactly constant
/// (e.g. 340, 340, 336, 340 on level 0).
pub struct Chunk {
/// The 16 opaque header bytes.
pub header: [u8; CHUNK_HEADER_BYTES],
/// Offset of this chunk within the region it was parsed from.
pub offset: usize,
/// Record count, from header byte 14.
///
/// Measured: equals `offsets.len()` on **1,123 of 1,123** chunks across
/// all 27 levels, zero mismatches. This is the one header field that is
/// decoded rather than merely measured.
pub count: u8,
/// The low 24 bits of each tagged offset, in file order.
pub offsets: Vec<u32>,
/// Record stride, from the first two offsets. Zero if fewer than two.
pub stride: usize,
/// The `u16` immediately before the first record's payload -- the record's
/// own leading word, which the loaded table points at.
///
/// **Measured on the disc:** the same at every record of a chunk on
/// **1,439 of 1,509 chunks** -- `None` on the 70 that vary -- and fixed
/// within every chunk type, over the 71 types. Its values are 1, 2, 4, 6,
/// 8 and 10; 554 chunks carry 2 and 520 carry 4.
///
/// **Measured against the running game:** in PCSX2's RAM the rewritten
/// offset table points at this word rather than at the payload, two bytes
/// earlier than the disc's table, so the game's record starts here.
///
/// Not named. What the value counts is not established: it is *not* the
/// payload's entry count (the payload length divided by it is a whole
/// number on 1,344 chunks and an odd fraction on the other 162, leaving 57
/// distinct "sizes"), and it is not the number of distinct leading bytes
/// (agrees on 0 of 1,506). An earlier note in this file read the
/// `02 00` seen in RAM as a prefix the loader had inserted; those bytes
/// are on the disc already, at every record, which is what this field is.
pub prefix: Option<u16>,
}
/// One chunk, with its records already bounds-checked.
pub struct ResolvedChunk<'a> {
/// The offset table.
pub chunk: Chunk,
/// Record bytes, one per entry in `chunk.offsets`.
pub records: Vec<&'a [u8]>,
}
impl<'a> ResolvedChunk<'a> {
/// The per-chunk phase and step that explain every record's leading byte, or
/// `None` when no `(step, phase)` in that form fits.
///
/// **Measured: 1,262 of 1,509 chunks** across all 27 levels are fully explained
/// by `record[i].leading == step * ((i + phase) mod (256 / step))`, with
/// `step` taking 8 on 51 chunks, 16 on 554, 32 on 520 and 64 on 137. The other
/// 247 chunks, across 11 chunk types, are not explained by it.
///
/// Where this returns `Some`, the leading byte carries **no per-record
/// information**: it is a deterministic function of the record's index and the
/// chunk's phase, so it is a position marker rather than data. The 247 chunks
/// where it returns `None` are the ones where that has *not* been shown, and
/// nothing should skip their leading byte on the strength of this.
pub fn bucket_rule(&self) -> Option<(u8, usize)> {
use std::collections::BTreeSet;
if self.records.is_empty() {
return None;
}
let n = self.records.len();
for step in [8u16, 16, 32, 64] {
let m = (256 / step) as usize;
for phase in 0..m {
let mut ok = true;
for (i, r) in self.records.iter().enumerate() {
let Some(&b) = r.first() else {
ok = false;
break;
};
if b != ((((i + phase) % m) * step as usize % 256) as u8) {
ok = false;
break;
}
}
if ok {
return Some((step as u8, phase));
}
}
}
let _ = n;
let _: Option<BTreeSet<u8>> = None;
None
}
/// True when every record in this chunk has the same length.
///
/// **Measured: true for 1,509 of 1,509 chunks** across all 27 levels. Record
/// length varies *between* chunks of the same type -- one type runs 76 to 104
/// bytes -- but never within one. An earlier claim that some type had
/// variable-length records was pooling across chunks; it is withdrawn.
pub fn has_uniform_record_length(&self) -> bool {
let mut it = self.records.iter().filter(|r| !r.is_empty());
let Some(first) = it.next().map(|r| r.len()) else {
return true;
};
it.all(|r| r.len() == first)
}
/// The per-chunk `(step, phase) that makes the first **two** bytes of every
/// record encode that record's own index, or `None` when no such pair fits.
///
/// With `g = 256 / step` and `j = index + phase`:
///
/// ```text
/// record[0] == step * (j mod g)
/// record[1] == (j / g) mod 256
/// ```
///
/// so the pair is the record index in mixed radix, and the leading bytes carry
/// **no per-record information** -- a record knows where it is.
///
/// **Measured: 1,235 of 1,509 chunks** across all 27 levels fit this exactly.
/// [`Self::bucket_rule`] -- byte 0 alone -- fits 1,262; the 27-chunk difference
/// are chunks whose first byte follows the rule but whose second does not.
///
/// The remaining 274 chunks are not explained by it.
/// Nothing should skip a leading byte on the strength of this where it returns
/// `None`.
pub fn index_rule(&self) -> Option<(u8, usize)> {
for step in [8u16, 16, 32, 64] {
let g = (256 / step) as usize;
for phase in 0..g {
let mut ok = true;
for (i, r) in self.records.iter().enumerate() {
if r.len() < 2 {
continue;
}
let j = i + phase;
if r[0] != ((j % g) * step as usize % 256) as u8
|| r[1] != ((j / g) % 256) as u8
{
ok = false;
break;
}
}
if ok && self.records.iter().any(|r| r.len() >= 2) {
return Some((step as u8, phase));
}
}
}
None
}
}
/// True if `offset` looks like the start of a chunk.
///
/// The signature is the header's last byte being `0xff`, the 10-byte gap
/// being zero, and at least `min_offsets` following words carrying the `0xf0`
/// tag.
pub fn is_chunk_start(region: &[u8], offset: usize, min_offsets: usize) -> bool {
let hdr_end = offset + CHUNK_HEADER_BYTES;
if hdr_end + CHUNK_GAP_BYTES + min_offsets * 4 > region.len() {
return false;
}
if region[hdr_end - 1] != 0xff {
return false;
}
if region[hdr_end..hdr_end + CHUNK_GAP_BYTES] != [0u8; CHUNK_GAP_BYTES] {
return false;
}
(0..min_offsets).all(|k| {
let at = hdr_end + CHUNK_GAP_BYTES + k * 4;
read_u32(region, at) >> 24 == CHUNK_TAG as u32
})
}
/// The start of the chunk container: the first offset at or after `from`
/// carrying a chunk signature immediately preceded by [`CHUNK_REGION_MARKER`].
///
/// This is what closes the last class region (shrub, or tie on the levels
/// without shrub). The two parts of the signature are each weak alone -- the
/// marker pair occurs by chance dozens of times per lump, and the chunk shape
/// matches random data often enough to need four tagged offsets to be believed
/// -- and together they are exact: on all 27 levels the marker sits exactly
/// two bytes before the first chunk header, with no further marker-paired
/// signature after the region start.
///
/// Returns `None` when the signature is not found, so an unlocated boundary
/// stays visible rather than being closed against a guess.
pub fn chunk_region_start(core: &[u8], from: usize) -> Option<usize> {
if from < 2 {
return None;
}
// The marker's first byte is the cheapest rejection, so test it before
// comparing the pair or the signature behind it.
let mut at = from - 2;
while at + 2 <= core.len() {
if core[at] == CHUNK_REGION_MARKER[0]
&& core[at + 1] == CHUNK_REGION_MARKER[1]
&& is_chunk_start(core, at + 2, 4)
{
return Some(at + 2);
}
at += 1;
}
None
}
/// Little-endian, matching `Buf::u32` and the PS2.
fn read_u32(bytes: &[u8], at: usize) -> u32 {
u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]])
}
/// Parse the chunk at `offset`, without resolving records.
///
/// Returns `None` if `offset` does not carry the chunk signature.
pub fn parse_chunk(region: &[u8], offset: usize) -> Result<Option<Chunk>> {
if !is_chunk_start(region, offset, 4) {
return Ok(None);
}
let mut b = Buf::new(region);
b.seek(offset);
let header_bytes = b.read_bytes(CHUNK_HEADER_BYTES, "chunk header")?;
let mut header = [0u8; CHUNK_HEADER_BYTES];
header.copy_from_slice(header_bytes);
b.skip(CHUNK_GAP_BYTES, "chunk gap")?;
// The offset table runs until the first word without the tag.
let table_start = b.pos();
let mut offsets = Vec::new();
while b.pos() + 4 <= region.len() {
let v = b.u32("chunk offset")?;
if v >> 24 != CHUNK_TAG as u32 {
b.seek(table_start);
break;
}
offsets.push(v & 0x00ff_ffff);
}
// The offset table runs to the first untagged word, but the header carries
// its own count. Truncating to that count stops a stray tagged word in the
// record data from extending the table.
offsets.truncate(header[14] as usize);
let stride = if offsets.len() >= 2 {
(offsets[1] - offsets[0]) as usize
} else {
0
};
// The leading word sits two bytes before the payload the table points at.
// Reading it and requiring it to be the same at every record is a
// signature check like the header's last byte: it holds on every chunk on
// The leading word sits two bytes before the payload the table points at.
// It is the same at every record of a chunk on 1,439 of 1,509 chunks; the
// other 70 vary, so it stays an Option rather than a value a caller could
// mistake for a field that is always there.
let read_prefix = |low: u32| -> Option<u16> {
let at = offset
.checked_add(low as usize)?
.checked_sub(RECORD_PREFIX_BYTES)?;
region
.get(at..at + RECORD_PREFIX_BYTES)
.map(|b| u16::from_le_bytes([b[0], b[1]]))
};
let prefix = offsets.first().copied().and_then(read_prefix).filter(|&p| {
offsets
.iter()
.skip(1)
.all(|&low| read_prefix(low) == Some(p))
});
Ok(Some(Chunk {
count: header[14],
header,
offset,
offsets,
stride,
prefix,
}))
}
/// Resolve every record a chunk addresses, bounds-checked against `region`.
///
/// Each record is `region[chunk.offset + low .. + stride]`. A record that
/// would run past the end of the region is dropped rather than clamped, so the
/// returned length may be shorter than `chunk.offsets` -- on the measured
/// levels it is not, but a parser that silently clamps would hide a malformed
/// chunk rather than report it.
pub fn resolve<'r>(chunk: &Chunk, region: &'r [u8]) -> Result<ResolvedChunk<'r>> {
let mut records = Vec::with_capacity(chunk.offsets.len());
for &low in &chunk.offsets {
let start = chunk
.offset
.checked_add(low as usize)
.ok_or(crate::buf::Error {
offset: chunk.offset,
context: "chunk record",
kind: ErrorKind::Invalid("chunk offset overflows usize"),
})?;
if start + chunk.stride > region.len() {
continue;
}
records.push(&region[start..start + chunk.stride]);
}
Ok(ResolvedChunk {
chunk: Chunk {
count: chunk.count,
header: chunk.header,
prefix: chunk.prefix,
offset: chunk.offset,
offsets: chunk.offsets.clone(),
stride: chunk.stride,
},
records,
})
}
/// Walk every chunk in `region`, resolving records.
///
/// Returns chunks that carried the signature but whose records did not all
/// resolve, alongside the ones that did. Callers that care about coverage
/// should check both.
pub fn parse_all_chunks(region: &[u8]) -> Result<Vec<ResolvedChunk<'_>>> {
let mut out = Vec::new();
let mut at = 0usize;
while at + CHUNK_HEADER_BYTES + CHUNK_GAP_BYTES + 16 <= region.len() {
let Some(chunk) = parse_chunk(region, at)? else {
at += 1;
continue;
};
// Resume past this chunk's header and offset table so a chunk's own
// record data is not rescanned as further chunk headers.
let consumed = CHUNK_HEADER_BYTES + CHUNK_GAP_BYTES + chunk.offsets.len() * 4;
let resolved = resolve(&chunk, region)?;
out.push(resolved);
at += consumed.max(1);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// A chunk signature at `at`, preceded by the marker: the marker pair, the
/// 16-byte header whose last byte is `0xff`, the 10 zero bytes, then four
/// tagged offsets.
fn plant_chunk(core: &mut [u8], at: usize) {
let base = at - 2;
core[base..base + 2].copy_from_slice(&CHUNK_REGION_MARKER);
// Header: byte 14 is the record count, matching the four tagged words.
core[at + 14] = 4;
core[at + 15] = 0xff;
// The 10 zero bytes of the gap at +0x10. Their low bits resolve to a
// zero stride, which is fine: only the signature is under test.
core[at + CHUNK_HEADER_BYTES..at + CHUNK_HEADER_BYTES + CHUNK_GAP_BYTES].fill(0);
for k in 0..4 {
let word = (CHUNK_TAG as u32) << 24;
core[at + CHUNK_HEADER_BYTES + CHUNK_GAP_BYTES + k * 4
..at + CHUNK_HEADER_BYTES + CHUNK_GAP_BYTES + k * 4 + 4]
.copy_from_slice(&word.to_le_bytes());
}
}
#[test]
fn finds_the_markered_signature() {
let mut core = vec![0x5au8; 256];
plant_chunk(&mut core, 128);
assert_eq!(chunk_region_start(&core, 64), Some(128));
// Scanning from earlier still finds it: the signature is what locates
// the boundary, not the search start.
assert_eq!(chunk_region_start(&core, 2), Some(128));
}
#[test]
fn a_marker_without_a_signature_is_not_a_boundary() {
let mut core = vec![0x5au8; 256];
core[10..12].copy_from_slice(&CHUNK_REGION_MARKER);
assert_eq!(chunk_region_start(&core, 2), None);
}
#[test]
fn a_signature_without_the_marker_is_not_a_boundary() {
let mut core = vec![0x5au8; 256];
// Plant the signature, then break the marker in front of it.
plant_chunk(&mut core, 128);
core[126] = 0x5a;
assert_eq!(chunk_region_start(&core, 2), None);
}
#[test]
fn an_unlocated_boundary_stays_none() {
assert_eq!(chunk_region_start(&[], 2), None);
// `from` below the marker's own minimum is refused rather than
// underflowing.
assert_eq!(chunk_region_start(&[0xf6], 0), None);
}
}