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)
}
+63 -1
View File
@@ -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.
+6 -1
View File
@@ -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