Files
Going-Commando/tools/trace/src/morph.rs
T
ReaperOfVeriod 3a1156d8e1 The tfrag morph is not in the data block: positions are verbatim in RAM
The morph was recorded as unverifiable because "reading a weight back out of
RAM does not work -- the positions live inside regenerated VU packets". That
is right about the correspondence and wrong about the cause. The cause is
measurable, and it comes back clean:

    patches                         833
    positions                     40,600   disc == ram
    positions identical           40,600
    block bytes differing         166,465
      inside a position unpack             0
      inside another unpack           49,484
      in a VIF command word          116,981

Range: level 0, all 833 tfrag patches, live PINE RAM against the disc. Two
independent measurements agree. Walking the VIF lists on both copies and
comparing parsed positions gives 40,600 identical and zero changed; a raw
per-byte diff of each patch's own extent classifies every differing byte, and
none of them is in a position unpack. The position ranges total 490,224 bytes.

So the blend is not precomputed into the geometry. The rewrites are packet
framing -- 70% command words -- plus other unpack payloads, which is the loader
rebuilding the VIF packet for the tier it selected while leaving the vertices
alone. The morph happens at draw time in the VU stream.

The standing note becomes a sharper one: there was never a correspondence
problem. The values were identical, so there was nothing to read.

gc-trace gains verify-morph, which does both measurements. TfragGeometry
gains position_ranges and other_ranges so the walk needed to classify a diff
is already paid for, and the ranges come from the same walk the rest of the
parse uses rather than a second one that could drift.

Per-patch extents are the gap to the next patch's data pointer. That is exact
and needs no guess about which header field marks a patch's end; the first
attempt at this compared each patch against the whole rest of the region and
reported 65 million differing bytes, which is what an unbounded comparison
looks like.

tfrag_positions_are_verbatim_and_the_rewrites_are_elsewhere is gated on
GC_SSTATE and asserts the four bounds, plus two guards: the rewrites must be
non-trivial (over 100,000 bytes) and mostly command words, so a RAM image that
was a verbatim copy of the disc -- which would make the zero look good for the
wrong reason -- fails instead.

Suite with the disc and GC_SSTATE: 68 disc tests, 156 gc-formats, 22
gc-trace, 23 gc-engine, 19 gc-game. Clippy clean.
2026-10-14 12:27:38 +02:00

375 lines
14 KiB
Rust

//! Verifying the tfrag morph against RAM.
//!
//! `resolve_vertex`'s formula is a blend toward the **parent vertices'
//! midpoint**, and reading a weight back out of RAM was reported as not working
//! because the positions live inside regenerated VU packets. There is a route
//! in that does not need the camera distance:
//!
//! * the loader regenerates each patch's packet stream for the tier it
//! selected, and it does that to **every** patch -- but only the entries that
//! actually morph change value;
//! * so on positions, disc versus RAM is identity where nothing morphs, and a
//! difference where it does. The diff names the morphed vertices, which is
//! the correspondence the problem lacked;
//! * with the vertex named, its morph link gives the two parents from the disc
//! side, and the observed RAM value gives the blend weight as
//! `u = (Q - P) / (mid - P)` per component.
//!
//! Three components give three estimates of one weight. If the formula is right
//! they agree and land in 0..1; if the game blends toward anything else they
//! disagree, and the spread is the finding.
use anyhow::{anyhow, Context, Result};
use gc_formats::tfrag::{
parse_tfrag, tfrag_lod_link, tfrag_morph_parents, TfragHeader, TfragsBlock, TFRAG_HEADER_SIZE,
};
use crate::ee::EeRam;
/// One patch's morph verdict.
#[derive(Debug, Clone)]
pub struct PatchMorph {
pub patch: usize,
pub positions_disc: usize,
pub positions_ram: usize,
pub identical: usize,
pub changed: usize,
/// Bytes that differ between the two block copies, classified by whether
/// they fall inside a V3_16 position unpack.
pub differing_bytes: usize,
pub differing_in_position_ranges: usize,
pub position_range_bytes: usize,
}
/// A single solved weight, with the spread across the three components.
#[derive(Debug, Clone)]
pub struct SolvedWeight {
pub patch: usize,
pub vertex: usize,
pub base: [i32; 3],
pub ram: [i32; 3],
pub mid: [i32; 3],
/// `u` solved per component. `None` where the component does not move.
pub per_component: [Option<f32>; 3],
pub spread: f32,
pub weight: f32,
}
#[derive(Debug, Default)]
pub struct Report {
pub level: usize,
pub block_base: usize,
pub patch_count: usize,
pub patches: Vec<PatchMorph>,
pub solved: Vec<SolvedWeight>,
/// Solved weights that are consistent and inside 0..1.
pub agreeing: usize,
/// Disc-side totals across every patch, for the diff classification.
pub differing_bytes: usize,
pub differing_in_positions: usize,
pub position_range_bytes: usize,
/// Of the differing bytes, how many land in another unpack's payload and
/// how many in a VIF command word.
pub differing_in_other_unpacks: usize,
pub differing_in_command_words: usize,
}
impl Report {
pub fn exit_code(&self) -> i32 {
i32::from(self.agreeing < 4)
}
}
pub fn print_report(r: &Report, out: &mut dyn std::fmt::Write) -> std::fmt::Result {
writeln!(
out,
"level {}: {} patches at block base {:#x}",
r.level, r.patch_count, r.block_base
)?;
let discs: usize = r.patches.iter().map(|p| p.positions_disc).sum();
let rams: usize = r.patches.iter().map(|p| p.positions_ram).sum();
let same: usize = r.patches.iter().map(|p| p.identical).sum();
let changed: usize = r.patches.iter().map(|p| p.changed).sum();
writeln!(
out,
" positions: disc {discs}, ram {rams}, identical {same}, changed {changed}"
)?;
writeln!(
out,
" solved weights: {} ({} agree)",
r.solved.len(),
r.agreeing
)?;
writeln!(
out,
" block bytes differing: {} -- {} in a position unpack ({} position bytes), \
{} in another unpack, {} in command words",
r.differing_bytes,
r.differing_in_positions,
r.position_range_bytes,
r.differing_in_other_unpacks,
r.differing_in_command_words
)?;
for s in r.solved.iter().take(8) {
writeln!(
out,
" patch {} v{}: base {:?} -> mid {:?}, ram {:?}, u {} pc {:?}",
s.patch, s.vertex, s.base, s.mid, s.ram, s.weight, s.per_component
)?;
}
Ok(())
}
/// Where the tfrags block sits in RAM, and the header table inside it.
fn locate(region: &[u8], ram: &EeRam, table_off: usize, count: usize) -> Result<usize> {
let key = &region[table_off..table_off + 16];
let mut best: Option<(usize, usize)> = None;
for cand in ram.find_all(key) {
if cand < table_off {
continue;
}
let base = cand - table_off;
let Some(hdr) = ram.slice(base, 0x10) else {
continue;
};
if hdr != &region[..0x10.min(region.len())] {
continue;
}
let mut matched = 0usize;
for i in 0..count.min(64) {
let at = i * TFRAG_HEADER_SIZE;
let d = region.get(table_off + at..table_off + at + TFRAG_HEADER_SIZE);
let r = ram.slice(cand + at, TFRAG_HEADER_SIZE);
if let (Some(a), Some(b)) = (d, r) {
if invariant_equal(a, b) {
matched += 1;
}
}
}
if best.is_none_or(|(_, m)| matched > m) {
best = Some((base, matched));
}
}
match best {
Some((base, m)) if m == count.min(64) => Ok(base),
Some((base, m)) => Err(anyhow!(
"tfrags block at {base:#x} matches {m} of {} headers; refusing a partial match",
count.min(64)
)),
None => Err(anyhow!("no tfrags block in RAM matches this level")),
}
}
fn invariant_equal(disc: &[u8], ram: &[u8]) -> bool {
let keep = |j: usize| !(0x10..0x14).contains(&j) && !(0x3a..0x3c).contains(&j);
disc.iter()
.zip(ram.iter())
.enumerate()
.filter(|&(j, _)| keep(j))
.all(|(_, (a, b))| a == b)
}
pub fn verify(disc_index: &[u8], disc_core: &[u8], level: usize, ram: &EeRam) -> Result<Report> {
let tfrags_ofs = i32_at(disc_index, 0x08).context("tfrags offset")?;
let region_end = [0x0c_usize, 0x10, 0x14]
.iter()
.filter_map(|&o| i32_at(disc_index, o))
.filter(|&v| v > tfrags_ofs)
.min()
.unwrap_or(disc_core.len() as i32);
let region = &disc_core[tfrags_ofs as usize..region_end as usize];
let block = TfragsBlock::parse(region).context("tfrags block")?;
let table_off = block.table_offset as usize;
let count = block.tfrag_count as usize;
if count == 0 {
anyhow::bail!("level{level} has no tfrags");
}
let base = locate(region, ram, table_off, count)?;
let table = base + table_off;
let _ = level;
let mut report = Report {
block_base: base,
patch_count: count,
..Default::default()
};
// Each patch's extent is the gap to the next one's `data`, which is exact
// and needs no guess about which header field marks the patch's end.
// Spans are `(start, length)` relative to the start of the disc region.
let mut starts: Vec<(usize, usize)> = (0..count)
.filter_map(|i| {
let v = i32_at(region, table_off + i * TFRAG_HEADER_SIZE + 0x10).filter(|&d| d > 0)?;
// `data` is relative to the table, and these spans index the
// region, so carry the table base along.
Some((table_off + v as usize, i))
})
.collect();
starts.sort_unstable();
starts.dedup_by_key(|&mut (v, _)| v);
let region_len = region.len();
let span_of = |i: usize| -> Option<(usize, usize)> {
let k = starts.iter().position(|&(_, j)| j == i)?;
let (start, _) = starts[k];
let end = starts.get(k + 1).map(|&(v, _)| v).unwrap_or(region_len);
Some((start, end.saturating_sub(start)))
};
for i in 0..count {
let at = i * TFRAG_HEADER_SIZE;
let disc_h = TfragHeader::parse(
region
.get(table_off + at..table_off + at + TFRAG_HEADER_SIZE)
.ok_or_else(|| anyhow!("tfrag header {i} is out of bounds"))?,
)
.with_context(|| format!("tfrag {i} header"))?;
let ram_h = TfragHeader::parse(
ram.slice(table + at, TFRAG_HEADER_SIZE)
.ok_or_else(|| anyhow!("tfrag {i} header is out of RAM"))?,
)
.with_context(|| format!("tfrag {i} RAM header"))?;
if disc_h.vert_count == 0 || disc_h.data == 0 {
continue;
}
// The patch's own extent, from the gap to the next one.
let Some((span_start, span_len)) = span_of(i) else {
continue;
};
let disc_block = region
.get(span_start..span_start + span_len)
.ok_or_else(|| anyhow!("tfrag {i} disc data runs past the region"))?;
let disc_geo = match parse_tfrag(disc_block, &disc_h) {
Ok(g) => g,
Err(_) => continue,
};
// The RAM copy: behind the relocated pointer, bounded by the region's
// RAM end. `parse_tfrag` indexes from the start of what it is given.
let ram_data = ram_h.data as usize;
let region_end_ram = base + region.len();
if ram_data < base {
anyhow::bail!("tfrag {i} data pointer {ram_data:#x} is before the block");
}
if ram_data + span_len > region_end_ram || ram_data + span_len > ram.bytes().len() {
continue;
}
let block_ram = &ram.bytes()[ram_data..ram_data + span_len];
let ram_geo = match parse_tfrag(block_ram, &ram_h) {
Ok(g) => g,
Err(_) => continue,
};
let dp = disc_geo.positions.clone();
let rp = ram_geo.positions;
let n = dp.len().min(rp.len());
let mut identical = 0usize;
let mut changed = 0usize;
for v in 0..n {
if dp[v] == rp[v] {
identical += 1;
} else {
changed += 1;
}
}
// Classify the raw byte diff. The two block copies start at their own
// absolute addresses but the header's field offsets are invariant, so a
// range from the disc walk indexes the RAM copy the same way.
let ranges = &disc_geo.position_ranges;
let other = &disc_geo.other_ranges;
let position_range_bytes: usize = ranges.iter().map(|&(a, b)| b.saturating_sub(a)).sum();
let mut differing_bytes = 0usize;
let mut differing_in_position_ranges = 0usize;
let mut differing_in_other_unpacks = 0usize;
let mut differing_in_command_words = 0usize;
let cmp_len = disc_block.len().min(block_ram.len());
for off in 0..cmp_len {
if disc_block[off] == block_ram[off] {
continue;
}
differing_bytes += 1;
if ranges.iter().any(|&(a, b)| off >= a && off < b) {
differing_in_position_ranges += 1;
} else if other.iter().any(|&(a, b)| off >= a && off < b) {
differing_in_other_unpacks += 1;
} else {
differing_in_command_words += 1;
}
}
report.differing_bytes += differing_bytes;
report.differing_in_positions += differing_in_position_ranges;
report.position_range_bytes += position_range_bytes;
report.differing_in_other_unpacks += differing_in_other_unpacks;
report.differing_in_command_words += differing_in_command_words;
report.patches.push(PatchMorph {
patch: i,
positions_disc: dp.len(),
positions_ram: rp.len(),
identical,
changed,
differing_bytes,
differing_in_position_ranges,
position_range_bytes,
});
// Every changed position whose morph link resolves gives a weight.
for v in 0..n {
if dp[v] == rp[v] {
continue;
}
let Some(link) = tfrag_lod_link(&disc_geo, v) else {
continue;
};
if !link.morphs {
continue;
}
let Some((p1, p2)) = tfrag_morph_parents(&disc_geo, v) else {
continue;
};
let a = dp[p1];
let b = dp[p2];
let mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2];
let base_pos = dp[v];
let ram_pos = rp[v];
let mut per_component = [None, None, None];
for k in 0..3 {
let denom = (mid[k] - base_pos[k]) as f32;
if denom.abs() > f32::EPSILON {
per_component[k] = Some((ram_pos[k] - base_pos[k]) as f32 / denom);
}
}
let present: Vec<f32> = per_component.iter().flatten().copied().collect();
if present.is_empty() {
continue;
}
let weight = present.iter().sum::<f32>() / present.len() as f32;
let spread =
present.iter().fold(0.0f32, |a, &u| a + (u - weight).abs()) / present.len() as f32;
let in_band = present.iter().all(|&u| (-0.05..=1.05).contains(&u));
if in_band && spread < 0.02 {
report.agreeing += 1;
}
report.solved.push(SolvedWeight {
patch: i,
vertex: v,
base: base_pos,
ram: ram_pos,
mid,
per_component,
spread,
weight,
});
}
}
Ok(report)
}
fn i32_at(bytes: &[u8], at: usize) -> Option<i32> {
bytes
.get(at..at + 4)
.map(|b| i32::from_le_bytes(b.try_into().unwrap()))
}