//! 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 { 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 { Self::connect_slot(DEFAULT_SLOT) } /// One framed round-trip; `payload` is the concatenated commands. fn roundtrip(&self, payload: &[u8]) -> Result> { 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 { let body = self.roundtrip(&[MSG_VERSION])?; Ok(String::from_utf8_lossy(&body) .trim_end_matches('\0') .to_string()) } pub fn title(&self) -> Result { 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> { 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> { self.read64(0, crate::ee::EE_SIZE / 8) } pub fn status(&self) -> Result { 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 = 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); } }