Add tfrag LOD selection, morph weights and parent linkage

The block header's float at +0x08 gives L and the switch distances are
[D0, D1, D2] = [6L, 4L, 2L] world units -- 192/128/64 on every level
measured. The distance test uses the patch's bounding sphere truncated
to integers against trunc(D * 1024), not the sphere's centre, so a
patch straddling a threshold takes the coarser mode. Six modes result:
LOD 2, LOD 1 collapse, LOD 1 morph, LOD 0 collapse, LOD 0 morph, LOD 0.

A morph weight runs 0 to 1 across its tier's band. Past the far edge an
entry is not blended but replaced by its first parent -- a collapse.

The tested property is that distance never moves back to a finer mode,
swept across the whole range, because that is what keeps terrain from
popping as the camera moves.

The four V4_8 parent arrays are classified by VU address and an
unrecognised address is now an error, which is a real check: all 18,102
patches still parse. Every recorded parent resolves to real vertex info
and real positions, and no common entry claims a parent.

That is as far as it goes, and the limit is a finding rather than a
rounding error. The addresses are not unique: on level 0 patch 1,
unk_indices_2_lod_01_addr == parent_indices_lod_0_addr == 291, with
unk_06 and unk_0a both zero, so nothing but unpacking order separates
them. Across all 27 levels 57,782 of 374,349 LOD entries resolve, and
316,548 unresolved LOD-0 entries against 28,739 resolved is far too
lopsided to be genuine -- either the arrays are misassigned where
addresses collide, or RAC2's positions_lod_0_count means something other
than RAC1's. Only 3,274 of 18,102 patches carry enough V4_8 bytes to
cover positions_lod_01_count + positions_lod_0_count + extras.

The vertex-info tiers themselves are confirmed: the addresses at +9 and
+11 match the observed V4_16 unpacks and the three tier counts sum to
vert_count on every patch, so the header layout is right and it is
specifically the parent arrays that are open. Ruled out so far:
address-only classification (collides) and one-parent-per-position (fails
on 15,000 patches). Not yet tried: ordering by VU address instead of by
declaration, and using unk_06/unk_0a to suppress an empty tier before
falling through. Written up in plan/level-assets.md.
This commit is contained in:
2026-10-10 02:36:40 +02:00
parent 29fdf89f53
commit 2a5307a157
4 changed files with 542 additions and 9 deletions
+134
View File
@@ -807,3 +807,137 @@ fn tfrag_textures_decode_to_plausible_images() {
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 lod01_missing = 0usize;
let mut lod0_missing = 0usize;
let mut resolved_common = 0usize;
let mut common_total = 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];
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 => {
if v < g.vinfo_tier[1] {
lod01_missing += 1;
} else if v < g.vinfo_tier[1] + g.vinfo_tier[2] {
lod0_missing += 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"
);
// RAC2 does not record a parent for every LOD entry: the parent arrays are
// shorter than the VU header's position counts on most patches, so many
// LOD entries have no recorded parent. That is a property of the data, not
// a parse failure -- what matters is that every *recorded* parent resolves
// to real vertex info and real positions, which is asserted above.
eprintln!(
"{entries} vertex-info entries over {patches} patches: {common_total} common, \
{lod01} + {lod0} LOD entries with a recorded parent, \
{lod01_missing} LOD-01 and {lod0_missing} LOD-0 entries without one"
);
}