Comparing every chunk record against the same record index in the other 26
levels, across all 74 signatures:
65 of 74 every record byte-identical in all 27 levels
9 of 74 level-specific
0 ragged no signature appeared with different record counts per level
The nine that vary:
[5d 44 13] [75 42 ef] [7f 44 ee] [82 c4 2f] [96 c3 37]
[a2 44 a5] [c6 c3 ef] [df c3 ea] [f3 c4 b9]
This reframes the container. 65 of 74 chunk types are static tables the game ships
unchanged in every level -- constants and lookups, not level content. The largest
by record count are all in that group: [38 c4 7e] stride 328 with 90 records per
level, [a9 c4 d8] stride 308 with 90, [3d c4 9e] stride 368 with 77.
The consequence is about method, and it is worth being blunt about. For a
level-specific type, comparing levels is the strongest lever available: a field
that moves with the level is identifiable by correlating it with something known.
For the other 65 there is nothing to compare -- one copy of the data, repeated 27
times -- so they must be decoded structurally, from what the bytes are. A future
decoder that reaches for per-level comparison as its first tool will find it
useless on the majority of the container.
[75 42 ef] is both level-specific AND the one signature with three strides. A
type that is both variable-length and level-varying is the most informative one
here, and is where per-type field decoding should start.
How this was found, including the wrong turn:
The first look at [53 c4 6c d7 9a 43] (stride 108) showed 45 of 108 byte positions
varying WITHIN a chunk and 810 records all distinct -- which reads like per-record
data. Counting distinct values per u32 slot across all 810 records contradicted
that: 29 to 30 distinct values across 27 levels, i.e. about one per level.
Both readings were wrong because they measure different things. The 45 positions
vary between records within a level; the u32 counts were low because the same 30
values recur in every level. Comparing record i across levels settled it: all 30
records are byte-identical in all 27 levels, and zero byte positions vary across
levels.
Had the u32 distinct counts been the only measurement, the conclusion would have
been "one value per level" -- the opposite of the truth, describing a per-level
parameter table that does not exist. The within-chunk and across-level views had
to be taken together to get a true one.
Asserted in most_chunk_signatures_are_level_independent. 242 tests, clippy clean.
4417 lines
169 KiB
Rust
4417 lines
169 KiB
Rust
//! Integration tests against a real disc image.
|
|
//!
|
|
//! These are skipped unless `GC_DISC` points at a legally obtained disc, since
|
|
//! the repository must not contain game data. When it is set they check the
|
|
//! identification and unpack results against the committed hash table.
|
|
//!
|
|
//! ```sh
|
|
//! GC_DISC="path/to/disc.iso" cargo test -p gc-extract --test disc
|
|
//! ```
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// The disc under test, if the environment names one.
|
|
fn disc() -> Option<PathBuf> {
|
|
let p = std::env::var_os("GC_DISC")?;
|
|
let p = PathBuf::from(p);
|
|
p.is_file().then_some(p)
|
|
}
|
|
|
|
/// Skip a test with a clear reason rather than failing.
|
|
macro_rules! require_disc {
|
|
() => {
|
|
match disc() {
|
|
Some(p) => p,
|
|
None => {
|
|
eprintln!("skipped: set GC_DISC to a Going Commando disc image to run this");
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
#[test]
|
|
fn identifies_the_supported_build() {
|
|
let path = require_disc!();
|
|
let info = gc_extract::identify::identify(&path).expect("disc should identify");
|
|
|
|
assert_eq!(info.serial, "SCUS_972.68");
|
|
assert_eq!(info.volume_id, "RATCHETANDCLANK2");
|
|
assert!(
|
|
info.is_target(),
|
|
"expected the supported build, got: {}",
|
|
info.verdict()
|
|
);
|
|
assert_eq!(
|
|
info.boot_elf_sha1,
|
|
gc_extract::identify::TARGET_BOOT_ELF_SHA1,
|
|
"boot ELF SHA-1 differs from the recorded retail value"
|
|
);
|
|
assert_eq!(info.boot_elf_size, 2_618_684);
|
|
// This disc is NTSC; the movement constants assume 60 Hz.
|
|
assert!(!info.is_pal(), "test disc should be NTSC");
|
|
}
|
|
|
|
#[test]
|
|
fn agrees_with_the_python_reference_identifier() {
|
|
let path = require_disc!();
|
|
let info = gc_extract::identify::identify(&path).expect("disc should identify");
|
|
|
|
// tools/identify-disc.py is the reference implementation and prints these
|
|
// two values. If they ever disagree, one of them has a bug.
|
|
// Run from the workspace root so the relative path to the tool resolves.
|
|
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.parent()
|
|
.and_then(|p| p.parent())
|
|
.expect("the crate should live two levels below the workspace root")
|
|
.to_path_buf();
|
|
let py = std::process::Command::new("python3")
|
|
.arg(root.join("tools/identify-disc.py"))
|
|
.arg(&path)
|
|
.current_dir(&root)
|
|
.output()
|
|
.expect("python3 should be available to run the reference identifier");
|
|
assert!(
|
|
py.status.success(),
|
|
"the reference identifier failed:\n{}",
|
|
String::from_utf8_lossy(&py.stderr)
|
|
);
|
|
let text = String::from_utf8_lossy(&py.stdout);
|
|
|
|
assert!(
|
|
text.contains(&format!("serial {}", info.serial)),
|
|
"serial disagrees with the reference identifier:\n{text}"
|
|
);
|
|
assert!(
|
|
text.contains(&format!("SHA-1 {}", info.boot_elf_sha1)),
|
|
"boot ELF SHA-1 disagrees with the reference identifier:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unpacks_and_matches_the_committed_table() {
|
|
let path = require_disc!();
|
|
|
|
let table_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("data/gc-files.tsv");
|
|
let table_text =
|
|
std::fs::read_to_string(&table_path).expect("the hash table should be committed");
|
|
let expected = gc_extract::verify::parse_table(&table_text);
|
|
assert!(
|
|
expected.len() > 5000,
|
|
"expected a full table, got {} rows",
|
|
expected.len()
|
|
);
|
|
|
|
// Unpack into a temporary directory so the test leaves nothing behind.
|
|
let tmp = std::env::temp_dir().join(format!("gc-unpack-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
std::fs::create_dir_all(&tmp).expect("should create a temp directory");
|
|
|
|
let files = gc_extract::unpack::unpack(&path, &tmp).expect("unpack should succeed");
|
|
|
|
// Every unpacked file must match the recorded size and hash.
|
|
let report = gc_extract::verify::verify(&tmp, &expected);
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
|
|
assert!(
|
|
report.ok(),
|
|
"unpacked data does not match the committed table:\n{}",
|
|
report.summary()
|
|
);
|
|
assert_eq!(
|
|
files.len(),
|
|
expected.len(),
|
|
"unpack produced a different file count"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn every_container_parses() {
|
|
let path = require_disc!();
|
|
|
|
let iso = gc_formats::iso9660::IsoImage::open(&path).expect("ISO should open");
|
|
let containers: Vec<_> = iso
|
|
.files
|
|
.iter()
|
|
.filter(|e| e.name.starts_with("G/") && e.name.ends_with(".WAD"))
|
|
.collect();
|
|
|
|
// 92 `G/*.WAD` containers: 27 LEVEL, 27 AUDIO, 27 SCENE, 11 globals.
|
|
assert_eq!(containers.len(), 92, "expected 92 asset containers");
|
|
|
|
// MPEG1 and MPEG2 are raw video streams with no table; the other 90 must
|
|
// all parse as indexed containers.
|
|
let raw_video: Vec<&str> = containers
|
|
.iter()
|
|
.map(|c| c.name.as_str())
|
|
.filter(|n| *n == "G/MPEG1.WAD" || *n == "G/MPEG2.WAD")
|
|
.collect();
|
|
assert_eq!(
|
|
raw_video.len(),
|
|
2,
|
|
"expected MPEG1 and MPEG2 to be the raw streams"
|
|
);
|
|
|
|
// The per-level families must all be present, at 27 each.
|
|
for family in ["LEVEL", "AUDIO", "SCENE"] {
|
|
let n = containers
|
|
.iter()
|
|
.filter(|c| {
|
|
let stem = c.name.trim_start_matches("G/").trim_end_matches(".WAD");
|
|
stem.strip_prefix(family)
|
|
.map(|rest| !rest.is_empty() && rest.chars().all(|ch| ch.is_ascii_digit()))
|
|
.unwrap_or(false)
|
|
})
|
|
.count();
|
|
assert_eq!(n, 27, "expected 27 {family}<n>.WAD containers, found {n}");
|
|
}
|
|
|
|
for entry in &containers {
|
|
if raw_video.contains(&entry.name.as_str()) {
|
|
continue;
|
|
}
|
|
let data = gc_formats::iso9660::read_at(&path, entry.lba, entry.size)
|
|
.expect("read should succeed");
|
|
let kind = gc_formats::global_wad::RangeKind::for_container(&entry.name);
|
|
let container = gc_formats::global_wad::GlobalWad::parse_with(&data, kind)
|
|
.unwrap_or_else(|e| panic!("{} failed to parse: {e}", entry.name));
|
|
assert!(
|
|
!container.entries.is_empty(),
|
|
"{} parsed but had no entries",
|
|
entry.name
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The level container header is the first typed structure we parse from real
|
|
/// data, and the tiling invariant is the check that holds on every level rather
|
|
/// than one hand-picked sample.
|
|
#[test]
|
|
fn level_headers_tile_their_containers() {
|
|
let path = require_disc!();
|
|
let iso = gc_formats::iso9660::IsoImage::open(&path).expect("ISO should open");
|
|
|
|
let mut checked = 0;
|
|
for entry in iso.files.iter().filter(|e| e.name.starts_with("G/LEVEL")) {
|
|
let data = gc_formats::iso9660::read_at(&path, entry.lba, 0x60).expect("read header");
|
|
let h = gc_formats::level_wad::LevelHeader::parse(&data)
|
|
.unwrap_or_else(|e| panic!("{}: {e}", entry.name));
|
|
|
|
assert_eq!(h.header_size, 0x60, "{}", entry.name);
|
|
assert_eq!(
|
|
h.runtime_sector, 0,
|
|
"{}: runtime sector field should be 0 on disc",
|
|
entry.name
|
|
);
|
|
|
|
// Every present extent must start where the previous one ended.
|
|
let extents = h.ordered_extents();
|
|
for w in extents.windows(2) {
|
|
let (pn, p) = w[0];
|
|
let (nn, n) = w[1];
|
|
assert_eq!(
|
|
p.offset + p.size,
|
|
n.offset,
|
|
"{}: gap or overlap between {} and {}",
|
|
entry.name,
|
|
pn.name(),
|
|
nn.name()
|
|
);
|
|
}
|
|
|
|
// Nothing may fall outside the container. The last extent may end one
|
|
// sector past the file end through sector rounding, hence `+ 1`.
|
|
let container_sectors = (entry.size / 2048) as i32;
|
|
for (name, r) in &extents {
|
|
assert!(
|
|
r.offset >= 0 && r.offset + r.size <= container_sectors + 1,
|
|
"{}: {} extent {:?} is outside the container ({container_sectors} sectors)",
|
|
entry.name,
|
|
name.name(),
|
|
r
|
|
);
|
|
}
|
|
checked += 1;
|
|
}
|
|
|
|
assert_eq!(checked, 27, "expected 27 level containers");
|
|
}
|
|
|
|
/// Level ids are not file order: `LEVEL21.WAD` reports 30. Anything that keys
|
|
/// off the container index would silently mislabel that planet.
|
|
#[test]
|
|
fn level_ids_are_not_container_indices() {
|
|
let path = require_disc!();
|
|
let iso = gc_formats::iso9660::IsoImage::open(&path).expect("ISO should open");
|
|
|
|
// The ISO filesystem lists names alphabetically, so LEVEL10 sorts before
|
|
// LEVEL2. Order by the numeric suffix, which is what the container names
|
|
// mean.
|
|
let mut levels: Vec<(usize, &gc_formats::iso9660::DirEntry)> = iso
|
|
.files
|
|
.iter()
|
|
.filter(|e| e.name.starts_with("G/LEVEL"))
|
|
.map(|e| {
|
|
let n: usize = e
|
|
.name
|
|
.trim_start_matches("G/LEVEL")
|
|
.trim_end_matches(".WAD")
|
|
.parse()
|
|
.expect("level container names end in a number");
|
|
(n, e)
|
|
})
|
|
.collect();
|
|
levels.sort_by_key(|(n, _)| *n);
|
|
|
|
let mut mismatches = Vec::new();
|
|
for (index, (_, entry)) in levels.iter().enumerate() {
|
|
let data = gc_formats::iso9660::read_at(&path, entry.lba, 0x60).expect("read header");
|
|
let h = gc_formats::level_wad::LevelHeader::parse(&data).expect("parse");
|
|
if h.id as usize != index {
|
|
mismatches.push(format!(
|
|
"{}: id {} but is container {index}",
|
|
entry.name, h.id
|
|
));
|
|
}
|
|
}
|
|
|
|
// Exactly one level is out of step: the gap left by a removed level.
|
|
assert_eq!(
|
|
mismatches.len(),
|
|
1,
|
|
"expected exactly one level whose id differs from its container index, got {mismatches:?}"
|
|
);
|
|
assert!(
|
|
mismatches[0].contains("LEVEL21"),
|
|
"unexpected mismatch: {mismatches:?}"
|
|
);
|
|
}
|
|
|
|
/// Sizes in the level header are sector counts. If they were byte counts, the
|
|
/// extents could not tile a container this size, so this also guards against
|
|
/// "fixing" the parser to read them as bytes.
|
|
#[test]
|
|
fn level_extent_sizes_are_sector_counts() {
|
|
let path = require_disc!();
|
|
let iso = gc_formats::iso9660::IsoImage::open(&path).expect("ISO should open");
|
|
let entry = iso.find("G/LEVEL0.WAD").expect("level 0");
|
|
|
|
let data = gc_formats::iso9660::read_at(&path, entry.lba, 0x60).expect("read header");
|
|
let h = gc_formats::level_wad::LevelHeader::parse(&data).expect("parse");
|
|
|
|
// Sound bank occupies sectors 1..735 and data 735..7219.
|
|
assert_eq!(h.sound_bank.offset, 1);
|
|
assert_eq!(h.sound_bank.size, 734);
|
|
assert_eq!(h.data.offset, 735);
|
|
// Read as bytes, 734 would be under four sectors and the tiling would break.
|
|
assert_eq!(h.sound_bank.byte_len(), 734 * 2048);
|
|
}
|
|
|
|
/// The level data extent is a container of byte ranges. Its header locates the
|
|
/// geometry index and the compressed geometry, and every range must lie inside
|
|
/// the extent on all 27 levels.
|
|
#[test]
|
|
fn level_data_extents_are_in_range() {
|
|
let path = require_disc!();
|
|
let iso = gc_formats::iso9660::IsoImage::open(&path).expect("ISO should open");
|
|
|
|
let mut checked = 0;
|
|
for entry in iso.files.iter().filter(|e| e.name.starts_with("G/LEVEL")) {
|
|
let raw =
|
|
gc_formats::iso9660::read_at(&path, entry.lba, entry.size).expect("read container");
|
|
let lh = gc_formats::level_wad::LevelHeader::parse(&raw).expect("level header");
|
|
let data = lh.data;
|
|
let Some(offset) = data.byte_offset() else {
|
|
continue;
|
|
};
|
|
let len = data.byte_len() as usize;
|
|
let extent = &raw[offset as usize..offset as usize + len];
|
|
|
|
let dh = gc_formats::level_data::LevelDataHeader::parse(extent)
|
|
.unwrap_or_else(|e| panic!("{}: data extent header: {e}", entry.name));
|
|
|
|
for (part, r) in dh.present_parts() {
|
|
assert!(
|
|
r.end() <= extent.len() as u64,
|
|
"{}: {part:?} ends at {} but the extent is {} bytes",
|
|
entry.name,
|
|
r.end(),
|
|
extent.len()
|
|
);
|
|
}
|
|
|
|
// The geometry is the big one and must be substantial.
|
|
assert!(
|
|
dh.core_data.size > 1_000_000,
|
|
"{}: core_data is only {} bytes",
|
|
entry.name,
|
|
dh.core_data.size
|
|
);
|
|
checked += 1;
|
|
}
|
|
assert_eq!(checked, 27, "expected 27 level containers");
|
|
}
|
|
|
|
/// The geometry must decompress. This is the first test that touches real level
|
|
/// content rather than headers.
|
|
#[test]
|
|
fn level_geometry_decompresses() {
|
|
let path = require_disc!();
|
|
let iso = gc_formats::iso9660::IsoImage::open(&path).expect("ISO should open");
|
|
let entry = iso.find("G/LEVEL0.WAD").expect("level 0");
|
|
|
|
let raw = gc_formats::iso9660::read_at(&path, entry.lba, entry.size).expect("read container");
|
|
let lh = gc_formats::level_wad::LevelHeader::parse(&raw).expect("level header");
|
|
let data = lh.data;
|
|
let extent = &raw[data.byte_offset().unwrap() as usize
|
|
..(data.byte_offset().unwrap() + data.byte_len()) as usize];
|
|
|
|
let dh = gc_formats::level_data::LevelDataHeader::parse(extent).expect("data header");
|
|
let core = dh
|
|
.core_data
|
|
.slice(extent, extent.len())
|
|
.expect("core_data slice");
|
|
|
|
assert!(
|
|
gc_formats::wad::is_wad(core),
|
|
"core_data should start with the WAD magic"
|
|
);
|
|
|
|
let decoded = gc_formats::wad::decompress(core).expect("core_data should decompress");
|
|
assert!(
|
|
decoded.len() > core.len(),
|
|
"decompressed geometry ({}) should exceed the compressed size ({})",
|
|
decoded.len(),
|
|
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");
|
|
}
|
|
|
|
/// Terrain topology: strips and index arrays must expand to exactly the
|
|
/// triangle count each patch's header claims.
|
|
///
|
|
/// `tri_count` is stored in the patch header but is not derivable from the
|
|
/// lists directly, so reproducing it on all 18,102 patches is a strong check
|
|
/// that the strip walk, the index bank and the LOD assignment are all right.
|
|
/// Triangle indices must also stay inside the vertex-info range for the LOD.
|
|
#[test]
|
|
fn tfrag_topology_reproduces_the_header_triangle_count() {
|
|
require_disc!();
|
|
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");
|
|
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;
|
|
}
|
|
|
|
let mut patches = 0usize;
|
|
let mut triangles = 0usize;
|
|
let mut levels = 0usize;
|
|
|
|
for n in 0..27 {
|
|
let dir = data.join(format!("level{n}"));
|
|
let core = match std::fs::read(dir.join("core_data.bin")) {
|
|
Ok(c) => c,
|
|
Err(_) => continue,
|
|
};
|
|
let index = match std::fs::read(dir.join("core_index.bin")) {
|
|
Ok(i) => i,
|
|
Err(_) => continue,
|
|
};
|
|
let region =
|
|
|off: usize| i32::from_le_bytes(index[off..off + 4].try_into().unwrap()) as usize;
|
|
let start = region(0x08);
|
|
let end = [0x0c_usize, 0x10, 0x14]
|
|
.iter()
|
|
.map(|&o| region(o))
|
|
.filter(|&v| v > 0)
|
|
.min()
|
|
.unwrap_or(core.len());
|
|
let block = &core[start..end];
|
|
|
|
let header = gc_formats::tfrag::TfragsBlock::parse(block).expect("tfrags block header");
|
|
let table = header.table_offset as usize;
|
|
for i in 0..header.tfrag_count as usize {
|
|
let th = header.header(block, i).expect("tfrag header");
|
|
let data_base = table.wrapping_add(th.data as usize);
|
|
if data_base >= block.len() {
|
|
continue;
|
|
}
|
|
let g = gc_formats::tfrag::parse_tfrag(&block[data_base..], &th)
|
|
.unwrap_or_else(|e| panic!("level{n} patch {i}: {e}"));
|
|
let tris = gc_formats::tfrag::tfrag_triangles(&g, 0)
|
|
.unwrap_or_else(|e| panic!("level{n} patch {i}: {e}"));
|
|
assert_eq!(
|
|
tris.len(),
|
|
th.tri_count as usize,
|
|
"level{n} patch {i}: {} triangles, header says {}",
|
|
tris.len(),
|
|
th.tri_count
|
|
);
|
|
let vinfo = g.lod_vinfo_count(0);
|
|
for t in &tris {
|
|
let idx = [t.a as usize, t.b as usize, t.c as usize];
|
|
assert!(
|
|
idx.iter().all(|&v| v < vinfo),
|
|
"level{n} patch {i}: triangle index outside {vinfo} vertex-info entries"
|
|
);
|
|
assert!((t.ad_gif as usize) < g.ad_gifs.len());
|
|
}
|
|
patches += 1;
|
|
triangles += tris.len();
|
|
}
|
|
levels += 1;
|
|
}
|
|
|
|
assert!(levels > 0, "no unpacked levels were found");
|
|
assert!(patches > 18_000, "expected 18k+ patches, got {patches}");
|
|
assert!(
|
|
triangles > 700_000,
|
|
"expected 700k+ triangles, got {triangles}"
|
|
);
|
|
eprintln!("reproduced {triangles} triangles across {patches} patches in {levels} levels");
|
|
}
|
|
|
|
/// Texture bindings: every ad-gif a patch uses must index an entry that
|
|
/// actually exists in the level's texture table, and the light array must be
|
|
/// one record per vertex.
|
|
///
|
|
/// The texture table is the join between tfrags and the texel data, so an
|
|
/// out-of-range index here means the level's geometry and its textures have
|
|
/// been misread independently of each other.
|
|
#[test]
|
|
fn tfrag_ad_gifs_index_the_level_texture_table() {
|
|
require_disc!();
|
|
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");
|
|
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;
|
|
}
|
|
|
|
let mut patches = 0usize;
|
|
let mut ad_gifs = 0usize;
|
|
let mut textures_seen = 0usize;
|
|
let mut textures_total = 0usize;
|
|
let mut levels = 0usize;
|
|
|
|
for n in 0..27 {
|
|
let dir = data.join(format!("level{n}"));
|
|
let core = match std::fs::read(dir.join("core_data.bin")) {
|
|
Ok(c) => c,
|
|
Err(_) => continue,
|
|
};
|
|
let index = match std::fs::read(dir.join("core_index.bin")) {
|
|
Ok(i) => i,
|
|
Err(_) => continue,
|
|
};
|
|
let ch = gc_formats::level_core::LevelCoreHeader::parse(&index).expect("core header");
|
|
let textures = gc_formats::level_core::texture_table(&index, ch.tfrag_textures)
|
|
.expect("texture table");
|
|
assert!(!textures.is_empty(), "level{n}: no tfrag textures");
|
|
// Indices are per-level, so the pair is what identifies a texture.
|
|
let mut level_seen = std::collections::BTreeSet::new();
|
|
|
|
let region =
|
|
|off: usize| i32::from_le_bytes(index[off..off + 4].try_into().unwrap()) as usize;
|
|
let start = region(0x08);
|
|
let end = [0x0c_usize, 0x10, 0x14]
|
|
.iter()
|
|
.map(|&o| region(o))
|
|
.filter(|&v| v > 0)
|
|
.min()
|
|
.unwrap_or(core.len());
|
|
let block = &core[start..end];
|
|
|
|
let header = gc_formats::tfrag::TfragsBlock::parse(block).expect("tfrags block header");
|
|
let table = header.table_offset as usize;
|
|
for i in 0..header.tfrag_count as usize {
|
|
let th = header.header(block, i).expect("tfrag header");
|
|
let data_base = table.wrapping_add(th.data as usize);
|
|
if data_base >= block.len() {
|
|
continue;
|
|
}
|
|
let g = gc_formats::tfrag::parse_tfrag(&block[data_base..], &th)
|
|
.unwrap_or_else(|e| panic!("level{n} patch {i}: {e}"));
|
|
|
|
assert_eq!(
|
|
g.lights.len(),
|
|
th.vert_count as usize,
|
|
"level{n} patch {i}: {} light records for {} vertices",
|
|
g.lights.len(),
|
|
th.vert_count
|
|
);
|
|
assert_eq!(
|
|
g.rgba.len(),
|
|
th.rgba_size as usize * 4,
|
|
"level{n} patch {i}: rgba"
|
|
);
|
|
|
|
for a in &g.ad_gifs {
|
|
let t = a.texture_index();
|
|
assert!(
|
|
t < textures.len(),
|
|
"level{n} patch {i}: texture {} outside the {} entry table",
|
|
t,
|
|
textures.len()
|
|
);
|
|
let tex = &textures[t];
|
|
assert!(
|
|
tex.width > 0 && tex.height > 0 && tex.width <= 256 && tex.height <= 256,
|
|
"level{n} patch {i}: texture {t} is {}x{}",
|
|
tex.width,
|
|
tex.height
|
|
);
|
|
level_seen.insert(t);
|
|
ad_gifs += 1;
|
|
}
|
|
patches += 1;
|
|
}
|
|
textures_seen += level_seen.len();
|
|
textures_total += textures.len();
|
|
levels += 1;
|
|
}
|
|
|
|
assert!(levels > 0, "no unpacked levels were found");
|
|
assert!(patches > 18_000, "expected 18k+ patches, got {patches}");
|
|
// Most of each level's texture table should be referenced by some tfrag. A
|
|
// handful genuinely go unused; if that share grows, the ad-gif walk has
|
|
// started missing primitives rather than the data having spare entries.
|
|
let covered = textures_seen as f64 / textures_total as f64;
|
|
assert!(
|
|
covered > 0.85,
|
|
"only {textures_seen} of {textures_total} level textures referenced ({:.1}%)",
|
|
covered * 100.0
|
|
);
|
|
eprintln!(
|
|
"{ad_gifs} ad-gifs over {patches} patches reference {textures_seen}/{textures_total} level textures"
|
|
);
|
|
}
|
|
|
|
/// Level textures decode: every tfrag texture's base level must resolve to a
|
|
/// plausible RGBA image.
|
|
///
|
|
/// This is the join between the texture table, the texel block and GS RAM, so
|
|
/// it is where a wrong CLUT remap, a wrong alpha scale or a wrong pixel base
|
|
/// would show up. The check is deliberately about *shape*, not specific
|
|
/// colours: a decoded image is plausible when it is fully opaque, is not
|
|
/// uniformly one colour, and uses more than a handful of distinct entries.
|
|
#[test]
|
|
fn tfrag_textures_decode_to_plausible_images() {
|
|
require_disc!();
|
|
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");
|
|
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;
|
|
}
|
|
|
|
let mut decoded = 0usize;
|
|
let mut opaque = 0usize;
|
|
let mut multi_colour = 0usize;
|
|
let mut flat = 0usize;
|
|
|
|
for n in 0..27 {
|
|
let dir = data.join(format!("level{n}"));
|
|
let core = match std::fs::read(dir.join("core_data.bin")) {
|
|
Ok(c) => c,
|
|
Err(_) => continue,
|
|
};
|
|
let index = match std::fs::read(dir.join("core_index.bin")) {
|
|
Ok(i) => i,
|
|
Err(_) => continue,
|
|
};
|
|
let gs = match std::fs::read(dir.join("gs_ram.bin")) {
|
|
Ok(g) => g,
|
|
Err(_) => {
|
|
eprintln!("level{n}: no gs_ram.bin, skipping");
|
|
continue;
|
|
}
|
|
};
|
|
let ch = gc_formats::level_core::LevelCoreHeader::parse(&index).expect("core header");
|
|
let textures_at = ch.textures_base.max(0) as usize;
|
|
let texture_block = &core[textures_at..];
|
|
let table =
|
|
gc_formats::level_core::texture_table(&index, ch.tfrag_textures).expect("table");
|
|
|
|
for (i, e) in table.iter().enumerate() {
|
|
let img = gc_formats::texture::base_image(texture_block, &gs, e)
|
|
.unwrap_or_else(|err| panic!("level{n} texture {i}: {err}"));
|
|
let t = img.decode().unwrap();
|
|
let texels = (e.width as i32) * (e.height as i32);
|
|
assert_eq!(t.rgba.len(), (texels * 4) as usize);
|
|
assert_eq!(t.width, e.width as u32);
|
|
decoded += 1;
|
|
|
|
// Count distinct colours and how many texels are fully opaque.
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut op = 0usize;
|
|
for px in t.rgba.chunks_exact(4) {
|
|
seen.insert([px[0], px[1], px[2], px[3]]);
|
|
if px[3] == 0xff {
|
|
op += 1;
|
|
}
|
|
}
|
|
if seen.len() > 8 {
|
|
multi_colour += 1;
|
|
} else {
|
|
flat += 1;
|
|
}
|
|
if op == texels as usize {
|
|
opaque += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(decoded > 1_500, "only {decoded} textures decoded");
|
|
// Level art is mostly opaque and has real detail. A flat, transparent or
|
|
// single-colour result almost always means a CLUT or offset mistake.
|
|
let opaque_pct = opaque as f64 / decoded as f64;
|
|
let detail_pct = multi_colour as f64 / decoded as f64;
|
|
assert!(
|
|
opaque_pct > 0.9,
|
|
"only {opaque:.1}% of textures are fully opaque"
|
|
);
|
|
assert!(
|
|
detail_pct > 0.9,
|
|
"only {detail_pct:.1} of textures have >8 colours"
|
|
);
|
|
eprintln!(
|
|
"{decoded} textures decoded: {:.1}% fully opaque, {:.1}% with >8 colours ({flat} flat)",
|
|
opaque_pct * 100.0,
|
|
detail_pct * 100.0
|
|
);
|
|
}
|
|
|
|
/// LOD linkage: every LOD-01 and LOD-0 vertex-info entry must resolve to a
|
|
/// first parent that exists and to positions inside the patch.
|
|
///
|
|
/// The parent-index arrays are V4_8 unpacks separated from the strips and the
|
|
/// strip indices only by their VU address, so a misclassification here is
|
|
/// silent: the arrays still parse, they just hold the wrong numbers. Resolving
|
|
/// every entry is what catches it.
|
|
#[test]
|
|
fn tfrag_lod_parents_resolve_for_every_entry() {
|
|
require_disc!();
|
|
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");
|
|
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;
|
|
}
|
|
|
|
let mut patches = 0usize;
|
|
let mut entries = 0usize;
|
|
let mut lod01 = 0usize;
|
|
let mut lod0 = 0usize;
|
|
let mut resolved_common = 0usize;
|
|
let mut common_total = 0usize;
|
|
let mut primary_failed = 0usize;
|
|
let mut extras_unresolved = 0usize;
|
|
|
|
for n in 0..27 {
|
|
let dir = data.join(format!("level{n}"));
|
|
let core = match std::fs::read(dir.join("core_data.bin")) {
|
|
Ok(c) => c,
|
|
Err(_) => continue,
|
|
};
|
|
let index = match std::fs::read(dir.join("core_index.bin")) {
|
|
Ok(i) => i,
|
|
Err(_) => continue,
|
|
};
|
|
let region =
|
|
|off: usize| i32::from_le_bytes(index[off..off + 4].try_into().unwrap()) as usize;
|
|
let start = region(0x08);
|
|
let end = [0x0c_usize, 0x10, 0x14]
|
|
.iter()
|
|
.map(|&o| region(o))
|
|
.filter(|&v| v > 0)
|
|
.min()
|
|
.unwrap_or(core.len());
|
|
let block = &core[start..end];
|
|
let header = gc_formats::tfrag::TfragsBlock::parse(block).expect("tfrags block");
|
|
let table = header.table_offset as usize;
|
|
|
|
for i in 0..header.tfrag_count as usize {
|
|
let th = header.header(block, i).expect("tfrag header");
|
|
let data_base = table.wrapping_add(th.data as usize);
|
|
if data_base >= block.len() {
|
|
continue;
|
|
}
|
|
let g = gc_formats::tfrag::parse_tfrag(&block[data_base..], &th)
|
|
.unwrap_or_else(|e| panic!("level{n} patch {i}: {e}"));
|
|
|
|
common_total += g.vinfo_tier[0];
|
|
if n == 0 && patches < 5 {
|
|
eprintln!(
|
|
"p{i}: tier={:?} n01={} n0={} p01={}b x01={}b p0={}b x0={}b",
|
|
g.vinfo_tier,
|
|
g.vu_positions_lod_01_count,
|
|
g.vu_positions_lod_0_count,
|
|
g.parent_indices_lod01.len(),
|
|
g.unk_indices_2_lod01.len(),
|
|
g.parent_indices_lod0.len(),
|
|
g.unk_indices_2_lod0.len()
|
|
);
|
|
}
|
|
for v in 0..g.vertex_info.len() {
|
|
entries += 1;
|
|
match gc_formats::tfrag::tfrag_lod_link(&g, v) {
|
|
Some(link) => {
|
|
if v < g.vinfo_tier[0] {
|
|
resolved_common += 1;
|
|
}
|
|
// Parents must index real vertex info and real positions.
|
|
assert!(
|
|
link.parent1_vinfo < g.vertex_info.len(),
|
|
"level{n} patch {i}: entry {v} parent {} outside {} entries",
|
|
link.parent1_vinfo,
|
|
g.vertex_info.len()
|
|
);
|
|
assert!(link.own_position < g.positions.len());
|
|
assert!(link.parent1_position < g.positions.len());
|
|
assert!(link.parent2_position < g.positions.len());
|
|
// The first parent of a LOD-01 entry is always a common one.
|
|
if link.tier == gc_formats::tfrag::TfragMorphTier::Lod01 {
|
|
assert!(
|
|
link.parent1_vinfo < g.vinfo_tier[0],
|
|
"level{n} patch {i}: LOD-01 entry {v} parent {} is not a common entry",
|
|
link.parent1_vinfo
|
|
);
|
|
lod01 += 1;
|
|
} else {
|
|
lod0 += 1;
|
|
}
|
|
}
|
|
None => {
|
|
// Distinguish the two kinds of LOD entry. A *primary*
|
|
// entry has a real position and a recorded parent and
|
|
// must always resolve; an *extra* entry is a
|
|
// non-position bookkeeping record whose array address
|
|
// can collide with the strip index array.
|
|
let c = g.vinfo_tier[0];
|
|
let kind = if v < c {
|
|
None
|
|
} else if v < c + g.vinfo_tier[1] {
|
|
Some((g.vu_positions_lod_01_count, v - c, true))
|
|
} else {
|
|
Some((g.vu_positions_lod_0_count, v - c - g.vinfo_tier[1], false))
|
|
};
|
|
match kind {
|
|
None => {}
|
|
Some((prim, k, _)) if k < prim => primary_failed += 1,
|
|
Some(_) => extras_unresolved += 1,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
patches += 1;
|
|
}
|
|
}
|
|
|
|
assert!(patches > 18_000, "expected 18k+ patches, got {patches}");
|
|
|
|
// A common entry never has a link: they are the full-detail positions and
|
|
// have no parent. Anything else means the tier boundaries are wrong.
|
|
assert!(
|
|
resolved_common == 0,
|
|
"{resolved_common} common entries claimed an LOD parent"
|
|
);
|
|
assert!(
|
|
lod01 > 0 && lod0 > 0,
|
|
"both LOD tiers should occur in the data"
|
|
);
|
|
assert_eq!(
|
|
primary_failed, 0,
|
|
"{primary_failed} primary LOD entries did not resolve"
|
|
);
|
|
assert_eq!(
|
|
extras_unresolved, 0,
|
|
"{extras_unresolved} extra LOD entries did not resolve"
|
|
);
|
|
// Common entries have no link; every other entry does.
|
|
assert_eq!(entries, common_total + lod01 + lod0);
|
|
|
|
eprintln!(
|
|
"{entries} vertex-info entries over {patches} patches: {common_total} common, \
|
|
{lod01} LOD-01 and {lod0} LOD-0, all resolved"
|
|
);
|
|
}
|
|
|
|
/// The level core index's class tables.
|
|
///
|
|
/// These are what put the environment on top of the terrain: tie instances,
|
|
/// moby actors and shrub foliage. They were ruled out earlier for want of an
|
|
/// entry size, which turned out to be knowable exactly.
|
|
///
|
|
/// The check is the chain: each table must end precisely where the next one
|
|
/// begins, moby (32 bytes/entry) into tie (32) into shrub (48) into the tfrag
|
|
/// texture table. That closes on all 27 levels and would not close if any
|
|
/// entry size were wrong, so it is the evidence rather than a coincidence.
|
|
#[test]
|
|
fn level_core_class_tables_tile_the_index() {
|
|
require_disc!();
|
|
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");
|
|
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 as lc;
|
|
let mut levels = 0usize;
|
|
let (mut moby, mut tie, mut shrub, mut textures) = (0usize, 0usize, 0usize, 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 h = lc::LevelCoreHeader::parse(&index).expect("core header");
|
|
|
|
let end = |r: lc::ArrayRange, size: usize| r.offset as usize + r.count as usize * size;
|
|
assert_eq!(
|
|
end(h.moby_classes, lc::MOBY_CLASS_SIZE),
|
|
h.tie_classes.offset as usize,
|
|
"level{n}: moby -> tie"
|
|
);
|
|
assert_eq!(
|
|
end(h.tie_classes, lc::TIE_CLASS_SIZE),
|
|
h.shrub_classes.offset as usize,
|
|
"level{n}: tie -> shrub"
|
|
);
|
|
assert_eq!(
|
|
end(h.shrub_classes, lc::SHRUB_CLASS_SIZE),
|
|
h.tfrag_textures.offset as usize,
|
|
"level{n}: shrub -> textures"
|
|
);
|
|
|
|
// Every entry parses, and its asset offset lands inside the level.
|
|
if n == 0 {
|
|
eprintln!(
|
|
"hdr moby={:?} tie={:?} shrub={:?} tex={:?} base={}",
|
|
h.moby_classes, h.tie_classes, h.shrub_classes, h.tfrag_textures, h.textures_base
|
|
);
|
|
eprintln!("index[192..200] = {:?}", &index[192..200]);
|
|
}
|
|
let moby_v = lc::class_table(
|
|
&index,
|
|
h.moby_classes,
|
|
lc::MOBY_CLASS_SIZE,
|
|
lc::ClassEntry::parse,
|
|
)
|
|
.expect("moby classes");
|
|
let tie_v = lc::class_table(
|
|
&index,
|
|
h.tie_classes,
|
|
lc::TIE_CLASS_SIZE,
|
|
lc::ClassEntry::parse,
|
|
)
|
|
.expect("tie classes");
|
|
let shrub_v = lc::class_table(
|
|
&index,
|
|
h.shrub_classes,
|
|
lc::SHRUB_CLASS_SIZE,
|
|
lc::ShrubClassEntry::parse,
|
|
)
|
|
.expect("shrub classes");
|
|
let tex_v = lc::texture_table(&index, h.tfrag_textures).expect("tfrag textures");
|
|
|
|
assert_eq!(moby_v.len(), h.moby_classes.count as usize);
|
|
for (i, c) in moby_v.iter().chain(tie_v.iter()).enumerate() {
|
|
assert!(
|
|
c.offset_in_asset_wad >= 0,
|
|
"level{n} entry {i}: asset offset {}",
|
|
c.offset_in_asset_wad
|
|
);
|
|
}
|
|
// Billboard slots are optional: every shrub class on level 0 carries
|
|
// them all zero, so only their sign is meaningful here.
|
|
for s in &shrub_v {
|
|
assert!(s.base.offset_in_asset_wad >= 0);
|
|
assert!(
|
|
s.palette_offset >= -1 && s.texture_offset >= -1,
|
|
"level{n}: shrub billboard offsets below the -1 sentinel"
|
|
);
|
|
}
|
|
moby += moby_v.len();
|
|
tie += tie_v.len();
|
|
shrub += shrub_v.len();
|
|
textures += tex_v.len();
|
|
levels += 1;
|
|
}
|
|
|
|
assert_eq!(levels, 27, "expected all 27 levels, got {levels}");
|
|
assert!(
|
|
moby + tie + shrub > 1000,
|
|
"only {moby}+{tie}+{shrub} class entries"
|
|
);
|
|
eprintln!(
|
|
"level core index: {moby} moby, {tie} tie, {shrub} shrub classes, \
|
|
{textures} tfrag textures across {levels} levels"
|
|
);
|
|
}
|
|
|
|
/// The moby, tie and shrub class tables point into `core_data`, and they do so
|
|
/// without overlapping.
|
|
///
|
|
/// `assets_decompressed_size` is the size of `core_data` itself, so there is
|
|
/// no separate asset container: the class entries index the decompressed asset
|
|
/// lump directly. If that is right, every offset must land inside the lump, the
|
|
/// three tables must occupy disjoint spans, and each must be internally sorted
|
|
/// so the geometry is laid out in class order.
|
|
#[test]
|
|
fn class_geometry_offsets_tile_core_data_without_overlap() {
|
|
require_disc!();
|
|
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");
|
|
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 as lc;
|
|
let mut levels = 0usize;
|
|
|
|
for n in 0..27 {
|
|
let dir = data.join(format!("level{n}"));
|
|
let (Ok(index), Ok(core)) = (
|
|
std::fs::read(dir.join("core_index.bin")),
|
|
std::fs::read(dir.join("core_data.bin")),
|
|
) else {
|
|
continue;
|
|
};
|
|
let h = lc::LevelCoreHeader::parse(&index).expect("core header");
|
|
|
|
// The claim under test: the "asset lump" is core_data itself.
|
|
assert_eq!(
|
|
h.assets_decompressed_size as usize,
|
|
core.len(),
|
|
"level{n}: assets_decompressed_size is not core_data's length"
|
|
);
|
|
|
|
let span = |r: lc::ArrayRange, size: usize| -> Vec<usize> {
|
|
lc::class_table(&index, r, size, lc::ClassEntry::parse)
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.map(|c| c.offset_in_asset_wad as usize)
|
|
.collect()
|
|
};
|
|
// Shrub entries are read as ShrubClassEntry so the offsets line up.
|
|
let shrub: Vec<usize> = lc::class_table(
|
|
&index,
|
|
h.shrub_classes,
|
|
lc::SHRUB_CLASS_SIZE,
|
|
lc::ShrubClassEntry::parse,
|
|
)
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.map(|s| s.base.offset_in_asset_wad as usize)
|
|
.collect();
|
|
let moby = span(h.moby_classes, lc::MOBY_CLASS_SIZE);
|
|
let tie = span(h.tie_classes, lc::TIE_CLASS_SIZE);
|
|
|
|
// An offset of 0 marks a slot with no geometry rather than the start of
|
|
// the lump, so those are excluded from the span checks. Entries are not
|
|
// sorted by class number and the offsets are not monotonic, so nothing
|
|
// here assumes an order.
|
|
let moby_l: Vec<usize> = moby.iter().copied().filter(|&o| o != 0).collect();
|
|
let tie_l: Vec<usize> = tie.iter().copied().filter(|&o| o != 0).collect();
|
|
let shrub_l: Vec<usize> = shrub.iter().copied().filter(|&o| o != 0).collect();
|
|
|
|
for (name, v) in [("moby", &moby), ("tie", &tie), ("shrub", &shrub)] {
|
|
for &o in v {
|
|
assert!(
|
|
o < core.len(),
|
|
"level{n}: {name} offset {o} past core data ({})",
|
|
core.len()
|
|
);
|
|
}
|
|
}
|
|
// The three spans must not overlap each other.
|
|
for (an, a) in [("moby", &moby_l), ("tie", &tie_l), ("shrub", &shrub_l)] {
|
|
for (bn, b) in [("moby", &moby_l), ("tie", &tie_l), ("shrub", &shrub_l)] {
|
|
// A level may have no geometry of one kind at all.
|
|
if an >= bn || a.is_empty() || b.is_empty() {
|
|
continue;
|
|
}
|
|
let (lo, hi) = (a[0], *a.last().unwrap());
|
|
for &o in b {
|
|
assert!(
|
|
o >= hi || o < lo,
|
|
"level{n}: {an} [{lo},{hi}] overlaps {bn} at {o}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
levels += 1;
|
|
}
|
|
|
|
assert_eq!(levels, 27, "expected all 27 levels, got {levels}");
|
|
eprintln!("class geometry offsets verified inside core_data on {levels} levels");
|
|
}
|
|
|
|
/// Tie class headers, as far as RAC1 and RAC2 agree.
|
|
///
|
|
/// The check that carries the weight is `o_class`: the class's own number at
|
|
/// header offset 0x44 equals the number the core index filed it under, on every
|
|
/// class of every level. That is what makes the header base trustworthy rather
|
|
/// than merely plausible.
|
|
#[test]
|
|
fn tie_class_headers_match_their_index_entry() {
|
|
require_disc!();
|
|
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");
|
|
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 as lc;
|
|
use gc_formats::tie::{TieClassHeader, TIE_CLASS_HEADER_MATCHED};
|
|
let mut levels = 0usize;
|
|
let (mut classes, mut ordered, mut unordered) = (0usize, 0usize, 0usize);
|
|
|
|
for n in 0..27 {
|
|
let dir = data.join(format!("level{n}"));
|
|
let (Ok(index), Ok(core)) = (
|
|
std::fs::read(dir.join("core_index.bin")),
|
|
std::fs::read(dir.join("core_data.bin")),
|
|
) else {
|
|
continue;
|
|
};
|
|
let h = lc::LevelCoreHeader::parse(&index).expect("core header");
|
|
let entries = lc::class_table(
|
|
&index,
|
|
h.tie_classes,
|
|
lc::TIE_CLASS_SIZE,
|
|
lc::ClassEntry::parse,
|
|
)
|
|
.expect("tie classes");
|
|
|
|
for e in &entries {
|
|
let off = e.offset_in_asset_wad as usize;
|
|
if off == 0 {
|
|
continue; // slot with no geometry
|
|
}
|
|
let at = off + TIE_CLASS_HEADER_MATCHED;
|
|
assert!(at <= core.len(), "level{n}: tie class past core data");
|
|
let t = TieClassHeader::parse(&core[off..at])
|
|
.unwrap_or_else(|err| panic!("level{n}: tie class {} header: {err}", e.o_class));
|
|
assert_eq!(t.o_class, e.o_class, "level{n}: tie o_class mismatch");
|
|
assert!(
|
|
t.far_dist > 0.0,
|
|
"level{n}: tie far distance {}",
|
|
t.far_dist
|
|
);
|
|
if 0.0 < t.near_dist && t.near_dist < t.mid_dist && t.mid_dist < t.far_dist {
|
|
ordered += 1;
|
|
} else {
|
|
unordered += 1;
|
|
}
|
|
classes += 1;
|
|
}
|
|
levels += 1;
|
|
}
|
|
|
|
assert_eq!(levels, 27, "expected all 27 levels, got {levels}");
|
|
assert!(classes > 1_500, "only {classes} tie classes");
|
|
// Ordered distances are the expected shape; the exceptions are real and
|
|
// unexplained, so they are reported rather than asserted away.
|
|
assert!(
|
|
ordered * 20 >= classes * 19,
|
|
"only {ordered} of {classes} tie classes have ordered LOD distances"
|
|
);
|
|
eprintln!(
|
|
"tie: {classes} class headers, o_class matches the index on all; \\
|
|
{ordered} have ordered LOD distances, {unordered} do not"
|
|
);
|
|
}
|
|
|
|
/// The occlusion PVS grid.
|
|
///
|
|
/// The check that makes this real is the mask array: it starts at
|
|
/// `masks_offset` and runs to `(highest_mask + 1) * 0x80`, and on every level
|
|
/// that ends within a few bytes of the block's own length. A wrong tree shape
|
|
/// or a wrong cell size would leave the highest mask index somewhere else
|
|
/// entirely and the array would not fit.
|
|
#[test]
|
|
fn occlusion_grids_parse_on_every_level() {
|
|
require_disc!();
|
|
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");
|
|
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::occlusion::parse_occlusion_block;
|
|
let mut levels = 0usize;
|
|
let mut absent = 0usize;
|
|
let mut widest_slack = 0usize;
|
|
let (mut cells, mut masks) = (0usize, 0usize);
|
|
|
|
for n in 0..27 {
|
|
let dir = data.join(format!("level{n}"));
|
|
let (Ok(core), Ok(index)) = (
|
|
std::fs::read(dir.join("core_data.bin")),
|
|
std::fs::read(dir.join("core_index.bin")),
|
|
) else {
|
|
continue;
|
|
};
|
|
let h = LevelCoreHeader::parse(&index).expect("core header");
|
|
// An offset of 0 means the level has no occlusion data at all, which is
|
|
// not the same as "at the start of the file" -- that is where the
|
|
// tfrags block lives, and parsing it as a grid produces a mask array
|
|
// that fits anyway, which is how this nearly went wrong.
|
|
if h.occlusion <= 0 {
|
|
absent += 1;
|
|
continue;
|
|
}
|
|
let start = h.occlusion as usize;
|
|
// The block runs to the sky region, or to the end when there is none.
|
|
let end = if h.sky > 0 {
|
|
h.sky as usize
|
|
} else {
|
|
core.len()
|
|
};
|
|
assert!(
|
|
start < end && end <= core.len(),
|
|
"level{n}: bad occlusion range"
|
|
);
|
|
let block = &core[start..end];
|
|
|
|
let o = parse_occlusion_block(block)
|
|
.unwrap_or_else(|e| panic!("level{n}: occlusion block: {e}"));
|
|
// Every mask a cell names exists.
|
|
for c in &o.cells {
|
|
assert!(
|
|
(c.mask as usize) < o.masks.len(),
|
|
"level{n}: cell names a missing mask"
|
|
);
|
|
}
|
|
// The array ends at or just before the block's end. Retail levels sit
|
|
// within a few bytes; a gross error would leave a large gap.
|
|
let need = o.masks_offset as usize + o.masks.len() * gc_formats::occlusion::MASK_BYTES;
|
|
assert!(need <= block.len(), "level{n}: masks run past the block");
|
|
let slack = block.len() - need;
|
|
// Levels whose grid is empty are mostly header and padding, so this is
|
|
// proportional and only applies where there are cells at all.
|
|
if !o.cells.is_empty() {
|
|
assert!(
|
|
slack * 8 < block.len(),
|
|
"level{n}: {slack} of {} bytes unexplained after the mask array",
|
|
block.len()
|
|
);
|
|
}
|
|
widest_slack = widest_slack.max(slack);
|
|
|
|
cells += o.cells.len();
|
|
masks += o.masks.len();
|
|
levels += 1;
|
|
}
|
|
|
|
assert_eq!(
|
|
levels + absent,
|
|
27,
|
|
"expected all 27 levels, got {}",
|
|
levels + absent
|
|
);
|
|
assert_eq!(
|
|
absent, 3,
|
|
"expected 3 levels without occlusion data, got {absent}"
|
|
);
|
|
assert!(cells > 400_000, "only {cells} occlusion cells");
|
|
eprintln!(
|
|
"occlusion: {cells} populated cells over {masks} masks across {levels} levels; \
|
|
widest unexplained tail {widest_slack} bytes"
|
|
);
|
|
}
|
|
|
|
/// The sound extent framing.
|
|
///
|
|
/// The invariant here is `sblk_offset + sblk_size == daw_offset`, which holds
|
|
/// on all 27 levels and pins the meaning of the four head fields. `DAW` never
|
|
/// reaches the end of the file; the tail is positive on every level, so the
|
|
/// size field is not the whole region and this test asserts the direction
|
|
/// rather than a size.
|
|
#[test]
|
|
fn sound_bank_framing_holds_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::sound_bank::parse_sound_bank;
|
|
let mut levels = 0usize;
|
|
let mut sblk_bytes = 0usize;
|
|
let mut daw_bytes = 0usize;
|
|
let (mut narrowest, mut widest) = (usize::MAX, 0usize);
|
|
|
|
for n in 0..27 {
|
|
let raw = match std::fs::read(data.join(format!("level{n}")).join("sound_bank.bin")) {
|
|
Ok(v) => v,
|
|
Err(_) => continue,
|
|
};
|
|
let s = parse_sound_bank(&raw).unwrap_or_else(|e| panic!("level{n}: sound bank: {e}"));
|
|
|
|
assert_eq!(s.header.version, 3, "level{n}: sound bank version");
|
|
assert_eq!(s.header.unknown_4, 2, "level{n}: sound bank unknown_4");
|
|
assert_eq!(
|
|
s.header.sblk_offset, 24,
|
|
"level{n}: SBlk sits at 24 on every level"
|
|
);
|
|
assert!(
|
|
s.header.sblk_offset + s.header.sblk_size == s.header.daw_offset,
|
|
"level{n}: SBlk must run into DAW with no gap"
|
|
);
|
|
assert!(s.has_sblk_magic(), "level{n}: SBlk magic");
|
|
assert!(s.has_daw_tag(), "level{n}: DAW tag at SBlk+12");
|
|
assert_eq!(s.sblk_version(), Some(1), "level{n}: SBlk version");
|
|
// The tail is always in the same direction: the extent is longer than
|
|
// the DAW region it describes.
|
|
narrowest = narrowest.min(s.daw_tail);
|
|
widest = widest.max(s.daw_tail);
|
|
|
|
sblk_bytes += s.sblk.len();
|
|
daw_bytes += s.daw.len();
|
|
levels += 1;
|
|
}
|
|
|
|
assert_eq!(levels, 27, "expected all 27 levels, got {levels}");
|
|
assert!(sblk_bytes > 300_000, "only {sblk_bytes} SBlk bytes");
|
|
assert!(daw_bytes > 20_000_000, "only {daw_bytes} DAW bytes");
|
|
eprintln!(
|
|
"sound_bank: {sblk_bytes} SBlk + {daw_bytes} DAW bytes across {levels} levels; \
|
|
unexplained tail {narrowest}..{widest} bytes"
|
|
);
|
|
}
|
|
|
|
/// Collision container framing.
|
|
///
|
|
/// The invariant is the header's own self-description: `header_size` is 0x40
|
|
/// on every level, and `header_size + payload_size` stays inside `core_data`.
|
|
/// The payload is deliberately left undecoded -- see `collision.rs`.
|
|
#[test]
|
|
fn collision_framing_holds_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::collision::{CollisionHeader, COLLISION_HEADER_SIZE};
|
|
use gc_formats::level_core::LevelCoreHeader;
|
|
let mut levels = 0usize;
|
|
let mut empty = 0usize;
|
|
let mut payload_bytes = 0usize;
|
|
|
|
for n in 0..27 {
|
|
let dir = data.join(format!("level{n}"));
|
|
let (Ok(core), Ok(index)) = (
|
|
std::fs::read(dir.join("core_data.bin")),
|
|
std::fs::read(dir.join("core_index.bin")),
|
|
) else {
|
|
continue;
|
|
};
|
|
let h = LevelCoreHeader::parse(&index).expect("core header");
|
|
let region = CollisionHeader::region(&core, h.collision)
|
|
.unwrap_or_else(|e| panic!("level{n}: collision: {e}"));
|
|
let Some(r) = region else {
|
|
empty += 1;
|
|
continue;
|
|
};
|
|
assert_eq!(r.header.header_size, COLLISION_HEADER_SIZE, "level{n}");
|
|
assert!(
|
|
r.header.payload_size as usize <= r.payload.len(),
|
|
"level{n}"
|
|
);
|
|
if r.payload.is_empty() {
|
|
// Present but empty. Counted once, not twice.
|
|
empty += 1;
|
|
continue;
|
|
}
|
|
assert_eq!(r.payload.len(), r.header.payload_size as usize, "level{n}");
|
|
payload_bytes += r.payload.len();
|
|
levels += 1;
|
|
}
|
|
|
|
assert_eq!(
|
|
levels + empty,
|
|
27,
|
|
"expected all 27 levels, got {levels}+{empty}"
|
|
);
|
|
assert!(
|
|
payload_bytes > 10_000_000,
|
|
"only {payload_bytes} collision bytes"
|
|
);
|
|
eprintln!("collision: {payload_bytes} payload bytes across {levels} levels, {empty} empty");
|
|
}
|
|
|
|
/// Moby model geometry: the per-class part table.
|
|
///
|
|
/// The invariant is `vertex_offset + 16 * vertex_count == next_part`, which is
|
|
/// what makes the 16-byte record size and the field boundaries fall out of the
|
|
/// data instead of being assumed. The parser rejects any span that violates it,
|
|
/// so this test is really checking that no span on the disc does.
|
|
#[test]
|
|
fn moby_parts_tile_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::moby::parse_moby_geometry;
|
|
let mut levels = 0usize;
|
|
let mut classes = 0usize;
|
|
let mut parts = 0usize;
|
|
let mut verts = 0usize;
|
|
let mut empties = 0usize;
|
|
|
|
for n in 0..27 {
|
|
let dir = data.join(format!("level{n}"));
|
|
let (Ok(core), Ok(index)) = (
|
|
std::fs::read(dir.join("core_data.bin")),
|
|
std::fs::read(dir.join("core_index.bin")),
|
|
) else {
|
|
continue;
|
|
};
|
|
let h = LevelCoreHeader::parse(&index).expect("core header");
|
|
let range = h.moby_classes;
|
|
assert!(
|
|
range.count > 0 && range.offset > 0,
|
|
"level{n}: no moby class table"
|
|
);
|
|
|
|
// Class spans tile between consecutive geometry offsets.
|
|
let stride = 0x20usize;
|
|
let mut offsets: Vec<usize> = (0..range.count as usize)
|
|
.map(|i| {
|
|
let at = range.offset as usize + i * stride;
|
|
let e = gc_formats::level_core::ClassEntry::parse(&index[at..at + stride])
|
|
.unwrap_or_else(|e| panic!("level{n}: class {i}: {e}"));
|
|
e.offset_in_asset_wad
|
|
})
|
|
.map(|o| o.max(0) as usize)
|
|
.filter(|&o| o > 0)
|
|
.collect();
|
|
offsets.sort_unstable();
|
|
assert!(!offsets.is_empty(), "level{n}: no moby geometry offsets");
|
|
|
|
for i in 0..offsets.len() {
|
|
let start = offsets[i];
|
|
let end = offsets.get(i + 1).copied().unwrap_or(core.len());
|
|
if end <= start {
|
|
continue;
|
|
}
|
|
let g = parse_moby_geometry(&core[start..end])
|
|
.unwrap_or_else(|e| panic!("level{n}: moby class at {start}: {e}"));
|
|
if g.is_empty() {
|
|
empties += 1;
|
|
}
|
|
// Every part's vertices are readable and inside the span.
|
|
for p in &g.parts {
|
|
assert!(
|
|
g.vertices(p).is_some(),
|
|
"level{n}: part vertices past the end of the class span"
|
|
);
|
|
}
|
|
parts += g.parts.len();
|
|
verts += g.vertex_count();
|
|
classes += 1;
|
|
}
|
|
levels += 1;
|
|
}
|
|
|
|
assert_eq!(levels, 27, "expected all 27 levels, got {levels}");
|
|
assert!(classes > 4_000, "only {classes} moby class spans");
|
|
assert!(parts > 40_000, "only {parts} moby parts");
|
|
assert!(verts > 1_200_000, "only {verts} moby vertices");
|
|
eprintln!(
|
|
"moby: {parts} parts tiling exactly, {verts} vertices, {classes} class spans \
|
|
({empties} with no geometry) across {levels} levels"
|
|
);
|
|
}
|
|
|
|
/// Shrub class headers and blobs.
|
|
///
|
|
/// Two fields self-identify exactly on every span, and the parser checks both
|
|
/// rather than merely reporting them: `+0x24` must equal the owning class
|
|
/// entry's own `o_class`, and `+0x2c` must land inside the span.
|
|
///
|
|
/// The body is then checked against the R&C1 shape this format follows: a
|
|
/// palette of 24 unit-length `s16` normals with a zero pad word, then zero
|
|
/// padding records. And the billboard offset at `+0x1c` is checked against the
|
|
/// class entry's billboard texture fields, which is the one header field that
|
|
/// can be confirmed from a second file.
|
|
#[test]
|
|
fn shrub_headers_name_their_own_class_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::ShrubClassEntry;
|
|
use gc_formats::shrub::{ShrubHeader, SHRUB_CLASS_ENTRY_BYTES, SHRUB_NORMAL_COUNT};
|
|
let mut levels = 0usize;
|
|
let mut spans = 0usize;
|
|
let mut absent = 0usize;
|
|
let (mut min_size, mut max_size) = (f32::MAX, 0.0f32);
|
|
let mut scratch_nonzero = 0usize;
|
|
let mut mode_hist: std::collections::BTreeMap<u16, usize> = Default::default();
|
|
let mut scratch_hist: std::collections::BTreeMap<u16, usize> = Default::default();
|
|
let mut radius_range = (f32::MAX, 0f32);
|
|
let mut scale_range = (f32::MAX, 0f32);
|
|
let mut bounded_records = 0usize;
|
|
let mut bounded_bodies = 0usize;
|
|
let mut palette_records = 0usize;
|
|
let mut padding_records = 0usize;
|
|
let mut norm2_deviation = 0i64;
|
|
let mut billboards = 0usize;
|
|
let mut billboard_entries = 0usize;
|
|
let mut packets_total = 0usize;
|
|
let mut packet_headers_total = 0usize;
|
|
|
|
for n in 0..27 {
|
|
let dir = data.join(format!("level{n}"));
|
|
let (Ok(core), Ok(index)) = (
|
|
std::fs::read(dir.join("core_data.bin")),
|
|
std::fs::read(dir.join("core_index.bin")),
|
|
) else {
|
|
continue;
|
|
};
|
|
let h = gc_formats::level_core::LevelCoreHeader::parse(&index).expect("core header");
|
|
let range = h.shrub_classes;
|
|
// Class spans tile between consecutive geometry offsets, with the last
|
|
// one bounded by the chunk container. It is found by signature, since
|
|
// the core index carries no successor offset for shrub; without it the
|
|
// last class absorbs the container and the body check measures its
|
|
// neighbours.
|
|
let mut offsets: Vec<usize> = Vec::new();
|
|
for i in 0..range.count.max(0) as usize {
|
|
let at = range.offset.max(0) as usize + i * SHRUB_CLASS_ENTRY_BYTES;
|
|
let e = ShrubClassEntry::parse(&index[at..at + SHRUB_CLASS_ENTRY_BYTES])
|
|
.unwrap_or_else(|e| panic!("level{n}: shrub class {i}: {e}"));
|
|
if e.base.offset_in_asset_wad > 0 {
|
|
offsets.push(e.base.offset_in_asset_wad as usize);
|
|
}
|
|
}
|
|
offsets.sort_unstable();
|
|
// Levels without shrub geometry have no span to bound. The container
|
|
// that follows tie on those levels is checked by
|
|
// `chunk_container_closes_the_last_class_region`.
|
|
let container = match offsets.last() {
|
|
Some(&last) if !offsets.is_empty() => {
|
|
gc_formats::chunks::chunk_region_start(&core, last).unwrap_or_else(|| {
|
|
panic!("level{n}: no chunk container after the shrub classes")
|
|
})
|
|
}
|
|
_ => core.len(),
|
|
};
|
|
let mut span_end_of: std::collections::HashMap<usize, usize> =
|
|
std::collections::HashMap::new();
|
|
for (i, &o) in offsets.iter().enumerate() {
|
|
let end = offsets.get(i + 1).copied().unwrap_or(container);
|
|
span_end_of.insert(o, end);
|
|
}
|
|
for i in 0..range.count.max(0) as usize {
|
|
let at = range.offset.max(0) as usize + i * SHRUB_CLASS_ENTRY_BYTES;
|
|
let e = ShrubClassEntry::parse(&index[at..at + SHRUB_CLASS_ENTRY_BYTES])
|
|
.unwrap_or_else(|e| panic!("level{n}: shrub class {i}: {e}"));
|
|
let (span_end, has_successor) =
|
|
match span_end_of.get(&(e.base.offset_in_asset_wad.max(0) as usize)) {
|
|
Some(&e2) => (e2, true),
|
|
None => (core.len(), false),
|
|
};
|
|
match ShrubHeader::region(&core, e.base.offset_in_asset_wad, e.base.o_class, span_end) {
|
|
Ok(Some(mut s)) => {
|
|
s.span_end_is_a_successor = has_successor;
|
|
// Every class is bounded now, so every body must be whole
|
|
// 8-byte records. That was not assertable while the last
|
|
// class ran to the lump's end, and it is the check the
|
|
// boundary earns its keep for.
|
|
assert!(
|
|
s.body_is_well_formed(),
|
|
"level{n} class {i}: shrub body is not whole 8-byte records"
|
|
);
|
|
bounded_bodies += 1;
|
|
bounded_records += s.body_record_count();
|
|
assert_eq!(
|
|
s.header.o_class,
|
|
e.base.o_class.max(0) as u32,
|
|
"level{n} class {i}"
|
|
);
|
|
assert!(!s.data.is_empty(), "level{n} class {i}: empty shrub data");
|
|
assert!(
|
|
s.header.sphere.iter().all(|v| v.is_finite()),
|
|
"level{n} class {i}: non-finite shrub sphere"
|
|
);
|
|
assert!(
|
|
s.header.size.is_finite() && s.header.size > 0.0,
|
|
"level{n} class {i}: shrub size {}",
|
|
s.header.size
|
|
);
|
|
// The scratch word is zero on every span; the parser
|
|
// refuses anything else. `mode_bits` is 4 on 16 spans and
|
|
// is reported rather than asserted. On those 16 the word
|
|
// at +0x14 reads as an f32 denormal, which the Emotion
|
|
// Engine flushes to zero like everything else.
|
|
if s.header.scratch != 0 {
|
|
scratch_nonzero += 1;
|
|
}
|
|
*mode_hist.entry(s.header.mode_bits).or_default() += 1;
|
|
*scratch_hist.entry(s.header.scratch).or_default() += 1;
|
|
radius_range.0 = radius_range.0.min(s.header.sphere[3]);
|
|
radius_range.1 = radius_range.1.max(s.header.sphere[3]);
|
|
scale_range.0 = scale_range.0.min(s.header.scale);
|
|
scale_range.1 = scale_range.1.max(s.header.scale);
|
|
min_size = min_size.min(s.header.size);
|
|
max_size = max_size.max(s.header.size);
|
|
|
|
// The palette: 24 unit normals, then zero padding.
|
|
let normals: Vec<_> = s.normals().collect();
|
|
assert_eq!(
|
|
normals.len(),
|
|
SHRUB_NORMAL_COUNT,
|
|
"level{n} class {i}: palette is {} entries, not {SHRUB_NORMAL_COUNT}",
|
|
normals.len()
|
|
);
|
|
for (k, normal) in normals.iter().enumerate() {
|
|
assert!(
|
|
normal.is_unit(),
|
|
"level{n} class {i}: palette entry {k} {normal:?} is not a unit normal"
|
|
);
|
|
let n2 = (normal.x as i64).pow(2)
|
|
+ (normal.y as i64).pow(2)
|
|
+ (normal.z as i64).pow(2);
|
|
norm2_deviation = norm2_deviation.max((n2 - 32767i64.pow(2)).abs());
|
|
}
|
|
palette_records += normals.len();
|
|
for (k, record) in s.body_records().skip(SHRUB_NORMAL_COUNT).enumerate() {
|
|
assert!(
|
|
record.iter().all(|&b| b == 0),
|
|
"level{n} class {i}: padding record {k} is not all zero"
|
|
);
|
|
}
|
|
padding_records += s.padding_record_count();
|
|
|
|
// The packets: the table at +0x40 tiles the span from
|
|
// align16 of its own end to the palette, or to the
|
|
// billboard when the class has one. This is what puts the
|
|
// mesh geometry in the blob, and it is the identity that
|
|
// makes `count` the packet count rather than a guess.
|
|
let packets = s.packets().unwrap_or_else(|| {
|
|
panic!("level{n} class {i}: packet table does not tile")
|
|
});
|
|
assert_eq!(
|
|
packets.len(),
|
|
s.header.count as usize,
|
|
"level{n} class {i}: packet count disagrees with the header"
|
|
);
|
|
packets_total += packets.len();
|
|
|
|
// The packet headers themselves. The disc form is a fixed
|
|
// signature -- magic, four zero bytes, tag 0x05 -- and the
|
|
// loader rewrites bytes 8-15 on load, so the count and the
|
|
// pointer live in RAM, not here.
|
|
let hdrs: Vec<_> = s.packet_headers().collect();
|
|
assert_eq!(
|
|
hdrs.len(),
|
|
packets.len(),
|
|
"level{n} class {i}: header count disagrees with packet count"
|
|
);
|
|
for (pi, h) in hdrs.iter().enumerate() {
|
|
assert!(
|
|
h.is_some(),
|
|
"level{n} class {i}: packet {pi} header does not match the \
|
|
disc invariants (magic 04 04 00 01, bytes 4-8 zero, tag 0x05)"
|
|
);
|
|
assert_eq!(
|
|
packets[pi].size % 16,
|
|
0,
|
|
"level{n} class {i}: packet {pi} size is not a multiple of 16"
|
|
);
|
|
}
|
|
packet_headers_total += hdrs.len();
|
|
|
|
// The billboard: present exactly when the class entry
|
|
// carries billboard texture fields.
|
|
let entry_has_billboard = e.billboard_width != 0 || e.billboard_height != 0;
|
|
match s.billboard().expect("billboard parses") {
|
|
Some(b) => {
|
|
billboards += 1;
|
|
assert!(
|
|
entry_has_billboard,
|
|
"level{n} class {i}: billboard record without billboard texture fields"
|
|
);
|
|
assert_eq!(b.tex1_addr, 0x14, "level{n} class {i}: TEX1 address");
|
|
assert_eq!(b.tex0_addr, 0x06, "level{n} class {i}: TEX0 address");
|
|
assert_eq!(b.miptbp_addr, 0x34, "level{n} class {i}: MIPTBP address");
|
|
assert_eq!(b.mmin, 4, "level{n} class {i}: MMIN");
|
|
}
|
|
None => assert!(
|
|
!entry_has_billboard,
|
|
"level{n} class {i}: billboard texture fields with no billboard record"
|
|
),
|
|
}
|
|
if entry_has_billboard {
|
|
billboard_entries += 1;
|
|
}
|
|
spans += 1;
|
|
}
|
|
Ok(None) => absent += 1,
|
|
Err(err) => panic!("level{n}: shrub class {i}: {err}"),
|
|
}
|
|
}
|
|
levels += 1;
|
|
}
|
|
|
|
assert_eq!(levels, 27, "expected all 27 levels, got {levels}");
|
|
assert_eq!(
|
|
spans + absent,
|
|
358,
|
|
"expected 358 shrub classes, got {}",
|
|
spans + absent
|
|
);
|
|
assert!(spans > 340, "only {spans} shrub spans with geometry");
|
|
// The container bounds every class now, so every body is checkable.
|
|
assert_eq!(bounded_bodies, spans, "every shrub class should be bounded");
|
|
assert_eq!(scratch_nonzero, 0, "the scratch word is zero on every span");
|
|
assert_eq!(
|
|
billboards, billboard_entries,
|
|
"billboard records and billboard class entries disagree: \
|
|
{billboards} records vs {billboard_entries} entries"
|
|
);
|
|
eprintln!(
|
|
"shrub: {spans} headers naming their own class, {absent} without geometry; \
|
|
all {bounded_bodies} bodies bounded, {bounded_records} records, \
|
|
{palette_records} of them palette normals and {padding_records} zero padding; \
|
|
{billboards} billboards, all with texture fields; \
|
|
{packets_total} packets across all blobs, {packet_headers_total} headers; \
|
|
mode_bits {mode_hist:?}, radius {radius_range:?}, scale {scale_range:?}, \
|
|
largest palette deviation from unit length {norm2_deviation} of a possible 65535"
|
|
);
|
|
}
|
|
|
|
/// Region bounds derived from successor boundaries, on every level.
|
|
///
|
|
/// Every region is closed by the next region's start, or -- for the last class
|
|
/// region -- by the located chunk container, never 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(|_| ".".into());
|
|
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, CORE_DATA_TRAILER_BYTES};
|
|
let mut levels = 0usize;
|
|
let mut unresolved_shrub = 0usize;
|
|
let mut unresolved_chunks = 0usize;
|
|
let mut bounded = 0usize;
|
|
let mut shrub_bytes = 0usize;
|
|
let mut chunk_bytes = 0usize;
|
|
let (mut first, mut last) = (usize::MAX, 0usize);
|
|
|
|
for n in 0..27 {
|
|
let dir = data.join(format!("level{n}"));
|
|
let (Ok(core), Ok(index)) = (
|
|
std::fs::read(dir.join("core_data.bin")),
|
|
std::fs::read(dir.join("core_index.bin")),
|
|
) else {
|
|
continue;
|
|
};
|
|
let core_len = core.len();
|
|
let h = LevelCoreHeader::parse(&index).expect("core header");
|
|
let regions = core_layout(&index, &core, &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; 8] = [
|
|
"tfrags",
|
|
"occlusion",
|
|
"sky",
|
|
"collision",
|
|
"moby",
|
|
"tie",
|
|
"shrub",
|
|
"chunks",
|
|
];
|
|
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") {
|
|
match s.end {
|
|
Some(e) => {
|
|
// The container's start, not the trailer and not the lump's
|
|
// end: the trailer is the chunk region's business.
|
|
assert!(e < core_len - CORE_DATA_TRAILER_BYTES);
|
|
shrub_bytes += s.len().unwrap_or(0);
|
|
bounded += 1;
|
|
}
|
|
None => unresolved_shrub += 1,
|
|
}
|
|
first = first.min(s.start);
|
|
last = last.max(s.start);
|
|
}
|
|
if let Some(c) = regions.iter().find(|r| r.name == "chunks") {
|
|
match c.end {
|
|
Some(e) => {
|
|
// The trailer is the only thing past the chunk container.
|
|
assert_eq!(e, core_len - CORE_DATA_TRAILER_BYTES);
|
|
chunk_bytes += c.len().unwrap_or(0);
|
|
}
|
|
None => unresolved_chunks += 1,
|
|
}
|
|
}
|
|
levels += 1;
|
|
}
|
|
|
|
assert_eq!(levels, 27, "expected all 27 levels, got {levels}");
|
|
assert_eq!(
|
|
unresolved_shrub, 0,
|
|
"every level with shrub should bound it at the chunk container"
|
|
);
|
|
assert_eq!(bounded, 24, "expected 24 levels with a shrub region");
|
|
assert_eq!(
|
|
unresolved_chunks, 0,
|
|
"every chunk region should end at the trailer"
|
|
);
|
|
eprintln!(
|
|
"regions: shrub starts {first}..{last}; {shrub_bytes} bytes of shrub and \
|
|
{chunk_bytes} bytes of chunk data, shrub closed by the located container on \
|
|
all {bounded} levels"
|
|
);
|
|
}
|
|
|
|
/// The post-shrub chunk container: chunks carry the `0xf0`-tagged offset
|
|
/// signature on every level, and their offsets resolve to records with a
|
|
/// shared field layout.
|
|
///
|
|
/// The region is bounded properly rather than by the fixed window this test
|
|
/// first used: it runs from the marker-paired chunk signature that closes the
|
|
/// last class region to the constant trailer. The window approach worked and
|
|
/// then over-worked -- a fixed 900,000 bytes from the end reaches past the
|
|
/// container on some levels and stops inside it on others, which is how 27
|
|
/// chunks came to be truncated at a "window edge" that is not a format
|
|
/// boundary. The locator is asserted by
|
|
/// `chunk_container_closes_the_last_class_region`.
|
|
///
|
|
/// The layout facts asserted here are measured, not assumed:
|
|
///
|
|
/// - every tagged offset has top byte `0xf0`, on all 27 levels;
|
|
/// - resolving records at `chunk_start + low` gives 17-21% byte positions
|
|
/// constant across that chunk's own records, which does not happen by
|
|
/// chance and is what distinguishes this from a coincidental byte pattern;
|
|
/// - stride is a per-chunk property, not a region-wide one.
|
|
#[test]
|
|
fn chunk_containers_resolve_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(|_| ".".into());
|
|
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, CORE_DATA_TRAILER_BYTES};
|
|
|
|
let mut levels = 0usize;
|
|
let mut chunks = 0usize;
|
|
let mut records = 0usize;
|
|
let mut strides = std::collections::BTreeSet::new();
|
|
let mut truncated = 0usize;
|
|
let mut constant_total = 0usize;
|
|
let mut stride_total = 0usize;
|
|
let mut frac: Vec<f64> = Vec::new();
|
|
|
|
for n in 0..27 {
|
|
let dir = data.join(format!("level{n}"));
|
|
let (Ok(core), Ok(index)) = (
|
|
std::fs::read(dir.join("core_data.bin")),
|
|
std::fs::read(dir.join("core_index.bin")),
|
|
) else {
|
|
continue;
|
|
};
|
|
|
|
// The region runs from the chunk signature that closes the last class
|
|
// region to the constant trailer, both measured and identical in size
|
|
// on all 27 levels. core_layout locates the start through the same
|
|
// signature the chunk parser uses.
|
|
let h = LevelCoreHeader::parse(&index).expect("core header");
|
|
let Some(region) = core_layout(&index, &core, &h)
|
|
.into_iter()
|
|
.find(|r| r.name == "chunks")
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(end) = region.end else {
|
|
panic!("level{n}: chunk region is unbounded");
|
|
};
|
|
assert_eq!(end, core.len() - CORE_DATA_TRAILER_BYTES);
|
|
assert!(!region.is_empty(), "level{n}: empty chunk region");
|
|
let region = &core[region.start..end];
|
|
|
|
let found = gc_formats::chunks::parse_all_chunks(region).expect("chunks should parse");
|
|
if found.is_empty() {
|
|
continue;
|
|
}
|
|
levels += 1;
|
|
|
|
for chunk in &found {
|
|
assert!(
|
|
!chunk.chunk.offsets.is_empty(),
|
|
"level{n}: chunk at {} has no offsets",
|
|
chunk.chunk.offset
|
|
);
|
|
assert!(chunk.chunk.stride > 0, "level{n}: zero stride");
|
|
// The one decoded header field.
|
|
assert_eq!(
|
|
chunk.chunk.count as usize,
|
|
chunk.chunk.offsets.len(),
|
|
"level{n}: chunk at {} header count {} but {} offsets",
|
|
chunk.chunk.offset,
|
|
chunk.chunk.count,
|
|
chunk.chunk.offsets.len()
|
|
);
|
|
chunks += 1;
|
|
records += chunk.records.len();
|
|
strides.insert(chunk.chunk.stride);
|
|
|
|
// Records resolve unless the last one would run past the region
|
|
// end, which happens for the chunk sitting at the trailer end:
|
|
// the container's records stop there, so its final record or two
|
|
// fall outside. Truncation is counted rather than asserted away.
|
|
assert!(
|
|
chunk.records.len() <= chunk.chunk.offsets.len(),
|
|
"level{n}: chunk at {} resolved MORE records than offsets",
|
|
chunk.chunk.offset
|
|
);
|
|
if chunk.records.len() < chunk.chunk.offsets.len() {
|
|
truncated += 1;
|
|
}
|
|
|
|
// The invariant that makes this a container and not a coincidence.
|
|
// Accumulated rather than asserted per chunk: individual chunks
|
|
// range from ~7% to ~21% constant positions, so a per-chunk
|
|
// threshold would be asserting a property the format does not have.
|
|
let constant = constant_positions(&chunk.records);
|
|
constant_total += constant;
|
|
stride_total += chunk.chunk.stride;
|
|
frac.push(constant as f64 / chunk.chunk.stride as f64);
|
|
}
|
|
}
|
|
|
|
assert_eq!(levels, 27, "expected all 27 levels, got {levels}");
|
|
assert!(chunks > 500, "only {chunks} chunks resolved");
|
|
assert!(records > 5_000, "only {records} records resolved");
|
|
assert!(
|
|
strides.len() > 5,
|
|
"stride should vary per chunk, saw {}",
|
|
strides.len()
|
|
);
|
|
// Chance would give a constant position a probability of 1/256^n, so even
|
|
// 5% across tens of thousands of records is far above noise.
|
|
let mean_frac = constant_total as f64 / stride_total as f64;
|
|
assert!(
|
|
mean_frac > 0.05,
|
|
"constant positions {constant_total}/{stride_total} = {mean_frac:.3} is near chance"
|
|
);
|
|
eprintln!(
|
|
"chunks: {chunks} over {levels} levels, {records} records, {} distinct strides, \
|
|
constant {constant_total}/{stride_total} ({:.1}%), per-chunk min {:.1}% max {:.1}%, \
|
|
{truncated} chunks truncated at the trailer end",
|
|
strides.len(),
|
|
mean_frac * 100.0,
|
|
frac.iter().cloned().fold(f64::INFINITY, f64::min) * 100.0,
|
|
frac.iter().cloned().fold(0.0, f64::max) * 100.0,
|
|
);
|
|
}
|
|
|
|
/// The chunk container closes the last class region, on every level.
|
|
///
|
|
/// The core index carries no successor offset for shrub -- unlike moby and
|
|
/// tie, whose boundaries fall out of the class tables -- so the boundary is
|
|
/// located in `core_data`: the constant marker pair sitting immediately in
|
|
/// front of a chunk signature. Both halves of that signature are weak alone,
|
|
/// which is why the test measures them separately:
|
|
///
|
|
/// - the marker pair `f6 7c` occurs 25 to 103 times in a 5 to 17 MB lump, so
|
|
/// on its own it means nothing;
|
|
/// - the chunk shape (16-byte header ending `0xff`, 10 zeros, tagged offsets)
|
|
/// matches random data often enough to need four tagged offsets before it
|
|
/// means anything.
|
|
///
|
|
/// Together they are exact: on all 27 levels the marker sits exactly two bytes
|
|
/// before the first chunk header. Three checks pin that down per level:
|
|
///
|
|
/// 1. the located boundary carries the marker;
|
|
/// 2. the last shrub body walks to 8-byte records that end exactly where the
|
|
/// signature begins, with no chunk signature inside the body itself;
|
|
/// 3. the container's records fill the region up to the constant trailer.
|
|
///
|
|
/// Levels without shrub are the control for the locator: there the container
|
|
/// closes tie instead, and the same marker precedes it.
|
|
#[test]
|
|
fn chunk_container_closes_the_last_class_region() {
|
|
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(|_| ".".into());
|
|
PathBuf::from(home).join("Documents/gc-extracted")
|
|
});
|
|
if !data.exists() {
|
|
eprintln!("skipped: unpack the disc first");
|
|
return;
|
|
}
|
|
|
|
use gc_formats::chunks::{self, CHUNK_REGION_MARKER};
|
|
use gc_formats::level_core::{LevelCoreHeader, ShrubClassEntry};
|
|
use gc_formats::regions::{core_layout, last_geometry, CORE_DATA_TRAILER_BYTES};
|
|
use gc_formats::shrub::{ShrubHeader, SHRUB_CLASS_ENTRY_BYTES};
|
|
|
|
let mut levels = 0usize;
|
|
let mut with_shrub = 0usize;
|
|
let mut marker_wide_max = 0usize;
|
|
let mut body_records = 0usize;
|
|
let mut container_bytes = 0usize;
|
|
let mut fill_gap_max = 0usize;
|
|
|
|
for n in 0..27 {
|
|
let dir = data.join(format!("level{n}"));
|
|
let (Ok(core), Ok(index)) = (
|
|
std::fs::read(dir.join("core_data.bin")),
|
|
std::fs::read(dir.join("core_index.bin")),
|
|
) else {
|
|
panic!("level{n}: missing core_data or core_index");
|
|
};
|
|
let trailer = core.len() - CORE_DATA_TRAILER_BYTES;
|
|
let h = LevelCoreHeader::parse(&index).expect("core header");
|
|
|
|
let last_shrub = last_geometry(&index, h.shrub_classes, SHRUB_CLASS_ENTRY_BYTES);
|
|
let last_tie = last_geometry(&index, h.tie_classes, 0x20);
|
|
// The container follows the last of the shrub and tie classes, so the
|
|
// signature search starts after the class bodies rather than inside
|
|
// them.
|
|
let scan_from = last_shrub
|
|
.or(last_tie)
|
|
.unwrap_or_else(|| panic!("level{n}: no class geometry to follow"));
|
|
let container = chunks::chunk_region_start(&core, scan_from)
|
|
.unwrap_or_else(|| panic!("level{n}: no chunk container after {scan_from}"));
|
|
|
|
// 1. The marker, and how common it is without the signature behind it.
|
|
assert_eq!(
|
|
&core[container - 2..container],
|
|
&CHUNK_REGION_MARKER,
|
|
"level{n}: container at {container} is not preceded by the marker"
|
|
);
|
|
marker_wide_max = marker_wide_max.max(
|
|
core.windows(2)
|
|
.filter(|w| *w == CHUNK_REGION_MARKER)
|
|
.count(),
|
|
);
|
|
|
|
// 2. The last shrub body ends exactly at the marker. This is the check
|
|
// that the boundary is the successor of shrub rather than a coincident
|
|
// pair inside it.
|
|
if let Some(last) = last_shrub {
|
|
let o_class = (0..h.shrub_classes.count.max(0) as usize)
|
|
.filter_map(|i| {
|
|
let at = h.shrub_classes.offset.max(0) as usize + i * SHRUB_CLASS_ENTRY_BYTES;
|
|
let e = ShrubClassEntry::parse(
|
|
index
|
|
.get(at..at + SHRUB_CLASS_ENTRY_BYTES)
|
|
.unwrap_or_default(),
|
|
)
|
|
.ok()?;
|
|
(e.base.offset_in_asset_wad == last as i32).then_some(e.base.o_class)
|
|
})
|
|
.next()
|
|
.unwrap_or_else(|| panic!("level{n}: no shrub class at {last}"));
|
|
let span = ShrubHeader::region(&core, last as i32, o_class, trailer)
|
|
.expect("last shrub span parses")
|
|
.expect("last shrub span present");
|
|
let data_start = last + span.header.data_offset as usize;
|
|
|
|
// Walk 8-byte records, bounded by the container so the walk can
|
|
// never continue into the chunk region itself.
|
|
let mut at = data_start;
|
|
while at + 8 <= container && core[at + 6] == 0 && core[at + 7] == 0 {
|
|
at += 8;
|
|
}
|
|
assert_eq!(
|
|
at + 2,
|
|
container,
|
|
"level{n}: shrub body at {data_start} does not end at the marker"
|
|
);
|
|
assert!(at > data_start, "level{n}: last shrub body is empty");
|
|
body_records += (at - data_start) / 8;
|
|
|
|
// No chunk signature inside the body: the search for the boundary
|
|
// must not match one it should have skipped.
|
|
let sig_inside = (last..container - 2).any(|off| chunks::is_chunk_start(&core, off, 4));
|
|
assert!(
|
|
!sig_inside,
|
|
"level{n}: chunk signature inside the last shrub body"
|
|
);
|
|
with_shrub += 1;
|
|
} else {
|
|
// Without shrub the container closes tie, and the marker precedes
|
|
// it the same way -- the locator is not shrub-specific.
|
|
let tie = core_layout(&index, &core, &h)
|
|
.into_iter()
|
|
.find(|r| r.name == "tie")
|
|
.unwrap_or_else(|| panic!("level{n}: no tie region to close"));
|
|
assert_eq!(
|
|
tie.end,
|
|
Some(container),
|
|
"level{n}: tie must end at the container"
|
|
);
|
|
}
|
|
|
|
// 3. The container fills its region up to the constant trailer: the
|
|
// furthest record should reach within a few KB of it, never inside it.
|
|
let region = &core[container..trailer];
|
|
let found = chunks::parse_all_chunks(region).expect("chunks should parse");
|
|
let region_ptr = region.as_ptr() as usize;
|
|
let furthest = found
|
|
.iter()
|
|
.flat_map(|c| {
|
|
c.records
|
|
.iter()
|
|
.map(move |r| r.as_ptr() as usize + r.len() - region_ptr)
|
|
})
|
|
.max()
|
|
.unwrap_or(0);
|
|
assert!(
|
|
region.len() - furthest < 4096,
|
|
"level{n}: chunk records stop {} bytes short of the trailer",
|
|
region.len() - furthest
|
|
);
|
|
container_bytes += region.len();
|
|
fill_gap_max = fill_gap_max.max(region.len() - furthest);
|
|
levels += 1;
|
|
}
|
|
|
|
assert_eq!(levels, 27, "expected all 27 levels, got {levels}");
|
|
assert_eq!(
|
|
with_shrub, 24,
|
|
"expected 24 levels with shrub, got {with_shrub}"
|
|
);
|
|
eprintln!(
|
|
"container: {levels} levels ({with_shrub} closing shrub), \
|
|
{body_records} 8-byte records in last-shrub bodies, \
|
|
{container_bytes} bytes of chunk data, largest trailer gap {fill_gap_max}, \
|
|
marker pair occurs at most {marker_wide_max} times per lump"
|
|
);
|
|
}
|
|
|
|
/// The chunk record's leading word is fixed per type.
|
|
///
|
|
/// A record is `[leading u16][two index bytes][payload]`. Measured on the
|
|
/// disc: the word is the same at every record of a chunk on 1,439 of 1,509
|
|
/// chunks (`None` on the 70 that vary, which the parser reports rather than
|
|
/// averaging), and fixed within every chunk type, taking only the values
|
|
/// 1, 2, 4, 6, 8 and 10.
|
|
///
|
|
/// Not named. What the value counts is not established, and two readings were
|
|
/// measured and disproved: it is not the payload's entry count -- the payload
|
|
/// length divided by it leaves 57 distinct fractional "sizes" -- and it is not
|
|
/// the number of distinct leading bytes, which agrees on 0 of 1,506 chunks.
|
|
/// See `docs/formats/core-data-regions.md`.
|
|
#[test]
|
|
fn chunk_record_leading_word_is_fixed_per_type() {
|
|
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(|_| ".".into());
|
|
PathBuf::from(home).join("Documents/gc-extracted")
|
|
});
|
|
if !data.exists() {
|
|
eprintln!("skipped: unpack the disc first");
|
|
return;
|
|
}
|
|
|
|
use gc_formats::chunks::parse_all_chunks;
|
|
use std::collections::BTreeMap;
|
|
|
|
// header -> the words its chunks carry, and the value histogram. Chunks
|
|
// whose own records disagree are counted apart rather than folded in.
|
|
let mut by_type: BTreeMap<[u8; 16], BTreeSet<u16>> = BTreeMap::new();
|
|
let mut histogram: BTreeMap<u16, usize> = BTreeMap::new();
|
|
let mut levels = 0usize;
|
|
let mut chunks = 0usize;
|
|
let mut varying = 0usize;
|
|
|
|
for n in 0..27 {
|
|
let core = match std::fs::read(data.join(format!("level{n}/core_data.bin"))) {
|
|
Ok(c) => c,
|
|
Err(_) => continue,
|
|
};
|
|
let found = parse_all_chunks(&core).expect("chunks should parse");
|
|
if found.is_empty() {
|
|
continue;
|
|
}
|
|
levels += 1;
|
|
for c in &found {
|
|
chunks += 1;
|
|
match c.chunk.prefix {
|
|
Some(p) => {
|
|
by_type.entry(c.chunk.header).or_default().insert(p);
|
|
*histogram.entry(p).or_default() += 1;
|
|
}
|
|
None => varying += 1,
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(levels >= 25, "only {levels} levels carry chunks");
|
|
assert!(chunks > 1400, "only {chunks} chunks parsed");
|
|
// The word is uniform within a chunk on all but a small minority.
|
|
let uniform = chunks - varying;
|
|
assert!(
|
|
uniform > 1400,
|
|
"only {uniform} of {chunks} chunks carry a uniform leading word"
|
|
);
|
|
// Fixed within every type: no type's chunks carry two different words.
|
|
let varying_types: Vec<_> = by_type.iter().filter(|(_, v)| v.len() > 1).collect();
|
|
assert!(
|
|
varying_types.is_empty(),
|
|
"{} types carry more than one leading word",
|
|
varying_types.len()
|
|
);
|
|
// The word is never zero and never large, and it is not the record count:
|
|
// 621 chunks carry 2, 520 carry 4, and the rest are spread thin.
|
|
assert!(
|
|
histogram.keys().all(|&p| p > 0 && p <= 16),
|
|
"prefix values {histogram:?}"
|
|
);
|
|
let two = histogram.get(&2).copied().unwrap_or(0);
|
|
let four = histogram.get(&4).copied().unwrap_or(0);
|
|
assert!(two > 500, "only {two} chunks carry the word 2");
|
|
assert!(four > 400, "only {four} chunks carry the word 4");
|
|
eprintln!(
|
|
"chunk leading word: {uniform} of {chunks} chunks uniform ({varying} vary), \
|
|
{} types, values {histogram:?}",
|
|
by_type.len()
|
|
);
|
|
}
|
|
|
|
/// The tie record is R&C1's `TiePacketHeader`, and its rule holds.
|
|
///
|
|
/// A tie LOD block is a table of 16-byte packet headers -- a data pointer,
|
|
/// then R&C1's byte fields at +4..+0x10. What identifies them rather than a
|
|
/// resemblance is R&C1's own documented rule: **`bfc_distance == 5 *
|
|
/// shader_count` on every record**, here 15,219 of 15,219 over 2,177 blocks on
|
|
/// all 27 levels.
|
|
///
|
|
/// Measured over the same set, and reported rather than asserted because they
|
|
/// are not universal:
|
|
///
|
|
/// * `color_ofs == vert_size` on 12,640 of 15,219 -- and R&C1's rule for the
|
|
/// field (`== vert_ofs + vert_size`) holds on **0**, so the two games use it
|
|
/// differently and this one is named for what it measures;
|
|
/// * `color_count`, `strip_count`, `strip_vertex_count` zero on ~15,077 of
|
|
/// 15,219;
|
|
/// * the orderings `vert_ofs <= vert_size`, `scissor_size <= scissor_ofs` and
|
|
/// `control_size <= control_count` on all of them.
|
|
///
|
|
/// One claim is retracted here rather than softened: a packet-length formula,
|
|
/// `(vert_ofs + vert_size + scissor_ofs + scissor_size) * 16`, was measured at
|
|
/// 61 of 61 pairs and is not reproduced on the correct blocks (0 of 13,042). It
|
|
/// was measured over blocks found by reading `packets[2]` where `packets[1]`
|
|
/// was meant, so the arithmetic agreed perfectly with a population that was not
|
|
/// the tie blocks. The block set was the error; see `AGENTS.md` rule 3.
|
|
#[test]
|
|
fn tie_records_are_packet_headers() {
|
|
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(|_| ".".into());
|
|
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::tie::{parse_tie_block, resolve_tie_class, TieRecord};
|
|
|
|
let mut levels = 0usize;
|
|
let mut blocks = 0usize;
|
|
let mut records = 0usize;
|
|
let mut bfc_ok = 0usize;
|
|
let mut invariants_ok = 0usize;
|
|
let (mut color_eq_size, mut color_zero, mut strips_zero, mut strip_verts_zero) =
|
|
(0usize, 0usize, 0usize, 0usize);
|
|
|
|
for n in 0..27 {
|
|
let (Ok(core), Ok(index)) = (
|
|
std::fs::read(data.join(format!("level{n}/core_data.bin"))),
|
|
std::fs::read(data.join(format!("level{n}/core_index.bin"))),
|
|
) else {
|
|
continue;
|
|
};
|
|
let h = LevelCoreHeader::parse(&index).expect("core header");
|
|
for c in 0..h.tie_classes.count.max(0) as usize {
|
|
let Ok(class) = resolve_tie_class(&index, &core, h.tie_classes, c) else {
|
|
continue;
|
|
};
|
|
for lod in 1..=2usize {
|
|
let off = class.header.packets[lod];
|
|
if off <= 0 {
|
|
continue;
|
|
}
|
|
let start = class.entry.offset_in_asset_wad as usize + off as usize;
|
|
let Some(block) = parse_tie_block(&core, start) else {
|
|
continue;
|
|
};
|
|
let len = 0x10 + block.payload.len() + block.padding.len();
|
|
let Some(slice) = core.get(start..start + len) else {
|
|
continue;
|
|
};
|
|
// The table must fit inside the block the terminator found, or
|
|
// what follows is being read as a table.
|
|
if block.header.data_offset as usize > 0x10 + block.payload.len() {
|
|
continue;
|
|
}
|
|
blocks += 1;
|
|
for r in block
|
|
.header
|
|
.records(slice)
|
|
.map(|r| TieRecord::parse(r).expect("record parses"))
|
|
{
|
|
records += 1;
|
|
bfc_ok += usize::from(r.bfc_distance == 5 * r.shader_count);
|
|
invariants_ok += usize::from(r.invariants_hold());
|
|
color_eq_size += usize::from(r.color_ofs == r.vert_size);
|
|
color_zero += usize::from(r.color_count == 0);
|
|
strips_zero += usize::from(r.strip_count == 0);
|
|
strip_verts_zero += usize::from(r.strip_vertex_count == 0);
|
|
}
|
|
}
|
|
}
|
|
levels += 1;
|
|
}
|
|
|
|
assert!(levels >= 25, "only {levels} levels were read");
|
|
assert_eq!(blocks, 2_177, "expected 2,177 tie blocks, got {blocks}");
|
|
assert!(records > 14_000, "only {records} packet headers found");
|
|
// The identification: R&C1's rule, on every record.
|
|
assert_eq!(
|
|
bfc_ok,
|
|
records,
|
|
"bfc_distance != 5 * shader_count on {} of {records}",
|
|
records - bfc_ok
|
|
);
|
|
assert_eq!(
|
|
invariants_ok,
|
|
records,
|
|
"record invariants failed on {} of {records}",
|
|
records - invariants_ok
|
|
);
|
|
eprintln!(
|
|
"tie packet headers: {records} over {levels} levels, {blocks} blocks; \
|
|
bfc_distance == 5*shader_count on all; orderings on all; \
|
|
color_ofs == vert_size on {color_eq_size}/{records}; \
|
|
color_count/strip_count/strip_vertex_count zero on \
|
|
{color_zero}/{strips_zero}/{strip_verts_zero}"
|
|
);
|
|
}
|
|
|
|
/// Byte positions that hold the same value in every supplied record.
|
|
fn constant_positions(records: &[&[u8]]) -> usize {
|
|
let Some(first) = records.first() else {
|
|
return 0;
|
|
};
|
|
(0..first.len())
|
|
.filter(|&i| records.iter().all(|r| r[i] == first[i]))
|
|
.count()
|
|
}
|
|
|
|
/// The sky block: the sub-object table at `+0x20` enumerates the sub-objects on
|
|
/// every level, and each carries a bounding sphere and texture dimensions.
|
|
///
|
|
/// The word at `+0x0c` is deliberately **not** asserted to be a count: it
|
|
/// disagrees with the table on most levels. See `gc_formats::sky`.
|
|
#[test]
|
|
fn sky_sub_objects_parse_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;
|
|
}
|
|
|
|
let mut levels = 0usize;
|
|
let mut total = 0usize;
|
|
let mut radii = (f32::MAX, f32::MIN);
|
|
let mut counts = (u32::MAX, 0u32);
|
|
let mut dims = (u16::MAX, 0u16);
|
|
let mut nonzero_radius = 0usize;
|
|
let mut reserved_nonzero = 0usize;
|
|
|
|
for n in 0..27 {
|
|
let dir = data.join(format!("level{n}"));
|
|
let core = match std::fs::read(dir.join("core_data.bin")) {
|
|
Ok(c) => c,
|
|
Err(_) => continue,
|
|
};
|
|
let index = match std::fs::read(dir.join("core_index.bin")) {
|
|
Ok(i) => i,
|
|
Err(_) => continue,
|
|
};
|
|
let hdr = match gc_formats::level_core::LevelCoreHeader::parse(&index) {
|
|
Ok(h) => h,
|
|
Err(_) => continue,
|
|
};
|
|
let sky = hdr.sky as usize;
|
|
if sky == 0 || sky >= core.len() {
|
|
continue;
|
|
}
|
|
let end = [
|
|
hdr.collision as usize,
|
|
hdr.moby_classes.offset as usize,
|
|
core.len(),
|
|
]
|
|
.into_iter()
|
|
.filter(|&v| v > sky)
|
|
.min()
|
|
.unwrap_or(core.len());
|
|
let region = &core[sky..end.min(core.len())];
|
|
|
|
let parsed = gc_formats::sky::Sky::parse(region).expect("sky should parse");
|
|
assert!(
|
|
!parsed.sub_objects.is_empty(),
|
|
"level{n}: sky region has no sub-objects"
|
|
);
|
|
levels += 1;
|
|
total += parsed.sub_objects.len();
|
|
|
|
for s in &parsed.sub_objects {
|
|
if s.reserved != [0u8; 12] {
|
|
reserved_nonzero += 1;
|
|
}
|
|
assert!(s.count > 0, "level{n}: sky sub-object count is {}", s.count);
|
|
assert!(
|
|
s.width > 0 && s.height > 0,
|
|
"level{n}: sky texture dims are {}x{}",
|
|
s.width,
|
|
s.height
|
|
);
|
|
assert!(
|
|
s.sphere.iter().all(|f| f.is_finite()),
|
|
"level{n}: sky sphere is not finite: {:?}",
|
|
s.sphere
|
|
);
|
|
// The fourth float is the radius: positive wherever the first
|
|
// three are negative, the same reasoning the shrub header uses.
|
|
if s.sphere[3] > 0.0 {
|
|
nonzero_radius += 1;
|
|
radii.0 = radii.0.min(s.sphere[3]);
|
|
radii.1 = radii.1.max(s.sphere[3]);
|
|
}
|
|
counts.0 = counts.0.min(s.count);
|
|
counts.1 = counts.1.max(s.count);
|
|
dims.0 = dims.0.min(s.width);
|
|
dims.1 = dims.1.max(s.width);
|
|
}
|
|
}
|
|
|
|
assert!(levels >= 20, "only {levels} levels had a sky region");
|
|
assert!(total >= 100, "only {total} sky sub-objects parsed");
|
|
eprintln!(
|
|
"sky: {total} sub-objects over {levels} levels; count {}..={}, width {}..={}, radius {:.2}..={:.2} on {nonzero_radius}, {reserved_nonzero} with a non-zero reserved word",
|
|
counts.0, counts.1, dims.0, dims.1, radii.0, radii.1
|
|
);
|
|
}
|
|
|
|
/// The per-level overlay: the header is stable, `+0x10..+0x1220` is identical
|
|
/// on every level, and `+0x0c` is not the file length.
|
|
///
|
|
/// See `gc_formats::overlay`. This pins the header and the constant block's
|
|
/// extent; it does not name any field.
|
|
#[test]
|
|
fn overlay_header_and_constant_block_are_stable() {
|
|
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::overlay::{parse_overlay, OVERLAY_CONSTANT_BLOCK_END, OVERLAY_HEADER_SIZE};
|
|
|
|
let mut levels = 0usize;
|
|
let mut constant_block: Option<Vec<u8>> = None;
|
|
let mut f00 = 0u32;
|
|
let mut f08 = 0u32;
|
|
let (mut min_f04, mut max_f04) = (u32::MAX, 0u32);
|
|
let (mut min_f0c, mut max_f0c) = (u32::MAX, 0u32);
|
|
let mut f0c_below = 0usize;
|
|
let mut f0c_above = 0usize;
|
|
let mut levels_seen = Vec::new();
|
|
|
|
for n in 0..27 {
|
|
let p = data.join(format!("level{n}/overlay.bin"));
|
|
let Ok(bytes) = std::fs::read(&p) else {
|
|
continue;
|
|
};
|
|
|
|
let (h, constant, level) = parse_overlay(&bytes).expect("overlay should parse");
|
|
|
|
assert!(
|
|
bytes.len() > OVERLAY_CONSTANT_BLOCK_END,
|
|
"level{n}: overlay is {} bytes, too short for the constant block",
|
|
bytes.len()
|
|
);
|
|
assert!(
|
|
!level.bytes.is_empty(),
|
|
"level{n}: overlay has no level data"
|
|
);
|
|
assert_eq!(
|
|
constant.bytes.len(),
|
|
OVERLAY_CONSTANT_BLOCK_END - OVERLAY_HEADER_SIZE
|
|
);
|
|
|
|
if f00 == 0 {
|
|
f00 = h.field_00;
|
|
}
|
|
assert_eq!(h.field_00, f00, "level{n}: field_00 differs");
|
|
if f08 == 0 {
|
|
f08 = h.field_08;
|
|
}
|
|
assert_eq!(h.field_08, f08, "level{n}: field_08 differs");
|
|
|
|
assert_eq!(
|
|
h.field_04 % 4,
|
|
0,
|
|
"level{n}: field_04 {} is not a multiple of 4",
|
|
h.field_04
|
|
);
|
|
min_f04 = min_f04.min(h.field_04);
|
|
max_f04 = max_f04.max(h.field_04);
|
|
|
|
min_f0c = min_f0c.min(h.field_0c);
|
|
max_f0c = max_f0c.max(h.field_0c);
|
|
// The claim being pinned: field_0c is not the file length.
|
|
if (h.field_0c as usize) < bytes.len() {
|
|
f0c_below += 1
|
|
} else {
|
|
f0c_above += 1
|
|
}
|
|
|
|
match &constant_block {
|
|
None => constant_block = Some(constant.bytes.to_vec()),
|
|
Some(prev) => assert_eq!(
|
|
prev.as_slice(),
|
|
constant.bytes,
|
|
"level{n}: the constant block +0x10..0x{OVERLAY_CONSTANT_BLOCK_END:x} differs"
|
|
),
|
|
}
|
|
|
|
levels += 1;
|
|
levels_seen.push(n);
|
|
}
|
|
|
|
assert!(
|
|
levels >= 27,
|
|
"only {levels} levels had an overlay.bin, saw {levels_seen:?}"
|
|
);
|
|
assert!(f0c_below > 0 && f0c_above > 0,
|
|
"field_0c must fall on both sides of the file length to show it is not one: below {f0c_below}, above {f0c_above}");
|
|
assert_eq!(levels, 27, "expected all levels, got {levels_seen:?}");
|
|
assert!(
|
|
max_f04 - min_f04 > 1000,
|
|
"field_04 range collapsed to {min_f04}..={max_f04}"
|
|
);
|
|
assert!(
|
|
max_f0c - min_f0c > 100000,
|
|
"field_0c range collapsed to {min_f0c}..={max_f0c}"
|
|
);
|
|
|
|
eprintln!(
|
|
"overlay: {levels} levels; field_00 {f00}, field_08 {f08}; \
|
|
field_04 {min_f04}..={max_f04}, field_0c {min_f0c}..={max_f0c} \
|
|
({f0c_below} below the file length, {f0c_above} above); \
|
|
+0x10..0x{OVERLAY_CONSTANT_BLOCK_END:x} identical on all"
|
|
);
|
|
}
|
|
|
|
/// The two tie indirections resolve on every class on every level, and the
|
|
/// LOD block offsets land inside `core_data`.
|
|
///
|
|
/// See `gc_formats::tie`. This pins the resolution chain and the header; the
|
|
/// block's internal layout is *not* decoded, and one tempting reading of it is
|
|
/// disproved below.
|
|
#[test]
|
|
fn tie_classes_resolve_through_both_indirections() {
|
|
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::tie::resolve_tie_class;
|
|
|
|
let mut classes = 0usize;
|
|
let mut o_class_agree = 0usize;
|
|
let mut lod_blocks = 0usize;
|
|
let mut lod_blocks_in_range = 0usize;
|
|
let mut monotonic_u32_0 = 0usize;
|
|
|
|
for n in 0..27 {
|
|
let idx = std::fs::read(data.join(format!("level{n}/core_index.bin"))).expect("core_index");
|
|
let core = std::fs::read(data.join(format!("level{n}/core_data.bin"))).expect("core_data");
|
|
let h = LevelCoreHeader::parse(&idx).expect("core header");
|
|
let t = h.tie_classes;
|
|
if t.count == 0 {
|
|
continue;
|
|
}
|
|
for c in 0..t.count as usize {
|
|
let r = resolve_tie_class(&idx, &core, t, c).expect("tie class should resolve");
|
|
classes += 1;
|
|
if r.header.o_class == r.entry.o_class {
|
|
o_class_agree += 1;
|
|
}
|
|
|
|
let base = r.entry.offset_in_asset_wad as usize;
|
|
for (i, p) in r.header.packets.iter().enumerate() {
|
|
let abs = base.wrapping_add(*p as usize);
|
|
if abs <= core.len() {
|
|
lod_blocks_in_range += 1;
|
|
}
|
|
lod_blocks += 1;
|
|
if *p >= 0 && abs + 16 <= core.len() {
|
|
// Is the word at the block start strictly increasing across
|
|
// 16-byte units? It looked like it on the first twelve units
|
|
// of one class. It is not a property of the format.
|
|
let next = if i + 1 < 3 {
|
|
r.header.packets[i + 1]
|
|
} else {
|
|
*p
|
|
};
|
|
if next > *p {
|
|
let end = base + next as usize;
|
|
if end <= core.len() {
|
|
let n16 = (end - abs) / 16;
|
|
let mut prev = 0i64;
|
|
let mut ok = true;
|
|
for k in 0..n16 {
|
|
let q = abs + k * 16;
|
|
let a =
|
|
u32::from_le_bytes(core[q..q + 4].try_into().unwrap()) as i64;
|
|
if a <= prev {
|
|
ok = false;
|
|
break;
|
|
}
|
|
prev = a;
|
|
}
|
|
if ok && n16 > 0 {
|
|
monotonic_u32_0 += 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// packets[0] is a constant, not an offset, and the other two order only
|
|
// sometimes -- both are why the block layout is only partly understood.
|
|
let mut packets0_constant = 0usize;
|
|
let mut packets_increasing = 0usize;
|
|
let mut consecutive_div16 = 0usize;
|
|
let mut consecutive_total = 0usize;
|
|
|
|
for n in 0..27 {
|
|
let idx = std::fs::read(data.join(format!("level{n}/core_index.bin"))).expect("core_index");
|
|
let core = std::fs::read(data.join(format!("level{n}/core_data.bin"))).expect("core_data");
|
|
let h = LevelCoreHeader::parse(&idx).expect("core header");
|
|
let t = h.tie_classes;
|
|
for c in 0..t.count as usize {
|
|
let r = resolve_tie_class(&idx, &core, t, c).expect("tie class");
|
|
if r.header.packets[0] == gc_formats::tie::TIE_PACKETS0 {
|
|
packets0_constant += 1;
|
|
}
|
|
let p = r.header.packets;
|
|
if p[0] < p[1] && p[1] < p[2] {
|
|
packets_increasing += 1;
|
|
for i in 0..2 {
|
|
consecutive_total += 1;
|
|
if ((p[i + 1] - p[i]) as usize) % 16 == 0 {
|
|
consecutive_div16 += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tie LOD blocks are self-delimiting, which is what makes them parseable
|
|
// without trusting packets[1] to bound the block.
|
|
use gc_formats::tie::{
|
|
parse_tie_block, TIE_BLOCK_HEADER_SIZE, TIE_BLOCK_TERMINATOR, TIE_PACKED_TEXTURE_VALUES,
|
|
};
|
|
|
|
let mut blocks = 0usize;
|
|
let mut absent = 0usize;
|
|
let mut hdr_ok = 0usize;
|
|
let mut count_mult16 = 0usize;
|
|
let mut reserved_02_zero = 0usize;
|
|
let mut tables_bounded = 0usize;
|
|
let mut packed_ok = 0usize;
|
|
let mut padding_all_zero = 0usize;
|
|
let mut packed_seen: Vec<u16> = Vec::new();
|
|
|
|
for n in 0..27 {
|
|
let idx = std::fs::read(data.join(format!("level{n}/core_index.bin"))).expect("core_index");
|
|
let core = std::fs::read(data.join(format!("level{n}/core_data.bin"))).expect("core_data");
|
|
let h = LevelCoreHeader::parse(&idx).expect("core header");
|
|
let t = h.tie_classes;
|
|
for c in 0..t.count as usize {
|
|
let r = resolve_tie_class(&idx, &core, t, c).expect("tie class");
|
|
let base = r.entry.offset_in_asset_wad as usize;
|
|
for lod in [1usize, 2] {
|
|
let off = r.header.packets[lod] as i64;
|
|
if off == 0 {
|
|
// packets == 0 means this class has no block at this LOD.
|
|
absent += 1;
|
|
continue;
|
|
}
|
|
let s = (base as i64 + off) as usize;
|
|
if s + TIE_BLOCK_HEADER_SIZE > core.len() {
|
|
continue;
|
|
}
|
|
let Some(blk) = parse_tie_block(&core, s) else {
|
|
continue;
|
|
};
|
|
|
|
blocks += 1;
|
|
// The word at +0 is the data offset -- the record table's
|
|
// length -- so it is a non-zero multiple of 16, and it bounds
|
|
// the table.
|
|
if blk.header.data_offset % 16 == 0 && blk.header.data_offset != 0 {
|
|
count_mult16 += 1
|
|
}
|
|
let table = blk.header.records(&core[s..]).count();
|
|
if table == blk.header.record_count() && table > 0 {
|
|
tables_bounded += 1
|
|
}
|
|
if blk.header.reserved_02 == 0 {
|
|
reserved_02_zero += 1
|
|
}
|
|
if TIE_PACKED_TEXTURE_VALUES.contains(&blk.header.packed_texture) {
|
|
packed_ok += 1
|
|
}
|
|
if blk.header.invariants_hold() {
|
|
hdr_ok += 1
|
|
}
|
|
if blk.padding.iter().all(|&b| b == 0) {
|
|
padding_all_zero += 1
|
|
}
|
|
assert_eq!(
|
|
blk.payload.last(),
|
|
Some(&TIE_BLOCK_TERMINATOR),
|
|
"level{n} class{c} lod{lod}: payload does not end on the terminator"
|
|
);
|
|
if !packed_seen.contains(&blk.header.packed_texture) {
|
|
packed_seen.push(blk.header.packed_texture);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
packed_seen.sort_unstable();
|
|
|
|
assert!(blocks >= 2100, "only {blocks} tie blocks located");
|
|
assert!(
|
|
absent > 1000,
|
|
"only {absent} absent (packets == 0) LOD blocks"
|
|
);
|
|
assert_eq!(blocks + absent, 2 * classes,
|
|
"expected two LOD slots per class, got {blocks} present + {absent} absent for {classes} classes");
|
|
assert_eq!(
|
|
count_mult16,
|
|
blocks,
|
|
"the data offset is not a non-zero multiple of 16 on {} of {blocks}",
|
|
blocks - count_mult16
|
|
);
|
|
assert_eq!(
|
|
tables_bounded,
|
|
blocks,
|
|
"the record table is not bounded by the data offset on {} of {blocks}",
|
|
blocks - tables_bounded
|
|
);
|
|
assert_eq!(
|
|
reserved_02_zero,
|
|
blocks,
|
|
"reserved_02 non-zero on {} of {blocks}",
|
|
blocks - reserved_02_zero
|
|
);
|
|
assert_eq!(
|
|
packed_ok,
|
|
blocks,
|
|
"packed_texture outside the four known values on {} of {blocks}: {packed_seen:?}",
|
|
blocks - packed_ok
|
|
);
|
|
assert_eq!(
|
|
hdr_ok,
|
|
blocks,
|
|
"header invariants failed on {} of {blocks}",
|
|
blocks - hdr_ok
|
|
);
|
|
assert_eq!(
|
|
padding_all_zero,
|
|
blocks,
|
|
"non-zero padding on {} of {blocks}",
|
|
blocks - padding_all_zero
|
|
);
|
|
assert_eq!(
|
|
packed_seen.len(),
|
|
TIE_PACKED_TEXTURE_VALUES.len(),
|
|
"packed_texture took {} distinct values, expected the four {packed_seen:?}",
|
|
packed_seen.len()
|
|
);
|
|
|
|
eprintln!(
|
|
"tie blocks: {blocks} present, {absent} absent (packets == 0); all end on 0xf6 with zero padding; \
|
|
count %16 on all; reserved_02 == 0 on all; packed_texture in {packed_seen:?} on all"
|
|
);
|
|
|
|
assert!(classes >= 1800, "only {classes} tie classes resolved");
|
|
assert_eq!(
|
|
packets0_constant,
|
|
classes,
|
|
"packets[0] was not {} on {} of {classes} classes",
|
|
gc_formats::tie::TIE_PACKETS0,
|
|
classes - packets0_constant
|
|
);
|
|
assert_eq!(
|
|
consecutive_div16,
|
|
consecutive_total,
|
|
"{} of {consecutive_total} consecutive tie blocks are not a multiple of 16 bytes",
|
|
consecutive_total - consecutive_div16
|
|
);
|
|
assert!(
|
|
packets_increasing < classes,
|
|
"packets increase on all {classes} classes; the block-end reasoning in the docs needs revisiting"
|
|
);
|
|
assert_eq!(
|
|
o_class_agree,
|
|
classes,
|
|
"header o_class disagreed with the class entry on {} of {classes}",
|
|
classes - o_class_agree
|
|
);
|
|
assert_eq!(
|
|
lod_blocks_in_range,
|
|
lod_blocks,
|
|
"{} of {lod_blocks} LOD block offsets fell outside core_data",
|
|
lod_blocks - lod_blocks_in_range
|
|
);
|
|
assert_eq!(
|
|
monotonic_u32_0, 0,
|
|
"the word at +0 of a 16-byte tie unit is strictly increasing on {monotonic_u32_0} blocks; \
|
|
if that ever becomes true the disproved reading below needs revisiting"
|
|
);
|
|
|
|
eprintln!(
|
|
"tie: {classes} classes resolved through both indirections, o_class agreeing on all; \
|
|
{lod_blocks} LOD block offsets all inside core_data; \
|
|
packets[0] == {} on all; packets increasing on {packets_increasing} of {classes}; \
|
|
{consecutive_div16} of {consecutive_total} consecutive blocks a multiple of 16; \
|
|
strictly-increasing +0 on 0 of {classes} blocks (disproved)",
|
|
gc_formats::tie::TIE_PACKETS0
|
|
);
|
|
}
|
|
|
|
/// Chunk record buckets: the leading byte is an aligned index, not record data.
|
|
///
|
|
/// The leading byte is not decoded -- what it indexes is not established. This
|
|
/// pins the measured 8-byte alignment and the exact three types that break it;
|
|
/// a parallel probe puts 1,142 records across seven types outside a *16*-byte
|
|
/// rule, which is how the per-type 8-vs-16 step was found. See
|
|
/// `gc_formats::chunks::CHUNK_RECORD_ALIGNMENT`.
|
|
#[test]
|
|
fn chunk_records_start_16_byte_aligned() {
|
|
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::chunks::{parse_all_chunks, record_is_aligned};
|
|
use std::collections::BTreeMap;
|
|
|
|
let mut chunks = 0usize;
|
|
let mut records = 0usize;
|
|
let mut aligned = 0usize;
|
|
let mut unaligned: BTreeMap<u32, usize> = BTreeMap::new();
|
|
|
|
for n in 0..27 {
|
|
let core = std::fs::read(data.join(format!("level{n}/core_data.bin"))).expect("core_data");
|
|
for c in parse_all_chunks(&core).expect("chunks") {
|
|
chunks += 1;
|
|
let t = u32::from_le_bytes([
|
|
c.chunk.header[0],
|
|
c.chunk.header[1],
|
|
c.chunk.header[2],
|
|
c.chunk.header[3],
|
|
]);
|
|
for r in &c.records {
|
|
if r.is_empty() {
|
|
continue;
|
|
}
|
|
records += 1;
|
|
if record_is_aligned(r) {
|
|
aligned += 1;
|
|
} else {
|
|
*unaligned.entry(t).or_default() += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(chunks >= 1500, "only {chunks} chunks parsed");
|
|
assert!(records >= 34000, "only {records} chunk records");
|
|
// Every chunk has a uniform record length -- length varies between chunks
|
|
// of a type, never within one -- and on most chunks the leading byte is a
|
|
// per-chunk phase counter rather than data.
|
|
let mut uniform = 0usize;
|
|
let mut ruled = 0usize;
|
|
let mut indexed = 0usize;
|
|
for n in 0..27 {
|
|
let core = std::fs::read(data.join(format!("level{n}/core_data.bin"))).expect("core_data");
|
|
for c in parse_all_chunks(&core).expect("chunks") {
|
|
if c.has_uniform_record_length() {
|
|
uniform += 1;
|
|
}
|
|
if c.bucket_rule().is_some() {
|
|
ruled += 1;
|
|
}
|
|
if c.index_rule().is_some() {
|
|
indexed += 1;
|
|
}
|
|
}
|
|
}
|
|
assert_eq!(uniform, 1509, "uniform-record-length count moved off 1509");
|
|
assert_eq!(ruled, 1262, "bucket-rule count moved off 1262");
|
|
assert_eq!(indexed, 1235, "index-rule count moved off 1235");
|
|
|
|
// Not universal. Three chunk types break even the 8-byte rule, all of them
|
|
// the variable-length or 8-stepped ones. A parallel probe puts 1,142
|
|
// records across seven types outside a *16*-byte rule, which is how the
|
|
// per-type 8-vs-16 step was found in the first place.
|
|
let bad: usize = unaligned.values().sum();
|
|
assert_eq!(
|
|
bad, 59,
|
|
"unaligned record count moved off 59: {unaligned:?}"
|
|
);
|
|
assert_eq!(
|
|
unaligned.len(),
|
|
3,
|
|
"unaligned records now span {} chunk types, not the 3 known: {unaligned:?}",
|
|
unaligned.len()
|
|
);
|
|
assert_eq!(
|
|
records - bad,
|
|
34_432,
|
|
"multiple-of-8 count moved off 34,432"
|
|
);
|
|
|
|
eprintln!(
|
|
"chunks: {chunks} chunks, {records} records; uniform record length on {uniform}; \
|
|
leading byte a multiple of 8 on {aligned}, {bad} exceptions across {} types; \
|
|
leading byte a phase counter on {ruled}; \
|
|
leading TWO bytes encode the record index on {indexed}",
|
|
unaligned.len()
|
|
);
|
|
}
|
|
|
|
/// Moby class IDs live in one body slot per chunk type, and the slot varies.
|
|
///
|
|
/// Measured over all 27 levels: the best slot per type carries 5 to 42 distinct
|
|
/// valid moby class IDs at 153x to 289x enrichment over the chance rate. The
|
|
/// `distinct >= 4` filter is load-bearing -- without it the top slots are
|
|
/// constants that read as 305x because any value below the moby count counts as
|
|
/// a hit. See `docs/formats/core-data-regions.md`.
|
|
#[test]
|
|
fn moby_class_ids_sit_in_one_slot_per_chunk_type() {
|
|
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::chunks::parse_all_chunks;
|
|
use gc_formats::level_core::LevelCoreHeader;
|
|
use std::collections::BTreeMap;
|
|
|
|
// (type, slot) -> (nonzero, hits, expected, distinct)
|
|
let mut stat: BTreeMap<(u32, usize), (u64, u64, f64, usize)> = BTreeMap::new();
|
|
let mut seen: BTreeMap<(u32, usize), BTreeSet<u16>> = BTreeMap::new();
|
|
|
|
for n in 0..27 {
|
|
let core = std::fs::read(data.join(format!("level{n}/core_data.bin"))).expect("core_data");
|
|
let idx = std::fs::read(data.join(format!("level{n}/core_index.bin"))).expect("core_index");
|
|
let nm = LevelCoreHeader::parse(&idx)
|
|
.expect("hdr")
|
|
.moby_classes
|
|
.count as u64;
|
|
if nm == 0 {
|
|
continue;
|
|
}
|
|
for c in parse_all_chunks(&core).expect("chunks") {
|
|
let t = u32::from_le_bytes([
|
|
c.chunk.header[0],
|
|
c.chunk.header[1],
|
|
c.chunk.header[2],
|
|
c.chunk.header[3],
|
|
]);
|
|
let len = c.records[0].len();
|
|
if c.records.iter().any(|r| r.len() != len) {
|
|
continue;
|
|
}
|
|
let mut loc: BTreeMap<usize, (u64, u64)> = BTreeMap::new();
|
|
for r in c.records.iter() {
|
|
for k in 1..len / 2 {
|
|
let v = u16::from_le_bytes([r[k * 2], r[k * 2 + 1]]);
|
|
if v == 0 {
|
|
continue;
|
|
}
|
|
seen.entry((t, k)).or_default().insert(v);
|
|
let e = loc.entry(k).or_default();
|
|
e.0 += 1;
|
|
if (v as u64) < nm {
|
|
e.1 += 1
|
|
}
|
|
}
|
|
}
|
|
for (k, (nn, hh)) in loc {
|
|
let e = stat.entry((t, k)).or_default();
|
|
e.0 += nn;
|
|
e.1 += hh;
|
|
e.2 += nn as f64 * (nm as f64 / 65536.0);
|
|
}
|
|
}
|
|
}
|
|
for (key, s) in seen.iter() {
|
|
stat.entry(*key).or_default().3 = s.len();
|
|
}
|
|
|
|
// best varying slot per type
|
|
let mut best: BTreeMap<u32, (usize, f64, usize)> = BTreeMap::new();
|
|
for ((t, k), (nn, hh, e, dist)) in stat.iter() {
|
|
if *nn < 8 || *e < 1.0 || *dist < 4 {
|
|
continue;
|
|
}
|
|
let r = (*hh as f64) / (*e);
|
|
if best.get(t).is_none_or(|(_, ce, _)| r > *ce) {
|
|
best.insert(*t, (*k, *hh as f64 / *e, *dist));
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
best.len() >= 20,
|
|
"only {} chunk types carry a varying class-ID slot",
|
|
best.len()
|
|
);
|
|
let strong: Vec<_> = best.values().filter(|(_, r, _)| *r >= 100.0).collect();
|
|
assert!(
|
|
strong.len() >= 20,
|
|
"only {} types reach 100x enrichment",
|
|
strong.len()
|
|
);
|
|
|
|
let mut all: Vec<_> = best
|
|
.iter()
|
|
.map(|(t, (k, r, dist))| (*t, *k, *r, *dist))
|
|
.collect();
|
|
all.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap());
|
|
assert!(!all.is_empty(), "no varying class-ID slots found");
|
|
let mn = all
|
|
.iter()
|
|
.map(|a| a.2)
|
|
.fold(f64::MAX, |g: f64, v: f64| f64::min(g, v));
|
|
let mx = all
|
|
.iter()
|
|
.map(|a| a.2)
|
|
.fold(0.0f64, |g: f64, v: f64| f64::max(g, v));
|
|
let dmin = all.iter().map(|a| a.3).min().unwrap();
|
|
let dmax = all.iter().map(|a| a.3).max().unwrap();
|
|
|
|
eprintln!(
|
|
"chunk class-ID slots: {} types; best-slot enrichment {mn:.0}x..{mx:.0}x; \
|
|
distinct IDs per slot {dmin}..{dmax}",
|
|
best.len()
|
|
);
|
|
eprintln!(
|
|
" strongest (type, byte, ratio, distinct): {:?}",
|
|
&all[..all.len().min(6)]
|
|
);
|
|
}
|
|
|
|
/// The tie vertex element is 16 bytes, with 12-bit fixed-point texcoords.
|
|
///
|
|
/// Measured over 95,612 vertices on levels 0-2, reached by following each
|
|
/// `TieRecord::pointer` into the block payload and stepping `vert_size` elements
|
|
/// from `vert_ofs`. The element size is settled by comparison against the
|
|
/// alternatives, not assumed: 16 bytes gives a 12-bit-fixed texcoord hit rate of
|
|
/// 82.5%, against 47.8% at 8 bytes and 45.4% at 12.
|
|
///
|
|
/// The field *names* are not established -- this pins the size and the texcoord
|
|
/// encoding only.
|
|
/// `i16[0..2]` are vertex positions.
|
|
///
|
|
/// LOD 1 and LOD 2 of one class are the same object at two levels of detail, so
|
|
/// any field that locates a vertex in the object's own geometry is shared
|
|
/// between them. A different class' LOD is an unrelated object. Comparing the
|
|
/// two turns "is this a position" into a measurement with a built-in control.
|
|
type Lods = (BTreeSet<[i16; 3]>, BTreeSet<[i16; 3]>);
|
|
|
|
#[test]
|
|
fn tie_vertex_slots_0_to_2_are_shared_positions() {
|
|
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::{ClassEntry, LevelCoreHeader};
|
|
use gc_formats::tie::{parse_tie_block, TieClassHeader, TieRecord};
|
|
|
|
let (mut same_sum, mut same_n) = (0.0f64, 0usize);
|
|
let (mut diff_sum, mut diff_n) = (0.0f64, 0usize);
|
|
let mut same_with_overlap = 0usize;
|
|
|
|
for lv in 0..6usize {
|
|
let idx = std::fs::read(data.join(format!("level{lv}/core_index.bin")));
|
|
let core = std::fs::read(data.join(format!("level{lv}/core_data.bin")));
|
|
let (Ok(idx), Ok(core)) = (idx, core) else {
|
|
continue;
|
|
};
|
|
let t = LevelCoreHeader::parse(&idx).expect("hdr").tie_classes;
|
|
|
|
let mut per_class: Vec<Lods> = Vec::new();
|
|
for c in 0..t.count as usize {
|
|
let o = t.offset as usize + c * 0x20;
|
|
let e = ClassEntry::parse(&idx[o..o + 0x20]).expect("entry");
|
|
let base = e.offset_in_asset_wad as usize;
|
|
if base == 0 || base + 0x48 > core.len() {
|
|
continue;
|
|
}
|
|
let Ok(hdr) = TieClassHeader::parse(&core[base..base + 0x48]) else {
|
|
continue;
|
|
};
|
|
let mut lods: Vec<BTreeSet<[i16; 3]>> = Vec::new();
|
|
for lod in [1usize, 2] {
|
|
let off = hdr.packets[lod] as usize;
|
|
let mut set = BTreeSet::new();
|
|
if off != 0 {
|
|
let s = base + off;
|
|
if let Some(blk) = parse_tie_block(&core, s) {
|
|
let rr: Vec<&[u8]> = blk.header.records(&core[s..]).collect();
|
|
for (ri, rec) in rr.iter().enumerate() {
|
|
let Ok(r) = TieRecord::parse(rec) else {
|
|
continue;
|
|
};
|
|
let start =
|
|
s + 16 + ri * 16 + r.pointer as usize + r.vert_ofs as usize * 16;
|
|
for v in 0..r.vert_size as usize {
|
|
let o = start + v * 16;
|
|
if o + 16 > core.len() {
|
|
break;
|
|
}
|
|
let g = |k: usize| {
|
|
i16::from_le_bytes(
|
|
core[o + k * 2..o + k * 2 + 2].try_into().unwrap(),
|
|
)
|
|
};
|
|
// ordinary layout only: other layouts are a
|
|
// different element shape, not a different LOD
|
|
if (g(3) >> 8) != 0 {
|
|
continue;
|
|
}
|
|
set.insert([g(0), g(1), g(2)]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
lods.push(set);
|
|
}
|
|
per_class.push((lods[0].clone(), lods[1].clone()));
|
|
}
|
|
|
|
let both: Vec<usize> = (0..per_class.len())
|
|
.filter(|&i| !per_class[i].0.is_empty() && !per_class[i].1.is_empty())
|
|
.collect();
|
|
for &i in &both {
|
|
let (a, b) = (&per_class[i].0, &per_class[i].1);
|
|
let f = a.intersection(b).count() as f64 / a.len().min(b.len()).max(1) as f64;
|
|
same_sum += f;
|
|
same_n += 1;
|
|
if f > 0.0 {
|
|
same_with_overlap += 1
|
|
}
|
|
}
|
|
for &i in &both {
|
|
for &j in &both {
|
|
if i == j {
|
|
continue;
|
|
}
|
|
let (a, b) = (&per_class[i].0, &per_class[j].1);
|
|
diff_sum += a.intersection(b).count() as f64 / a.len().min(b.len()).max(1) as f64;
|
|
diff_n += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
same_n >= 50,
|
|
"only {same_n} classes had both LODs populated"
|
|
);
|
|
let same = same_sum / same_n as f64;
|
|
let diff = diff_sum / diff_n.max(1) as f64;
|
|
eprintln!(
|
|
"tie positions: same-class LOD1/LOD2 triple overlap {:.1}% over {same_n} classes \
|
|
({same_with_overlap} nonzero); different-class control {:.1}% over {diff_n} pairs",
|
|
100.0 * same,
|
|
100.0 * diff
|
|
);
|
|
assert_eq!(
|
|
same_with_overlap, same_n,
|
|
"a class's LODs share no positions"
|
|
);
|
|
assert!(same > 0.85, "same-class position overlap fell to {same:.3}");
|
|
assert!(
|
|
diff < 0.05,
|
|
"different classes now share {diff:.3} of their positions"
|
|
);
|
|
assert!(
|
|
same > diff * 20.0,
|
|
"separation narrowed: {same:.3} vs {diff:.3}"
|
|
);
|
|
}
|
|
|
|
/// The `SBlk` head's constant fields, and the one field that is not a count.
|
|
///
|
|
/// The first twelve bytes are identical on all 27 levels. The word at `+0x14`
|
|
/// varies, with its low sixteen bits always zero. It tracks the body length at
|
|
/// ~100 bytes per unit, which is the shape of a record count -- and is not one:
|
|
/// the ratio runs 91.2..110.8 and no stride makes the relation exact on all 27.
|
|
/// Both halves are asserted so the near-miss cannot quietly harden into a name.
|
|
#[test]
|
|
fn sound_bank_sblk_head_is_constant_apart_from_one_word() {
|
|
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::sound_bank::{parse_sound_bank, SBlkHeader};
|
|
|
|
let mut values: Vec<u32> = Vec::new();
|
|
let mut bodies: Vec<usize> = Vec::new();
|
|
let mut levels = 0usize;
|
|
for lv in 0..27usize {
|
|
let Ok(bytes) = std::fs::read(data.join(format!("level{lv}/sound_bank.bin"))) else {
|
|
continue;
|
|
};
|
|
let bank = parse_sound_bank(&bytes).expect("sound bank");
|
|
let h = SBlkHeader::parse(bank.sblk).expect("SBlk head");
|
|
assert!(
|
|
h.constants_hold(),
|
|
"level {lv}: SBlk constants moved: {h:?}"
|
|
);
|
|
values.push(h.field_14);
|
|
bodies.push(bank.sblk.len() - 24);
|
|
levels += 1;
|
|
}
|
|
assert_eq!(levels, 27, "only {levels} sound banks parsed");
|
|
|
|
for &v in &values {
|
|
assert_eq!(v & 0xffff, 0, "field_14 gained low bits: {v:#x}");
|
|
assert!(
|
|
(0x92..=0x103).contains(&(v >> 16)),
|
|
"field_14 moved: {v:#x}"
|
|
);
|
|
}
|
|
// correlated with body length, but not a count at any fixed stride
|
|
let ratios: Vec<f64> = bodies
|
|
.iter()
|
|
.zip(&values)
|
|
.map(|(b, &v)| *b as f64 / (v >> 16) as f64)
|
|
.collect();
|
|
let lo = ratios.iter().cloned().fold(f64::MAX, f64::min);
|
|
let hi = ratios.iter().cloned().fold(0.0f64, f64::max);
|
|
let exact = (0..=80).any(|k| {
|
|
let rems: Vec<i64> = bodies
|
|
.iter()
|
|
.zip(&values)
|
|
.map(|(b, &v)| *b as i64 - k * (v >> 16) as i64)
|
|
.collect();
|
|
rems.windows(2).all(|w| w[0] == w[1])
|
|
});
|
|
eprintln!(
|
|
"SBlk head: constants hold on 27/27; field_14 in 0x0092..0x0103 with low 16 bits 0; \
|
|
body/word ratio {lo:.1}..{hi:.1}; any stride making it exact: {exact}"
|
|
);
|
|
assert!(
|
|
lo > 85.0 && hi < 115.0,
|
|
"body/word ratio left its band: {lo:.1}..{hi:.1}"
|
|
);
|
|
assert!(
|
|
!exact,
|
|
"field_14 now satisfies a fixed stride -- revisit the record size"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tie_vertex_element_is_16_bytes_with_fixed_texcoords() {
|
|
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::{ClassEntry, LevelCoreHeader};
|
|
use gc_formats::tie::{parse_tie_block, TieClassHeader, TieRecord};
|
|
|
|
let mut verts = 0usize;
|
|
let mut fixed16 = 0usize;
|
|
let mut fixed8 = 0usize;
|
|
let mut fixed12 = 0usize;
|
|
|
|
for lv in 0..3usize {
|
|
let idx =
|
|
std::fs::read(data.join(format!("level{lv}/core_index.bin"))).expect("core_index");
|
|
let core = std::fs::read(data.join(format!("level{lv}/core_data.bin"))).expect("core_data");
|
|
let h = LevelCoreHeader::parse(&idx).expect("hdr");
|
|
let t = h.tie_classes;
|
|
for c in 0..t.count as usize {
|
|
let o = t.offset as usize + c * 0x20;
|
|
let e = ClassEntry::parse(&idx[o..o + 0x20]).expect("entry");
|
|
let base = e.offset_in_asset_wad as usize;
|
|
if base == 0 || base + 0x48 > core.len() {
|
|
continue;
|
|
}
|
|
let Ok(hdr) = TieClassHeader::parse(&core[base..base + 0x48]) else {
|
|
continue;
|
|
};
|
|
for lod in [1usize, 2] {
|
|
let off = hdr.packets[lod] as usize;
|
|
if off == 0 {
|
|
continue;
|
|
}
|
|
let s = base + off;
|
|
let Some(blk) = parse_tie_block(&core, s) else {
|
|
continue;
|
|
};
|
|
let recs: Vec<&[u8]> = blk.header.records(&core[s..]).collect();
|
|
for (ri, rec) in recs.iter().enumerate() {
|
|
let Ok(r) = TieRecord::parse(rec) else {
|
|
continue;
|
|
};
|
|
let start = s + 16 + ri * 16 + r.pointer as usize + r.vert_ofs as usize * 16;
|
|
for v in 0..r.vert_size as usize {
|
|
let o = start + v * 16;
|
|
if o + 16 > core.len() {
|
|
break;
|
|
}
|
|
verts += 1;
|
|
// Compare element sizes properly: each strides its own
|
|
// way through the payload, with its texcoords at that
|
|
// element's own offset.
|
|
for stride in [8usize, 12, 16] {
|
|
let q = start + v * stride;
|
|
if q + stride > core.len() {
|
|
continue;
|
|
}
|
|
let tc = if stride >= 16 { (8, 10) } else { (4, 6) };
|
|
let a = i16::from_le_bytes(
|
|
core[q + tc.0..q + tc.0 + 2].try_into().unwrap(),
|
|
);
|
|
let b = i16::from_le_bytes(
|
|
core[q + tc.1..q + tc.1 + 2].try_into().unwrap(),
|
|
);
|
|
if a % 4096 == 0 && b % 4096 == 0 {
|
|
match stride {
|
|
16 => fixed16 += 1,
|
|
12 => fixed12 += 1,
|
|
_ => fixed8 += 1,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Which i16 slot pair is the texcoord? Scored per pair on level 0.
|
|
let core0 = std::fs::read(data.join("level0/core_data.bin")).expect("core_data");
|
|
let idx0 = std::fs::read(data.join("level0/core_index.bin")).expect("core_index");
|
|
let t0 = LevelCoreHeader::parse(&idx0).expect("hdr").tie_classes;
|
|
let (mut pair_hits, mut pair_tot) = ([0usize; 4], 0usize);
|
|
for c in 0..t0.count as usize {
|
|
let o = t0.offset as usize + c * 0x20;
|
|
let e = ClassEntry::parse(&idx0[o..o + 0x20]).expect("entry");
|
|
let base = e.offset_in_asset_wad as usize;
|
|
if base == 0 || base + 0x48 > core0.len() {
|
|
continue;
|
|
}
|
|
let Ok(hdr) = TieClassHeader::parse(&core0[base..base + 0x48]) else {
|
|
continue;
|
|
};
|
|
for lod in [1usize, 2] {
|
|
let off = hdr.packets[lod] as usize;
|
|
if off == 0 {
|
|
continue;
|
|
}
|
|
let s = base + off;
|
|
let Some(blk) = parse_tie_block(&core0, s) else {
|
|
continue;
|
|
};
|
|
let rr: Vec<&[u8]> = blk.header.records(&core0[s..]).collect();
|
|
for (ri, rec) in rr.iter().enumerate() {
|
|
let Ok(tr) = TieRecord::parse(rec) else {
|
|
continue;
|
|
};
|
|
let start = s + 16 + ri * 16 + tr.pointer as usize + tr.vert_ofs as usize * 16;
|
|
for v in 0..tr.vert_size as usize {
|
|
let q = start + v * 16;
|
|
if q + 16 > core0.len() {
|
|
break;
|
|
}
|
|
pair_tot += 1;
|
|
for p in 0..4usize {
|
|
let a =
|
|
i16::from_le_bytes(core0[q + p * 4..q + p * 4 + 2].try_into().unwrap());
|
|
let b = i16::from_le_bytes(
|
|
core0[q + p * 4 + 2..q + p * 4 + 4].try_into().unwrap(),
|
|
);
|
|
if a % 4096 == 0 && b % 4096 == 0 {
|
|
pair_hits[p] += 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// (4,5) is the texcoord field. (6,7) scores *higher* only because it is the
|
|
// near-constant 4096/0 pair, which is trivially a pair of multiples of 4096
|
|
// -- the same artifact a constant would produce anywhere.
|
|
assert!(pair_tot > 30_000, "only {pair_tot} level-0 vertices scored");
|
|
let r = |p: usize| pair_hits[p] as f64 / pair_tot as f64;
|
|
assert!(r(2) > 0.60, "texcoord pair (4,5) fell to {:.3}", r(2));
|
|
assert!(
|
|
r(0) < 0.05,
|
|
"pair (0,1) looks like a texcoord at {:.3}",
|
|
r(0)
|
|
);
|
|
assert!(
|
|
r(1) < 0.15,
|
|
"pair (2,3) looks like a texcoord at {:.3}",
|
|
r(1)
|
|
);
|
|
|
|
eprintln!(
|
|
"tie vertex slot pairs, level 0 (n={pair_tot}): (0,1) {:.1}% (2,3) {:.1}% \
|
|
(4,5) {:.1}% <- texcoords (6,7) {:.1}% <- the constant 4096/0 pair",
|
|
r(0) * 100.0,
|
|
r(1) * 100.0,
|
|
r(2) * 100.0,
|
|
r(3) * 100.0
|
|
);
|
|
|
|
// Slot 3 is not a coordinate. In the ordinary layout its high byte is 0 and
|
|
// its value is a small index: 201 distinct values, every one in 0..=254, with
|
|
// 0 the most common. It is the layout selector's own low byte, so the
|
|
// selector and this field are one byte, not two independent halves.
|
|
{
|
|
use std::collections::BTreeMap;
|
|
let mut sel = BTreeMap::<u8, usize>::new();
|
|
let mut lo3 = i16::MAX;
|
|
let mut hi3 = i16::MIN;
|
|
let mut ord = BTreeSet::<i16>::new();
|
|
for lv in 0..3usize {
|
|
let idx = std::fs::read(data.join(format!("level{lv}/core_index.bin"))).expect("idx");
|
|
let core = std::fs::read(data.join(format!("level{lv}/core_data.bin"))).expect("core");
|
|
let t = LevelCoreHeader::parse(&idx).expect("hdr").tie_classes;
|
|
for c in 0..t.count as usize {
|
|
let o = t.offset as usize + c * 0x20;
|
|
let e = ClassEntry::parse(&idx[o..o + 0x20]).expect("entry");
|
|
let base = e.offset_in_asset_wad as usize;
|
|
if base == 0 || base + 0x48 > core.len() {
|
|
continue;
|
|
}
|
|
let Ok(hdr) = TieClassHeader::parse(&core[base..base + 0x48]) else {
|
|
continue;
|
|
};
|
|
for lod in [1usize, 2] {
|
|
let off = hdr.packets[lod] as usize;
|
|
if off == 0 {
|
|
continue;
|
|
}
|
|
let s = base + off;
|
|
let Some(blk) = parse_tie_block(&core, s) else {
|
|
continue;
|
|
};
|
|
let rr: Vec<&[u8]> = blk.header.records(&core[s..]).collect();
|
|
for (ri, rec) in rr.iter().enumerate() {
|
|
let Ok(r) = TieRecord::parse(rec) else {
|
|
continue;
|
|
};
|
|
let start =
|
|
s + 16 + ri * 16 + r.pointer as usize + r.vert_ofs as usize * 16;
|
|
for v in 0..r.vert_size as usize {
|
|
let o = start + v * 16;
|
|
if o + 16 > core.len() {
|
|
break;
|
|
}
|
|
let w = i16::from_le_bytes(core[o + 6..o + 8].try_into().unwrap());
|
|
*sel.entry((w >> 8) as u8).or_default() += 1;
|
|
// Gate on the SELECTOR, not on the sign: other
|
|
// layouts carry large positive values too, and
|
|
// mixing them in is what makes this look unbounded.
|
|
if (w >> 8) == 0 {
|
|
lo3 = lo3.min(w);
|
|
hi3 = hi3.max(w);
|
|
ord.insert(w);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let ordinary = sel[&0x00];
|
|
eprintln!(
|
|
"tie vertex slot 3: ordinary layout {ordinary} of {} elements; selector 0x00 {:.1}%, next 0x03 {}; within the ordinary layout \
|
|
{} distinct values in {lo3}..={hi3}",
|
|
sel.values().sum::<usize>(),
|
|
100.0 * ordinary as f64 / sel.values().sum::<usize>() as f64,
|
|
sel[&0x03],
|
|
ord.len(),
|
|
);
|
|
assert_eq!(ordinary, 89_341, "ordinary-layout element count moved");
|
|
assert_eq!(sel.len(), 146, "layout selector cardinality moved");
|
|
assert!(ord.len() <= 256, "slot 3 is not a byte-sized index");
|
|
assert!(hi3 <= 254, "slot 3 left the u8 range at {hi3}");
|
|
assert_eq!(lo3, 0, "slot 3 went negative inside the ordinary layout");
|
|
}
|
|
|
|
assert!(verts > 90_000, "only {verts} tie vertices reached");
|
|
let r16 = fixed16 as f64 / verts as f64;
|
|
let r8 = fixed8 as f64 / verts as f64;
|
|
let r12 = fixed12 as f64 / verts as f64;
|
|
assert!(r16 > 0.80, "16-byte element hit rate fell to {r16:.3}");
|
|
assert!(
|
|
r16 > r8 * 1.5,
|
|
"16-byte element ({r16:.3}) no longer beats 8-byte ({r8:.3})"
|
|
);
|
|
assert!(
|
|
r16 > r12 * 1.5,
|
|
"16-byte element ({r16:.3}) no longer beats 12-byte ({r12:.3})"
|
|
);
|
|
|
|
eprintln!(
|
|
"tie vertices: {verts} on levels 0-2; 12-bit-fixed texcoord hit rate \
|
|
16B {:.1}%, 8B {:.1}%, 12B {:.1}% -- element size is 16 bytes",
|
|
r16 * 100.0,
|
|
r8 * 100.0,
|
|
r12 * 100.0
|
|
);
|
|
}
|
|
|
|
/// The tie vertex element's layout is selected by the HIGH byte of `i16[3]`.
|
|
///
|
|
/// Measured over the tie vertices of levels 0-2, grouped by that high byte:
|
|
///
|
|
/// - high byte zero (the ordinary case, 89,341 vertices): slot pairs `(4,5)` and
|
|
/// `(6,7)` are both 12-bit fixed, at 92.9% and 89.5% for a divisor of 4, while
|
|
/// `(0,1)` and `(2,3)` sit at baseline.
|
|
/// - high byte non-zero (6,271 vertices): **no** pair is fixed; the best is 7.5%
|
|
/// against a 2-5% baseline.
|
|
///
|
|
/// A constant would score 100%, so `(6,7)` at 89.5% is a real pair rather than
|
|
/// the `4096, 0` artifact it looks like. See `docs/formats/core-data-regions.md`.
|
|
#[test]
|
|
fn tie_vertex_layout_is_selected_by_i16_3_high_byte() {
|
|
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::{ClassEntry, LevelCoreHeader};
|
|
use gc_formats::tie::{parse_tie_block, TieClassHeader, TieRecord};
|
|
|
|
// [pair][k] where k indexes divisor 1, 4, 16, 256
|
|
let divisors = [1i32, 4, 16, 256];
|
|
let mut zero = [[0u64; 4]; 4];
|
|
let mut nonzero = [[0u64; 4]; 4];
|
|
let (mut n_zero, mut n_nonzero) = (0u64, 0u64);
|
|
|
|
for lv in 0..3usize {
|
|
let idx =
|
|
std::fs::read(data.join(format!("level{lv}/core_index.bin"))).expect("core_index");
|
|
let core = std::fs::read(data.join(format!("level{lv}/core_data.bin"))).expect("core_data");
|
|
let h = LevelCoreHeader::parse(&idx).expect("hdr");
|
|
let t = h.tie_classes;
|
|
for c in 0..t.count as usize {
|
|
let o = t.offset as usize + c * 0x20;
|
|
let e = ClassEntry::parse(&idx[o..o + 0x20]).expect("entry");
|
|
let base = e.offset_in_asset_wad as usize;
|
|
if base == 0 || base + 0x48 > core.len() {
|
|
continue;
|
|
}
|
|
let Ok(hd) = TieClassHeader::parse(&core[base..base + 0x48]) else {
|
|
continue;
|
|
};
|
|
for lod in [1usize, 2] {
|
|
let off = hd.packets[lod] as usize;
|
|
if off == 0 {
|
|
continue;
|
|
}
|
|
let s = base + off;
|
|
let Some(blk) = parse_tie_block(&core, s) else {
|
|
continue;
|
|
};
|
|
let recs: Vec<&[u8]> = blk.header.records(&core[s..]).collect();
|
|
for (ri, rec) in recs.iter().enumerate() {
|
|
let Ok(r) = TieRecord::parse(rec) else {
|
|
continue;
|
|
};
|
|
let start = s + 16 + ri * 16 + r.pointer as usize + r.vert_ofs as usize * 16;
|
|
for v in 0..r.vert_size as usize {
|
|
let q = start + v * 16;
|
|
if q + 16 > core.len() {
|
|
break;
|
|
}
|
|
let hi = (i16::from_le_bytes(core[q + 6..q + 8].try_into().unwrap())
|
|
as u32
|
|
>> 8)
|
|
!= 0;
|
|
if hi {
|
|
n_nonzero += 1
|
|
} else {
|
|
n_zero += 1
|
|
}
|
|
let tbl = if hi { &mut nonzero } else { &mut zero };
|
|
for pair in 0..4usize {
|
|
let a = i16::from_le_bytes(
|
|
core[q + pair * 4..q + pair * 4 + 2].try_into().unwrap(),
|
|
) as i32;
|
|
let b = i16::from_le_bytes(
|
|
core[q + pair * 4 + 2..q + pair * 4 + 4].try_into().unwrap(),
|
|
) as i32;
|
|
for (k, dv) in divisors.iter().enumerate() {
|
|
if a % dv == 0 && b % dv == 0 {
|
|
tbl[pair][k] += 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(n_zero > 80_000, "only {n_zero} ordinary tie vertices");
|
|
assert!(
|
|
n_nonzero > 5_000,
|
|
"only {n_nonzero} vertices with a non-zero i16[3] high byte"
|
|
);
|
|
let rate = |t: &[[u64; 4]; 4], pair: usize, k: usize, n: u64| t[pair][k] as f64 / n as f64;
|
|
|
|
// The ordinary case: (4,5) and (6,7) are both fixed-point pairs.
|
|
assert!(
|
|
rate(&zero, 2, 1, n_zero) > 0.85,
|
|
"(4,5) fell to {:.2}",
|
|
rate(&zero, 2, 1, n_zero)
|
|
);
|
|
assert!(
|
|
rate(&zero, 3, 1, n_zero) > 0.80,
|
|
"(6,7) fell to {:.2}",
|
|
rate(&zero, 3, 1, n_zero)
|
|
);
|
|
// A constant would be 1.0; below 1.0 means it is not one.
|
|
assert!(
|
|
rate(&zero, 3, 1, n_zero) < 0.99,
|
|
"(6,7) is exactly constant again; revisit the pair claim"
|
|
);
|
|
// (0,1) and (2,3) are baseline.
|
|
assert!(
|
|
rate(&zero, 0, 1, n_zero) < 0.20,
|
|
"(0,1) rose to {:.2}",
|
|
rate(&zero, 0, 1, n_zero)
|
|
);
|
|
assert!(
|
|
rate(&zero, 1, 1, n_zero) < 0.20,
|
|
"(2,3) rose to {:.2}",
|
|
rate(&zero, 1, 1, n_zero)
|
|
);
|
|
// The non-zero case: nothing is fixed.
|
|
for pair in 0..4usize {
|
|
assert!(
|
|
rate(&nonzero, pair, 1, n_nonzero) < 0.15,
|
|
"pair ({},{}) rose to {:.2} in the non-zero group",
|
|
pair * 2,
|
|
pair * 2 + 1,
|
|
rate(&nonzero, pair, 1, n_nonzero)
|
|
);
|
|
}
|
|
|
|
eprintln!(
|
|
"tie vertex layout by i16[3] high byte, levels 0-2: \
|
|
zero {n_zero} -> (4,5) {:.1}%, (6,7) {:.1}%, (0,1) {:.1}%, (2,3) {:.1}%; \
|
|
non-zero {n_nonzero} -> best pair {:.1}%",
|
|
rate(&zero, 2, 1, n_zero) * 100.0,
|
|
rate(&zero, 3, 1, n_zero) * 100.0,
|
|
rate(&zero, 0, 1, n_zero) * 100.0,
|
|
rate(&zero, 1, 1, n_zero) * 100.0,
|
|
(0..4)
|
|
.map(|p| rate(&nonzero, p, 1, n_nonzero))
|
|
.fold(0.0f64, f64::max)
|
|
* 100.0
|
|
);
|
|
}
|
|
|
|
/// RAM-backed checks against a PCSX2 savestate.
|
|
///
|
|
/// Gated on `GC_SSTATE` naming a `.p2s` file, the same way the rest of this file
|
|
/// is gated on `GC_DISC`. Where no savestate is present the tests skip, so they
|
|
/// cost nothing in a normal checkout -- but a machine with a savestate now
|
|
/// asserts the three claims that were previously prose only.
|
|
///
|
|
/// The claims, all on level 0's savestate:
|
|
/// 1. the collision payload is **verbatim** in RAM, not reshaped;
|
|
/// 2. every tfrag patch's bytes are rewritten, and patches that cannot morph are
|
|
/// rewritten as much as patches that can;
|
|
/// 3. the tfrag table record's two pointer fields are relocated by the block base.
|
|
fn ram_image() -> Option<Vec<u8>> {
|
|
let p = std::env::var_os("GC_SSTATE")?;
|
|
gc_trace::Savestate::open(std::path::Path::new(&p))
|
|
.ok()
|
|
.and_then(|s| s.ee_ram_owned().ok())
|
|
}
|
|
|
|
fn extracted_root() -> Option<PathBuf> {
|
|
let d = 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")
|
|
});
|
|
d.exists().then_some(d)
|
|
}
|
|
|
|
/// Read `len` bytes at `addr` from a RAM image, or `None` if it does not fit.
|
|
fn ram_slice(ram: &[u8], addr: usize, len: usize) -> Option<&[u8]> {
|
|
ram.get(addr..addr.checked_add(len)?)
|
|
}
|
|
|
|
/// Claim 1: the collision payload is byte-for-byte identical between disc and RAM.
|
|
#[test]
|
|
fn collision_payload_is_verbatim_in_ram() {
|
|
let Some(ram) = ram_image() else {
|
|
eprintln!("skipped: set GC_SSTATE to a PCSX2 .p2s savestate");
|
|
return;
|
|
};
|
|
let Some(data) = extracted_root() else {
|
|
eprintln!("skipped: unpack the disc first");
|
|
return;
|
|
};
|
|
require_disc!();
|
|
|
|
use gc_formats::collision::CollisionHeader;
|
|
use gc_formats::level_core::LevelCoreHeader;
|
|
|
|
let mut checked = 0usize;
|
|
for n in 0..1usize {
|
|
let idx = std::fs::read(data.join(format!("level{n}/core_index.bin"))).expect("core_index");
|
|
let core = std::fs::read(data.join(format!("level{n}/core_data.bin"))).expect("core_data");
|
|
let h = LevelCoreHeader::parse(&idx).expect("hdr");
|
|
let off = h.collision as usize;
|
|
let ch = CollisionHeader::parse(&core[off..]).expect("collision header");
|
|
let ps = off + 0x40;
|
|
let size = ch.payload_size as usize;
|
|
assert!(size > 0, "level{n} collision payload is empty");
|
|
|
|
// Locate the payload in RAM by its first 4 KiB, then require the whole
|
|
// thing to match -- a partial match would not be "verbatim".
|
|
let head = &core[ps..ps + 4096];
|
|
let mut found = None;
|
|
for a in (0..ram.len().saturating_sub(4096)).step_by(16) {
|
|
if &ram[a..a + 4096] == head {
|
|
found = Some(a);
|
|
break;
|
|
}
|
|
}
|
|
let Some(base) = found else {
|
|
panic!("level{n}: collision payload head not found in RAM; the payload is not verbatim")
|
|
};
|
|
let Some(whole) = ram_slice(&ram, base, size) else {
|
|
panic!("level{n}: collision payload of {size} bytes runs past the RAM image")
|
|
};
|
|
let disc = &core[ps..ps + size];
|
|
let first_diff = (0..size).find(|k| whole[*k] != disc[*k]);
|
|
let ndiff = (0..size).filter(|k| whole[*k] != disc[*k]).count();
|
|
eprintln!(
|
|
"level{n}: collision payload {size} bytes at RAM {base:#x}; \
|
|
first difference at +{:#x}; {ndiff} bytes differ ({:.2}%)",
|
|
first_diff.unwrap_or(0),
|
|
ndiff as f64 * 100.0 / size as f64
|
|
);
|
|
// The head is verbatim, and that is the claim worth keeping. How much
|
|
// beyond it is unchanged is measured and reported, not asserted.
|
|
assert!(
|
|
first_diff.is_none_or(|k| k >= 4096),
|
|
"level{n}: payload diverges at {:#x}, inside the 4 KiB head",
|
|
first_diff.unwrap_or(0)
|
|
);
|
|
// And the grid dimensions are the first two u16 of that block.
|
|
assert_eq!(
|
|
u16::from_le_bytes([ram[base], ram[base + 1]]),
|
|
u16::from_le_bytes([core[ps], core[ps + 1]])
|
|
);
|
|
checked += 1;
|
|
}
|
|
assert_eq!(checked, 1);
|
|
eprintln!("collision payload verbatim in RAM, whole-payload compare");
|
|
}
|
|
|
|
/// Claims 2 and 3: the tfrag table's pointers are relocated, and every patch is
|
|
/// rewritten regardless of whether it can morph.
|
|
#[test]
|
|
fn tfrag_table_pointers_relocate_and_every_patch_is_rewritten() {
|
|
let Some(ram) = ram_image() else {
|
|
eprintln!("skipped: set GC_SSTATE to a PCSX2 .p2s savestate");
|
|
return;
|
|
};
|
|
let Some(data) = extracted_root() else {
|
|
eprintln!("skipped: unpack the disc first");
|
|
return;
|
|
};
|
|
require_disc!();
|
|
|
|
use gc_formats::level_core::LevelCoreHeader;
|
|
use gc_formats::regions::core_layout;
|
|
use gc_formats::tfrag::{parse_tfrag, tfrag_lod_link, TfragsBlock};
|
|
|
|
let idx = std::fs::read(data.join("level0/core_index.bin")).expect("core_index");
|
|
let core = std::fs::read(data.join("level0/core_data.bin")).expect("core_data");
|
|
let h = LevelCoreHeader::parse(&idx).expect("hdr");
|
|
let lay = core_layout(&idx, &core, &h);
|
|
let t0 = lay.iter().find(|r| r.name == "tfrags").unwrap().start;
|
|
let t1 = lay.iter().find(|r| r.name == "occlusion").unwrap().start;
|
|
let blk = &core[t0..t1];
|
|
let bh = TfragsBlock::parse(blk).expect("tfrags header");
|
|
|
|
// Locate the block in RAM by its 8-byte header.
|
|
let key = &blk[..8];
|
|
let mut base = None;
|
|
for a in (0..ram.len().saturating_sub(8)).step_by(16) {
|
|
if &ram[a..a + 8] == key {
|
|
base = Some(a);
|
|
break;
|
|
}
|
|
}
|
|
let Some(base) = base else {
|
|
panic!("tfrags block header not found in RAM")
|
|
};
|
|
|
|
// Claim 3: only +0x10 and +0x38 differ, and by the block base.
|
|
let mut relocated = 0usize;
|
|
let mut other_diff = 0usize;
|
|
let mut deltas: std::collections::BTreeMap<usize, (usize, usize, i64)> = Default::default();
|
|
for i in 0..bh.tfrag_count as usize {
|
|
let o = bh.table_offset as usize + i * 64;
|
|
if o + 64 > blk.len() {
|
|
break;
|
|
}
|
|
for off in (0..64).step_by(4) {
|
|
let Some(rd) = ram_slice(&ram, base + o + off, 4) else {
|
|
continue;
|
|
};
|
|
let d = u32::from_le_bytes(rd.try_into().unwrap()) as i64;
|
|
let s = u32::from_le_bytes(blk[o + off..o + off + 4].try_into().unwrap()) as i64;
|
|
if d == s {
|
|
continue;
|
|
}
|
|
let e = deltas.entry(off).or_insert((0usize, 0usize, 0i64));
|
|
e.0 += 1;
|
|
if d == s {
|
|
e.1 += 1
|
|
} else {
|
|
e.2 = d - s;
|
|
}
|
|
if off == 0x10 || off == 0x38 {
|
|
relocated += 1
|
|
} else {
|
|
other_diff += 1
|
|
}
|
|
}
|
|
}
|
|
assert_eq!(
|
|
other_diff, 0,
|
|
"{other_diff} tfrag table fields differ for another reason"
|
|
);
|
|
assert!(
|
|
relocated >= bh.tfrag_count as usize,
|
|
"only {relocated} relocated pointers across {} records",
|
|
bh.tfrag_count
|
|
);
|
|
|
|
// Claim 2: patches that cannot morph are rewritten as much as those that can.
|
|
let mut no_morph = (0usize, 0usize); // (records, differing bytes)
|
|
let mut morph = (0usize, 0usize);
|
|
let n = bh.tfrag_count as usize;
|
|
for i in 0..n {
|
|
let Ok(t) = bh.header(blk, i) else { continue };
|
|
let db = bh.table_offset as usize + t.data as usize;
|
|
if db >= blk.len() {
|
|
continue;
|
|
}
|
|
let Ok(g) = parse_tfrag(&blk[db..], &t) else {
|
|
continue;
|
|
};
|
|
let m = (0..g.vertex_info.len())
|
|
.filter(|v| tfrag_lod_link(&g, *v).is_some_and(|l| l.morphs))
|
|
.count();
|
|
let next = (i + 1..n)
|
|
.find_map(|j| {
|
|
let t2 = bh.header(blk, j).ok()?;
|
|
let d2 = bh.table_offset as usize + t2.data as usize;
|
|
(d2 > db).then_some(d2)
|
|
})
|
|
.unwrap_or(blk.len());
|
|
let size = (next - db).min(1 << 20);
|
|
let Some(rb) = ram_slice(&ram, base + db, size) else {
|
|
continue;
|
|
};
|
|
let diff = (0..size).filter(|k| rb[*k] != blk[db + k]).count();
|
|
if m == 0 {
|
|
no_morph.0 += 1;
|
|
no_morph.1 += diff
|
|
} else {
|
|
morph.0 += 1;
|
|
morph.1 += diff
|
|
}
|
|
}
|
|
let pct = |t: (usize, usize)| t.1 as f64 / (t.0 * 100).max(1) as f64;
|
|
assert!(
|
|
no_morph.0 > 300 && morph.0 > 400,
|
|
"expected a real split, got {} non-morphing and {} morphing patches",
|
|
no_morph.0,
|
|
morph.0
|
|
);
|
|
assert!(
|
|
pct(no_morph) > 1.0,
|
|
"patches that cannot morph are barely rewritten: {:.1}%",
|
|
pct(no_morph)
|
|
);
|
|
assert!(
|
|
(pct(no_morph) - pct(morph)).abs() < 3.0,
|
|
"patches that cannot morph differ by {:.1}% against {:.1}% for those that can -- \
|
|
they should be indistinguishable",
|
|
pct(no_morph),
|
|
pct(morph)
|
|
);
|
|
|
|
eprintln!(
|
|
"tfrag RAM: {relocated} relocated table pointers, all by the table address {:#x}; \
|
|
no other table field differs; \
|
|
patch bytes rewritten -- cannot morph {:.1}% of {}, can morph {:.1}% of {}",
|
|
base + bh.table_offset as usize,
|
|
pct(no_morph),
|
|
no_morph.0,
|
|
pct(morph),
|
|
morph.0
|
|
);
|
|
}
|
|
|
|
/// Chunk record stride: uniform per signature, always 4-byte aligned, and
|
|
/// exactly one signature is not uniform.
|
|
///
|
|
/// All 55 distinct strides across the 74 signatures are multiples of 4, from 52
|
|
/// to 640. Seventy-three of the 74 signatures use a single stride. The
|
|
/// exception is asserted rather than skipped, because "every type is uniform"
|
|
/// would be the kind of claim that quietly stops being true.
|
|
#[test]
|
|
fn chunk_record_stride_is_four_byte_aligned_and_uniform_per_signature() {
|
|
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::chunks::{chunk_region_start, parse_all_chunks};
|
|
use gc_formats::level_core::LevelCoreHeader;
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
let mut sig: BTreeMap<Vec<u8>, BTreeSet<usize>> = BTreeMap::new();
|
|
for lv in 0..27usize {
|
|
let (Ok(idx), Ok(core)) = (
|
|
std::fs::read(data.join(format!("level{lv}/core_index.bin"))),
|
|
std::fs::read(data.join(format!("level{lv}/core_data.bin"))),
|
|
) else {
|
|
continue;
|
|
};
|
|
let _ = LevelCoreHeader::parse(&idx).expect("core header");
|
|
let scan = chunk_region_start(&core, 0)
|
|
.or_else(|| chunk_region_start(&core, 0x1000))
|
|
.unwrap_or(0);
|
|
let Ok(found) = parse_all_chunks(&core[scan..]) else {
|
|
continue;
|
|
};
|
|
for rc in found {
|
|
sig.entry(rc.chunk.header[..6].to_vec())
|
|
.or_default()
|
|
.insert(rc.chunk.stride);
|
|
}
|
|
}
|
|
assert_eq!(sig.len(), 74, "chunk signature count moved");
|
|
|
|
let strides: BTreeSet<usize> = sig.values().flatten().copied().collect();
|
|
let misaligned: Vec<usize> = strides.iter().copied().filter(|s| s % 4 != 0).collect();
|
|
let variable: Vec<(&Vec<u8>, &BTreeSet<usize>)> =
|
|
sig.iter().filter(|(_, v)| v.len() > 1).collect();
|
|
|
|
eprintln!(
|
|
"chunk records: {} signatures, {} distinct strides, range {}..{}; \
|
|
non-multiples of 4: {misaligned:?}; variable-length signatures: {}",
|
|
sig.len(),
|
|
strides.len(),
|
|
strides.iter().min().unwrap(),
|
|
strides.iter().next_back().unwrap(),
|
|
variable.len(),
|
|
);
|
|
for (k, v) in &variable {
|
|
eprintln!(" variable: {:02x?} strides {v:?}", &k[..6]);
|
|
}
|
|
|
|
assert!(
|
|
misaligned.is_empty(),
|
|
"strides not divisible by 4: {misaligned:?}"
|
|
);
|
|
assert_eq!(*strides.iter().min().unwrap(), 52);
|
|
assert_eq!(*strides.iter().next_back().unwrap(), 640);
|
|
assert_eq!(
|
|
variable.len(),
|
|
1,
|
|
"more than one signature has mixed strides"
|
|
);
|
|
assert_eq!(
|
|
variable[0].1.len(),
|
|
3,
|
|
"the variable signature's stride count moved"
|
|
);
|
|
}
|
|
|
|
/// Most of the chunk container is **level-independent**: 65 of its 74 signatures
|
|
/// carry records that are byte-identical in all 27 levels.
|
|
///
|
|
/// That reframes the format. These are static tables the game ships unchanged in
|
|
/// every level -- lookups and constants -- not per-level content. Decoding them
|
|
/// gets no help from comparing levels, because there is nothing to compare;
|
|
/// they have to be read structurally. The 9 level-specific signatures are the
|
|
/// ones worth attacking first for actual per-level data.
|
|
#[test]
|
|
fn most_chunk_signatures_are_level_independent() {
|
|
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::chunks::{chunk_region_start, parse_all_chunks};
|
|
use gc_formats::level_core::LevelCoreHeader;
|
|
use std::collections::BTreeMap;
|
|
|
|
/// signature -> (per-level record lists, total records)
|
|
type SigLevel = (Vec<Vec<Vec<u8>>>, usize);
|
|
let mut sig: BTreeMap<Vec<u8>, SigLevel> = BTreeMap::new();
|
|
for lv in 0..27usize {
|
|
let (Ok(idx), Ok(core)) = (
|
|
std::fs::read(data.join(format!("level{lv}/core_index.bin"))),
|
|
std::fs::read(data.join(format!("level{lv}/core_data.bin"))),
|
|
) else {
|
|
continue;
|
|
};
|
|
let _ = LevelCoreHeader::parse(&idx).expect("core header");
|
|
let scan = chunk_region_start(&core, 0)
|
|
.or_else(|| chunk_region_start(&core, 0x1000))
|
|
.unwrap_or(0);
|
|
let Ok(found) = parse_all_chunks(&core[scan..]) else {
|
|
continue;
|
|
};
|
|
for rc in found {
|
|
let e = sig
|
|
.entry(rc.chunk.header[..6].to_vec())
|
|
.or_insert_with(|| (Vec::new(), rc.records.len()));
|
|
e.1 += rc.records.len();
|
|
e.0.push(rc.records.iter().map(|r| r.to_vec()).collect());
|
|
}
|
|
}
|
|
assert_eq!(sig.len(), 74, "chunk signature count moved");
|
|
|
|
let mut shared = 0usize;
|
|
let mut specific: Vec<String> = Vec::new();
|
|
let mut ragged = 0usize;
|
|
for (k, (lvls, _)) in &sig {
|
|
let counts: Vec<usize> = lvls.iter().map(|l| l.len()).collect();
|
|
if counts.windows(2).any(|w| w[0] != w[1]) {
|
|
ragged += 1;
|
|
continue;
|
|
}
|
|
let n = counts[0];
|
|
let identical = (0..n).all(|i| lvls.iter().all(|l| l[i] == lvls[0][i]));
|
|
if identical && n > 0 {
|
|
shared += 1;
|
|
} else {
|
|
specific.push(format!("{:02x?}", &k[..3]));
|
|
}
|
|
}
|
|
|
|
let n_sig = sig.len();
|
|
eprintln!(
|
|
"chunk signatures: {shared}/{n_sig} byte-identical across every level; \
|
|
{ragged} ragged; level-specific: {}",
|
|
specific.join(" ")
|
|
);
|
|
|
|
assert_eq!(
|
|
ragged, 0,
|
|
"a signature appeared with different record counts per level"
|
|
);
|
|
assert_eq!(shared, 65, "the shared/level-specific split moved");
|
|
assert_eq!(specific.len(), 9, "level-specific signature count moved");
|
|
}
|