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,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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user