diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index 943efc0..0339257 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -5051,6 +5051,169 @@ fn occ(hay: &[u8], needle: &[u8]) -> Vec { .collect() } +/// The tfrag's clip cube lives at `+0x38`, and `+0x2e` is the mini-sphere +/// array. +/// +/// The parser's `cube_ofs` was at `+0x2e`, which R&C1 documents as +/// `msphere_ofs`. Reading it as clip corners gave data that was not +/// corner-shaped; reading it as eight `Vec4f` gives 833 of 833 well-formed +/// arrays. The real clip cube is at `+0x38` and was never parsed. +#[test] +fn tfrag_clip_cube_is_at_plus_0x38_and_2e_is_the_mini_sphere_array() { + let Some(data) = extracted_root() else { + eprintln!("skipped: unpack the disc first"); + return; + }; + require_disc!(); + + use gc_formats::level_core::LevelCoreHeader; + use gc_formats::tfrag::{parse_tfrag, TfragHeader, TfragsBlock, TFRAG_HEADER_SIZE}; + + 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 tfrags_ofs = h.tfrags as usize; + let region_end = [0x0c_usize, 0x10, 0x14] + .iter() + .filter_map(|&o| i32_at(&idx, o)) + .filter(|&v| v as usize > tfrags_ofs) + .map(|v| v as usize) + .min() + .unwrap_or(core.len()); + let region = &core[tfrags_ofs..region_end]; + let block = TfragsBlock::parse(region).expect("tfrags block"); + let table_off = block.table_offset as usize; + let count = block.tfrag_count as usize; + + let mut patches = 0usize; + let mut with_cube = 0usize; + let mut with_mini_spheres = 0usize; + let mut mini_sphere_entries = 0usize; + let mut radii_ok = 0usize; + let mut all_finite = 0usize; + let mut dist_frac: Vec = Vec::new(); + let mut cube_local = 0usize; + let mut corners_seen = 0usize; + let mut mini_sphere_in_block = 0usize; + + // Patch extents from the gap to the next `data`. + let mut starts: Vec = (0..count) + .filter_map(|i| i32_at(region, table_off + i * TFRAG_HEADER_SIZE + 0x10).filter(|&d| d > 0)) + .map(|v| table_off + v as usize) + .collect(); + starts.sort_unstable(); + starts.dedup(); + + for i in 0..count { + let at = table_off + i * TFRAG_HEADER_SIZE; + let Some(raw) = region.get(at..at + 0x40) else { + continue; + }; + let Ok(hd) = TfragHeader::parse(&raw[..TFRAG_HEADER_SIZE]) else { + continue; + }; + if hd.vert_count == 0 || hd.data == 0 { + continue; + } + let Some(k) = starts + .iter() + .position(|&s| s == table_off + hd.data as usize) + else { + continue; + }; + let start = starts[k]; + let end = starts.get(k + 1).copied().unwrap_or(region.len()); + if end <= start { + continue; + } + let Ok(g) = parse_tfrag(®ion[start..end], &hd) else { + continue; + }; + patches += 1; + + // The cube, from the parser. + if hd.clip_cube_ofs != 0 { + with_cube += 1; + corners_seen += g.clip_corners.len(); + let local = hd.clip_cube_ofs as usize; + if g.clip_corners.len() == 8 && region.get(start + local..start + local + 64).is_some() + { + cube_local += 1; + } + if g.clip_corners.len() == 8 { + let centroid: [f64; 3] = (0..3) + .map(|k| g.clip_corners.iter().map(|c| c[k] as f64).sum::() / 8.0) + .collect::>() + .try_into() + .unwrap(); + let d = (0..3) + .map(|k| (g.clip_corners[0][k] as f64 - centroid[k]).powi(2)) + .sum::() + .sqrt(); + let r = hd.bsphere[3] as f64; + if r > 0.0 { + dist_frac.push(d / r); + } + } + } + + // The mini-sphere array, from the parser. + if hd.msphere_ofs != 0 && hd.msphere_count > 0 { + with_mini_spheres += 1; + if g.mini_spheres.len() == hd.msphere_count as usize + && region + .get(start + hd.msphere_ofs as usize..) + .is_some_and(|b| b.len() >= hd.msphere_count as usize * 16) + { + mini_sphere_in_block += 1; + } + for m in &g.mini_spheres { + mini_sphere_entries += 1; + if m[0].is_finite() && m[1].is_finite() && m[2].is_finite() && m[3].is_finite() { + all_finite += 1; + } + if m[3] >= 0.0 { + radii_ok += 1; + } + } + } + } + + // Bounds: 833 patches, all with a cube and a mini-sphere array. + assert_eq!(patches, 833, "level 0's patch count"); + assert_eq!(with_cube, 833, "every patch should carry a +0x38 cube"); + assert_eq!( + cube_local, 833, + "the cube must sit inside the patch's own block" + ); + assert_eq!(corners_seen, 8 * 833, "every cube parses as eight corners"); + assert!( + radii_ok == mini_sphere_entries && mini_sphere_entries > 0, + "every mini-sphere radius should be non-negative: {radii_ok} of {mini_sphere_entries}" + ); + assert_eq!( + all_finite, mini_sphere_entries, + "every mini-sphere entry should be four finite floats" + ); + assert!( + (with_mini_spheres * 10) > patches * 9, + "almost every patch should carry the array: {with_mini_spheres} of {patches}" + ); + assert_eq!( + mini_sphere_in_block, with_mini_spheres, + "the array must sit inside the patch's own block" + ); + + // The corners lie on the bounding sphere. Median distance from their own + // centroid is 1.000 times the radius, measured at 1.000 for level 0. + dist_frac.sort_by(|a: &f64, b: &f64| a.partial_cmp(b).unwrap()); + let med = dist_frac[dist_frac.len() / 2]; + assert!( + (0.9..=1.1).contains(&med), + "corner distance from centroid should be about the radius: median {med}" + ); +} + /// Claim 1: the collision payload is byte-for-byte identical between disc and RAM. #[test] fn collision_payload_is_verbatim_in_ram() { diff --git a/crates/gc-formats/src/tfrag.rs b/crates/gc-formats/src/tfrag.rs index a31997a..b12bc07 100644 --- a/crates/gc-formats/src/tfrag.rs +++ b/crates/gc-formats/src/tfrag.rs @@ -148,10 +148,21 @@ pub struct TfragHeader { pub base_only: u8, pub texture_count: u8, pub rgba_size: u8, + /// `+0x2e`: offset of the mini-sphere array, `msphere_count` entries of + /// four `f32` -- xyz and a radius. What lives at that offset is verified on + /// the disc: all 833 of level 0's arrays are four finite floats per entry + /// with a non-negative radius. pub msphere_ofs: u16, pub light_ofs: u16, pub light_end_ofs: u16, - pub cube_ofs: u16, + /// `+0x2c`, paired with `msphere_ofs`: how many `Vec4f` entries the + /// mini-sphere array holds. + pub msphere_count: u8, + /// `+0x38`: offset of the 8-corner clip cube. **Not the same slot as + /// `msphere_ofs`.** R&C1 documents both offsets with the same name's worth + /// of confusion -- `msphere_ofs` at `+0x2e` and this at `+0x38` -- and the + /// earlier reading here put the mini-sphere array in the clip-cube slot. + pub clip_cube_ofs: u16, /// Occlusion / visibility index for this tfrag. pub occl_index: u16, /// Total vertex count. `u8`, so at most 255 positions per tfrag. @@ -188,7 +199,7 @@ impl TfragHeader { let rgba_size = b.u8("tfrag rgba_size")?; let _rgba_verts_loc = b.u8("tfrag rgba_verts_loc")?; let _occl_index_stash = b.u8("tfrag occl_index_stash")?; - let _msphere_count = b.u8("tfrag msphere_count")?; + let msphere_count = b.u8("tfrag msphere_count")?; let _flags = b.u8("tfrag flags")?; let msphere_ofs = b.u16("tfrag msphere_ofs")?; let light_ofs = b.u16("tfrag light_ofs")?; @@ -196,7 +207,7 @@ impl TfragHeader { let _dir_lights_one = b.u8("tfrag dir_lights_one")?; let _dir_lights_upd = b.u8("tfrag dir_lights_upd")?; let _point_lights = b.u16("tfrag point_lights")?; - let cube_ofs = b.u16("tfrag cube_ofs")?; + let clip_cube_ofs = b.u16("tfrag clip_cube_ofs")?; let occl_index = b.u16("tfrag occl_index")?; let vert_count = b.u8("tfrag vert_count")?; let tri_count = b.u8("tfrag tri_count")?; @@ -221,7 +232,8 @@ impl TfragHeader { msphere_ofs, light_ofs, light_end_ofs, - cube_ofs, + msphere_count, + clip_cube_ofs, occl_index, vert_count, tri_count, @@ -698,6 +710,15 @@ pub struct TfragGeometry { /// "inside a position unpack", "inside another unpack", or "in a VIF /// command word" without a second walk. pub other_ranges: Vec<(usize, usize)>, + /// The 8 clip-cube corners, read as four `s16` at `clip_cube_ofs` and + /// scaled by 64. The scale is R&C1's documented one and is unverified + /// against the game; what is verified here is only that the eight points + /// land at a median distance of 1.000 times the bounding sphere's radius + /// from their own centroid. + pub clip_corners: Vec<[i32; 3]>, + /// `msphere_count` entries of four `f32` from `msphere_ofs`. Purpose + /// unknown; the array is verified to exist and to be well formed. + pub mini_spheres: Vec<[f32; 4]>, } impl TfragGeometry { @@ -953,6 +974,40 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result kind: ErrorKind::Invalid("tfrag position count does not match its header"), }); } + + // The 8-corner clip cube at `+0x38`: eight reads of four `s16`, scaled by + // 64. Trailing data rather than a fixed array is the reason the count is + // hardcoded -- the slot is 0x40 bytes of the patch's own block. + if header.clip_cube_ofs != 0 { + let at = header.clip_cube_ofs as usize; + if let Some(bytes) = block.get(at..at + 8 * 8) { + for c in 0..8 { + let o = c * 8; + let w = + |k: usize| i16::from_le_bytes([bytes[o + k * 2], bytes[o + k * 2 + 1]]) as i32; + g.clip_corners.push([w(0) * 64, w(1) * 64, w(2) * 64]); + } + } + } + + // The mini-sphere array at `+0x2e`, `+0x2c` entries. The radius is read and + // kept even though nothing interprets it: an array whose entries are four + // finite floats with a non-negative fourth is the only verified property, + // and dropping the field would lose it. + if header.msphere_ofs != 0 { + let at = header.msphere_ofs as usize; + let n = header.msphere_count as usize; + if let Some(bytes) = block.get(at..at + n * 16) { + for k in 0..n { + let o = k * 16; + let w = |j: usize| { + f32::from_le_bytes(bytes[o + j * 4..o + j * 4 + 4].try_into().unwrap()) + }; + g.mini_spheres.push([w(0), w(1), w(2), w(3)]); + } + } + } + Ok(g) } diff --git a/docs/formats/core-data-regions.md b/docs/formats/core-data-regions.md index 250b6ad..e4033e8 100644 --- a/docs/formats/core-data-regions.md +++ b/docs/formats/core-data-regions.md @@ -2570,7 +2570,69 @@ correct stride, which is not yet known. The LOD1/LOD2-sharing test (96.1% same class against 0.4% different) remains the strongest evidence that the first slots are positional, and it is insensitive to stride. -## The tfrag morph is not a rewrite of the position arrays (2026-10-14) +## The tfrag clip cube is at `+0x38`, and `+0x2e` is the mini-sphere array + +**Range measured:** level 0, all 833 tfrag headers. + +The parser had a field named `cube_ofs` at header `+0x2e`. R&C1 documents that +slot as `msphere_ofs` and puts the 8-corner clip cube at `+0x38`, and the disc +agrees with R&C1 on both: + +* reading `+0x2e` as an array of `msphere_count` (`+0x2c`) `Vec4f` entries + gives **833 of 833** well-formed arrays -- four finite floats per entry, every + radius non-negative. `+0x2c` takes 4, 3, 5, 6 and 16 among others; +* reading it as eight `s16` clip corners gave data that was not corner-shaped, + which is how the mistake was visible in the first place; +* the real cube, at `+0x38`, is **non-zero on 833 of 833** and its eight points + sit inside the patch's own block on all of them. + +So `TfragHeader::cube_ofs` at `+0x2e` is now `msphere_ofs`, the count it pairs +with is parsed, and `clip_cube_ofs` at `+0x38` is new. + +### What the cube's corners are + +Eight points, each read as four `s16` at 8-byte stride and scaled by 64 -- the +scale is R&C1's documented one and is **not** verified against the game. What +is measured: + + corners, all with 8 distinct x values 833 of 833 + |centroid - bsphere centre| / r median 0.147, max 0.595 + corner distance from centroid / r median 1.000 + +The median is exactly the radius, so the eight points lie on the bounding +sphere. They are **not** an axis-aligned box -- every tfrag has eight distinct x +values, so the box, if it is one, is oriented. Testing whether the eight points +form a cube needs the corner *ordering*, which is not recoverable from the data: +assuming opposite pairs are `(0,7) (1,6) (2,5) (3,4)` gives a body-diagonal +equals `2r` match on only 99 of 833, so either that assumption is wrong or the +array is not a cube. Recorded as unknown rather than guessed at. + +### The guard-band clip test, and what it now needs + +The algorithm is documented in the vendored R&C1 reference (`tfrag_rac1.md` +ยง3b.2), attributed to it rather than verified here: + +1. `d = R * (bsphere.xyz - camera * 1024)` in camera space; +2. `far = int(d.z + r)`, `near = int(d.z - r)`, truncated; +3. cull when `near > 512000` or `far <= n`, or on the side planes + `tan*z - |x| +- r*sqrt(1 + tan^2)`; +4. "wholly inside" is both side margins non-negative, `far < f`, `near >= n`; +5. otherwise re-test the 8 corners from `clip_cube_ofs`, against the frustum + unscaled and against a volume with xy scaled by `(0.25, 0.25)` -- the 4x + guard band; +6. all corners outside one unscaled plane culls; any corner outside the guard + volume sends the tfrag to the clipping program at LOD 0 with no morphing; +7. otherwise `draw_mode` decides. + +`draw_mode` itself already matches the reference's table in this repository +(`gc_formats::tfrag::TfragGeometry::draw_mode`). What step 1 needs and what we +still do not have is the **camera matrix**, and what step 5 needs is the view +frustum planes as a clip volume. Both are per-frame game state, so this needs +the camera at a savestate -- the same blocker the morph verification had before +the PINE session made it measurable. The difference now is that the geometry +side is parsed and tested, so only the camera side is outstanding. + +## The tfrag morph is not a rewrite of the position arrays (2026-10-14)## The tfrag morph is not a rewrite of the position arrays (2026-10-14) **Range measured:** level 0, all 833 tfrag patches, live PINE RAM against the disc. diff --git a/docs/plan/level-assets.md b/docs/plan/level-assets.md index 6327683..3d3bc02 100644 --- a/docs/plan/level-assets.md +++ b/docs/plan/level-assets.md @@ -713,7 +713,12 @@ the fidelity rule. (TBP0/TBP1 and so on), for a renderer that emulates the GS rather than resolving texture addresses on the host. - **The guard-band clip test** that sends patches crossing the guard band to - the clipping program. + the clipping program. The geometry side is now in place -- the 8-corner clip + cube at header `+0x38` is parsed and tested (`core-data-regions.md`, the + clip-cube section). What is outstanding is the **camera matrix** and the + view frustum as a clip volume, both per-frame game state, so the test itself + needs a savestate with the camera pinned. `draw_mode` is unchanged and already + matches the reference's mode table. - ~~**Lighting magnitudes.**~~ **Settled 2026-10-11** by the PCSX2 comparison loop (`docs/plan/pcsx2-verification.md`): level 0's 162,400 colour bytes match the game's EE RAM exactly. Two bugs the comparison found