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:
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user