The tfrag clip cube is at +0x38, and +0x2e is the mini-sphere array

TfragHeader carried a field named `cube_ofs` at +0x2e. R&C1 documents that
slot as `msphere_ofs` and puts the 8-corner clip cube at +0x38. The disc is on
R&C1's side for 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, 16 and others. Reading that same slot as
eight s16 clip corners gives data that is not corner-shaped, which is how the
error was visible at all.

The real cube at +0x38 is non-zero on 833 of 833 headers and its eight points
sit inside the patch's own block on every one.

So: `cube_ofs` at +0x2e is renamed to `msphere_ofs`, the count it pairs with is
parsed and the array is read into TfragGeometry as `mini_spheres`, and
`clip_cube_ofs` at +0x38 is new, read into `clip_corners`.

What the corners are, measured over all 833:

    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 bounding 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 if it is a box it is oriented. Whether they 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) matches body-diagonal == 2r on only 99
of 833, so either that pairing is wrong or the array is not a cube. Recorded as
unknown.

This was picked to unblock the guard-band clip test from the roadmap. The
algorithm is from the vendored R&C1 reference and is recorded with its
attribution: camera-space sphere, near/far truncation, side-plane cull, then
the 8-corner re-test against the frustum unscaled and against a volume with xy
x (0.25, 0.25) -- the 4x guard band -- with any corner outside the guard volume
sending the tfrag to the clipping path at LOD 0 with no morphing. draw_mode
already matches that table. What the test still needs is the camera matrix and
the frustum as a clip volume, both per-frame state, so the camera has to be
pinned at a savestate. The geometry side is now parsed and asserted, which
leaves only the camera side outstanding.

The s16 x 64 corner scale is R&C1's documented one and is not verified against
the game; the assertion covers that the parsed corners sit inside the patch's
own block and lie on the bounding sphere, not that the scale is right.

Suite with the disc: 69 disc tests, 156 gc-formats, 22 gc-trace, 23
gc-engine, 19 gc-game. Clippy clean.
This commit is contained in:
2026-10-14 13:09:54 +02:00
parent 3a1156d8e1
commit 66e83f1665
4 changed files with 291 additions and 6 deletions
+163
View File
@@ -5051,6 +5051,169 @@ fn occ(hay: &[u8], needle: &[u8]) -> Vec<usize> {
.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<f64> = 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<usize> = (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(&region[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::<f64>() / 8.0)
.collect::<Vec<_>>()
.try_into()
.unwrap();
let d = (0..3)
.map(|k| (g.clip_corners[0][k] as f64 - centroid[k]).powi(2))
.sum::<f64>()
.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() {
+59 -4
View File
@@ -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<TfragGeometry>
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)
}