Make the tie record relocation a command, and verify it on two levels

The relocation rule from the previous commit was established by a script that
lived in a shell history, which means the next person would have had to
rediscover it. It is now `gc-trace verify-tie-records <unpacked-dir> <level>
<class> <lod> <src>`: resolve the class, parse the block, search the RAM image
for the loaded record table, report what it found, and exit 0 only when every
record's pointer has become table + word and every field byte is verbatim.

Swept over every (class, lod) slot of two levels, from PCSX2 savestates:

  level 0: 87 slots with a block, 87 verified, 0 mismatched, 61 absent
  level 1: 104 slots with a block, 104 verified, 0 mismatched, 78 absent

So the rule holds on every LOD block of two levels the game had loaded, which
is what makes the record layout a finding rather than a reading of one block.

Writing the command also caught an off-by-one in the parser it exercises:
TieBlockHeader::records started its slice after the 16-byte header, but the
header IS the first record -- the table runs from the block start to
data_offset, and a block with data_offset 0x50 holds five records, the first
being the header itself and its pointer naming where the payload begins. Found
because the tool's first run relocated nothing while the same check in a script
had passed; the unit test fixture was wrong in the same way and is fixed.

Two operational notes now in the format doc: the live emulator is not a stable
input (verifying level 1 over PINE matched 104 of 104 while the user was on
that planet, and the same command minutes later found no matching copy because
they had returned to level 0 -- a miss there is not a counterexample, so the
report says "no copy matched" rather than "failed"), and a class index past the
table count is an error rather than a mismatch, so a sweep should stop at the
level's class count rather than a round number.
This commit is contained in:
2026-10-12 15:24:48 +02:00
parent e6eecb871f
commit 4e90c282ad
7 changed files with 255 additions and 14 deletions
+1
View File
@@ -28,6 +28,7 @@ pub mod ee;
pub mod p2s;
pub mod pine;
pub mod tfrag_cmp;
pub mod tie_verify;
pub mod zip;
pub use ee::EeRam;
+29
View File
@@ -64,6 +64,34 @@ fn run(args: &[String]) -> Result<i32> {
let len = require(&rest, "--len")?;
read(&src, &addr, &len)
}
"verify-tie-records" => {
// gc-trace verify-tie-records <unpacked-dir> <level> <class> <lod> <src>
let (src, rest) = Source::parse_with_rest(&args[1..])?;
let (dir, level, class, lod) = match rest.as_slice() {
[dir, level, class, lod, ..] => (
PathBuf::from(dir),
level.parse::<usize>().context("level")?,
class.parse::<usize>().context("class")?,
lod.parse::<usize>().context("lod")?,
),
_ => {
return Err(anyhow!(
"usage: gc-trace verify-tie-records <unpacked-dir> <level> <class> <lod> <src>"
))
}
};
let idx = std::fs::read(dir.join(format!("level{level}/core_index.bin")))
.with_context(|| format!("level{level}/core_index.bin"))?;
let core = std::fs::read(dir.join(format!("level{level}/core_data.bin")))
.with_context(|| format!("level{level}/core_data.bin"))?;
let bytes = src.load()?;
let ram = EeRam::new(&bytes)?;
let report = gc_trace::tie_verify::verify(&idx, &core, class, lod, &ram)?;
let mut text = String::new();
gc_trace::tie_verify::print_report(&report, &mut text)?;
println!("{text}");
Ok(report.exit_code())
}
"compare-tfrag-light" => {
let (src, rest) = Source::parse_with_rest(&args[1..])?;
let dir = rest
@@ -95,6 +123,7 @@ fn run(args: &[String]) -> Result<i32> {
const USAGE: &str = "\
usage: gc-trace <command> ...
compare-tfrag-light <unpacked-dir> [--level N] <src>
verify-tie-records <unpacked-dir> <level> <class> <lod> <src>
info <src>
dump-ee <src> [--out FILE]
find <src> --bytes HEX
+172
View File
@@ -0,0 +1,172 @@
//! The tie record relocation, verified against the game's own RAM.
//!
//! A tie LOD block is a table of 16-byte records -- a leading `u32` pointer plus
//! twelve bytes of fields -- and the loader relocates every pointer to
//! `table_address + word`, leaving the twelve bytes verbatim. That is what
//! [`verify`] checks, and it is the only way to see it: nothing in the disc
//! bytes distinguishes a relocated pointer from any other small integer.
//!
//! The search is by content, because each block has **several copies in RAM**
//! and only one of them is the loaded record table. Checking the first hit is
//! how a first attempt at this reported 0 of 16 blocks: the copy it found had
//! *modified* field bytes, which is itself interesting and is why every
//! candidate is tried rather than the first.
use anyhow::{anyhow, Result};
use gc_formats::level_core::LevelCoreHeader;
use gc_formats::tie::{
parse_tie_block, resolve_tie_class, TieBlockHeader, TieRecord, TIE_BLOCK_HEADER_SIZE,
};
use crate::ee::EeRam;
/// One verification, everything a report needs.
#[derive(Debug, Clone)]
pub struct TieReport {
pub class_index: usize,
pub lod: usize,
/// Block offset from the class header, and its length in bytes.
pub block_offset: usize,
pub block_len: usize,
/// Records in the block's table.
pub records: usize,
/// Copies of the record table found in RAM.
pub copies: usize,
/// RAM address of the loaded table, when one matched.
pub table_ram: Option<usize>,
/// Pointers relocated as `table + word`, and records whose twelve field
/// bytes are verbatim.
pub pointers_relocated: usize,
pub fields_verbatim: usize,
}
impl TieReport {
/// 0 = every record relocated with verbatim fields, 2 = mismatches.
pub fn exit_code(&self) -> i32 {
if self.table_ram.is_some()
&& self.pointers_relocated == self.records
&& self.fields_verbatim == self.records
{
0
} else {
2
}
}
}
/// Verify one class's LOD block against a RAM image.
///
/// `lod` is 1 or 2 -- slot 0 is the constant [`gc_formats::tie::TIE_PACKETS0`]
/// and never a block.
pub fn verify(
core_index: &[u8],
core_data: &[u8],
class_index: usize,
lod: usize,
ram: &EeRam,
) -> Result<TieReport> {
let header = LevelCoreHeader::parse(core_index)?;
let class = resolve_tie_class(core_index, core_data, header.tie_classes, class_index)?;
let offset = class
.header
.packets
.get(lod)
.copied()
.ok_or_else(|| anyhow!("lod must be 1 or 2, got {lod}"))?;
if offset <= 0 {
anyhow::bail!("class {class_index} has no block at LOD {lod}: the offset is {offset}");
}
let start = class.entry.offset_in_asset_wad as usize + offset as usize;
let parsed = parse_tie_block(core_data, start)
.ok_or_else(|| anyhow!("no tie block at {start:#x} in core_data"))?;
// Header, then everything up to and including the terminator and padding.
let block_len = TIE_BLOCK_HEADER_SIZE + parsed.payload.len() + parsed.padding.len();
let slice = core_data
.get(start..start + block_len)
.ok_or_else(|| anyhow!("the tie block at {start:#x} runs past core_data"))?;
let table = TieBlockHeader::parse(slice)?;
let count = table.record_count();
if count == 0 {
anyhow::bail!("the block's table is empty");
}
let records: Vec<TieRecord> = table
.records(slice)
.map(|r| TieRecord::parse(r).map_err(Into::into))
.collect::<Result<_>>()?;
// The probe is the first record whose twelve field bytes are distinctive
// enough to find: a run of one repeated byte occurs all over RAM.
let probe = records
.iter()
.map(|r| r.fields)
.find(|f| f.iter().collect::<std::collections::HashSet<_>>().len() >= 4)
.ok_or_else(|| anyhow!("no record has a distinctive field run"))?;
// Every copy of the table in RAM. The one where every pointer has become
// table + word and every field run is verbatim is the loaded copy.
let mut copies = 0usize;
let mut table_ram = None;
let (mut relocated, mut verbatim) = (0usize, 0usize);
for hit in ram.find_all(&probe) {
let base = match hit.checked_sub(4) {
Some(b) => b,
None => continue,
};
copies += 1;
let ok_ptr = records.iter().enumerate().all(|(k, r)| {
ram.slice(base + k * 16, 4)
.map(|b| u32::from_le_bytes(b.try_into().unwrap()) as usize)
== Some(r.pointer as usize + base)
});
let ok_fields = records
.iter()
.enumerate()
.all(|(k, r)| ram.slice(base + k * 16 + 4, 12) == Some(&r.fields[..]));
if ok_ptr && ok_fields {
table_ram = Some(base);
relocated = records.len();
verbatim = records.len();
break;
}
}
Ok(TieReport {
class_index,
lod,
block_offset: offset as usize,
block_len: slice.len(),
records: count,
copies,
table_ram,
pointers_relocated: relocated,
fields_verbatim: verbatim,
})
}
/// Render a report for a terminal.
pub fn print_report(r: &TieReport, out: &mut dyn std::fmt::Write) -> std::fmt::Result {
let _ = writeln!(
out,
"class {} LOD {}: block at +{:#x}, {} bytes, {} records of 16",
r.class_index, r.lod, r.block_offset, r.block_len, r.records
);
let _ = writeln!(out, " copies of the record table in RAM: {}", r.copies);
match r.table_ram {
Some(at) => {
writeln!(out, " loaded table at {at:#x}")?;
writeln!(
out,
" pointers relocated to table + word: {}/{}",
r.pointers_relocated, r.records
)?;
writeln!(
out,
" field bytes verbatim: {}/{}",
r.fields_verbatim, r.records
)
}
None => writeln!(out, " no copy of the table matched the relocation rule"),
}
}