Verify the tfrag lighting magnitudes against the running game

The harness that compares the port against the game's own memory existed but
had never been compiled: tools/trace did not build (27 errors, all in the
argument plumbing), and the tfrag_light.rs light-bank correction it motivated
was unverified. This finishes it and runs it, and the run finds two real bugs.

Result, on level 0 against three inputs -- the live emulator over PINE and two
PCSX2 savestates from the same level, agreeing byte for byte:

  headers matching the disc on invariant bytes: 833/833
  data pointers relocated as table + disc offset: 833/833
  light bank in RAM at 0x50ab20, byte-identical to the disc's
  vertices compared: 40600 (162400 colour bytes)
  equal: 162400 (100.00%)

The locator finds the level's tfrags block in RAM by tfrag 0's bounding
sphere and accepts a candidate only when every header matches the disc on the
bytes the game leaves alone and every data pointer is the relocated form, so
"the block was found" is itself a verification rather than an assumption. The
bank is searched in RAM by its exact section bytes, which is what settles its
framing: offset at +0x04, an i32 set count, twelve bytes of pad, then the sets.
The previous reading (offset at +0x00, count at +0x04) was wrong.

Two bugs the comparison found, both of which produced plausible output:

1. vertex_normal_bits negated x and y and left z positive, while
   NormalTable::normal negates all three. dA came out -0.707 where the game
   had +0.707, so max(dA, dA*wA) took the wrong branch and every vertex lit
   near its base colour: dark, but not wrong-dark. 95.88% agreement with the
   bug, 100.00% without.
2. A record whose select names a set the bank lacks drew transparent black.
   The game writes the record's base colour; 3,130 of level 0's 40,600
   vertices do this (select 0x000f, set 15 -- a runtime slot the level's
   directional bank does not have). The vertex is unlit, not invisible.

Both are now regression tests in the engine. The comparison prints the pass's
per-vertex terms (light_vertex_terms) for the first mismatches, because a
colour mismatch alone cannot be attributed and the comparison is worthless
without attribution -- that is how the second bug was identified from a
three-line read rather than guessed at.

Docs: docs/plan/pcsx2-verification.md records the harness, the result, the
two bugs and what to compare next; roadmap and level-assets.md stop carrying
lighting magnitudes as an open item. Findings reach tests only as numbers --
no test reads a savestate, a dump or a live emulator.
This commit is contained in:
2026-10-11 19:51:25 +02:00
parent 2c7555fe16
commit 69a8398f6e
15 changed files with 2144 additions and 25 deletions
+344
View File
@@ -0,0 +1,344 @@
//! The `gc-trace` command line.
//!
//! ```text
//! gc-trace compare-tfrag-light <unpacked-dir> --level N <src>
//! gc-trace info <src>
//! gc-trace dump-ee <src> [--out FILE]
//! gc-trace find <src> --bytes HEX
//! gc-trace read <src> --addr HEX --len N
//! gc-trace pine-info
//! gc-trace pine-savestate SLOT
//! ```
//!
//! `<src>` is where the memory comes from, and it is one of:
//!
//! | Flag | Reads |
//! |---|---|
//! | `--state FILE` | a PCSX2 `.p2s` savestate |
//! | `--state latest` | the newest state in PCSX2's folder |
//! | `--ee FILE` | a raw 32 MiB EE RAM dump |
//! | `--pine [slot]` | the running emulator over PINE |
use std::path::PathBuf;
use anyhow::{anyhow, Context, Result};
use gc_trace::ee::EeRam;
use gc_trace::{compare, LevelInputs};
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
match run(&args) {
Ok(code) => std::process::exit(code),
Err(e) => {
eprintln!("error: {e:#}");
std::process::exit(1);
}
}
}
/// Returns the process exit code.
fn run(args: &[String]) -> Result<i32> {
let Some(cmd) = args.first() else {
print!("{USAGE}");
return Ok(0);
};
match cmd.as_str() {
"info" => {
let src = Source::parse(&args[1..])?;
info(&src)
}
"dump-ee" => {
let (src, rest) = Source::parse_with_rest(&args[1..])?;
let out = flag(&rest, "--out").unwrap_or_else(|| "ee.bin".into());
dump_ee(&src, &out)
}
"find" => {
let (src, rest) = Source::parse_with_rest(&args[1..])?;
let hex = require(&rest, "--bytes")?;
find(&src, &hex)
}
"read" => {
let (src, rest) = Source::parse_with_rest(&args[1..])?;
let addr = require(&rest, "--addr")?;
let len = require(&rest, "--len")?;
read(&src, &addr, &len)
}
"compare-tfrag-light" => {
let (src, rest) = Source::parse_with_rest(&args[1..])?;
let dir = rest
.first()
.map(PathBuf::from)
.ok_or_else(|| anyhow!("which unpacked level directory?"))?;
let level: usize = flag(&rest, "--level")
.unwrap_or_else(|| "0".into())
.parse()
.context("--level")?;
compare_tfrag_light(&dir, level, &src)
}
"pine-info" => pine_info(),
"pine-savestate" => {
let slot: u8 = args
.get(1)
.ok_or_else(|| anyhow!("which slot?"))?
.parse()
.context("slot")?;
pine_savestate(slot)
}
_ => {
print!("{USAGE}");
Ok(0)
}
}
}
const USAGE: &str = "\
usage: gc-trace <command> ...
compare-tfrag-light <unpacked-dir> [--level N] <src>
info <src>
dump-ee <src> [--out FILE]
find <src> --bytes HEX
read <src> --addr HEX --len N
pine-info
pine-savestate SLOT
<src> is one of:
--state FILE a PCSX2 .p2s savestate
--state latest the newest state in PCSX2's folder
--ee FILE a raw 32 MiB EE RAM dump
--pine [slot] the running emulator over PINE
";
/// Where a memory image comes from.
enum Source {
State(PathBuf),
Latest,
Raw(PathBuf),
Pine(u32),
}
impl Source {
/// The first three arguments after the command.
fn parse(args: &[String]) -> Result<Source> {
Ok(Self::parse_with_rest(args)?.0)
}
/// The source flag, and everything else.
///
/// The flag may sit anywhere: the usage line puts the level directory
/// first, which is the order people type, so a first-argument-only reader
/// would reject its own documented invocation. Whatever is left after the
/// flag and its value is passed back for the caller's own flags.
fn parse_with_rest(args: &[String]) -> Result<(Source, Vec<String>)> {
let Some(at) = args
.iter()
.position(|a| matches!(a.as_str(), "--state" | "--ee" | "--pine"))
else {
return Err(anyhow!("no source; pass --state FILE, --ee FILE or --pine"));
};
let (flag, tail) = (args[at].as_str(), &args[at + 1..]);
let (src, used) = match flag {
"--state" => {
let v = tail
.first()
.ok_or_else(|| anyhow!("--state needs a path or `latest`"))?;
let src = if v == "latest" {
Source::Latest
} else {
Source::State(PathBuf::from(v))
};
(src, 1)
}
"--ee" => {
let v = tail.first().ok_or_else(|| anyhow!("--ee needs a path"))?;
(Source::Raw(PathBuf::from(v)), 1)
}
// `--pine [slot]`: a following slot number is consumed, and
// anything else -- a directory, another flag -- is not.
_ => {
let slot = match tail.first() {
Some(s) if s.parse::<u32>().is_ok() => s.parse()?,
_ => gc_trace::pine::DEFAULT_SLOT,
};
let used = usize::from(tail.first().is_some_and(|s| s.parse::<u32>().is_ok()));
(Source::Pine(slot), used)
}
};
let mut rest: Vec<String> = args[..at].to_vec();
rest.extend_from_slice(&tail[used..]);
Ok((src, rest))
}
/// Load the RAM image this source names.
fn load(&self) -> Result<Vec<u8>> {
match self {
Source::State(p) => gc_trace::Savestate::open(p)
.and_then(|s| s.ee_ram_owned())
.with_context(|| format!("savestate {}", p.display())),
Source::Latest => {
let p = latest_state()?;
let bytes = gc_trace::Savestate::open(&p).and_then(|s| s.ee_ram_owned());
bytes.with_context(|| format!("savestate {} (latest)", p.display()))
}
Source::Raw(p) => {
let b = std::fs::read(p).with_context(|| format!("read {}", p.display()))?;
Ok(b)
}
Source::Pine(slot) => gc_trace::pine::Pine::connect_slot(*slot)
.and_then(|p| p.dump_ee())
.context("PINE dump"),
}
}
}
fn latest_state() -> Result<PathBuf> {
let dir = pcsx2_state_dir();
let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let p = entry.path();
if p.extension().map(|e| e == "p2s").unwrap_or(false) {
if let Ok(meta) = entry.metadata() {
if let Ok(t) = meta.modified() {
if newest.as_ref().is_none_or(|(nt, _)| t > *nt) {
newest = Some((t, p));
}
}
}
}
}
newest
.map(|(_, p)| p)
.ok_or_else(|| anyhow!("no .p2s savestates in {}", dir.display()))
}
/// PCSX2's savestate folder, per platform.
fn pcsx2_state_dir() -> PathBuf {
if cfg!(target_os = "macos") {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join("Library/Application Support/PCSX2/sstates")
} else {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".config")
});
base.join("PCSX2/sstates")
}
}
fn info(src: &Source) -> Result<i32> {
match src {
Source::State(p) => info_state(p)?,
Source::Latest => info_state(&latest_state()?)?,
Source::Raw(_) => {
let bytes = src.load()?;
let ram = EeRam::new(&bytes)?;
println!("raw EE RAM image, {} bytes", ram.bytes().len());
}
Source::Pine(slot) => {
let pine = gc_trace::pine::Pine::connect_slot(*slot)?;
println!("PINE: version {}", pine.version()?);
println!("PINE: title {}", pine.title()?);
}
}
Ok(0)
}
fn info_state(p: &std::path::Path) -> Result<()> {
let state = gc_trace::Savestate::open(p)?;
println!(
"{}: v{:#010x} build {}",
p.display(),
state.version.unwrap_or(0),
state.build
);
for e in state.entries() {
println!(" {e}");
}
Ok(())
}
fn dump_ee(src: &Source, out: &str) -> Result<i32> {
let bytes = src.load()?;
let ram = EeRam::new(&bytes)?;
std::fs::write(out, ram.bytes()).with_context(|| format!("write {out}"))?;
println!("wrote {} bytes to {out}", ram.bytes().len());
Ok(0)
}
fn find(src: &Source, hex: &str) -> Result<i32> {
let bytes = src.load()?;
let ram = EeRam::new(&bytes)?;
let needle = parse_hex(hex)?;
for at in ram.find_all(&needle) {
println!("{at:#x}");
}
Ok(0)
}
fn read(src: &Source, addr_hex: &str, len_hex: &str) -> Result<i32> {
let bytes = src.load()?;
let ram = EeRam::new(&bytes)?;
let addr = usize::from_str_radix(addr_hex.trim_start_matches("0x"), 16)?;
let len = usize::from_str_radix(len_hex.trim_start_matches("0x"), 16)?;
let slice = ram
.slice(addr, len)
.ok_or_else(|| anyhow!("{addr:#x}+{len:#x} runs past the RAM image"))?;
for (i, row) in slice.chunks(16).enumerate() {
let hex: Vec<String> = row.iter().map(|b| format!("{b:02x}")).collect();
println!("{:08x} {}", addr + i * 16, hex.join(" "));
}
Ok(0)
}
fn compare_tfrag_light(dir: &std::path::Path, level: usize, src: &Source) -> Result<i32> {
let inputs = LevelInputs::load(dir, level)?;
let bytes = src.load()?;
let ram = EeRam::new(&bytes)?;
let report = compare(&inputs, level, &ram)?;
let mut text = String::new();
gc_trace::print_report(&report, &mut text)?;
println!("{text}");
Ok(report.exit_code())
}
fn pine_info() -> Result<i32> {
let pine = gc_trace::pine::Pine::connect()?;
println!("version: {}", pine.version()?);
println!("title: {}", pine.title()?);
println!(
"PINE is up on {}",
gc_trace::pine::socket_path(gc_trace::pine::DEFAULT_SLOT).display()
);
Ok(0)
}
fn pine_savestate(slot: u8) -> Result<i32> {
let pine = gc_trace::pine::Pine::connect()?;
pine.save_state(slot)?;
println!("savestate requested for slot {slot}");
Ok(0)
}
/// Parse `hex` bytes, ignoring whitespace.
fn parse_hex(s: &str) -> Result<Vec<u8>> {
let compact: String = s.chars().filter(|c| !c.is_whitespace()).collect();
if compact.len() % 2 != 0 {
return Err(anyhow!("hex string has an odd length"));
}
(0..compact.len())
.step_by(2)
.map(|i| u8::from_str_radix(&compact[i..i + 2], 16).map_err(Into::into))
.collect()
}
/// Look up `--flag VALUE` in the rest.
fn flag(args: &[String], name: &str) -> Option<String> {
let at = args.iter().position(|a| a == name)?;
args.get(at + 1).cloned()
}
fn require(args: &[String], name: &str) -> Result<String> {
flag(args, name).ok_or_else(|| anyhow!("missing {name}"))
}