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.
190 lines
6.4 KiB
Rust
190 lines
6.4 KiB
Rust
//! 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);
|
|
}
|
|
}
|