Compute core_data region bounds once, from successor boundaries

Region spans were being re-derived per format while probing, usually
running from one known offset to the next with the last span running to
core_data.len(). On level 0 that gave the final shrub class a 925,008-byte
span absorbing most of the lump's tail, and every aggregate computed over
it -- record counts, value distributions, "this looks random" -- was
silently wrong. Nothing errored, which is what made it survive two
commits and a correction.

core_layout computes the layout once: each region ends where the next one
begins, class regions start at the lowest non-zero geometry offset in their
table, and a region whose successor is unknown gets end: None rather than a
clamped guess. Zero class entries mean absent, not position zero -- the
same trap occlusion and collision each fell into separately.

The disc test asserts what the module is actually for: that the unknown is
visible. Shrub's end is unresolved on the 24 levels that have a shrub
region, and that is asserted rather than hidden.

It also found that not every level has every region -- level 10 has neither
occlusion nor shrub -- so the test checks ordering against the canonical
sequence instead of demanding an identical set.

Still open: where the shrub region ends. Texture texels cannot bound it;
they sit before moby, not after shrub. See docs/formats/core-data-regions.md.
This commit is contained in:
2026-10-11 03:02:02 +02:00
parent aec462df21
commit cb38b8c279
4 changed files with 396 additions and 0 deletions
+112
View File
@@ -1712,3 +1712,115 @@ fn shrub_headers_name_their_own_class_on_every_level() {
size field {min_size}..{max_size}; {denormals} denormal reserved words"
);
}
/// Region bounds derived from successor boundaries, on every level.
///
/// The point is not that these are correct -- shrub's end is still unknown --
/// but that the unknown is visible. Every region here is closed by the next
/// region's start rather than by `core_data.len()`, so no span silently
/// absorbs the tail of the lump the way the last shrub class was doing.
#[test]
fn core_regions_chain_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::level_core::LevelCoreHeader;
use gc_formats::regions::core_layout;
let mut levels = 0usize;
let mut unresolved_shrub = 0usize;
let (mut first, mut last) = (usize::MAX, 0usize);
for n in 0..27 {
let dir = data.join(format!("level{n}"));
let Ok(index) = std::fs::read(dir.join("core_index.bin")) else {
continue;
};
let core_len = std::fs::metadata(dir.join("core_data.bin"))
.map(|m| m.len() as usize)
.unwrap_or(0);
let h = LevelCoreHeader::parse(&index).expect("core header");
let regions = core_layout(&index, &h);
// Regions appear in order, never overlap, and never run past the lump.
for pair in regions.windows(2) {
assert!(
pair[0].start <= pair[1].start,
"level{n}: {} vs {}",
pair[0].name,
pair[1].name
);
if let Some(end) = pair[0].end {
assert!(
end <= pair[1].start,
"level{n}: {} ends after {}",
pair[0].name,
pair[1].name
);
}
}
for r in &regions {
assert!(
r.start < core_len,
"level{n}: {} starts past the lump",
r.name
);
if let Some(end) = r.end {
assert!(end <= core_len, "level{n}: {} ends past the lump", r.name);
}
}
// Regions appear in the canonical order, but not every level has every
// region -- level 10 has no occlusion and no shrub at all.
const ORDER: [&str; 7] = [
"tfrags",
"occlusion",
"sky",
"collision",
"moby",
"tie",
"shrub",
];
let names: Vec<_> = regions.iter().map(|r| r.name).collect();
let ranks: Vec<usize> = names
.iter()
.map(|name| {
ORDER
.iter()
.position(|o| o == name)
.unwrap_or_else(|| panic!("level{n}: unknown region {name}"))
})
.collect();
assert!(
ranks.windows(2).all(|w| w[0] < w[1]),
"level{n}: regions out of order: {names:?}"
);
if let Some(s) = regions.iter().find(|r| r.name == "shrub") {
if s.end.is_none() {
unresolved_shrub += 1;
}
first = first.min(s.start);
last = last.max(s.start);
}
levels += 1;
}
assert_eq!(levels, 27, "expected all 27 levels, got {levels}");
// Known gap, not a bug: the offset ending the shrub region is not in the
// core index and textures sit before moby, so they cannot bound it.
assert!(
unresolved_shrub > 20,
"shrub end should still be unresolved, saw {unresolved_shrub}"
);
eprintln!(
"regions: shrub starts {first}..{last}, end unresolved on {unresolved_shrub}/27 levels"
);
}
+1
View File
@@ -7,6 +7,7 @@ pub mod level_data;
pub mod level_wad;
pub mod moby;
pub mod occlusion;
pub mod regions;
pub mod shrub;
pub mod sound_bank;
pub mod texture;
+233
View File
@@ -0,0 +1,233 @@
//! Region bounds for a level's `core_data`.
//!
//! Spec: `docs/formats/core-data-regions.md`.
//!
//! This exists because of a specific failure. Region spans were being derived
//! per-format during probing, usually by running from one known offset to the
//! next, and usually with the last span running to `core_data.len()`. On level
//! 0 that made the final shrub class a 925,008-byte span absorbing most of the
//! lump's tail. Aggregates computed over that -- record counts, value
//! distributions -- were silently wrong, and nothing errored.
//!
//! So bounds are computed once, here, from successor boundaries, and every
//! caller uses these rather than inventing its own. Where a bound is not yet
//! known it is `None`, not a guess: [`Region::end`] being `None` means
//! "unresolved", and [`core_layout`] refuses to close a region whose successor
//! it cannot find.
//!
//! Measured on the disc:
//!
//! | region | start | end |
//! |------------|--------------|-------------------------|
//! | tfrags | 0 | occlusion |
//! | occlusion | occlusion | sky |
//! | sky | sky | collision |
//! | collision | collision | first moby class |
//! | moby | first moby | first tie class |
//! | tie | first tie | first shrub class |
//! | shrub | first shrub | **unresolved** |
//!
//! The shrub end is the open item. Texture texels sit *before* moby, not after
//! shrub, so they cannot bound it: on level 0 the texel range is 3539328 to
//! 5326208 while the last shrub class starts at 16044288. The ~925 KB after
//! the last shrub class is real, non-zero data that is not accounted for yet.
use crate::level_core::{ArrayRange, LevelCoreHeader};
/// A byte range within `core_data`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Region {
pub name: &'static str,
pub start: usize,
/// Exclusive end, or `None` when the successor boundary is not known.
pub end: Option<usize>,
}
impl Region {
pub fn len(&self) -> Option<usize> {
self.end.map(|e| e.saturating_sub(self.start))
}
pub fn is_empty(&self) -> bool {
self.len() == Some(0)
}
}
/// The lowest geometry offset in a class table, ignoring absent entries.
///
/// Absent entries are zero, and treating zero as an offset would drag the
/// region's start to zero -- the same "zero means absent, not position" trap
/// that the occlusion and collision headers both fell into.
pub fn first_geometry(index: &[u8], range: ArrayRange, stride: usize) -> Option<usize> {
let count = range.count.max(0) as usize;
let base = range.offset.max(0) as usize;
let mut best: Option<usize> = None;
for i in 0..count {
let at = base + i * stride;
let chunk = index.get(at..at + 4)?;
let off = i32::from_le_bytes(chunk.try_into().expect("4 bytes"));
if off > 0 {
best = Some(best.map_or(off as usize, |b: usize| b.min(off as usize)));
}
}
best
}
/// Compute the region's bounds for one level.
pub fn core_layout(index: &[u8], h: &LevelCoreHeader) -> Vec<Region> {
let at = |v: i32| -> Option<usize> { (v > 0).then_some(v as usize) };
let first_moby = first_geometry(index, h.moby_classes, 0x20);
let first_tie = first_geometry(index, h.tie_classes, 0x20);
let first_shrub = first_geometry(index, h.shrub_classes, 0x30);
// Each region ends where the next one begins. `chained` keeps `None`
// propagating rather than silently closing a region against a gap.
fn chained(name: &'static str, start: Option<usize>, next: Option<usize>) -> Option<Region> {
let start = start?;
Some(Region {
name,
start,
end: next.filter(|&n| n > start),
})
}
let regions = [
chained("tfrags", Some(0), at(h.occlusion)),
chained("occlusion", at(h.occlusion), at(h.sky)),
chained("sky", at(h.sky), at(h.collision)),
chained("collision", at(h.collision), first_moby),
chained("moby", first_moby, first_tie),
chained("tie", first_tie, first_shrub),
chained("shrub", first_shrub, None),
];
regions.into_iter().flatten().collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// One index buffer carrying both the header fields and the class tables,
/// so a test cannot accidentally pair a header with unrelated class data.
fn index(occlusion: i32, sky: i32, collision: i32, classes: &[(usize, &[i32])]) -> Vec<u8> {
let mut v = vec![0u8; 4096];
v[0x0c..0x10].copy_from_slice(&occlusion.to_le_bytes());
v[0x10..0x14].copy_from_slice(&sky.to_le_bytes());
v[0x14..0x18].copy_from_slice(&collision.to_le_bytes());
let bases = [0x18usize, 0x20, 0x28];
for (slot, (offset, vals)) in classes.iter().enumerate() {
v[bases[slot]..bases[slot] + 4].copy_from_slice(&(vals.len() as i32).to_le_bytes());
v[bases[slot] + 4..bases[slot] + 8].copy_from_slice(&(*offset as i32).to_le_bytes());
for (i, g) in vals.iter().enumerate() {
let at = offset + i * 0x20;
v[at..at + 4].copy_from_slice(&g.to_le_bytes());
}
}
v
}
fn layout(v: &[u8]) -> Vec<Region> {
let h = crate::level_core::LevelCoreHeader::parse(v).unwrap();
core_layout(v, &h)
}
#[test]
fn regions_chain_and_do_not_overlap() {
let v = index(
2_117_888,
2_192_576,
2_615_232,
&[(200, &[3000]), (400, &[4000]), (600, &[5000])],
);
let regions = layout(&v);
let names: Vec<_> = regions.iter().map(|r| r.name).collect();
assert_eq!(
names,
[
"tfrags",
"occlusion",
"sky",
"collision",
"moby",
"tie",
"shrub"
]
);
assert_eq!(regions[0].start, 0);
assert_eq!(regions[1].start, 2_117_888);
assert_eq!(regions[1].end, Some(2_192_576));
assert_eq!(regions[4].start, 3000);
assert_eq!(regions[4].end, Some(4000));
for pair in regions.windows(2) {
if let Some(end) = pair[0].end {
assert!(
end <= pair[1].start,
"{:?} ends after {:?} starts",
pair[0],
pair[1]
);
}
}
}
#[test]
fn a_zero_class_offset_is_absent_not_position_zero() {
let v = index(
100,
200,
300,
&[(200, &[0, 3000]), (400, &[4000]), (600, &[5000])],
);
let moby = layout(&v).into_iter().find(|r| r.name == "moby").unwrap();
assert_eq!(moby.start, 3000);
}
#[test]
fn an_unresolved_end_is_none_rather_than_a_guess() {
let v = index(
100,
200,
300,
&[(200, &[1000]), (400, &[2000]), (600, &[3000])],
);
let regions = layout(&v);
let shrub = regions.iter().find(|r| r.name == "shrub").unwrap();
assert_eq!(shrub.start, 3000);
assert_eq!(shrub.end, None);
assert_eq!(shrub.len(), None);
// Everything before it still resolves.
let tie = regions.iter().find(|r| r.name == "tie").unwrap();
assert_eq!(tie.end, Some(3000));
}
#[test]
fn an_absent_region_is_omitted_entirely() {
let v = index(0, 0, 0, &[]);
let regions = layout(&v);
// Only tfrags can be placed at all, and only with an unknown end.
assert_eq!(regions.len(), 1, "{regions:?}");
assert_eq!(regions[0].name, "tfrags");
assert_eq!(regions[0].start, 0);
assert_eq!(regions[0].end, None);
}
#[test]
fn a_gap_does_not_close_a_region() {
// Sky starts before occlusion ends: nonsense, so neither may be closed
// against the other.
let v = index(
500,
400,
600,
&[(200, &[3000]), (400, &[4000]), (600, &[5000])],
);
let regions = layout(&v);
// occlusion at 500 cannot be closed against sky at 400, so it stays
// open rather than being clamped into a backwards range.
let occ = regions.iter().find(|r| r.name == "occlusion").unwrap();
assert_eq!(occ.start, 500);
assert_eq!(occ.end, None);
assert_eq!(occ.len(), None);
}
}