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:
@@ -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}"))
|
||||
}
|
||||
Reference in New Issue
Block a user