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
+189
View File
@@ -0,0 +1,189 @@
//! PINE: PCSX2's live debug protocol.
//!
//! PCSX2 exposes a unix socket (`$XDG_RUNTIME_DIR/pcsx2.sock` on Linux,
//! `$TMPDIR` on macOS, `/tmp/pcsx2.sock` otherwise, `.N` appended for a
//! non-default slot) that answers framed messages with the emulator's own
//! memory. The framing is simple and versions of it appear in several
//! clients, so it is spelled out from the v2.8.2 sources:
//!
//! ```text
//! request: u32 length | command bytes, command after command
//! reply: u32 length | u8 status | one reply per command, in order
//! ```
//!
//! A read is `u8 MsgRead64` followed by a `u32` address and answers with the
//! eight bytes at that address; commands batch, so a 32 MiB dump is 84
//! round-trips of 50,000 reads each, which is the reply-size ceiling.
//!
//! Live reads mean the input is whatever the emulator is doing *right now*.
//! That makes this mode excellent for exploring and poor for a repeatable
//! test, which is why the harness also reads savestates (`crate::p2s`).
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use anyhow::{anyhow, Context, Result};
/// PCSX2's default slot.
pub const DEFAULT_SLOT: u32 = 28011;
const MSG_READ64: u8 = 3;
const MSG_VERSION: u8 = 8;
const MSG_SAVE_STATE: u8 = 9;
const MSG_TITLE: u8 = 0xB;
const MSG_STATUS: u8 = 0xF;
/// Reads per batched message. The reply budget is 450,000 bytes, so 50,000
/// eight-byte reads (400,000 bytes of payload plus framing) is the largest
/// batch that cannot overflow it.
const BATCH: usize = 50_000;
/// The emulator's state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Running,
Paused,
Shutdown,
}
/// A connected PINE session.
pub struct Pine {
stream: UnixStream,
}
impl Pine {
/// Connect to the socket for `slot`.
pub fn connect_slot(slot: u32) -> Result<Pine> {
let path = socket_path(slot);
let stream = UnixStream::connect(&path).with_context(|| {
format!(
"cannot connect to PINE at {}\n\
is PCSX2 running with PINE enabled (Settings > Advanced)?",
path.display()
)
})?;
Ok(Pine { stream })
}
/// Connect at the default slot.
pub fn connect() -> Result<Pine> {
Self::connect_slot(DEFAULT_SLOT)
}
/// One framed round-trip; `payload` is the concatenated commands.
fn roundtrip(&self, payload: &[u8]) -> Result<Vec<u8>> {
let mut msg = Vec::with_capacity(4 + payload.len());
msg.extend_from_slice(&((4 + payload.len()) as u32).to_le_bytes());
msg.extend_from_slice(payload);
let mut stream = &self.stream;
stream.write_all(&msg).context("PINE write")?;
let mut header = [0u8; 5];
stream
.read_exact(&mut header)
.context("PINE reply header")?;
let size = u32::from_le_bytes(header[..4].try_into().unwrap()) as usize;
let status = header[4];
if status != 0 {
return Err(anyhow!("PINE refused the message (status {status:#x})"));
}
let mut body = vec![0u8; size - 5];
stream.read_exact(&mut body).context("PINE reply body")?;
Ok(body)
}
/// The emulator's build string, which is how the protocol is discovered.
pub fn version(&self) -> Result<String> {
let body = self.roundtrip(&[MSG_VERSION])?;
Ok(String::from_utf8_lossy(&body)
.trim_end_matches('\0')
.to_string())
}
pub fn title(&self) -> Result<String> {
let body = self.roundtrip(&[MSG_TITLE])?;
Ok(String::from_utf8_lossy(&body)
.trim_end_matches('\0')
.to_string())
}
/// Read `count` u64s starting at `addr`.
///
/// Addresses are physical: EE RAM is 0..32 MiB, so a physical address is
/// what this takes and what the game stores once its pointers are
/// relocated.
pub fn read64(&self, addr: u32, count: usize) -> Result<Vec<u8>> {
let mut out = Vec::with_capacity(count * 8);
let mut done = 0usize;
while done < count {
let n = (count - done).min(BATCH);
let mut payload = Vec::with_capacity(n * 5);
for i in 0..n {
payload.push(MSG_READ64);
payload.extend_from_slice(&(addr + (done + i) as u32 * 8).to_le_bytes());
}
out.extend_from_slice(&self.roundtrip(&payload)?);
done += n;
}
Ok(out)
}
/// All of EE RAM, which is what the comparisons want.
pub fn dump_ee(&self) -> Result<Vec<u8>> {
self.read64(0, crate::ee::EE_SIZE / 8)
}
pub fn status(&self) -> Result<Status> {
let body = self.roundtrip(&[MSG_STATUS])?;
match u32::from_le_bytes(body[..4].try_into().unwrap()) {
0 => Ok(Status::Running),
1 => Ok(Status::Paused),
_ => Ok(Status::Shutdown),
}
}
/// Ask PCSX2 to write a savestate to `slot`. Saving happens on the
/// emulator's own thread, so the state lands shortly after this returns.
pub fn save_state(&self, slot: u8) -> Result<()> {
self.roundtrip(&[MSG_SAVE_STATE, slot])?;
Ok(())
}
}
/// The socket path for a slot, following PCSX2's own resolution order.
pub fn socket_path(slot: u32) -> PathBuf {
let base = std::env::var_os("XDG_RUNTIME_DIR")
.or_else(|| std::env::var_os("TMPDIR"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"));
let mut path = base.join("pcsx2.sock");
if slot != DEFAULT_SLOT {
path = PathBuf::from(format!("{}.{slot}", path.display()));
}
path
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_socket_path_has_the_slot_suffix_only_when_needed() {
let path = socket_path(DEFAULT_SLOT);
assert!(path.ends_with("pcsx2.sock"));
let other = socket_path(28012);
assert!(other.ends_with("pcsx2.sock.28012"));
}
#[test]
fn a_reply_is_framed_length_then_status_then_body() {
// A failed command is `05 000000 FF` on the wire; the parser reads
// exactly that shape, so check it by constructing the reply the same
// way the reader does.
let mut reply: Vec<u8> = 0x05u32.to_le_bytes().to_vec();
reply.push(0xFF);
assert_eq!(reply.len(), 5);
assert_eq!(u32::from_le_bytes(reply[..4].try_into().unwrap()), 5);
assert_eq!(reply[4], 0xFF);
}
}