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
+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)
}