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:
@@ -1712,3 +1712,115 @@ fn shrub_headers_name_their_own_class_on_every_level() {
|
|||||||
size field {min_size}..{max_size}; {denormals} denormal reserved words"
|
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 ®ions {
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod level_data;
|
|||||||
pub mod level_wad;
|
pub mod level_wad;
|
||||||
pub mod moby;
|
pub mod moby;
|
||||||
pub mod occlusion;
|
pub mod occlusion;
|
||||||
|
pub mod regions;
|
||||||
pub mod shrub;
|
pub mod shrub;
|
||||||
pub mod sound_bank;
|
pub mod sound_bank;
|
||||||
pub mod texture;
|
pub mod texture;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# core_data region bounds
|
||||||
|
|
||||||
|
How a level's `core_data` lump divides up, and how each region is bounded.
|
||||||
|
|
||||||
|
## The rule
|
||||||
|
|
||||||
|
A region's end is the **next region's start**, never `core_data.len()`. The
|
||||||
|
last region in a layout is the exception, and when its successor is unknown the
|
||||||
|
end is `None` rather than a guess.
|
||||||
|
|
||||||
|
This exists because region bounds were being re-derived per format during
|
||||||
|
probing, usually by running from one known offset to the next with the last
|
||||||
|
span running to the end of the lump. On level 0 that made the final shrub class
|
||||||
|
a 925,008-byte span absorbing most of the lump's tail. Every aggregate computed
|
||||||
|
over it -- record counts, value distributions, "this data looks random" -- was
|
||||||
|
silently wrong, and nothing errored.
|
||||||
|
|
||||||
|
`gc_formats::regions::core_layout` computes these once. Callers use it rather
|
||||||
|
than inventing their own.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| 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 | **unknown** |
|
||||||
|
|
||||||
|
Class regions start at the lowest non-zero geometry offset in their class
|
||||||
|
table. A zero entry means *absent*, not position zero -- the same trap the
|
||||||
|
occlusion and collision headers each fell into separately.
|
||||||
|
|
||||||
|
Not every level has every region: level 10 has no occlusion and no shrub.
|
||||||
|
|
||||||
|
## Open: where shrub ends
|
||||||
|
|
||||||
|
The offset ending the shrub region is not in the core index.
|
||||||
|
|
||||||
|
Texture texels do **not** bound it. They sit before moby, not after shrub: on
|
||||||
|
level 0 the texel range is 3539328..5326208 while the last shrub class starts
|
||||||
|
at 16044288, leaving ~925 KB of real, non-zero data after the last shrub class
|
||||||
|
that nothing yet accounts for.
|
||||||
|
|
||||||
|
`core_layout` reports `end: None` for shrub on the 24 levels that have one, and
|
||||||
|
the disc test asserts it, so the gap stays visible rather than being papered
|
||||||
|
over by clamping to the end of the lump.
|
||||||
Reference in New Issue
Block a user