A breakpoint hit reached a WebSocket client as two fields on cpu.stepping: a reason string and one address. Everything else the hit site knew was formatted into a log line and dropped. What was missing per kind: - exec: hit count, condition, symbol. - memory: the address actually accessed, read vs write, size, and who did it. The address that reached the client was the *start of the watched range*, so a client watching 4KB learned only that something in it was touched. - register: which register. Entirely - the event carried pc and nothing else. There's now a BreakpointHit captured where the hit happens and carried through Core_Break() on the stepping reason, rendered as a "hit" object on cpu.stepping. It's absent rather than empty when the break wasn't a breakpoint (a pause, a savestate load, an exception), so presence is the test. relatedAddress keeps reporting the range start for compatibility; hit.address is the accurate one. The formatter is shared with the new event below, so the two can't drift. And a new cpu.breakpoint.hit broadcast fires on *every* hit whose condition passes, whether or not it stops the CPU. That's the part that makes log-only breakpoints usable for automation: until now their only trace was a line in the log stream, so a client couldn't count hits, or react to one, without scraping text. Same "hit" object, plus a sequence number. Volume needed handling, since a log-only breakpoint in a hot loop produces events far faster than a connection drains them - measured 13719 hits in three seconds of one homebrew's draw function. The per-connection queue is capped and drops rather than growing without bound, and the sequence number is what makes that honest: a gap tells a client exactly how many it missed. Clients that don't want the traffic at all can disallow the new "breakpoint" broadcast category. Building the hit record is skipped entirely when no debugger is connected, which is one relaxed atomic load on that path. Verified against a running game, all three kinds. The memory case shows why the address/range split matters - accessed address 200540160 against a watched range starting at 200941120, with source "ThreadFillStack" identifying the HLE call responsible. libretro gets stubs: it builds Core.cpp and Breakpoints.cpp but not Core/Debugger/WebSocket.cpp. pspautotests 314/314, UnitTest 55/55. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
17 KiB
PPSSPP WebSocket Debugger
PPSSPP has a JSON/WebSocket-based debugger and automation API, served from the same HTTP server used for "Remote ISO" disc sharing and file upload. It lets an external tool (a script, a web page, another editor/IDE) inspect and control a running emulation session: read/write memory, set breakpoints, step the CPU, read GE/GPU state, send fake input, tail the log, etc.
This doc is a local reference, the user-facing documentation is on the website.
Where the code lives
Core/WebServer.cpp/Core/WebServer.h- the shared HTTP server (also used for Remote ISO and file upload). It owns the listening socket and dispatches/debuggerrequests.Core/Debugger/WebSocket.cpp- upgrades the HTTP request to a WebSocket and runs the per-connection event loop (HandleDebuggerRequest).Core/Debugger/WebSocket/*.cpp/.h- one "subscriber" or "broadcaster" per feature area (CPU, memory, GPU, HLE, input, breakpoints, ...). Each file's top comment documents its events in detail - this doc gives the overview and an index into those files.Core/Debugger/WebSocket/WebSocketUtils.h- sharedDebuggerRequesthelper (parameter parsing, response/error helpers) andDebuggerSubscriberbase class.Common/Net/WebsocketServer.h/.cpp- the low-level WebSocket framing.
Transport
- Runs on the same port as Remote ISO sharing (
g_Config.iRemoteISOPort;0means "pick a free port automatically" - the actual bound port is written back to that config value and logged:Listening on port N). - URL path:
/debugger. - WebSocket subprotocol:
debugger.ppsspp.org(required - a plain HTTP GET to/debuggerwithout a websocket Upgrade just redirects to the bundled web UI at/debugger/index.html). - Messages are JSON, both directions, always shaped as
{"event": "NAME", ...}. - One WebSocket connection = one client; PPSSPP does not limit the number of simultaneous debugger connections.
- The debugger only actually does anything while
WebServerFlags::DEBUGGERis enabled (see "Enabling it" below) - the HTTP server itself may also be running for other reasons (Remote ISO, upload).
Message protocol
Requests you send:
{ "event": "cpu.status" }
Optionally include a "ticket" field (any JSON value) - PPSSPP echoes it
back verbatim in the response/error, so you can correlate requests and
responses when firing several at once. Tools/wsdbg (see below) assigns an
incrementing integer ticket automatically.
Responses use the same event name as the request:
{ "event": "cpu.status", "ticket": 1, ... }
The ticket convention: send one whenever you care about the answer. Since a response reuses the request's event name, a ticket is the only thing that distinguishes your answer from an unsolicited broadcast of the same name, or from the answer to an identical request you sent a moment earlier. Conversely, for a request that doesn't answer immediately (below), leaving the ticket off says you aren't waiting for anything.
Responses are not always immediate. cpu.resume, cpu.stepInto,
cpu.stepOver, cpu.stepOut, cpu.runUntil, cpu.runUntilTime,
cpu.nextHLE, cpu.stepping and gpu.stats.feed send nothing back at the
time of the request; what follows later is the broadcast that reports the
actual outcome (cpu.stepping / cpu.resume), which carries no ticket.
input.buttons.press is the odd one out - it answers with the request's own
event name and ticket, but only once the button has been held for the
requested number of frames.
If you would rather not track which those are, ask to be told explicitly:
-> { "event": "client.config.set", "acknowledgeDeferred": true }
-> { "event": "cpu.resume", "ticket": 7 }
<- { "event": "deferred", "for": "cpu.resume", "ticket": 7 }
<- { "event": "cpu.resume" }
With that on, every request draws exactly one immediate reply - a response, an
error, or a deferred - so a client can correlate without a hardcoded list,
including for events added in future versions. It is off by default and must
stay that way: an extra message would break a client that correlates purely by
ticket, and it can't reuse the request's event name because for
input.buttons.press that is exactly what the real, later answer looks like.
Errors look like this:
{ "event": "error", "message": "...", "level": 2, "ticket": 1 }
level is a LogLevel (1=NOTICE, 2=ERROR, 3=WARN, 4=INFO, 5=DEBUG, 6=VERBOSE).
PPSSPP also sends unsolicited ("broadcast") events with no request - see below.
By convention, send a version event right after connecting (see
WebSocket/GameSubscriber.cpp):
{ "event": "version", "name": "my-tool", "version": "1.0" }
PPSSPP responds with its own name/version, and remembers yours (currently
just for internal bookkeeping/future logging). The response also carries
pid (OS process id) and path (the executable/disc currently loaded, or
null), so an automation client can confirm it attached to the instance it
meant to - a port alone doesn't prove that, since a leftover process may still
be holding the port you asked for.
Broadcast (unsolicited) events
Sent without you asking, whenever the underlying state changes:
| Event | Sent when | Source |
|---|---|---|
log |
A new log line is emitted | LogBroadcaster.cpp |
game.start |
A game finishes booting | GameBroadcaster.cpp |
game.quit |
The game is closed/reset | GameBroadcaster.cpp |
game.pause / game.resume |
User opens/leaves the pause menu | GameBroadcaster.cpp |
cpu.stepping |
CPU enters a stepping/break state | SteppingBroadcaster.cpp |
cpu.resume |
CPU resumes from stepping | SteppingBroadcaster.cpp |
cpu.breakpoint.hit |
Any breakpoint trips, whether or not it stops the CPU | WebSocket.cpp |
input.buttons |
Any emulated button changes state | InputBroadcaster.cpp |
input.analog |
An analog stick position changes | InputBroadcaster.cpp |
A client can opt out of specific broadcast categories with
broadcast.config.set ({"disallowed": {"logger": true, "game": true, "stepping": true, "input": true, "breakpoint": true}}),
see ClientConfigSubscriber.cpp. gpu.stats.feed (see below) works the same
way for periodic GPU stats. client.config.set in the same file carries
per-connection settings that aren't about broadcasts - currently just
acknowledgeDeferred, described under "Message protocol" above.
Breakpoint hits
cpu.breakpoint.hit fires every time a breakpoint's condition passes and it has
some action set - including log-only breakpoints, which never stop the CPU.
That's what makes them usable for automation: before this event existed, a
log-only breakpoint's only trace was a line in the log stream.
{
"event": "cpu.breakpoint.hit",
"sequence": 1,
"hit": {
"kind": "exec",
"pc": 142876568,
"address": 142876568,
"hits": 1,
"logged": true,
"paused": false,
"condition": null,
"symbol": "rendering.mesh.Mesh(rendering.Vertex.PspVertex).draw",
"breakpoint": { "start": 142876568, "end": 142876568 }
}
}
The same hit object is attached to cpu.stepping when a breakpoint is what
stopped the CPU, so both can be parsed the same way. It is absent when the
break came from something else (the user pausing, a savestate load, an
exception), so test for its presence rather than for a kind.
Fields common to every kind:
| Field | Meaning |
|---|---|
kind |
"exec", "memory" or "register" |
pc |
The instruction responsible |
address |
Exec: the instruction. Memory: the address actually accessed |
hits |
Total times this breakpoint has tripped, matching *.list |
logged / paused |
Which actions it had - paused false means the CPU kept running |
condition |
The condition expression, or null |
symbol |
Symbol at address, or null - resolved here to save a round trip |
breakpoint |
{start, end} identifying which breakpoint fired. Absent for "register", whose identity is the register, not an address |
Extra fields for "memory":
| Field | Meaning |
|---|---|
size |
Bytes accessed |
access |
"read" or "write" |
source |
Who performed it - "interpret", "CPU", "HLE", or an allocation tag such as "ThreadFillStack" |
Extra fields for "register": register (GPR index) and registerName
(e.g. "a0").
Note address and breakpoint.start are not the same thing for a memory
breakpoint watching a range - the first is the byte touched, the second is the
range being watched. On cpu.stepping the legacy relatedAddress field keeps
reporting the range start; hit.address is the accurate one.
sequence counts hits produced, not delivered. A connection whose queue backs
up (easy to do with a log-only breakpoint in a hot loop - one can produce tens
of thousands of hits per second) drops events rather than growing without bound,
so a gap in sequence tells a client exactly how many it missed. Turning the
breakpoint category off via broadcast.config.set avoids the traffic
entirely.
Request/response event catalog
Full details (parameters, response shape) are documented as comments above
each handler in the corresponding Core/Debugger/WebSocket/*Subscriber.cpp
file - this is just an index.
| Category | Events | File |
|---|---|---|
| Game/version | game.reset, game.status, version |
GameSubscriber.cpp |
| CPU core | cpu.stepping, cpu.resume, cpu.status (reports ticks plus us, emulated microseconds, and clockHz - use us to line up with wall-clock timings, since games change the clock frequency and the ticks-per-second ratio isn't fixed), cpu.getAllRegs, cpu.getReg, cpu.setReg, cpu.evaluate |
CPUCoreSubscriber.cpp |
| Stepping | cpu.stepInto, cpu.stepOver, cpu.stepOut, cpu.runUntil, cpu.runUntilTime (run until a point in emulated time - us absolute or relativeUs from now - and break there; this is how to get a scripted repro reproducibly "N seconds into the game" instead of polling cpu.status in a loop), cpu.nextHLE |
SteppingSubscriber.cpp |
| Breakpoints | cpu.breakpoint.add/update/remove/list, memory.breakpoint.add/update/remove/list, cpu.regBreakpoint.add/update/remove/list (break when a register is written to, by any instruction anywhere - currently GPRs only; interpreter-only, no effect under a JIT backend) |
BreakpointSubscriber.cpp |
| Memory read/write | memory.read_u8/u16/u32, memory.read, memory.readString, memory.write_u8/u16/u32, memory.write. The numeric ones report the result as both value and uintValue - the latter is what cpu.getReg/cpu.getAllRegs call it, so a client can read either without caring which event answered |
MemorySubscriber.cpp |
| Memory search | memory.search - scan a range for a u8/u16/u32/float value or a bytes pattern (with an optional wildcard mask), for narrowing down where an unknown value lives (Cheat Engine style) |
MemorySubscriber.cpp |
| Memory info/annotations | memory.mapping, memory.info.config/set/list/search |
MemoryInfoSubscriber.cpp |
| Disassembly | memory.base, memory.disasm (add compact=true for plain-text lines instead of full per-field objects), memory.searchDisasm (add findAll=true for every match instead of just the first - e.g. "every caller of this address"), memory.assemble |
DisasmSubscriber.cpp |
| GE display list disassembly | gpu.displaylist.disasm - like memory.disasm but for GE command words (CLEARMODE, PRIM, etc.) instead of CPU instructions; also supports compact=true |
GPUDisasmSubscriber.cpp |
| HLE | hle.thread.list/wake/stop, hle.func.list/add/remove/removeRange/rename/scan, hle.module.list, hle.module.saveSymbols/loadSymbols (save/load one module's symbols to/from its standard PSP/SYSTEM/SYMBOLS/<moduleName>_<crc>.ppsym file, shared across any game that loads the same module - see SymbolMap::GetModuleSymbolsPath), hle.game.saveSymbols/loadSymbols (the same for symbols that aren't inside any module - heap, stack, scratchpad, hardware registers - which describe one game's memory layout and so go to a per-game PSP/SYSTEM/SYMBOLS/<gameID>_syms.ppsym instead; see SymbolMap::GetGameSymbolsPath), hle.backtrace |
HLESubscriber.cpp |
| Data symbols | hle.data.list/add/remove/rename - label discovered data (structs, tables, buffers) with a name/type, same idea as hle.func.* but for ST_DATA symbols |
HLESubscriber.cpp |
| Kernel objects | hle.object.list (every live kernel object of every type at once, with an optional type filter - uid/type/name/one-line summary only); hle.eventflag.list/info, hle.mutex.list/info, hle.semaphore.list/info, hle.msgpipe.list/info, hle.callback.list/info (per-type full detail, including waiting-thread lists) - all read-only, never mutate kernel state |
HLEKernelObjectSubscriber.cpp |
| GPU stats | gpu.stats.get, gpu.stats.feed |
GPUStatsSubscriber.cpp |
| GPU recording | gpu.record.dump |
GPURecordSubscriber.cpp |
| GPU buffers | gpu.buffer.screenshot, gpu.buffer.renderColor/renderDepth/renderStencil, gpu.buffer.texture, gpu.buffer.clut |
GPUBufferSubscriber.cpp |
| Input injection | input.buttons.send, input.buttons.press, input.analog.send |
InputSubscriber.cpp |
| Replay | replay.begin/abort/flush/execute/status, replay.time.get/set |
ReplaySubscriber.cpp |
| Client config | broadcast.config.get/set, client.config.get/set |
ClientConfigSubscriber.cpp |
| Log channels | log.channels.list, log.channel.set - query/change a log channel's level (string: notice/error/warning/info/debug/verbose) and/or enabled state; the log event itself (the passive message stream, unaffected by this) keeps its existing numeric level, see LogBroadcaster.cpp |
LogConfigSubscriber.cpp |
Enabling it
- UI: Settings > Tools > Developer Tools > "Allow remote debugger"
checkbox (
UI/DeveloperToolsScreen.cpp). The "Local Server Port" slider on the Networking screen sets the port (shared with Remote ISO sharing;0= auto-pick). - Config:
RemoteDebuggerOnStartup=trueinppsspp.ini(g_Config.bRemoteDebuggerOnStartup) starts it automatically on launch (UI/NativeApp.cpp). - Command line (both application and headless builds):
--debugger=PORT(0= pick a port automatically) - a shared auto-param inCore/CmdLine.cpp/.h(CmdLineMode::Both).ApplyToConfig()setsiRemoteISOPort/bRemoteDebuggerOnStartupfor that run without persisting them to the config file.- A non-zero
PORTis treated as mandatory (WebServerSetRequireExactPort()): if it can't be bound, the server does not silently fall back to some other free port the way the "Local Server Port" preference does, because a client was told to connect there. Headless exits non-zero; the application build logs an error and shows an OSD message but keeps running. Use--debugger=0and read the actual port from theListening on port Nlog line if you'd rather not care which port you get. - On the application build this is exactly like ticking "Allow remote debugger" - the game boots and runs normally, debugger listening alongside it.
- On the headless build (
headless/Headless.cpp) it additionally forcescoreParameter.startBreak = true, so the CPU halts before running anything - useful for setting breakpoints before launch.
- A non-zero
Discovery
For LAN auto-discovery (mainly useful for mobile), the server periodically
reports its (local ip, port) to report.ppsspp.org/match/update (see RegisterServer() in
Core/WebServer.cpp). Clients can query report.ppsspp.org/match/list to
get a list of candidate endpoints on the same network and try connecting to
each in turn.
The bundled web-based JS debugger
assets/debugger/ is a git submodule
(https://github.com/unknownbrackets/ppsspp-debugger.git, bundled branch -
see .gitmodules) containing a prebuilt React app. PPSSPP serves it directly
at /debugger/ (Core/WebServer.cpp's HandleFallback/ServeAssetFile),
so opening http://<ip>:<port>/debugger/ in a browser gets you a full GUI
debugger for free. The actual editable source lives in a different branch of
that same repo (the bundled branch only holds the built output that gets
checked in here).
From reading the minified bundle (assets/debugger/static/js/main.*.js),
it connects like this:
- Manual connect:
new WebSocket("ws://ip:port/debugger", "debugger.ppsspp.org"). - Auto connect:
fetch("//report.ppsspp.org/match/list")for a list of{ip, port}candidates (as registered byRegisterServer()above), then tries each with the same WebSocket call until one succeeds.
Talking to it yourself
scripts/websocket-test.py- old minimal Python one-shot script (needs thewebsocket-clientpip package).Tools/wsdbg/- a small Rust CLI/REPL client for this session's work (seeTools/wsdbg/README.md): connects, does theversionhandshake, and lets you fire off events by hand or from a one-shot command line, printing responses and broadcasts as they arrive. Built and smoke-tested against a live PPSSPP instance while writing this doc.