has a uniform record length Follows the alignment result and settles what the leading byte is. Within one chunk the leading byte steps by a constant and wraps: offsets: 1420 1748 2072 2392 2716 3044 3372 3700 4028 4352 ... byte0: 0 16 32 48 64 80 96 112 128 144 ... Sweeping every (step, phase) over all 27 levels: fully explained: 1,262 of 1,509 chunks by step: 8 -> 51 chunks, 16 -> 554, 32 -> 520, 64 -> 137 unexplained: 247 chunks across 11 chunk types So on 1,262 chunks the leading byte carries no per-record information: it is a deterministic function of the record's index and the chunk's phase, which is a position marker rather than data. That is why the earlier "16 discrete values, uniform counts" pattern appeared at all. ResolvedChunk::bucket_rule returns the (step, phase) when the whole chunk fits and None otherwise, so the 247 unexplained chunks stay distinguishable and nothing skips a leading byte on the strength of a rule that does not apply to it. Every chunk has a uniform record length: 1,509 of 1,509. Length varies BETWEEN chunks of a type -- one type runs 76 to 104 bytes -- but never within one. This withdraws the previous commit's "variable-length type", which came from pooling minlen/maxlen across all of a type's records at once. That is the pooled-claim error rule 3a exists to prevent, and it is the third time this session a pooled measurement produced a claim that partitioning reversed. The type is not variable-length; the chunk is fixed-length and different chunks differ.
407 lines
16 KiB
Rust
407 lines
16 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;
|
|
|
|
/// 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,
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
};
|
|
|
|
Ok(Some(Chunk {
|
|
count: header[14],
|
|
header,
|
|
offset,
|
|
offsets,
|
|
stride,
|
|
}))
|
|
}
|
|
|
|
/// 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(®ion[start..start + chunk.stride]);
|
|
}
|
|
Ok(ResolvedChunk {
|
|
chunk: Chunk {
|
|
count: chunk.count,
|
|
header: chunk.header,
|
|
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);
|
|
}
|
|
}
|