The REPL already reads a piped script over one connection, but there was no way
to wait *inside* it. Anything that needed a pause - let the game run a few
seconds, wait for a breakpoint that isn't the direct answer to the previous
line - had to be split across several wsdbg invocations, each paying for a
process, a TCP connection and a handshake. A polling loop built that way took
minutes per run and was the main reason driving headless felt slow.
Adds four directives that run inside the session:
:sleep <seconds> pause, still draining and printing messages
:wait <event> [timeout] block until that event arrives (e.g. cpu.stepping)
:echo <text> mark up the output
# comment ignored
and --compact, which prints one line per message (`<- event {json}`) instead of
pretty-printed JSON and drops the banner and prompt, so a shell can grep the
output instead of reassembling it. wsdbg now also exits non-zero if a :wait
timed out, so a script can be checked without parsing output at all.
:sleep deliberately keeps reading the socket rather than blocking on a timer -
otherwise broadcasts stop printing and the connection backs up behind them.
Together with cpu.runUntilTime this collapses a repro that needed a shell
driver, a polling loop and a JSON-reassembling filter into one file:
{"event":"broadcast.config.set","disallowed":{"logger":true,"input":true}}
cpu.runUntilTime us=1500000
:wait cpu.stepping 60
cpu.status
cpu.stepInto
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
wsdbg - WebSocket debugger CLI
A small standalone client for talking directly to PPSSPP's WebSocket debugger
interface (see docs/WebSocketDebugger.md in the root of the repo for the
full protocol reference and event catalog).
To install Rust and cargo, go here.
To run, with rust installed, change to this Tools/wsdbg directory, then:
cargo run -- <port>
(Or build once with cargo build --release and run the binary directly from
target/release/.)
Usage
Start PPSSPP with the remote debugger enabled (--debugger on the command
line, or Settings > Tools > Developer Tools > Allow remote debugger) and note
the port it's listening on (shown in that same settings screen, and logged at
startup).
Interactive REPL - type an event name and key=value params, get responses
and broadcasts (log messages, stepping notifications, etc.) printed as they
arrive:
cargo run -- 12345
> game.status
-> (ticket 2) {"event":"game.status","ticket":2}
<- {
"event": "game.status",
"game": null,
"paused": false
}
One-shot mode - send a single event and exit after a short wait, handy for scripting:
cargo run -- 12345 gpu.stats.get
cargo run -- 12345 cpu.setReg thread=0 name=4 value=1000
cargo run -- 12345 --raw '{"event":"cpu.evaluate","expression":"pc"}'
Type :help in the REPL for a quick reminder, :quit to disconnect.
Scripting a multi-step sequence
Piping several commands into the REPL ((echo cmd1; echo cmd2; ...) | wsdbg PORT) sends them all
immediately by default - nothing waits for a response before moving to the next line, so scripts
traditionally needed sleep N between commands to guess how long each one takes. Pass --sync
to remove the guessing: each line blocks until its own response arrives (matched by ticket) before
the next line is read, and for cpu.resume/cpu.stepInto/cpu.stepOver/cpu.stepOut/
cpu.runUntil/cpu.nextHLE it also waits for the following cpu.stepping broadcast - the actual
"the CPU stopped again" signal those commands imply. Everything still prints as it arrives; this
only changes when the next line gets sent. A breakpoint that never trips would otherwise hang
the script forever, so it gives up after --sync-timeout seconds (default 30) and moves on.
(
echo 'cpu.breakpoint.add address=134348800 enabled=true'
echo 'cpu.resume'
echo 'cpu.getAllRegs'
) | cargo run -- 12345 --sync
Script directives
A script often needs to wait for something that isn't a direct response to the line before it.
Doing that by splitting the script across several wsdbg invocations costs a process, a TCP
connection and a handshake per pause, which is slow enough to matter - a polling loop built that
way took minutes per run. These run inside the one session instead:
| Directive | What it does |
|---|---|
:sleep <seconds> |
Wall-clock pause. Keeps draining and printing messages while it waits. |
:wait <event> [timeout] |
Blocks until a message with that event name arrives. Exits non-zero if it never does. |
:echo <text> |
Prints text, for marking up a script's output. |
# comment |
Ignored. |
--compact prints one line per message (<- event {json}) instead of pretty-printed JSON, and
drops the banner and prompt - much easier for a shell to grep. wsdbg exits non-zero if a :wait
timed out, so a script can be checked without parsing its output at all.
A complete repro - boot, get several seconds in, step, inspect - in one file and one connection:
# hand this to: wsdbg PORT --sync --compact < script.txt
{"event":"broadcast.config.set","disallowed":{"logger":true,"input":true}}
:echo === run to 1.5s of emulated time ===
cpu.runUntilTime us=1500000
:wait cpu.stepping 60
cpu.status
:echo === step twice ===
cpu.stepInto
cpu.stepInto
:quit
cpu.runUntilTime (see docs/WebSocketDebugger.md) is what makes that reproducible: it stops on
the requested emulated microsecond, so the same script reaches the same instruction every run.
Polling cpu.status in a loop instead lands somewhere different each time.