From 1e35f009656630b661ee0be96be1ece22df634f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 14 Aug 2026 11:00:21 +0200 Subject: [PATCH] wsdbg: add :snapshot/:snapshots/:diff for quick memory comparisons New REPL-only commands, kept entirely client-side (no new PPSSPP-side event) since a snapshot is a debugging-session-scoped concept with no real emulator-side meaning - memory.read's existing base64 response is already the only primitive actually needed: - :snapshot
- blocks on a memory.read (unlike the rest of the REPL, which is fire-and-forget or just waits without looking at the payload) and stores the decoded bytes locally under . address/size are passed through to the server exactly as typed, same as any other event param. - :snapshots - list what's been captured so far. - :diff - byte-compare two snapshots, printing each differing run as "+offset (N bytes): old_hex -> new_hex". Replaces the throwaway PowerShell/Bash diffing scripts written by hand at least twice during the VSH boot investigation (see docs/VSHBootInvestigation.md - the sceBSMan before/after test, and the GE display list re-checks) with one correct, reusable implementation. In memory only for now (lives as long as the wsdbg process does, which is fine for the actual usage pattern - one piped batch of commands per invocation, same as everything else in this tool); can add disk persistence later if a real need for cross-invocation snapshots comes up. Verified live against PPSSPPHeadless: snapshot/list/diff (both a real detected difference and an identical-buffer comparison) all produce correct output. --- Tools/wsdbg/Cargo.lock | 7 ++ Tools/wsdbg/Cargo.toml | 1 + Tools/wsdbg/src/main.rs | 186 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 188 insertions(+), 6 deletions(-) diff --git a/Tools/wsdbg/Cargo.lock b/Tools/wsdbg/Cargo.lock index 71668640e3..f30e4e61f0 100644 --- a/Tools/wsdbg/Cargo.lock +++ b/Tools/wsdbg/Cargo.lock @@ -58,6 +58,12 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "block-buffer" version = "0.10.4" @@ -477,6 +483,7 @@ name = "wsdbg" version = "0.1.0" dependencies = [ "anyhow", + "base64", "clap", "serde_json", "tungstenite", diff --git a/Tools/wsdbg/Cargo.toml b/Tools/wsdbg/Cargo.toml index 20803824e0..ea5cee7c4f 100644 --- a/Tools/wsdbg/Cargo.toml +++ b/Tools/wsdbg/Cargo.toml @@ -9,3 +9,4 @@ clap = { version = "4.5", features = ["derive"] } tungstenite = "0.24" serde_json = "1.0" anyhow = "1.0" +base64 = "0.22" diff --git a/Tools/wsdbg/src/main.rs b/Tools/wsdbg/src/main.rs index 75225ba1a5..29914f08b2 100644 --- a/Tools/wsdbg/src/main.rs +++ b/Tools/wsdbg/src/main.rs @@ -7,6 +7,7 @@ // wsdbg 12345 game.status # one-shot: send an event, print responses, exit // wsdbg 12345 cpu.setReg thread=0 name=0 value=42 +use std::collections::HashMap; use std::io::{self, BufRead, Write}; use std::net::TcpStream; use std::sync::atomic::{AtomicU64, Ordering}; @@ -15,11 +16,15 @@ use std::thread; use std::time::{Duration, Instant}; use anyhow::{Context, Result, anyhow}; +use base64::Engine as _; use clap::Parser; use tungstenite::client::IntoClientRequest; use tungstenite::http::HeaderValue; use tungstenite::{Message, WebSocket}; +// name -> (address, as the user typed it - not reparsed, just echoed back for display; bytes). +type Snapshots = HashMap)>; + const SUBPROTOCOL: &str = "debugger.ppsspp.org"; #[derive(Parser, Debug)] @@ -233,8 +238,11 @@ fn print_help() { println!(" cpu.setReg thread=0 name=4 value=1000"); println!("Or paste a full JSON message starting with '{{' to send it verbatim."); println!("A numeric 'ticket' is auto-assigned to shorthand commands so you can match up responses."); - println!(":help show this message"); - println!(":quit / :q disconnect and exit"); + println!(":help show this message"); + println!(":quit / :q disconnect and exit"); + println!(":snapshot memory.read into a locally-named byte buffer"); + println!(":snapshots list saved snapshots"); + println!(":diff byte-compare two snapshots"); println!("See docs/WebSocketDebugger.md in the ppsspp repo for the full event catalog."); println!("Piping a script in? Pass --sync so each line waits for its response (and, for"); println!("cpu.resume/step*/runUntil, the following cpu.stepping) before the next line runs -"); @@ -319,6 +327,160 @@ fn wait_for_sync_response(socket: &mut WebSocket, ticket: u64, event: } } +// Send a ticketed request and block until its response arrives (or timeout), returning the +// parsed JSON. Unlike the normal REPL dispatch (fire-and-forget, or --sync's "wait but don't +// look at the payload"), :snapshot needs the actual returned data before it can do anything - +// there's no useful way to "move on to the next input line" first. +fn send_and_wait( + socket: &mut WebSocket, + event: &str, + params: &[String], + timeout_secs: f64, +) -> Result { + let ticket = next_ticket(); + let json_text = build_event_json(event, params, Some(ticket))?; + println!("-> (ticket {ticket}) {json_text}"); + socket.send(Message::Text(json_text.into()))?; + + let deadline = Instant::now() + Duration::from_secs_f64(timeout_secs.max(0.0)); + while Instant::now() < deadline { + match socket.read() { + Ok(Message::Text(text)) => { + print_incoming(&text); + if let Ok(v) = serde_json::from_str::(&text) { + if v.get("ticket").and_then(|t| t.as_u64()) == Some(ticket) { + return Ok(v); + } + } + } + Ok(Message::Close(frame)) => return Err(anyhow!("connection closed by PPSSPP: {frame:?}")), + Ok(_) => {} + Err(ref e) if is_would_block(e) => {} + Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => { + return Err(anyhow!("connection closed")); + } + Err(e) => return Err(anyhow!("connection error: {e}")), + } + } + Err(anyhow!("timed out after {timeout_secs}s waiting for a response")) +} + +// :snapshot
- reads memory.read once (blocking, unlike the rest of the +// REPL) and stores the decoded bytes locally under . address/size are passed through to +// the server exactly as typed (same as any other event param - the server already accepts +// "0x..." hex strings for address-like fields), not reparsed here. +// +// Kept entirely client-side rather than as a new PPSSPP-side memory.snapshot.* event: a +// snapshot is a debugging-*session*-scoped concept (how long should the emulator hold onto one? +// does it survive a savestate load or game restart? does it leak if a script forgets to clean +// up?) with no real emulator-side meaning, and memory.read's existing base64 response is already +// the only primitive actually needed - no new protocol surface required. +fn cmd_snapshot(socket: &mut WebSocket, snapshots: &mut Snapshots, args: &[&str], timeout_secs: f64) { + if args.len() != 3 { + eprintln!("! Usage: :snapshot
"); + return; + } + let name = args[0]; + let params = vec![format!("address={}", args[1]), format!("size={}", args[2])]; + match send_and_wait(socket, "memory.read", ¶ms, timeout_secs) { + Ok(resp) => { + if resp.get("event").and_then(|e| e.as_str()) == Some("error") { + let msg = resp.get("message").and_then(|m| m.as_str()).unwrap_or("unknown error"); + eprintln!("! memory.read failed: {msg}"); + return; + } + let b64 = match resp.get("base64").and_then(|b| b.as_str()) { + Some(b) => b, + None => { + eprintln!("! Response had no 'base64' field: {resp}"); + return; + } + }; + match base64::engine::general_purpose::STANDARD.decode(b64) { + Ok(bytes) => { + let len = bytes.len(); + snapshots.insert(name.to_string(), (args[1].to_string(), bytes)); + println!("snapshot '{name}' saved: {len} bytes at {}", args[1]); + } + Err(e) => eprintln!("! Could not decode base64 response: {e}"), + } + } + Err(e) => eprintln!("! {e}"), + } +} + +// :snapshots - list what's been captured so far in this session. +fn cmd_list_snapshots(snapshots: &Snapshots) { + if snapshots.is_empty() { + println!("No snapshots saved. Use :snapshot
to take one."); + return; + } + let mut names: Vec<&String> = snapshots.keys().collect(); + names.sort(); + for name in names { + let (addr, bytes) = &snapshots[name]; + println!(" {name}: {} bytes at {addr}", bytes.len()); + } +} + +// :diff - byte-compare two snapshots and print each differing run as +// "+offset (N bytes): old_hex -> new_hex". Replaces the throwaway PowerShell/Bash diffing this +// was needed for by hand at least twice during the VSH boot investigation (see +// docs/VSHBootInvestigation.md) with one correct implementation. +fn cmd_diff(snapshots: &Snapshots, args: &[&str]) { + if args.len() != 2 { + eprintln!("! Usage: :diff "); + return; + } + let Some((addr1, bytes1)) = snapshots.get(args[0]) else { + eprintln!("! No snapshot named '{}' (see :snapshots)", args[0]); + return; + }; + let Some((addr2, bytes2)) = snapshots.get(args[1]) else { + eprintln!("! No snapshot named '{}' (see :snapshots)", args[1]); + return; + }; + + let len = bytes1.len().min(bytes2.len()); + if bytes1.len() != bytes2.len() { + println!( + "'{}' ({} bytes at {addr1}) and '{}' ({} bytes at {addr2}) differ in size - comparing the first {len} bytes", + args[0], bytes1.len(), args[1], bytes2.len() + ); + } + + const MAX_RUNS: usize = 50; + let mut runs = 0usize; + let mut i = 0usize; + while i < len { + if bytes1[i] == bytes2[i] { + i += 1; + continue; + } + let start = i; + while i < len && bytes1[i] != bytes2[i] { + i += 1; + } + runs += 1; + if runs <= MAX_RUNS { + let old_hex: Vec = bytes1[start..i].iter().map(|b| format!("{b:02x}")).collect(); + let new_hex: Vec = bytes2[start..i].iter().map(|b| format!("{b:02x}")).collect(); + let count = i - start; + println!( + " +{start:#06x} ({count} byte{}): {} -> {}", + if count == 1 { "" } else { "s" }, + old_hex.join(" "), + new_hex.join(" ") + ); + } + } + if runs == 0 { + println!("'{}' and '{}' are identical (first {len} bytes)", args[0], args[1]); + } else if runs > MAX_RUNS { + println!(" ... and {} more differing run(s)", runs - MAX_RUNS); + } +} + fn run_repl(mut socket: WebSocket, sync: bool, sync_timeout: f64) -> Result<()> { socket.get_ref().set_read_timeout(Some(Duration::from_millis(100)))?; @@ -353,14 +515,26 @@ fn run_repl(mut socket: WebSocket, sync: bool, sync_timeout: f64) -> print!("> "); io::stdout().flush().ok(); + let mut snapshots: Snapshots = Snapshots::new(); + loop { match rx.try_recv() { Ok(line) => { let line = line.trim(); - match line { - ":quit" | ":q" | ":exit" => return Ok(()), - ":help" | ":h" => print_help(), - "" => {} + let mut words = line.split_whitespace(); + match words.next() { + Some(":quit") | Some(":q") | Some(":exit") => return Ok(()), + Some(":help") | Some(":h") => print_help(), + Some(":snapshot") => { + let args: Vec<&str> = words.collect(); + cmd_snapshot(&mut socket, &mut snapshots, &args, sync_timeout); + } + Some(":snapshots") => cmd_list_snapshots(&snapshots), + Some(":diff") => { + let args: Vec<&str> = words.collect(); + cmd_diff(&snapshots, &args); + } + None => {} _ => match handle_repl_line(&mut socket, line) { Err(e) => eprintln!("! {e}"), Ok(Some((ticket, event))) if sync => {