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
+182
View File
@@ -0,0 +1,182 @@
//! A PCSX2 savestate (`.p2s`), read for its EE RAM.
//!
//! The format is a zip archive written by libzip, whose entries are the
//! emulator's state: `eeMemory.bin` is main RAM, and there is a version id
//! entry that says which PCSX2 wrote it. All of this is read from the file,
//! never assumed: the version check and the size check are what stop a
//! plausible-looking wrong read — a partial file, a state from a much older
//! PCSX2 — from being compared against as though it were the game's RAM.
//!
//! Compression is whatever PCSX2 chose (zstd by default, deflate or stored if
//! the user configured otherwise), which is [`crate::zip`]'s job.
use std::path::Path;
use anyhow::{anyhow, Context, Result};
use crate::zip::Zip;
/// The savestate version PCSX2 v2.x writes; the high 16 bits are the load
/// gate, the low 16 the patch level.
const SAVE_VERSION: u32 = 0x9A59_0000;
const VERSION_ID: &str = "PCSX2 Savestate Version.id";
const EE_MEMORY: &str = "eeMemory.bin";
/// A loaded savestate.
pub struct Savestate {
bytes: Vec<u8>,
zip: Zip,
/// The version word from the version-id entry, if present.
pub version: Option<u32>,
/// The version string that follows it.
pub build: String,
}
impl Savestate {
/// Open and fully validate a savestate file.
pub fn open(path: &Path) -> Result<Savestate> {
let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
let state = Self::from_bytes(bytes)?;
state
.validate()
.with_context(|| format!("{}: not a supported savestate", path.display()))?;
Ok(state)
}
/// The version gate. A state whose version entry is missing or from a
/// different major line may have a different entry layout, so stop here
/// rather than guess which entry is the RAM.
///
/// Exposed so a fixture is checked the same way a real file is; `open`
/// calls exactly this.
fn validate(&self) -> Result<()> {
let v = self
.version
.ok_or_else(|| anyhow!("no {VERSION_ID} entry; not a PCSX2 v2.x savestate"))?;
if v & 0xFFFF_0000 != SAVE_VERSION {
return Err(anyhow!(
"savestate version {v:#010x}, this reader handles {SAVE_VERSION:#010x} (PCSX2 v2.8.x)"
));
}
Ok(())
}
/// Build from an in-memory image.
pub fn from_bytes(bytes: Vec<u8>) -> Result<Savestate> {
let zip = Zip::parse(&bytes)?;
let (version, build) = match zip.find(VERSION_ID) {
Some(_) => {
let id = zip.read(&bytes, VERSION_ID)?;
if id.len() < 4 {
return Err(anyhow!("version id entry is {} bytes", id.len()));
}
// The build string is read out of the first 32 bytes after the
// version word. A longer entry carries more after it, which is
// not read: only the version is load-bearing here.
(
Some(u32::from_le_bytes(id[..4].try_into().unwrap())),
String::from_utf8_lossy(&id[4..36.min(id.len())])
.trim_end_matches('\0')
.to_string(),
)
}
None => (None, String::new()),
};
Ok(Savestate {
bytes,
zip,
version,
build,
})
}
/// The entry names, which is also a cheap sanity check on what is here.
pub fn entries(&self) -> Vec<&str> {
self.zip.entries().iter().map(|e| e.name.as_str()).collect()
}
/// One decompressed entry.
pub fn entry(&self, name: &str) -> Result<Vec<u8>> {
self.zip.read(&self.bytes, name)
}
/// The decompressed `eeMemory.bin` as an owned buffer.
///
/// Owned rather than borrowed: the caller wants a stable `EeRam` over the
/// bytes, and the zip entry's payload cannot outlive this call anyway.
pub fn ee_ram_owned(&self) -> Result<Vec<u8>> {
let ram = self.entry(EE_MEMORY)?;
Ok(ram)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::zip::LOCAL_HEADER;
fn build_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut out = Vec::new();
for (name, payload) in entries {
out.extend_from_slice(&LOCAL_HEADER.to_le_bytes());
out.extend_from_slice(&20u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // stored
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&crate::zip::crc32(payload).to_le_bytes());
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(payload);
}
out
}
fn version_id() -> Vec<u8> {
let mut v = SAVE_VERSION.to_le_bytes().to_vec();
v.extend_from_slice(b"v2.8.2");
v.resize(36, 0);
v
}
#[test]
fn a_state_round_trips_its_ram() {
let ram = vec![0x5au8; 16];
let z = build_zip(&[(VERSION_ID, &version_id()), (EE_MEMORY, &ram)]);
let state = Savestate::from_bytes(z).expect("should read");
assert_eq!(state.version, Some(SAVE_VERSION));
assert!(state.build.starts_with("v2.8"));
assert_eq!(state.entry(EE_MEMORY).unwrap(), ram);
}
#[test]
fn a_foreign_savestate_version_is_an_error() {
let bad = 0x9A3B_0000u32; // a future major line
let mut v = bad.to_le_bytes().to_vec();
v.resize(36, 0);
let z = build_zip(&[(VERSION_ID, &v), (EE_MEMORY, &[0u8; 8])]);
let state = Savestate::from_bytes(z).expect("reads the archive");
assert_eq!(state.version, Some(bad));
let err = state.validate().unwrap_err().to_string();
assert!(err.contains("version"), "got {err}");
// The gate reads the high half only: a newer patch level of the same
// major line is a state this reader handles.
let patched = 0x9A59_0001u32;
let mut v = patched.to_le_bytes().to_vec();
v.resize(36, 0);
let z = build_zip(&[(VERSION_ID, &v), (EE_MEMORY, &[0u8; 8])]);
assert!(Savestate::from_bytes(z).unwrap().validate().is_ok());
}
#[test]
fn a_missing_version_entry_is_reported_not_guessed() {
let z = build_zip(&[(EE_MEMORY, &[0u8; 8])]);
let state = Savestate::from_bytes(z).unwrap();
assert_eq!(state.version, None);
assert!(state.validate().is_err());
}
}