//! 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; 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, pub solved: Vec, /// 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 { let key = ®ion[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 != ®ion[..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 { 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 = per_component.iter().flatten().copied().collect(); if present.is_empty() { continue; } let weight = present.iter().sum::() / 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 { bytes .get(at..at + 4) .map(|b| i32::from_le_bytes(b.try_into().unwrap())) }