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.
426 lines
15 KiB
Rust
426 lines
15 KiB
Rust
//! 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};
|
|
|
|
/// Compare every patch's positions in RAM against the disc's, and solve the
|
|
/// blend weight for the ones that moved.
|
|
fn verify_morph(dir: &std::path::Path, level: usize, src: &Source) -> Result<i32> {
|
|
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::morph::verify(&idx, &core, level, &ram)?;
|
|
let mut text = String::new();
|
|
gc_trace::morph::print_report(&report, &mut text)?;
|
|
print!("{text}");
|
|
Ok(report.exit_code())
|
|
}
|
|
|
|
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)
|
|
}
|
|
"verify-overlay-base" => {
|
|
// gc-trace verify-overlay-base <unpacked-dir> <level> <src>
|
|
let (src, rest) = Source::parse_with_rest(&args[1..])?;
|
|
let (dir, level) = match rest.as_slice() {
|
|
[dir, level, ..] => (PathBuf::from(dir), level.parse::<usize>().context("level")?),
|
|
_ => {
|
|
return Err(anyhow!(
|
|
"usage: gc-trace verify-overlay-base <unpacked-dir> <level> <src>"
|
|
))
|
|
}
|
|
};
|
|
let overlay = std::fs::read(dir.join(format!("level{level}/overlay.bin")))
|
|
.with_context(|| format!("level{level}/overlay.bin"))?;
|
|
let bytes = src.load()?;
|
|
let ram = EeRam::new(&bytes)?;
|
|
let report = gc_trace::overlay_base::verify(&overlay, &ram)?;
|
|
let mut text = String::new();
|
|
gc_trace::overlay_base::print_report(&report, &mut text)?;
|
|
println!("{text}");
|
|
Ok(i32::from(!report.names.iter().all(|&b| b)))
|
|
}
|
|
"verify-morph" => {
|
|
// gc-trace verify-morph <unpacked-dir> [--level N] <src>
|
|
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")?;
|
|
verify_morph(&dir, level, &src)
|
|
}
|
|
"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
|
|
.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>
|
|
verify-morph <unpacked-dir> [--level N] <src>
|
|
verify-tie-records <unpacked-dir> <level> <class> <lod> <src>
|
|
verify-overlay-base <unpacked-dir> <level> <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}"))
|
|
}
|