diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index 1c031c7..22cc327 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -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" + ); +} diff --git a/crates/gc-formats/src/tfrag.rs b/crates/gc-formats/src/tfrag.rs index 8147e14..d67ee93 100644 --- a/crates/gc-formats/src/tfrag.rs +++ b/crates/gc-formats/src/tfrag.rs @@ -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 { 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, + /// 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, + /// Same, for the extra (non-position) LOD-01 entries. + pub unk_indices_2_lod01: Vec, + pub parent_indices_lod0: Vec, + pub unk_indices_2_lod0: Vec, /// Per-vertex colours, indexed by position index. pub rgba: Vec, /// 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 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 } } (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 } // 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 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 { + 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 { + 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> { 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); + } +} diff --git a/docs/plan/level-assets.md b/docs/plan/level-assets.md index c3911af..fac3563 100644 --- a/docs/plan/level-assets.md +++ b/docs/plan/level-assets.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 640428e..334cd50 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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