Parse tfrag terrain blocks and decode all vertex positions
Adds gc-formats::tfrag and a gated real-disc test that decodes every terrain patch on all 27 levels. Two rules decide whether this works at all, and both were wrong on the first attempt: The vector format is encoded in the VIF command byte, not the immediate. Bits 2-3 are vn (component count minus one) and bits 0-1 are vl, giving element_size = ((32 >> vl) * (vn + 1)) / 8. Terrain positions are vn=2, vl=1 (command 0x69) at 6 bytes. Reading the format from the immediate's low nibble -- the more commonly quoted layout -- yields 12 or 16 bytes and misaligns everything after it. Unpack payloads pad to 4 bytes, and several non-unpack commands carry inline data: STROW/STCOL 16, STMASK 4, MPG num*8, DIRECT/DIRECTHL imm*16. Ignoring those desynchronises the walk, and a desynchronised walk still finds code words that look like positions. Positions are also not all in one list. vert_count is the total across three tiers, the LOD-2 list precedes the common one in offset order, and the LOD-0-only list starts at shared_ofs + lod_1_size*16 rather than at an offset of its own. Scanning only the common list matches about a quarter of patches, each exactly -- which is what made it convincing. The invariant is that every patch yields exactly its own vert_count. It caught both errors, so tfrag_positions returns None on a mismatch rather than partial geometry. All 27 levels: 18,102 patches, 821,439 vertices, zero mismatches. Still open: strips, triangle indices and the RGBA vertex-info arrays. Positions alone are not drawable.
This commit is contained in:
@@ -385,3 +385,105 @@ fn level_geometry_decompresses() {
|
||||
core.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// The terrain itself. This is the first test to touch real level content: it
|
||||
/// parses the tfrags directory and decodes vertex positions from every patch.
|
||||
///
|
||||
/// The gate is deliberately about *coverage*, not correctness of a sample: a
|
||||
/// parser that works on one tfrag and silently returns `None` on the rest would
|
||||
/// otherwise pass.
|
||||
#[test]
|
||||
fn tfrag_vertices_decode_for_every_level() {
|
||||
require_disc!();
|
||||
// Opening the image also confirms the disc is readable, not merely present.
|
||||
gc_formats::iso9660::IsoImage::open(std::path::Path::new(
|
||||
&std::env::var("GC_DISC").expect("GC_DISC is set by require_disc!"),
|
||||
))
|
||||
.expect("ISO should open");
|
||||
// Where the unpacked data lives. GC_EXTRACTED overrides; otherwise the
|
||||
// default location used during development.
|
||||
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, e.g.\
|
||||
gc-extract unpack <disc.iso> ~/Documents/gc-extracted\
|
||||
or point GC_EXTRACTED at an existing unpack"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut levels = 0usize;
|
||||
let mut total_verts = 0usize;
|
||||
let mut total_tfrags = 0usize;
|
||||
|
||||
for n in 0..27 {
|
||||
let dir = data.join(format!("level{n}"));
|
||||
let core_path = dir.join("core_data.bin");
|
||||
let index_path = dir.join("core_index.bin");
|
||||
if !core_path.exists() || !index_path.exists() {
|
||||
continue;
|
||||
}
|
||||
let core = std::fs::read(&core_path).expect("core_data");
|
||||
let index = std::fs::read(&index_path).expect("core_index");
|
||||
|
||||
// The tfrags block starts at the core header's 0x08 field and runs to
|
||||
// the next region that exists.
|
||||
let region_start =
|
||||
|off: usize| -> i32 { i32::from_le_bytes(index[off..off + 4].try_into().unwrap()) };
|
||||
let start = region_start(0x08) as usize;
|
||||
let end = [0x0c_usize, 0x10, 0x14]
|
||||
.iter()
|
||||
.map(|&o| region_start(o))
|
||||
.filter(|&v| v > 0)
|
||||
.min()
|
||||
.unwrap_or(core.len() as i32) as usize;
|
||||
|
||||
let block = &core[start..end];
|
||||
let header = gc_formats::tfrag::TfragsBlock::parse(block).expect("tfrags block header");
|
||||
assert_eq!(header.table_offset, 0x40, "level{n}: table offset");
|
||||
|
||||
let table_offset = header.table_offset as usize;
|
||||
let mut decoded = 0usize;
|
||||
let mut verts = 0usize;
|
||||
|
||||
for i in 0..header.tfrag_count as usize {
|
||||
let th = header.header(block, i).expect("tfrag header");
|
||||
let data_base = table_offset.wrapping_add(th.data as usize);
|
||||
if data_base >= block.len() {
|
||||
continue;
|
||||
}
|
||||
let slice = &block[data_base..];
|
||||
if let Some(p) = gc_formats::tfrag::tfrag_positions(slice, &th) {
|
||||
decoded += 1;
|
||||
verts += p.len();
|
||||
assert_eq!(p.len(), th.vert_count as usize);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(header.tfrag_count > 0, "level{n}: no tfrags");
|
||||
assert_eq!(
|
||||
decoded, header.tfrag_count as usize,
|
||||
"level{n}: only {decoded} of {} tfrags decoded",
|
||||
header.tfrag_count
|
||||
);
|
||||
levels += 1;
|
||||
total_tfrags += header.tfrag_count as usize;
|
||||
total_verts += verts;
|
||||
}
|
||||
|
||||
assert!(levels > 0, "no unpacked levels were found");
|
||||
assert!(
|
||||
total_tfrags > 5000,
|
||||
"expected thousands of tfrags, got {total_tfrags}"
|
||||
);
|
||||
assert!(
|
||||
total_verts > 100_000,
|
||||
"expected 100k+ vertices, got {total_verts}"
|
||||
);
|
||||
eprintln!("decoded {total_verts} vertices from {total_tfrags} tfrags across {levels} levels");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user