Files
Going-Commando/tools/trace/src/main.rs
T
ReaperOfVeriod 4abce518e5 Decode the overlay's load base, from its own profiler table
Range measured: all 27 levels' overlays.

The overlay carries the game's profiler menu definition -- a run of 8-byte
padded names (normal, camera, cam+chr, volume, bars, render, mobys, ties,
shrubs, cpu/gpu, occl, ...) followed by runs of absolute RAM addresses that
point back at those same names. On every level the four addresses after
normal/camera/cam+chr/volume, each minus its target name's overlay offset, give
the same number: 1,735,792.

That is field_00 - 16, where field_00 is the header word that read 1,735,808 on
all 27 levels with nothing attached to it. The overlay is loaded with its
16-byte header stripped, so overlay offset X sits at RAM X + 1,735,792.
OverlayHeader::ram_address carries it, and the disc test asserts the derivation
holds on all 27 levels.

Confirmed, not only derived: level 0's RAM 0x1acf80 holds
"normal\0camera\0cam+chr\0volume\0", byte-identical to overlay 0x5310, and
0x5310 + 1735792 == 0x1acf80. All four names land where the overlay's own table
says. `gc-trace verify-overlay-base` runs that check, savestate-gated the same
way as the tie and tfrag ones.

The overlay is relocated, not copied: only 37.67% of level 0's is
byte-identical at the base, and the 256-entry sine table -- at overlay 0xb650c
-- is not at 0xb650c + base. So the base locates the overlay's image, but
individual tables inside it move. That is the shape of a loader applying
relocations, and it means the base is necessary but not sufficient for reading
the game's tables.

The value of the base is that any overlay offset can now be carried to a RAM
address, which is the prerequisite for reading the game's live level state --
the thing the tie relocation and tfrag lighting checks had to work around.

Two things get deleted rather than finished. The collision u16 table was probed
four ways -- magnitude distribution, level-independence, run starts, run
lengths -- and every one came back level-dependent: the run-start pattern is
identical on 1 of 19 levels. So the first 1024 bytes of the collision payload
are data, not a fixed table, and nothing was decoded. Recorded as the negative
it is rather than left as four half-findings.
2026-10-13 13:22:50 +02:00

396 lines
14 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};
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-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-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}"))
}