Add gc-extract probe to dump structures through the real parser
Reading these formats by unpacking raw offsets out of core_index.bin has cost real time. The index interleaves single-word region offsets with count/offset pairs, so miscounting one shifts every field after it: that produced a page of implausible collision headers on all 27 levels, sent me chasing a tie-breakoff that turned out not to exist, and nearly had me "fix" a stride bug in code that was already right. probe dumps a level's core header and all three class tables through LevelCoreHeader, so a field name printed is the field name the parser uses. It also reports how many entries carry geometry inside core_data, which is what pins the class table strides rather than guessing at them: shrub reads 7/7 sane at 0x30 against 4/7 read at 0x20. Reading a structure by hand in a shell still works; this just stops it being the default, and stops the offset arithmetic being re-derived every time a new region is investigated.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
//! A structure dumper for reverse engineering.
|
||||
//!
|
||||
//! This exists because of a specific, repeated failure. Probing these formats
|
||||
//! by unpacking raw offsets out of `core_index.bin` got `collision` read as
|
||||
//! `sky` on all 27 levels, produced a page of implausible collision headers,
|
||||
//! and led to "fixing" a stride bug that did not exist. The core index
|
||||
//! interleaves single-word region offsets with count/offset **pairs**, so every
|
||||
//! field after a pair shifts if you miscount it.
|
||||
//!
|
||||
//! Everything here goes through the same `gc_formats` types the rest of the
|
||||
//! project uses, so a field name printed here is the field name the parser
|
||||
//! uses. Reading a structure by hand in a shell remains possible; this just
|
||||
//! stops it being the default.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// Byte size of a moby or tie class entry.
|
||||
pub const CLASS_ENTRY_BYTES: usize = 0x20;
|
||||
/// Byte size of a shrub class entry, which carries billboard fields the other
|
||||
/// two do not.
|
||||
pub const SHRUB_CLASS_ENTRY_BYTES: usize = 0x30;
|
||||
|
||||
/// One level's core header plus its three class tables.
|
||||
pub struct LevelProbe {
|
||||
pub level: u32,
|
||||
pub core_len: usize,
|
||||
pub index_len: usize,
|
||||
/// Rendered field-by-field dump of the core header.
|
||||
pub header: Vec<(String, String)>,
|
||||
/// The three class tables, in the order the header lists them.
|
||||
pub classes: Vec<ClassTable>,
|
||||
}
|
||||
|
||||
/// One class table, with its entries already sliced at the right stride.
|
||||
pub struct ClassTable {
|
||||
pub name: &'static str,
|
||||
pub stride: usize,
|
||||
pub entries: Vec<ClassRow>,
|
||||
}
|
||||
|
||||
/// One class table row.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ClassRow {
|
||||
pub index: usize,
|
||||
pub geometry_offset: i32,
|
||||
pub o_class: i32,
|
||||
pub unknown_8: i32,
|
||||
pub unknown_c: i32,
|
||||
/// The entry's raw bytes, so a probe can look at what the struct drops.
|
||||
pub raw: [u8; SHRUB_CLASS_ENTRY_BYTES],
|
||||
}
|
||||
|
||||
impl ClassRow {
|
||||
/// Whether this row's geometry offset lands inside `core_data`.
|
||||
pub fn in_core(&self, core_len: usize) -> bool {
|
||||
self.geometry_offset > 0 && (self.geometry_offset as usize) < core_len
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a level's core index and dump its header and class tables.
|
||||
pub fn probe_level(dir: &Path, level: u32) -> Result<LevelProbe> {
|
||||
let level_dir = dir.join(format!("level{level}"));
|
||||
let core = std::fs::read(level_dir.join("core_data.bin"))
|
||||
.with_context(|| format!("reading {}", level_dir.join("core_data.bin").display()))?;
|
||||
let index = std::fs::read(level_dir.join("core_index.bin"))
|
||||
.with_context(|| format!("reading {}", level_dir.join("core_index.bin").display()))?;
|
||||
|
||||
let h = gc_formats::level_core::LevelCoreHeader::parse(&index)
|
||||
.context("parsing the core header")?;
|
||||
|
||||
let range = |v: i32| {
|
||||
if v < 0 {
|
||||
"(absent)".to_string()
|
||||
} else {
|
||||
v.to_string()
|
||||
}
|
||||
};
|
||||
let mut header: Vec<(String, String)> = Vec::new();
|
||||
header.push(("gs_ram count".into(), h.gs_ram.count.to_string()));
|
||||
header.push(("gs_ram offset".into(), h.gs_ram.offset.to_string()));
|
||||
for (name, v) in [
|
||||
("tfrags", h.tfrags),
|
||||
("occlusion", h.occlusion),
|
||||
("sky", h.sky),
|
||||
("collision", h.collision),
|
||||
("textures_base", h.textures_base),
|
||||
("assets compressed", h.assets_compressed_size),
|
||||
("assets decompressed", h.assets_decompressed_size),
|
||||
] {
|
||||
header.push((name.into(), range(v)));
|
||||
}
|
||||
|
||||
let mut classes = Vec::new();
|
||||
for (name, r, stride) in [
|
||||
("moby", h.moby_classes, CLASS_ENTRY_BYTES),
|
||||
("tie", h.tie_classes, CLASS_ENTRY_BYTES),
|
||||
("shrub", h.shrub_classes, SHRUB_CLASS_ENTRY_BYTES),
|
||||
] {
|
||||
classes.push(ClassTable {
|
||||
name,
|
||||
stride,
|
||||
entries: read_class_table(&index, r, stride)?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(LevelProbe {
|
||||
level,
|
||||
core_len: core.len(),
|
||||
index_len: index.len(),
|
||||
header,
|
||||
classes,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_class_table(
|
||||
index: &[u8],
|
||||
r: gc_formats::level_core::ArrayRange,
|
||||
stride: usize,
|
||||
) -> Result<Vec<ClassRow>> {
|
||||
let base = r.offset.max(0) as usize;
|
||||
let count = r.count.max(0) as usize;
|
||||
let mut out = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let at = base + i * stride;
|
||||
let mut raw = [0u8; SHRUB_CLASS_ENTRY_BYTES];
|
||||
let Some(chunk) = index.get(at..at + stride) else {
|
||||
break;
|
||||
};
|
||||
raw[..stride].copy_from_slice(chunk);
|
||||
out.push(ClassRow {
|
||||
index: i,
|
||||
geometry_offset: gc_formats::level_core::ClassEntry::parse(chunk)
|
||||
.map(|e| e.offset_in_asset_wad)
|
||||
.unwrap_or(0),
|
||||
o_class: gc_formats::level_core::ClassEntry::parse(chunk)
|
||||
.map(|e| e.o_class)
|
||||
.unwrap_or(0),
|
||||
unknown_8: gc_formats::level_core::ClassEntry::parse(chunk)
|
||||
.map(|e| e.unknown_8)
|
||||
.unwrap_or(0),
|
||||
unknown_c: gc_formats::level_core::ClassEntry::parse(chunk)
|
||||
.map(|e| e.unknown_c)
|
||||
.unwrap_or(0),
|
||||
raw,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn class_rows_report_whether_geometry_is_in_core() {
|
||||
let r = ClassRow {
|
||||
index: 0,
|
||||
geometry_offset: 100,
|
||||
o_class: 1,
|
||||
unknown_8: 0,
|
||||
unknown_c: 0,
|
||||
raw: [0; SHRUB_CLASS_ENTRY_BYTES],
|
||||
};
|
||||
assert!(r.in_core(200));
|
||||
assert!(!r.in_core(50));
|
||||
assert!(!ClassRow {
|
||||
geometry_offset: 0,
|
||||
..r
|
||||
}
|
||||
.in_core(200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_past_the_index_stops_rather_than_panicking() {
|
||||
let index = vec![0u8; 16];
|
||||
let r = gc_formats::level_core::ArrayRange {
|
||||
count: 100,
|
||||
offset: 8,
|
||||
};
|
||||
let rows = read_class_table(&index, r, CLASS_ENTRY_BYTES).unwrap();
|
||||
assert!(rows.len() < 100, "should stop at the end of the index");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user