Debugger: report structured breakpoint hits, including log-only ones

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
This commit is contained in:
Henrik Rydgård
2026-08-18 10:59:08 +02:00
co-authored by Claude Opus 5
parent d8a1808b3d
commit bb5d7d5b65
10 changed files with 309 additions and 9 deletions
+43 -1
View File
@@ -16,6 +16,7 @@
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
#include <algorithm>
#include <atomic>
#include <mutex>
#include <condition_variable>
#include <vector>
@@ -117,6 +118,13 @@ static void UpdateConnected(int delta) {
// debugger was reading pc, the tick count, the UI state and the param SFO out from under the CPU
// thread on every lap of its loop. Now the CPU thread notices the transition once, formats the
// event, and drops it in here; the connection's own thread just drains and sends.
// A log-only breakpoint in a hot loop can produce events far faster than a connection drains them
// (the drain runs once per lap of ws->Process, so at best a few hundred times a second). Without a
// cap the queue grows without bound and the connection falls further and further behind. Dropping
// is the only sane answer; cpu.breakpoint.hit carries a sequence number so a client can tell
// exactly how many it missed rather than silently believing it saw everything.
static constexpr size_t MAX_PENDING_EVENTS = 4096;
struct DebuggerEventSink {
std::mutex lock;
std::vector<std::pair<const char *, std::string>> pending;
@@ -125,6 +133,8 @@ struct DebuggerEventSink {
void Push(const char *category, std::string json) {
std::lock_guard<std::mutex> guard(lock);
if (pending.size() >= MAX_PENDING_EVENTS)
return;
pending.emplace_back(category, std::move(json));
}
@@ -137,15 +147,47 @@ struct DebuggerEventSink {
static std::mutex g_sinkLock;
static std::vector<DebuggerEventSink *> g_sinks;
// Mirrors g_sinks.size() so the breakpoint path can check "is anyone listening" with one relaxed
// load, instead of taking g_sinkLock on every single breakpoint hit.
static std::atomic<int> g_sinkCount{ 0 };
static void RegisterSink(DebuggerEventSink *sink) {
std::lock_guard<std::mutex> guard(g_sinkLock);
g_sinks.push_back(sink);
g_sinkCount.store((int)g_sinks.size(), std::memory_order_relaxed);
}
static void UnregisterSink(DebuggerEventSink *sink) {
std::lock_guard<std::mutex> guard(g_sinkLock);
g_sinks.erase(std::remove(g_sinks.begin(), g_sinks.end(), sink), g_sinks.end());
g_sinkCount.store((int)g_sinks.size(), std::memory_order_relaxed);
}
bool WebSocketDebuggerHasClients() {
return g_sinkCount.load(std::memory_order_relaxed) != 0;
}
void WebSocketNotifyBreakpointHit(const BreakpointHit &hit) {
// Counts hits produced, not hits delivered, so a gap in what a client receives tells it how
// many were dropped by the cap in Push().
static uint64_t g_hitSequence = 0;
std::lock_guard<std::mutex> guard(g_sinkLock);
if (g_sinks.empty())
return;
// Formatted once here on the CPU thread, then shared - same rule as the other pushed events:
// a connection's own thread must never be the one reading emulator state.
JsonWriter j;
j.begin();
j.writeString("event", "cpu.breakpoint.hit");
j.writeFloat("sequence", (double)++g_hitSequence);
WriteBreakpointHit(j, hit);
j.end();
const std::string json = j.str();
for (DebuggerEventSink *sink : g_sinks)
sink->Push("breakpoint", json);
}
void WebSocketDebuggerTick() {
@@ -193,7 +235,7 @@ void HandleDebuggerRequest(const http::ServerRequest &request) {
// as a side effect of operator[] the first time each category actually broadcasts - which
// meant "game" and "stepping" were rejected as unsupported until one happened to fire, even
// though they're documented and valid. Keep in sync with the Broadcast calls further down.
for (const char *category : { "logger", "input", "game", "stepping" })
for (const char *category : { "logger", "input", "game", "stepping", "breakpoint" })
disallowed_config[category] = false;
LogBroadcaster logger;