--launch starts PPSSPP itself, learns the debugger port from its output, connects once the socket accepts, and kills it on exit. That replaces some wrapper scripts, which had to background the emulator, poll a log file for "Listening on port N", sleep a guessed interval before connecting, and taskkill afterwards. The polling was also racy - a fixed port plus a leftover process from an earlier run is a good way to drive the wrong emulator - and nothing cleaned up, so --timeout's wall-clock budget left processes alive for hours. To make that dependable, the port is now also written straight to stderr from Core/WebServer.cpp, outside the log system entirely. A tool that launches PPSSPP has to learn the port before it can connect to anything, so that line must not be losable to a log level or a disabled channel. wsdbg watches both child streams for it, since which one it lands on depends on how a given build routes logging. --quiet sends the broadcast.config.set that every script was hand-writing, disabling the logger and input broadcasts. The log one is expensive - each line gets encoded as JSON and pushed down the socket. The rest is one bug, in the docs rather than the code, which cost a lot of time: the scripting example paired cpu.runUntilTime with ":wait cpu.stepping". --sync already waits for the cpu.stepping that follows a resume-family command, so the explicit :wait waits for a second one that never comes and burns the whole --sync-timeout. With --sync-timeout 400 that turns a 3-second run into a 7-minute one that looks exactly like a slow boot, because the emulator really has stopped where it was asked to. Example fixed, and both that and muting the 'stepping' category (same failure, different cause) are called out - wsdbg now warns when a raw broadcast.config.set disables 'stepping'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 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).
Letting wsdbg start PPSSPP
For headless scripting, --launch removes all of that. It starts PPSSPP, learns the debugger port
from its output, connects once the socket accepts, and kills it again on the way out:
wsdbg --sync --compact --quiet --launch ./PPSSPPHeadless.exe --vsh -i --graphics=vulkan < script.txt
--launch takes the executable and all of its arguments, so it has to come last - everything after
it belongs to PPSSPP, and wsdbg's own flags go before it. --debugger=0 is appended unless the
arguments already ask for a debugger port, so the OS picks a free one and nothing has to agree on a
port number in advance. PPSSPP's output is forwarded to wsdbg's stderr, separate from the protocol
messages on stdout, so 2>emu.log keeps them apart.
This replaces the wrapper script the same job used to need: no launching in the background, no
polling a log file for the port (a race - you can attach to a previous run that still holds one),
no fixed sleep before connecting, and no leftover emulator processes, which --timeout's
wall-clock budget otherwise leaves running for as long as it says.
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 as soon as its reply arrives, 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"}'
The reply is matched by ticket, so this returns in milliseconds rather than
padding every invocation with a fixed sleep. --wait (default 10s) is only the
upper bound before it gives up and exits non-zero. Pass --wait-all to go back
to "print everything that arrives for --wait seconds", which is what you want
when watching broadcasts (log lines, gpu.stats.feed) rather than asking a
question.
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.runUntilTime/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), reports it, and makes the run exit non-zero.
Matching is by ticket, always. A raw JSON line (the only way to send nested parameters) gets a
ticket assigned if it doesn't carry one, so it's waited for like any other line - previously it
had none, --sync had nothing to match, and it skipped waiting entirely, which let the next
line's response be read as this one's and quietly desynchronised the rest of the script. Raw lines
are also rejected up front, rather than sent and left to fail somewhere downstream, if they aren't
valid JSON, aren't an object, have no string event, or carry a ticket that isn't an integer.
(
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 --quiet < script.txt
:echo === run to 1.5s of emulated time ===
cpu.runUntilTime us=1500000
cpu.status
:echo === step twice ===
cpu.stepInto
cpu.stepInto
:quit
Don't add :wait cpu.stepping after cpu.runUntilTime there (an earlier version of this
example did). Under --sync those commands already wait for the cpu.stepping that follows, so an
explicit :wait blocks for a second one that never arrives and burns the entire
--sync-timeout. It is a nasty failure to diagnose, because the emulator has already stopped
exactly where you asked: the run just sits there, and a script written with --sync-timeout 400
takes seven minutes instead of three seconds while looking like a slow boot. :wait is for events
nothing else is waiting on - a breakpoint hit during a free run, say.
For the same reason, never put "stepping":true in broadcast.config.set's disallowed:
cpu.stepping is a broadcast with no ticket of its own, and muting it means nothing can ever
observe that a run finished. --quiet above is the safe way to get the same noise reduction - it
disables logger and input only, and wsdbg warns if a hand-written broadcast.config.set mutes
stepping.
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.