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() {
|
||||
|
||||
Reference in New Issue
Block a user