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:
@@ -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(®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::<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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user