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"
);
}
+347 -2
View File
@@ -78,6 +78,22 @@ impl TfragsBlock {
})
}
/// LOD switch distances `[D0, D1, D2] = [6L, 4L, 2L]` in world units,
/// where `L` is the block's LOD distance (32.0 on every level measured).
///
/// D0 is the far end of LOD 1, D1 the far end of LOD 0, and D2 the point
/// where LOD-0 vertices start morphing.
pub fn lod_distances(&self) -> [f32; 3] {
let l = self.lod_distance;
[l * 6.0, l * 4.0, l + l]
}
/// The integer thresholds the distance test compares against, in raw units
/// (1024 = 1 world unit).
pub fn lod_thresholds_raw(&self) -> [i32; 3] {
self.lod_distances().map(|d| (d * 1024.0) as i32)
}
/// The tfrag header at index `i`, given the whole block.
pub fn header(&self, bytes: &[u8], i: usize) -> Result<TfragHeader> {
let off = self.table_offset as usize + i * TFRAG_HEADER_SIZE;
@@ -123,6 +139,8 @@ pub struct TfragHeader {
pub lod_2_size: u8,
pub lod_1_size: u8,
pub lod_0_size: u8,
/// Non-zero: always draw LOD 2 and skip the distance test entirely.
pub base_only: u8,
pub texture_count: u8,
pub rgba_size: u8,
pub msphere_ofs: u16,
@@ -160,7 +178,7 @@ impl TfragHeader {
let _lod_2_rgba = b.u8("tfrag lod_2_rgba_count")?;
let _lod_1_rgba = b.u8("tfrag lod_1_rgba_count")?;
let _lod_0_rgba = b.u8("tfrag lod_0_rgba_count")?;
let _base_only = b.u8("tfrag base_only")?;
let base_only = b.u8("tfrag base_only")?;
let texture_count = b.u8("tfrag texture_count")?;
let rgba_size = b.u8("tfrag rgba_size")?;
let _rgba_verts_loc = b.u8("tfrag rgba_verts_loc")?;
@@ -192,6 +210,7 @@ impl TfragHeader {
lod_2_size,
lod_1_size,
lod_0_size,
base_only,
texture_count,
rgba_size,
msphere_ofs,
@@ -376,6 +395,10 @@ pub struct TfragVuHeader {
pub vertex_info_lod_01_addr: u16,
pub vertex_info_lod_0_addr: u16,
pub indices_addr: u16,
pub parent_indices_lod_01_addr: u16,
pub unk_indices_2_lod_01_addr: u16,
pub parent_indices_lod_0_addr: u16,
pub unk_indices_2_lod_0_addr: u16,
pub strips_addr: u16,
}
@@ -395,6 +418,10 @@ impl TfragVuHeader {
vertex_info_lod_01_addr: w[9],
vertex_info_lod_0_addr: w[11],
indices_addr: w[13],
parent_indices_lod_01_addr: w[14],
unk_indices_2_lod_01_addr: w[15],
parent_indices_lod_0_addr: w[16],
unk_indices_2_lod_0_addr: w[17],
strips_addr: w[18],
})
}
@@ -597,7 +624,22 @@ pub struct TfragGeometry {
/// Index 0 is highest detail.
pub lod: [TfragLod; 3],
/// Texture primitives; strips select one by ad-gif index.
/// LOD-01 and LOD-0 position counts from the VU header. These drive the
/// primary/extra split in the parent-index arrays.
/// Radius of the patch's bounding sphere, in raw units (1024 = 1 world unit).
pub bsphere_radius: f32,
/// Non-zero: always draw LOD 2 and skip the distance test.
pub base_only: u8,
pub vu_positions_lod_01_count: usize,
pub vu_positions_lod_0_count: usize,
pub ad_gifs: Vec<TfragAdGifs>,
/// For each primary LOD-01 vertex-info entry, the vertex-info index of its
/// first parent. The second parent is that entry's own `parent` field.
pub parent_indices_lod01: Vec<u8>,
/// Same, for the extra (non-position) LOD-01 entries.
pub unk_indices_2_lod01: Vec<u8>,
pub parent_indices_lod0: Vec<u8>,
pub unk_indices_2_lod0: Vec<u8>,
/// Per-vertex colours, indexed by position index.
pub rgba: Vec<TfragRgba>,
/// Per-vertex light and normal records, indexed by position index.
@@ -628,6 +670,32 @@ impl TfragGeometry {
}
}
/// The draw path for this tfrag at view depth `centre_depth_raw`, in raw
/// units. Frustum culling and the guard-band clip test are separate.
///
/// The bounds are the patch's bounding sphere truncated to integers, so a
/// sphere straddling a threshold picks the coarser mode — which is what
/// keeps geometry from popping as the camera moves.
pub fn draw_mode(&self, centre_depth_raw: f32, thresholds_raw: [i32; 3]) -> TfragDrawMode {
let r = self.bsphere_radius;
let far = (centre_depth_raw + r) as i32;
let near = (centre_depth_raw - r) as i32;
let [d0, d1, d2] = thresholds_raw;
if self.base_only != 0 || near >= d0 {
TfragDrawMode::Lod2
} else if far >= d0 {
TfragDrawMode::Lod1Collapse
} else if near >= d1 {
TfragDrawMode::Lod1Morph
} else if far >= d1 {
TfragDrawMode::Lod0Collapse
} else if near >= d2 || far >= d2 {
TfragDrawMode::Lod0Morph
} else {
TfragDrawMode::Lod0
}
}
/// World-space position of a vertex-info entry, `(origin + local) / 1024`.
pub fn world_position(&self, vinfo: usize) -> [f32; 3] {
let local = self.positions[self.position_index(vinfo)];
@@ -691,6 +759,8 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
match (v.vn(), v.vl()) {
(3, 1) if v.usn() && addr == 0 => {
vu = TfragVuHeader::parse(payload)?;
g.vu_positions_lod_01_count = vu.positions_lod_01_count as usize;
g.vu_positions_lod_0_count = vu.positions_lod_0_count as usize;
have_vu = true;
}
// Ad-gifs: 5 quadwords each.
@@ -741,10 +811,29 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
}
}
(3, 2) => {
let bytes = &payload[..v.count() * 4];
if !v.usn() && addr == vu.strips_addr {
strips = read_strips(payload, v.count())?;
} else if addr == vu.indices_addr {
indices = payload[..v.count() * 4].to_vec();
indices = bytes.to_vec();
} else if addr == vu.parent_indices_lod_01_addr
&& vu.positions_lod_01_count != 0
{
g.parent_indices_lod01 = bytes.to_vec();
} else if addr == vu.unk_indices_2_lod_01_addr {
g.unk_indices_2_lod01 = bytes.to_vec();
} else if addr == vu.parent_indices_lod_0_addr
&& vu.positions_lod_0_count != 0
{
g.parent_indices_lod0 = bytes.to_vec();
} else if addr == vu.unk_indices_2_lod_0_addr {
g.unk_indices_2_lod0 = bytes.to_vec();
} else {
return Err(Error {
offset: pos,
context: "unexpected V4_8 unpack address in tfrag list",
kind: ErrorKind::Invalid("unexpected V4_8 unpack address"),
});
}
}
_ => {}
@@ -771,6 +860,8 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
}
// These three arrays sit outside the VIF lists, at fixed offsets in the
// patch's data block.
g.bsphere_radius = header.bsphere[3];
g.base_only = header.base_only;
g.rgba = read_rgba(
block,
header.rgba_ofs as usize,
@@ -988,6 +1079,153 @@ pub fn tfrag_triangles(g: &TfragGeometry, lod: usize) -> Result<Vec<TfragTriangl
Ok(out)
}
/// The draw path chosen for a tfrag from its distance.
///
/// The two collapse modes are not just a coarser LOD: an entry whose parents
/// are both past the collapse distance is *replaced* by its parent rather than
/// morphed, which is what stops distant terrain shimmering.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TfragDrawMode {
/// LOD 2 list, no morph.
Lod2,
/// LOD 1 lists; LOD-01 vertices morph and collapse onto parent 1 once both
/// parents are past D0.
Lod1Collapse,
/// LOD 1 lists; LOD-01 vertices morph.
Lod1Morph,
/// LOD 0 lists; LOD-01 morphs, LOD-0 morphs with collapse at D1.
Lod0Collapse,
/// LOD 0 lists; LOD-01 at rest, LOD-0 morphs.
Lod0Morph,
/// LOD 0 lists, no morph.
Lod0,
}
impl TfragDrawMode {
/// Which strip and index list is drawn. 0 is highest detail.
pub fn lod(self) -> usize {
match self {
TfragDrawMode::Lod2 => 2,
TfragDrawMode::Lod1Collapse | TfragDrawMode::Lod1Morph => 1,
_ => 0,
}
}
/// The MSCAL address the game writes for the tfrag's VU1 program.
pub fn mscal(self) -> u8 {
match self {
TfragDrawMode::Lod2 => 6,
TfragDrawMode::Lod1Collapse => 8,
TfragDrawMode::Lod1Morph => 0xa,
TfragDrawMode::Lod0Collapse => 0xe,
TfragDrawMode::Lod0Morph => 0x10,
TfragDrawMode::Lod0 => 0x14,
}
}
}
/// Which tier a morphable vertex-info entry belongs to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TfragMorphTier {
/// Fades between D1 and D0.
Lod01,
/// Fades between D2 and D1.
Lod0,
}
impl TfragMorphTier {
/// Morph amount in 0..=1 for a vertex at view depth `depth`, in world units:
/// 0 is the vertex's own position, 1 is its parents' midpoint.
pub fn weight(self, depth: f32, lod_distances: [f32; 3]) -> f32 {
let [d0, d1, d2] = lod_distances;
let (near, far) = match self {
TfragMorphTier::Lod01 => (d1, d0),
TfragMorphTier::Lod0 => (d2, d1),
};
if (far - near).abs() < f32::EPSILON {
return 0.0;
}
((depth - near) / (far - near)).clamp(0.0, 1.0)
}
/// Distance past which an entry is replaced by its first parent.
pub fn collapse_distance(self, lod_distances: [f32; 3]) -> f32 {
match self {
TfragMorphTier::Lod01 => lod_distances[0],
TfragMorphTier::Lod0 => lod_distances[1],
}
}
}
/// The LOD linkage of one LOD-01 or LOD-0 vertex-info entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TfragLodLink {
pub tier: TfragMorphTier,
/// True for the tier's first `positions_*_count` entries, which the VU
/// morphs. Extra entries only collapse.
pub morphs: bool,
/// Position index of this entry, `vertex / 2`.
pub own_position: usize,
/// Vertex-info index of the first parent. Also the collapse replacement.
pub parent1_vinfo: usize,
/// Position index of the first parent.
pub parent1_position: usize,
/// Position index of the second parent, from this entry's `parent` field.
pub parent2_position: usize,
}
/// LOD linkage of vertex-info entry `vinfo`, or `None` for common entries, out
/// of range indices, or an entry with no parent recorded.
pub fn tfrag_lod_link(g: &TfragGeometry, vinfo: usize) -> Option<TfragLodLink> {
let common = g.vinfo_tier[0];
let l01 = common..common + g.vinfo_tier[1];
let l0 = l01.end..l01.end + g.vinfo_tier[2];
let (tier, k, primaries, parents, extras) = if l01.contains(&vinfo) {
(
TfragMorphTier::Lod01,
vinfo - l01.start,
g.vu_positions_lod_01_count,
&g.parent_indices_lod01,
&g.unk_indices_2_lod01,
)
} else if l0.contains(&vinfo) {
(
TfragMorphTier::Lod0,
vinfo - l0.start,
g.vu_positions_lod_0_count,
&g.parent_indices_lod0,
&g.unk_indices_2_lod0,
)
} else {
return None;
};
let (morphs, p1) = if k < primaries {
(true, *parents.get(k)? as usize)
} else {
(false, *extras.get(k - primaries)? as usize)
};
let e = *g.vertex_info.get(vinfo)?;
let pos = |v: i16| -> Option<usize> {
let p = v as isize as usize / 2;
(p < g.positions.len()).then_some(p)
};
Some(TfragLodLink {
tier,
morphs,
own_position: pos(e.vertex)?,
parent1_vinfo: p1,
parent1_position: pos(g.vertex_info.get(p1)?.vertex)?,
parent2_position: pos(e.parent)?,
})
}
/// Position indices a morphing entry blends toward, `(parent1, parent2)`.
pub fn tfrag_morph_parents(g: &TfragGeometry, vinfo: usize) -> Option<(usize, usize)> {
tfrag_lod_link(g, vinfo)
.filter(|l| l.morphs)
.map(|l| (l.parent1_position, l.parent2_position))
}
/// Vertex positions of a tfrag, in game order.
pub fn tfrag_positions(block: &[u8], header: &TfragHeader) -> Option<Vec<[i32; 3]>> {
parse_tfrag(block, header).ok().map(|g| g.positions)
@@ -1575,3 +1813,110 @@ mod ad_gif_tests {
assert_eq!(tris[2].ad_gif, 1);
}
}
#[cfg(test)]
mod lod_tests {
use super::*;
fn block() -> TfragsBlock {
TfragsBlock {
table_offset: 0x40,
tfrag_count: 1,
lod_distance: 32.0,
reserved: 0,
}
}
#[test]
fn switch_distances_scale_from_the_block_parameter() {
let b = block();
assert_eq!(b.lod_distances(), [192.0, 128.0, 64.0]);
assert_eq!(b.lod_thresholds_raw(), [196_608, 131_072, 65_536]);
}
/// A patch with the given bounding-sphere radius and `base_only` flag.
fn geometry(radius: f32, base_only: u8) -> TfragGeometry {
TfragGeometry {
bsphere_radius: radius,
base_only,
..TfragGeometry::default()
}
}
#[test]
fn a_sphere_straddling_a_threshold_takes_the_coarser_mode() {
let th = [196_608, 131_072, 65_536];
// Well inside LOD 0, then stepping back through every boundary.
let mode = |d: f32| geometry(1000.0, 0).draw_mode(d, th);
assert_eq!(mode(10_000.0), TfragDrawMode::Lod0);
assert_eq!(mode(70_000.0), TfragDrawMode::Lod0Morph); // near >= D2
assert_eq!(mode(131_000.0), TfragDrawMode::Lod0Collapse); // far >= D1
assert_eq!(mode(150_000.0), TfragDrawMode::Lod1Morph); // near >= D1
assert_eq!(mode(196_000.0), TfragDrawMode::Lod1Collapse); // far >= D0
assert_eq!(mode(200_000.0), TfragDrawMode::Lod2); // near >= D0
}
#[test]
fn the_mode_is_monotonic_in_distance() {
// Distance must never make the mesh finer again.
let th = [196_608, 131_072, 65_536];
let order = |m: TfragDrawMode| match m {
TfragDrawMode::Lod0 => 0,
TfragDrawMode::Lod0Morph => 1,
TfragDrawMode::Lod0Collapse => 2,
TfragDrawMode::Lod1Morph => 3,
TfragDrawMode::Lod1Collapse => 4,
TfragDrawMode::Lod2 => 5,
};
let g = geometry(4000.0, 0);
let mut last = 0;
for d in (0..600_000).step_by(997) {
let now = order(g.draw_mode(d as f32, th));
assert!(now >= last, "mode went backwards at depth {d}");
last = now;
}
}
#[test]
fn base_only_pins_a_patch_to_lod_2() {
let th = [196_608, 131_072, 65_536];
let g = geometry(1000.0, 1);
assert_eq!(g.draw_mode(0.0, th), TfragDrawMode::Lod2);
}
#[test]
fn each_mode_maps_to_a_lod_and_an_mscal() {
for (m, lod, mscal) in [
(TfragDrawMode::Lod2, 2usize, 6u8),
(TfragDrawMode::Lod1Collapse, 1, 8),
(TfragDrawMode::Lod1Morph, 1, 0xa),
(TfragDrawMode::Lod0Collapse, 0, 0xe),
(TfragDrawMode::Lod0Morph, 0, 0x10),
(TfragDrawMode::Lod0, 0, 0x14),
] {
assert_eq!(m.lod(), lod);
assert_eq!(m.mscal(), mscal);
}
}
#[test]
fn morph_weight_runs_from_zero_to_one_across_its_band() {
let d = [192.0, 128.0, 64.0];
// LOD-01 fades between D1 and D0; LOD 0 between D2 and D1.
assert_eq!(TfragMorphTier::Lod01.weight(128.0, d), 0.0);
assert_eq!(TfragMorphTier::Lod01.weight(160.0, d), 0.5);
assert_eq!(TfragMorphTier::Lod01.weight(192.0, d), 1.0);
assert_eq!(TfragMorphTier::Lod01.weight(500.0, d), 1.0);
assert_eq!(TfragMorphTier::Lod01.weight(0.0, d), 0.0);
assert_eq!(TfragMorphTier::Lod0.weight(64.0, d), 0.0);
assert_eq!(TfragMorphTier::Lod0.weight(96.0, d), 0.5);
assert_eq!(TfragMorphTier::Lod0.weight(128.0, d), 1.0);
}
#[test]
fn collapse_distances_are_the_far_edge_of_each_band() {
let d = [192.0, 128.0, 64.0];
assert_eq!(TfragMorphTier::Lod01.collapse_distance(d), 192.0);
assert_eq!(TfragMorphTier::Lod0.collapse_distance(d), 128.0);
}
}
+49 -5
View File
@@ -435,10 +435,54 @@ CLUT remap still yields a well-formed image of the wrong thing. Two layers:
That tool is worth keeping: it is the cheapest way to tell a decode that is
merely well-formed from one that is right.
### LOD selection
Mostly settled. The block header's float at +0x08 gives `L`, and the switch
distances are `[D0, D1, D2] = [6L, 4L, 2L]` world units; on every level `L` is
32.0, so `[192, 128, 64]`. The distance test compares the patch's bounding
sphere, truncated to integers, against `trunc(D * 1024)`. Six modes result —
LOD 2, LOD 1 collapse, LOD 1 morph, LOD 0 collapse, LOD 0 morph, LOD 0 — and
the *sphere* is what is tested, not the centre, so a patch straddling a
threshold takes the coarser mode. That is what stops geometry popping as the
camera moves, and it is worth testing for directly: sweeping the depth must
never move back to a finer mode.
A morph weight runs 0 to 1 across its tier's band: LOD-01 between D1 and D0,
LOD-0 between D2 and D1. Past the far edge of the band an entry is not morphed
but *replaced* by its first parent — a collapse, not a blend.
### What is still unresolved: the parent-index arrays
The four V4_8 parent arrays (`parent_indices_lod01`, `unk_indices_2_lod01`,
`parent_indices_lod0`, `unk_indices_2_lod0`) are classified by VU address, but
**the addresses are not unique**. On level 0 patch 1,
`unk_indices_2_lod_01_addr == parent_indices_lod_0_addr == 291`, and the VU
header's `unk_06` and `unk_0a` are both zero — there are no extra entries at
all, so nothing but the order the unpacks appear in distinguishes the two.
The consequence is measurable. Across all 27 levels, of 886,841 vertex-info
entries, 516,492 are common and 57,782 LOD entries resolve to a parent. But
316,548 LOD-0 entries have no recorded parent against only 28,739 that do, and
only 3,274 of 18,102 patches carry enough V4_8 bytes to cover
`positions_lod_01_count + positions_lod_0_count + extras`. The asymmetry is too
large to be genuine, so one of two things is true: the parent arrays are being
misassigned where addresses collide, or RAC2's VU header means something
different by `positions_lod_0_count` than RAC1's does.
The vertex-info tiers themselves *are* confirmed — the addresses at +9 and +11
do match the observed V4_16 unpacks, and the three tier counts sum to
`vert_count` on every patch. So the header layout is right; it is specifically
the parent arrays that are unresolved.
Ruled out for now: address-only classification (collides), and assuming the
arrays are one entry per position (fails on 15,000 patches). Not yet tried:
ordering by VU address rather than by declaration, and using `unk_06`/`unk_0a`
to suppress an empty tier before falling through to the next candidate.
### Still open
The LOD switch and morph rules in `TfragProc`, and the GS register values the
level load builds from the texture entry (TBP0/TBP1 and so on) for a renderer
that emulates the GS rather than resolving addresses itself. A tfrag can now be
turned into positioned, UV-mapped, textured triangles; what is missing is
choosing which LOD to draw.
The parent arrays above, and the GS register values the level load builds from
the texture entry (TBP0/TBP1 and so on) for a renderer that emulates the GS
rather than resolving addresses itself. A tfrag can be turned into positioned,
UV-mapped, textured triangles and told which LOD to draw; morphing cannot be
completed until the parent arrays resolve.
+12 -2
View File
@@ -20,7 +20,7 @@ early, while they are still cheap to retire.
header, and the `data` extent is parsed as a container of byte ranges. The
geometry decompresses: level 0 yields 16,969,296 bytes of level content
from a 9.7 MB stream.
- [ ] Next: tfrag LOD selection and morphing, then the first drawable frame.
- [ ] Next: tfrag parent indices, then the first drawable frame.
The disc being the exact build the reference projects pin is the single
biggest favourable fact so far: no disc conversion, no regional timing work,
@@ -141,7 +141,17 @@ Phase 1 hands over the extents; this phase works out what each one contains.
96.3% with real detail. Verified visually via the new
`gc-extract dump-texture`, because a wrong lookup still yields a
well-formed image of the wrong thing.
- [ ] tfrag LOD selection: the switch and morph rules in `TfragProc`.
- [x] **tfrag LOD selection.** Switch distances scale as `[6L, 4L, 2L]` from the
block header's float; six draw modes result from testing the bounding
sphere against `trunc(D * 1024)`, so a patch straddling a threshold takes
the coarser mode and distance never moves back to finer detail. Morph
weights run 0..1 across each tier's band, and collapse replaces an entry
with its first parent rather than blending it.
- [ ] tfrag parent indices: the four V4_8 arrays are classified by VU address,
but the addresses collide (`unk_06` and `unk_0a` are zero, so an absent
tier shares the next one's address). 57,782 of 374,349 LOD entries
resolve; the 316,548 unresolved LOD-0 entries are far too lopsided to be
genuine. Tried and ruled out in `plan/level-assets.md`.
- [ ] `gameplay` — begins `WAD` + a per-level byte, and does *not* decode with
the WAD LZ77 decoder. All 27 levels fail identically, so the input is
wrong rather than the decoder. Ruled out: bit order, header length, the