Files
psxrecomp/tools/duckstation/psxrecomp_oracle.patch
T
Matthew Stanley 685687f36f Restructure duckstation as submodule + patch; salvage TCP docs + debug_client
Recovery + restructure after rm -rf destroyed duckstation/build/ earlier this
session. Adopts the nesrecomp pattern (see F:/Projects/nesrecomp): pristine
upstream as submodule, our changes as a patch file that gets auto-applied by a
setup script.

Submodule:
- duckstation/ now pins stenzek/duckstation@ffb33c281 (release-20260328 era)
- .gitmodules records the pin; .gitignore stops ignoring the submodule path but
  still ignores build/ and dep/prebuilt/ (the submodule's own .gitignore also
  handles these)

Patch + setup:
- tools/duckstation/psxrecomp_oracle.patch (1187 lines, 7 files) captures our
  TCP debug-server on port 4371 plus the unfinished pc_break family of commands
  (with the VirtualMemoryAddress compile fix)
- tools/duckstation/setup.sh clones submodule, fetches + verifies + extracts
  prebuilt Windows deps, rewrites stale absolute paths baked into the prebuilt
  CMake metadata, applies the oracle patch. Idempotent.
- tools/duckstation/build.sh configures CMake (VS 17 2022) + runs MSBuild, then
  provisions bin/ with portable marker, BIOS copy, and settings.ini post-fixes
- tools/duckstation/README.md documents the layout, the regeneration flow, and
  the known headless-boot configuration issue

TCP documentation (salvaged from v3):
- TCP_COMMANDS.md: full command table with native (4370) / duckstation (4371)
  / both columns, reflecting actual v4 implementation including wtrace_* and
  pc_break/pc_hit_last families
- tools/debug_client.py: unified CLI from v3 (ports 4370/4371 match v4)
- DEBUG.md: merged v3's END-TO-END COMPLETION RULE, Stale Hypothesis Rule,
  TOOLING RULE, and TOOL LIFECYCLE RULE sections into v4's existing loop

Phase 4 diagnostic artefacts from this session:
- generated/relocation_proofs/vblank_registrar/ — corrected VBlank-stall
  diagnosis. Prior handoff claimed the shell VBlank handler was never installed;
  live TCP inspection of both runtime and DuckStation oracle proved handler IS
  installed at 0x800DFEE0. Real gap is one level up in the kernel IRQ chain at
  *(0xA000E014).
- tools/duck_query*.py, duck_search.py, disasm_ram.py, scan_*.py — focused
  probes used to establish the above; reusable against either server

CLAUDE.md §2 and new §15 document the submodule-based oracle setup and the
"never edit submodule, regenerate patch instead" rule.

Known issues (tracked in tools/duckstation/README.md):
- Fresh settings.ini doesn't complete BIOS boot under -nogui; one-time launch
  with GUI is required to warm up the settings before headless mode works

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 01:34:27 -07:00

1188 lines
42 KiB
Diff

diff --git a/src/common/log_channels.h b/src/common/log_channels.h
index a1576e7..6dfdb45 100644
--- a/src/common/log_channels.h
+++ b/src/common/log_channels.h
@@ -47,6 +47,7 @@
X(PerfMon) \
X(PlatformMisc) \
X(PostProcessing) \
+ X(PSXRecompDebug) \
X(PIO) \
X(ReShadeFXShader) \
X(Recompiler) \
diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt
index e2d0294..b290bd3 100644
--- a/src/core/CMakeLists.txt
+++ b/src/core/CMakeLists.txt
@@ -116,6 +116,8 @@ add_library(core
playstation_mouse.h
psf_loader.cpp
psf_loader.h
+ psxrecomp_debug_server.cpp
+ psxrecomp_debug_server.h
save_state_version.h
settings.cpp
settings.h
diff --git a/src/core/gpu.h b/src/core/gpu.h
index 975f8d5..05fa360 100644
--- a/src/core/gpu.h
+++ b/src/core/gpu.h
@@ -212,6 +212,19 @@ public:
ALWAYS_INLINE u16 GetCRTCActiveStartLine() const { return m_crtc_state.vertical_display_start; }
ALWAYS_INLINE u16 GetCRTCActiveEndLine() const { return m_crtc_state.vertical_display_end; }
+ // PSXRecomp debug accessors
+ ALWAYS_INLINE u16 GetDisplayVRAMLeft() const { return m_crtc_state.display_vram_left; }
+ ALWAYS_INLINE u16 GetDisplayVRAMTop() const { return m_crtc_state.display_vram_top; }
+ ALWAYS_INLINE u16 GetDisplayVRAMWidth() const { return m_crtc_state.display_vram_width; }
+ ALWAYS_INLINE u16 GetDisplayVRAMHeight() const { return m_crtc_state.display_vram_height; }
+ ALWAYS_INLINE s32 GetDrawingOffsetX() const { return m_drawing_offset.x; }
+ ALWAYS_INLINE s32 GetDrawingOffsetY() const { return m_drawing_offset.y; }
+ ALWAYS_INLINE u32 GetGPUSTATBits() const { return m_GPUSTAT.bits; }
+ ALWAYS_INLINE u32 GetDrawingAreaLeft() const { return m_drawing_area.left; }
+ ALWAYS_INLINE u32 GetDrawingAreaTop() const { return m_drawing_area.top; }
+ ALWAYS_INLINE u32 GetDrawingAreaRight() const { return m_drawing_area.right; }
+ ALWAYS_INLINE u32 GetDrawingAreaBottom() const { return m_drawing_area.bottom; }
+
// Returns the video clock frequency.
TickCount GetCRTCFrequency() const;
diff --git a/src/core/psxrecomp_debug_server.cpp b/src/core/psxrecomp_debug_server.cpp
new file mode 100644
index 0000000..1fcc225
--- /dev/null
+++ b/src/core/psxrecomp_debug_server.cpp
@@ -0,0 +1,1053 @@
+// PSXRecomp TCP debug server for DuckStation
+// JSON-over-newline protocol on localhost:4371 (same as runner debug server).
+// Includes 36000-frame ring buffer with CPU, GPU, pad, and RAM snapshot state.
+
+#include "psxrecomp_debug_server.h"
+#include "bus.h"
+#include "controller.h"
+#include "cpu_core.h"
+#include "cpu_types.h"
+#include "digital_controller.h"
+#include "dma.h"
+#include "gpu.h"
+#include "interrupt_controller.h"
+#include "pad.h"
+#include "system.h"
+
+#include "common/assert.h"
+#include "common/log.h"
+#include "common/small_string.h"
+
+#include "util/image.h"
+#include "util/sockets.h"
+
+#include <algorithm>
+#include <cstdio>
+#include <cstring>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include "fmt/format.h"
+
+LOG_CHANNEL(PSXRecompDebug);
+
+// ---------------------------------------------------------------------------
+// Minimal JSON helpers
+// ---------------------------------------------------------------------------
+
+static bool json_get_str(const char* json, const char* key, char* out, size_t out_sz)
+{
+ char pattern[128];
+ std::snprintf(pattern, sizeof(pattern), "\"%s\"", key);
+ const char* p = std::strstr(json, pattern);
+ if (!p) return false;
+ p += std::strlen(pattern);
+ while (*p == ' ' || *p == ':') p++;
+ if (*p != '"') return false;
+ p++;
+ size_t i = 0;
+ while (*p && *p != '"' && i + 1 < out_sz) out[i++] = *p++;
+ out[i] = 0;
+ return true;
+}
+
+static bool json_get_u32(const char* json, const char* key, u32* out)
+{
+ char pattern[128];
+ std::snprintf(pattern, sizeof(pattern), "\"%s\"", key);
+ const char* p = std::strstr(json, pattern);
+ if (!p) return false;
+ p += std::strlen(pattern);
+ while (*p == ' ' || *p == ':') p++;
+ if (*p == '"') p++;
+ *out = static_cast<u32>(std::strtoul(p, nullptr, 0));
+ return true;
+}
+
+static s32 json_get_int(const char* json, const char* key, s32 def)
+{
+ u32 v;
+ if (!json_get_u32(json, key, &v)) return def;
+ return static_cast<s32>(v);
+}
+
+// ---------------------------------------------------------------------------
+// Frame record (ring buffer element)
+// ---------------------------------------------------------------------------
+
+static constexpr u32 FRAME_HISTORY_CAP = 36000; // ~10 min @ 60fps
+
+// Snapshot of a RAM region stored per frame
+static constexpr int RAM_SNAPSHOT_REGIONS = 4;
+static constexpr int RAM_SNAPSHOT_SIZE = 128; // bytes per region
+
+struct FrameRecord
+{
+ u32 frame_number;
+
+ // CPU
+ u32 gpr[32];
+ u32 pc, hi, lo;
+
+ // GPU
+ u16 display_vram_left, display_vram_top;
+ u16 display_vram_width, display_vram_height;
+ u8 display_disable;
+ u8 interlaced;
+ u8 pal_mode;
+ u8 _gpu_pad;
+
+ // Pad
+ u16 pad1_buttons; // inverted PS1 style: 0=pressed
+
+ u16 _reserved;
+
+ // RAM snapshots — configurable regions captured every frame
+ u32 snapshot_addr[RAM_SNAPSHOT_REGIONS];
+ u8 snapshot_data[RAM_SNAPSHOT_REGIONS][RAM_SNAPSHOT_SIZE];
+};
+
+// ---------------------------------------------------------------------------
+// State
+// ---------------------------------------------------------------------------
+
+namespace PSXRecompDebug {
+
+namespace {
+
+class ClientSocket final : public BufferedStreamSocket
+{
+public:
+ ClientSocket(SocketMultiplexer& multiplexer, SocketDescriptor descriptor);
+ ~ClientSocket() override;
+
+ void SendJSON(const std::string& json);
+
+protected:
+ void OnConnected() override;
+ void OnDisconnected(const Error& error) override;
+ void OnRead() override;
+
+private:
+ void ProcessLine(std::string_view line);
+};
+
+} // namespace
+
+static std::shared_ptr<ListenSocket> s_listen_socket;
+static std::vector<std::shared_ptr<ClientSocket>> s_clients;
+
+// Frame counter & control
+static u32 s_frame_counter = 0;
+static int s_step_frames = 0;
+static bool s_paused = false;
+static int s_request_id = 0;
+static u32 s_run_to_frame = 0; // target frame for run_to_frame (0=disabled)
+
+// Ring buffer
+static FrameRecord s_history[FRAME_HISTORY_CAP];
+static u64 s_history_count = 0;
+
+// Watchpoints
+static constexpr int MAX_WATCHPOINTS = 8;
+static struct { u32 addr; u8 prev_val; bool active; } s_watchpoints[MAX_WATCHPOINTS] = {};
+
+// PC breakpoints — execute breakpoint with state capture on hit.
+// When the callback fires we capture all GPRs + PC + $ra into a pending-hit
+// record, pause the system, and broadcast an event. The pending record stays
+// until queried via "pc_hit_last" (or cleared via "pc_hit_clear").
+static constexpr int MAX_PC_BREAKS = 16;
+static struct { u32 addr; bool active; } s_pc_breaks[MAX_PC_BREAKS] = {};
+static struct {
+ bool valid;
+ u32 addr_hit;
+ u32 pc;
+ u32 ra;
+ u32 hi, lo;
+ u32 gpr[32];
+ u32 cop0_sr, cop0_cause, cop0_epc;
+} s_pc_hit_last = {};
+
+// Input override (-1 = no override, otherwise PS1 inverted bitmask)
+static s32 s_input_override = -1;
+
+// RAM snapshot regions (user-configurable via "set_snapshot" command)
+static u32 s_snapshot_addrs[RAM_SNAPSHOT_REGIONS] = {
+ 0x00097C40, // DMA context (default for memcard investigation)
+ 0x00097840, // staging buffer
+ 0, 0
+};
+
+// Helper: broadcast to all clients
+static void BroadcastJSON(const std::string& msg)
+{
+ for (auto& c : s_clients)
+ c->SendJSON(msg);
+}
+
+} // namespace PSXRecompDebug
+
+// ---------------------------------------------------------------------------
+// Client socket
+// ---------------------------------------------------------------------------
+
+PSXRecompDebug::ClientSocket::ClientSocket(SocketMultiplexer& multiplexer, SocketDescriptor descriptor)
+ : BufferedStreamSocket(multiplexer, descriptor, 8192, 65536)
+{
+}
+
+PSXRecompDebug::ClientSocket::~ClientSocket() = default;
+
+void PSXRecompDebug::ClientSocket::OnConnected()
+{
+ INFO_LOG("PSXRecomp debug client connected from {}", GetRemoteAddress().ToString());
+ s_clients.push_back(std::static_pointer_cast<ClientSocket>(shared_from_this()));
+}
+
+void PSXRecompDebug::ClientSocket::OnDisconnected(const Error& error)
+{
+ INFO_LOG("PSXRecomp debug client disconnected: {}", error.GetDescription());
+ auto it = std::find_if(s_clients.begin(), s_clients.end(),
+ [this](const auto& c) { return c.get() == this; });
+ if (it != s_clients.end()) s_clients.erase(it);
+}
+
+void PSXRecompDebug::ClientSocket::OnRead()
+{
+ const std::span<const u8> buffer = AcquireReadBuffer();
+ if (buffer.empty()) return;
+
+ size_t consumed = 0;
+ for (size_t i = 0; i < buffer.size(); i++)
+ {
+ if (buffer[i] == '\n')
+ {
+ std::string_view line(reinterpret_cast<const char*>(buffer.data() + consumed), i - consumed);
+ if (!line.empty() && line.back() == '\r') line.remove_suffix(1);
+ if (!line.empty()) ProcessLine(line);
+ consumed = i + 1;
+ }
+ }
+ ReleaseReadBuffer(consumed);
+}
+
+void PSXRecompDebug::ClientSocket::SendJSON(const std::string& json)
+{
+ std::string msg = json + "\n";
+ Write(msg.data(), msg.size());
+}
+
+// ---------------------------------------------------------------------------
+// Register names
+// ---------------------------------------------------------------------------
+
+static const char* const s_reg_names[32] = {
+ "zero","at","v0","v1","a0","a1","a2","a3",
+ "t0","t1","t2","t3","t4","t5","t6","t7",
+ "s0","s1","s2","s3","s4","s5","s6","s7",
+ "t8","t9","k0","k1","gp","sp","fp","ra",
+};
+
+// ---------------------------------------------------------------------------
+// Hex encoding helper
+// ---------------------------------------------------------------------------
+
+static std::string hex_encode(const u8* data, size_t len)
+{
+ std::string hex;
+ hex.reserve(len * 2);
+ for (size_t i = 0; i < len; i++)
+ fmt::format_to(std::back_inserter(hex), "{:02x}", data[i]);
+ return hex;
+}
+
+// ---------------------------------------------------------------------------
+// Command handlers
+// ---------------------------------------------------------------------------
+
+void PSXRecompDebug::ClientSocket::ProcessLine(std::string_view line)
+{
+ std::string line_str(line);
+ const char* json = line_str.c_str();
+
+ char cmd[64] = {};
+ json_get_str(json, "cmd", cmd, sizeof(cmd));
+ u32 id = 0;
+ json_get_u32(json, "id", &id);
+ s_request_id = static_cast<int>(id);
+
+ // ---- ping / frame ----
+ if (std::strcmp(cmd, "ping") == 0 || std::strcmp(cmd, "frame") == 0)
+ {
+ SendJSON(fmt::format(R"({{"id":{},"ok":true,"frame":{}}})", s_request_id, s_frame_counter));
+ }
+ // ---- registers ----
+ else if (std::strcmp(cmd, "get_registers") == 0 || std::strcmp(cmd, "regs") == 0)
+ {
+ std::string resp = fmt::format(
+ R"({{"id":{},"ok":true,"pc":"0x{:08X}","hi":"0x{:08X}","lo":"0x{:08X}","regs":{{)",
+ s_request_id, CPU::g_state.pc, CPU::g_state.regs.hi, CPU::g_state.regs.lo);
+ for (int i = 0; i < 32; i++)
+ {
+ if (i > 0) resp += ",";
+ resp += fmt::format(R"("{}":"0x{:08X}")", s_reg_names[i], CPU::g_state.regs.r[i]);
+ }
+ resp += "}}";
+ SendJSON(resp);
+ }
+ // ---- read_ram ----
+ else if (std::strcmp(cmd, "read_ram") == 0)
+ {
+ u32 addr = 0, len = 1;
+ json_get_u32(json, "addr", &addr);
+ json_get_u32(json, "len", &len);
+ if (len > 65536) len = 65536;
+
+ u32 phys = addr & 0x1FFFFFFFu;
+ std::string hex;
+ hex.reserve(len * 2);
+ for (u32 i = 0; i < len; i++)
+ {
+ u8 byte = (phys + i < Bus::g_ram_size) ? Bus::g_ram[phys + i] : 0;
+ fmt::format_to(std::back_inserter(hex), "{:02x}", byte);
+ }
+ SendJSON(fmt::format(
+ R"({{"id":{},"ok":true,"addr":"0x{:08X}","len":{},"hex":"{}"}})",
+ s_request_id, addr, len, hex));
+ }
+ // ---- write_ram ----
+ else if (std::strcmp(cmd, "write_ram") == 0)
+ {
+ u32 addr = 0;
+ json_get_u32(json, "addr", &addr);
+ char hex_data[131072] = {};
+ json_get_str(json, "hex", hex_data, sizeof(hex_data));
+
+ u32 phys = addr & 0x1FFFFFFFu;
+ size_t hex_len = std::strlen(hex_data);
+ u32 byte_count = 0;
+ for (size_t i = 0; i + 1 < hex_len; i += 2)
+ {
+ char bs[3] = {hex_data[i], hex_data[i + 1], 0};
+ u8 byte = static_cast<u8>(std::strtoul(bs, nullptr, 16));
+ if (phys + byte_count < Bus::g_ram_size)
+ Bus::g_ram[phys + byte_count] = byte;
+ byte_count++;
+ }
+ SendJSON(fmt::format(R"({{"id":{},"ok":true,"bytes_written":{}}})", s_request_id, byte_count));
+ }
+ // ---- gpu_state ----
+ else if (std::strcmp(cmd, "gpu_state") == 0)
+ {
+ // Map display width to h_resolution index (0=256,1=320,2=512,3=640)
+ u16 w = g_gpu.GetDisplayVRAMWidth();
+ u8 h_res = (w >= 640) ? 3 : (w >= 512) ? 2 : (w >= 320) ? 1 : 0;
+ // Map display height to v_resolution index (0=240,1=480i)
+ u16 h = g_gpu.GetDisplayVRAMHeight();
+ u8 v_res = (h > 240) ? 1 : 0;
+ // display_enable: 0=on, 1=off (same as native convention)
+ u8 disp_en = g_gpu.IsDisplayDisabled() ? 1 : 0;
+
+ SendJSON(fmt::format(
+ R"({{"id":{},"ok":true,"display_area_x":{},"display_area_y":{},"draw_offset_x":{},"draw_offset_y":{},"h_resolution":{},"v_resolution":{},"display_enable":{},"gpustat":"0x{:08X}","disp_w":{},"disp_h":{},"clip":[{},{},{},{}]}})",
+ s_request_id,
+ g_gpu.GetDisplayVRAMLeft(), g_gpu.GetDisplayVRAMTop(),
+ g_gpu.GetDrawingOffsetX(), g_gpu.GetDrawingOffsetY(),
+ h_res, v_res, disp_en,
+ g_gpu.GetGPUSTATBits(),
+ g_gpu.GetDisplayVRAMWidth(), g_gpu.GetDisplayVRAMHeight(),
+ g_gpu.GetDrawingAreaLeft(), g_gpu.GetDrawingAreaTop(),
+ g_gpu.GetDrawingAreaRight(), g_gpu.GetDrawingAreaBottom()));
+ }
+ // ---- pause ----
+ else if (std::strcmp(cmd, "pause") == 0)
+ {
+ s_paused = true;
+ System::PauseSystem(true);
+ SendJSON(fmt::format(R"({{"id":{},"ok":true,"paused":true,"frame":{}}})", s_request_id, s_frame_counter));
+ }
+ // ---- continue ----
+ else if (std::strcmp(cmd, "continue") == 0 || std::strcmp(cmd, "c") == 0)
+ {
+ s_paused = false;
+ System::PauseSystem(false);
+ SendJSON(fmt::format(R"({{"id":{},"ok":true,"paused":false,"frame":{}}})", s_request_id, s_frame_counter));
+ }
+ // ---- step ----
+ else if (std::strcmp(cmd, "step") == 0)
+ {
+ u32 n = 1;
+ json_get_u32(json, "count", &n);
+ if (n < 1) n = 1;
+ s_step_frames = static_cast<int>(n);
+ s_paused = false;
+ System::PauseSystem(false);
+ SendJSON(fmt::format(R"({{"id":{},"ok":true,"stepping":{},"frame":{}}})", s_request_id, n, s_frame_counter));
+ }
+ // ---- watch ----
+ else if (std::strcmp(cmd, "watch") == 0)
+ {
+ u32 addr = 0;
+ json_get_u32(json, "addr", &addr);
+ bool added = false;
+ for (int i = 0; i < MAX_WATCHPOINTS; i++)
+ {
+ if (!s_watchpoints[i].active)
+ {
+ s_watchpoints[i].addr = addr & 0x1FFFFFFFu;
+ s_watchpoints[i].prev_val = (s_watchpoints[i].addr < Bus::g_ram_size) ?
+ Bus::g_ram[s_watchpoints[i].addr] : 0;
+ s_watchpoints[i].active = true;
+ added = true;
+ break;
+ }
+ }
+ SendJSON(fmt::format(R"({{"id":{},"ok":{},"addr":"0x{:08X}"}})",
+ s_request_id, added ? "true" : "false", addr));
+ }
+ // ---- unwatch ----
+ else if (std::strcmp(cmd, "unwatch") == 0)
+ {
+ u32 addr = 0;
+ json_get_u32(json, "addr", &addr);
+ u32 phys = addr & 0x1FFFFFFFu;
+ for (int i = 0; i < MAX_WATCHPOINTS; i++)
+ if (s_watchpoints[i].active && s_watchpoints[i].addr == phys)
+ s_watchpoints[i].active = false;
+ SendJSON(fmt::format(R"({{"id":{},"ok":true,"addr":"0x{:08X}"}})", s_request_id, addr));
+ }
+ // ---- pc_break: set execute breakpoint at given PC (full state capture on hit) ----
+ else if (std::strcmp(cmd, "pc_break") == 0)
+ {
+ u32 addr = 0;
+ json_get_u32(json, "addr", &addr);
+ bool added = false;
+ int slot = -1;
+ for (int i = 0; i < MAX_PC_BREAKS; i++)
+ {
+ if (!s_pc_breaks[i].active) { slot = i; break; }
+ }
+ if (slot >= 0)
+ {
+ bool ok = CPU::AddBreakpointWithCallback(
+ CPU::BreakpointType::Execute, addr,
+ [](CPU::BreakpointType, VirtualMemoryAddress pc_hit, VirtualMemoryAddress) -> bool {
+ // Capture full state at hit.
+ s_pc_hit_last.valid = true;
+ s_pc_hit_last.addr_hit = pc_hit;
+ s_pc_hit_last.pc = CPU::g_state.pc;
+ s_pc_hit_last.ra = CPU::g_state.regs.r[31];
+ s_pc_hit_last.hi = CPU::g_state.regs.hi;
+ s_pc_hit_last.lo = CPU::g_state.regs.lo;
+ for (int i = 0; i < 32; i++) s_pc_hit_last.gpr[i] = CPU::g_state.regs.r[i];
+ s_pc_hit_last.cop0_sr = CPU::g_state.cop0_regs.sr.bits;
+ s_pc_hit_last.cop0_cause = CPU::g_state.cop0_regs.cause.bits;
+ s_pc_hit_last.cop0_epc = CPU::g_state.cop0_regs.EPC;
+ // Pause the system so the client can inspect before anything else runs.
+ s_paused = true;
+ System::PauseSystem(true);
+ // Broadcast event (best-effort; client may reconnect and query pc_hit_last).
+ BroadcastJSON(fmt::format(
+ R"({{"event":"pc_hit","addr":"0x{:08X}","pc":"0x{:08X}","ra":"0x{:08X}","frame":{}}})",
+ pc_hit, CPU::g_state.pc, CPU::g_state.regs.r[31], s_frame_counter));
+ return true; // keep breakpoint
+ });
+ if (ok)
+ {
+ s_pc_breaks[slot].addr = addr;
+ s_pc_breaks[slot].active = true;
+ added = true;
+ }
+ }
+ SendJSON(fmt::format(R"({{"id":{},"ok":{},"addr":"0x{:08X}","slot":{}}})",
+ s_request_id, added ? "true" : "false", addr, slot));
+ }
+ // ---- pc_unbreak: remove execute breakpoint at given PC ----
+ else if (std::strcmp(cmd, "pc_unbreak") == 0)
+ {
+ u32 addr = 0;
+ json_get_u32(json, "addr", &addr);
+ bool removed = CPU::RemoveBreakpoint(CPU::BreakpointType::Execute, addr);
+ for (int i = 0; i < MAX_PC_BREAKS; i++)
+ if (s_pc_breaks[i].active && s_pc_breaks[i].addr == addr)
+ s_pc_breaks[i].active = false;
+ SendJSON(fmt::format(R"({{"id":{},"ok":{},"addr":"0x{:08X}"}})",
+ s_request_id, removed ? "true" : "false", addr));
+ }
+ // ---- pc_break_list ----
+ else if (std::strcmp(cmd, "pc_break_list") == 0)
+ {
+ std::string resp = fmt::format(R"({{"id":{},"ok":true,"breaks":[)", s_request_id);
+ bool first = true;
+ for (int i = 0; i < MAX_PC_BREAKS; i++)
+ {
+ if (!s_pc_breaks[i].active) continue;
+ if (!first) resp += ",";
+ resp += fmt::format(R"("0x{:08X}")", s_pc_breaks[i].addr);
+ first = false;
+ }
+ resp += "]}";
+ SendJSON(resp);
+ }
+ // ---- pc_hit_last: return captured state from most recent PC break hit ----
+ else if (std::strcmp(cmd, "pc_hit_last") == 0)
+ {
+ if (!s_pc_hit_last.valid)
+ {
+ SendJSON(fmt::format(R"({{"id":{},"ok":true,"valid":false}})", s_request_id));
+ }
+ else
+ {
+ std::string resp = fmt::format(
+ R"({{"id":{},"ok":true,"valid":true,"addr_hit":"0x{:08X}","pc":"0x{:08X}","ra":"0x{:08X}",)"
+ R"("hi":"0x{:08X}","lo":"0x{:08X}","cop0_sr":"0x{:08X}","cop0_cause":"0x{:08X}","cop0_epc":"0x{:08X}","regs":{{)",
+ s_request_id, s_pc_hit_last.addr_hit, s_pc_hit_last.pc, s_pc_hit_last.ra,
+ s_pc_hit_last.hi, s_pc_hit_last.lo,
+ s_pc_hit_last.cop0_sr, s_pc_hit_last.cop0_cause, s_pc_hit_last.cop0_epc);
+ for (int i = 0; i < 32; i++)
+ {
+ if (i > 0) resp += ",";
+ resp += fmt::format(R"("{}":"0x{:08X}")", s_reg_names[i], s_pc_hit_last.gpr[i]);
+ }
+ resp += "}}";
+ SendJSON(resp);
+ }
+ }
+ // ---- pc_hit_clear ----
+ else if (std::strcmp(cmd, "pc_hit_clear") == 0)
+ {
+ s_pc_hit_last.valid = false;
+ SendJSON(fmt::format(R"({{"id":{},"ok":true}})", s_request_id));
+ }
+ // ---- history (ring buffer stats) ----
+ else if (std::strcmp(cmd, "history") == 0)
+ {
+ u64 oldest = (s_history_count > FRAME_HISTORY_CAP) ? s_history_count - FRAME_HISTORY_CAP : 0;
+ u64 newest = s_history_count > 0 ? s_history_count - 1 : 0;
+ SendJSON(fmt::format(
+ R"({{"id":{},"ok":true,"count":{},"oldest":{},"newest":{}}})",
+ s_request_id, s_history_count, oldest, newest));
+ }
+ // ---- get_frame (single frame from ring buffer) ----
+ else if (std::strcmp(cmd, "get_frame") == 0)
+ {
+ s32 f = json_get_int(json, "frame", -1);
+ if (f < 0) { SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"missing frame"}})", s_request_id)); return; }
+
+ u64 oldest = (s_history_count > FRAME_HISTORY_CAP) ? s_history_count - FRAME_HISTORY_CAP : 0;
+ if (static_cast<u64>(f) < oldest || static_cast<u64>(f) >= s_history_count)
+ {
+ SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"frame not in buffer"}})", s_request_id));
+ return;
+ }
+
+ u32 idx = static_cast<u32>(f) % FRAME_HISTORY_CAP;
+ const FrameRecord& r = s_history[idx];
+
+ // Encode snapshot regions as hex
+ std::string snaps = "[";
+ for (int si = 0; si < RAM_SNAPSHOT_REGIONS; si++)
+ {
+ if (si > 0) snaps += ",";
+ if (r.snapshot_addr[si] == 0)
+ {
+ snaps += "null";
+ }
+ else
+ {
+ snaps += fmt::format(R"({{"addr":"0x{:08X}","hex":"{}"}})",
+ r.snapshot_addr[si] | 0x80000000u,
+ hex_encode(r.snapshot_data[si], RAM_SNAPSHOT_SIZE));
+ }
+ }
+ snaps += "]";
+
+ std::string resp = fmt::format(
+ R"({{"id":{},"ok":true,"frame":{},"pc":"0x{:08X}","hi":"0x{:08X}","lo":"0x{:08X}",)"
+ R"("sp":"0x{:08X}","ra":"0x{:08X}","v0":"0x{:08X}","a0":"0x{:08X}",)"
+ R"("gpu":{{"da_x":{},"da_y":{},"do_x":0,"do_y":0}},)"
+ R"("pad":"0x{:04X}",)"
+ R"("overlay_base":"0x00000000",)"
+ R"("dispatch_misses":0,)"
+ R"("game_data":"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",)"
+ R"("last_func":"",)"
+ R"("snapshots":{}}})",
+ s_request_id, r.frame_number, r.pc, r.hi, r.lo,
+ r.gpr[29], r.gpr[31], r.gpr[2], r.gpr[4],
+ r.display_vram_left, r.display_vram_top,
+ r.pad1_buttons,
+ snaps);
+ SendJSON(resp);
+ }
+ // ---- frame_range (batch query, max 200) ----
+ else if (std::strcmp(cmd, "frame_range") == 0)
+ {
+ s32 start = json_get_int(json, "start", -1);
+ s32 end = json_get_int(json, "end", -1);
+ if (start < 0 || end < 0) { SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"missing start/end"}})", s_request_id)); return; }
+ if (end - start + 1 > 200) { SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"max 200 frames"}})", s_request_id)); return; }
+
+ u64 oldest = (s_history_count > FRAME_HISTORY_CAP) ? s_history_count - FRAME_HISTORY_CAP : 0;
+
+ std::string resp = fmt::format(R"({{"id":{},"ok":true,"frames":[)", s_request_id);
+ bool first = true;
+ for (s32 f = start; f <= end; f++)
+ {
+ if (!first) resp += ",";
+ first = false;
+
+ if (static_cast<u64>(f) < oldest || static_cast<u64>(f) >= s_history_count)
+ {
+ resp += fmt::format(R"({{"frame":{},"available":false}})", f);
+ continue;
+ }
+
+ u32 idx = static_cast<u32>(f) % FRAME_HISTORY_CAP;
+ const FrameRecord& r = s_history[idx];
+
+ // Include first snapshot region hex for compact view
+ std::string snap0_hex = (r.snapshot_addr[0] != 0) ?
+ hex_encode(r.snapshot_data[0], RAM_SNAPSHOT_SIZE) : "";
+
+ resp += fmt::format(
+ R"({{"frame":{},"pc":"0x{:08X}","pad":"0x{:04X}","game_data":"0000000000000000000000000000000000000000000000000000000000000000","snap0":"{}"}})",
+ r.frame_number, r.pc, r.pad1_buttons, snap0_hex);
+ }
+ resp += "]}";
+ SendJSON(resp);
+ }
+ // ---- frame_timeseries (compact batch, max 200) ----
+ else if (std::strcmp(cmd, "frame_timeseries") == 0)
+ {
+ s32 start = json_get_int(json, "start", -1);
+ s32 end = json_get_int(json, "end", -1);
+ if (start < 0 || end < 0) { SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"missing start/end"}})", s_request_id)); return; }
+ if (end - start + 1 > 200) { SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"max 200 frames"}})", s_request_id)); return; }
+
+ u64 oldest = (s_history_count > FRAME_HISTORY_CAP) ? s_history_count - FRAME_HISTORY_CAP : 0;
+
+ std::string resp = fmt::format(R"({{"id":{},"ok":true,"ts":[)", s_request_id);
+ bool first = true;
+ for (s32 f = start; f <= end; f++)
+ {
+ if (!first) resp += ",";
+ first = false;
+
+ if (static_cast<u64>(f) < oldest || static_cast<u64>(f) >= s_history_count)
+ {
+ resp += "null";
+ continue;
+ }
+
+ u32 idx = static_cast<u32>(f) % FRAME_HISTORY_CAP;
+ const FrameRecord& r = s_history[idx];
+
+ resp += fmt::format(
+ R"({{"f":{},"pc":"0x{:08X}","sp":"0x{:08X}","ra":"0x{:08X}","pad":{},"da_x":{},"da_y":{},"misses":0,"gd":"0000000000000000000000000000000000000000000000000000000000000000"}})",
+ r.frame_number, r.pc, r.gpr[29], r.gpr[31],
+ r.pad1_buttons, r.display_vram_left, r.display_vram_top);
+ }
+ resp += "]}";
+ SendJSON(resp);
+ }
+ // ---- set_snapshot (configure RAM snapshot regions) ----
+ else if (std::strcmp(cmd, "set_snapshot") == 0)
+ {
+ // set_snapshot: {"cmd":"set_snapshot","slot":0,"addr":"0x80097C40"}
+ u32 slot = 0, addr = 0;
+ json_get_u32(json, "slot", &slot);
+ json_get_u32(json, "addr", &addr);
+ if (slot < RAM_SNAPSHOT_REGIONS)
+ {
+ s_snapshot_addrs[slot] = addr & 0x1FFFFFFFu;
+ SendJSON(fmt::format(R"({{"id":{},"ok":true,"slot":{},"addr":"0x{:08X}"}})",
+ s_request_id, slot, addr));
+ }
+ else
+ {
+ SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"slot must be 0-{}"}})",
+ s_request_id, RAM_SNAPSHOT_REGIONS - 1));
+ }
+ }
+ // ---- get_snapshots (show current snapshot config) ----
+ else if (std::strcmp(cmd, "get_snapshots") == 0)
+ {
+ std::string resp = fmt::format(R"({{"id":{},"ok":true,"regions":[)", s_request_id);
+ for (int i = 0; i < RAM_SNAPSHOT_REGIONS; i++)
+ {
+ if (i > 0) resp += ",";
+ if (s_snapshot_addrs[i] == 0)
+ resp += "null";
+ else
+ resp += fmt::format(R"({{"slot":{},"addr":"0x{:08X}","size":{}}})",
+ i, s_snapshot_addrs[i] | 0x80000000u, RAM_SNAPSHOT_SIZE);
+ }
+ resp += "]}";
+ SendJSON(resp);
+ }
+ // ---- read_scratch (scratchpad memory) ----
+ else if (std::strcmp(cmd, "read_scratch") == 0)
+ {
+ u32 addr = 0, len = 1;
+ json_get_u32(json, "addr", &addr);
+ json_get_u32(json, "len", &len);
+ if (len > 1024) len = 1024;
+
+ std::string hex;
+ hex.reserve(len * 2);
+ for (u32 i = 0; i < len; i++)
+ {
+ u32 off = (addr + i) & 0x3FFu;
+ fmt::format_to(std::back_inserter(hex), "{:02x}", CPU::g_state.scratchpad[off]);
+ }
+ SendJSON(fmt::format(
+ R"({{"id":{},"ok":true,"addr":"0x{:08X}","len":{},"hex":"{}"}})",
+ s_request_id, addr, len, hex));
+ }
+ // ---- overlay_state (not applicable for DS, returns zeros) ----
+ else if (std::strcmp(cmd, "overlay_state") == 0)
+ {
+ SendJSON(fmt::format(
+ R"({{"id":{},"ok":true,"overlay_base":"0x00000000","overlay_pc":"0x00000000"}})",
+ s_request_id));
+ }
+ // ---- run_to_frame ----
+ else if (std::strcmp(cmd, "run_to_frame") == 0)
+ {
+ u32 target = 0;
+ json_get_u32(json, "frame", &target);
+ if (target <= s_frame_counter)
+ {
+ SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"target frame already passed"}})", s_request_id));
+ }
+ else
+ {
+ s_run_to_frame = target;
+ s_paused = false;
+ System::PauseSystem(false);
+ SendJSON(fmt::format(R"({{"id":{},"ok":true,"running_to":{}}})", s_request_id, target));
+ }
+ }
+ // ---- dump_ram (chunked large reads, up to 64KB) ----
+ else if (std::strcmp(cmd, "dump_ram") == 0)
+ {
+ u32 addr = 0, len = 256;
+ json_get_u32(json, "addr", &addr);
+ json_get_u32(json, "len", &len);
+ if (len > 65536) len = 65536;
+
+ u32 phys = addr & 0x1FFFFFFFu;
+ u32 offset = 0;
+ while (offset < len)
+ {
+ u32 chunk = std::min(len - offset, 4096u);
+ std::string hex;
+ hex.reserve(chunk * 2);
+ for (u32 i = 0; i < chunk; i++)
+ {
+ u32 p = phys + offset + i;
+ u8 byte = (p < Bus::g_ram_size) ? Bus::g_ram[p] : 0;
+ fmt::format_to(std::back_inserter(hex), "{:02x}", byte);
+ }
+ SendJSON(fmt::format(
+ R"({{"id":{},"ok":true,"addr":"0x{:08X}","offset":{},"len":{},"hex":"{}"}})",
+ s_request_id, addr + offset, offset, chunk, hex));
+ offset += chunk;
+ }
+ }
+ // ---- read_vram (read VRAM pixels, max 128x128) ----
+ else if (std::strcmp(cmd, "read_vram") == 0)
+ {
+ u32 x = 0, y = 0, w = 1, h = 1;
+ json_get_u32(json, "x", &x);
+ json_get_u32(json, "y", &y);
+ json_get_u32(json, "w", &w);
+ json_get_u32(json, "h", &h);
+ if (w > 128) w = 128;
+ if (h > 128) h = 128;
+ if (w == 0) w = 1;
+ if (h == 0) h = 1;
+
+ std::string hex;
+ hex.reserve(w * h * 4);
+ for (u32 row = 0; row < h; row++)
+ {
+ u32 vy = (y + row) & (VRAM_HEIGHT - 1);
+ for (u32 col = 0; col < w; col++)
+ {
+ u32 vx = (x + col) & (VRAM_WIDTH - 1);
+ u16 pixel = g_vram[vy * VRAM_WIDTH + vx];
+ fmt::format_to(std::back_inserter(hex), "{:04x}", pixel);
+ }
+ }
+ SendJSON(fmt::format(
+ R"({{"id":{},"ok":true,"x":{},"y":{},"w":{},"h":{},"pixels":"{}"}})",
+ s_request_id, x, y, w, h, hex));
+ }
+ // ---- set_input ----
+ else if (std::strcmp(cmd, "set_input") == 0)
+ {
+ u32 buttons = 0xFFFF;
+ json_get_u32(json, "buttons", &buttons);
+ s_input_override = static_cast<s32>(buttons & 0xFFFF);
+ SendJSON(fmt::format(R"({{"id":{},"ok":true,"buttons":"0x{:04X}"}})", s_request_id, s_input_override));
+ }
+ // ---- clear_input ----
+ else if (std::strcmp(cmd, "clear_input") == 0)
+ {
+ s_input_override = -1;
+ SendJSON(fmt::format(R"({{"id":{},"ok":true}})", s_request_id));
+ }
+ // ---- dma_state ----
+ else if (std::strcmp(cmd, "dma_state") == 0)
+ {
+ static const char* const dma_names[7] = {"MDECin","MDECout","GPU","CDROM","SPU","PIO","OTC"};
+ u32 dpcr = DMA::ReadRegister(0x70);
+ u32 dicr = DMA::ReadRegister(0x74);
+ std::string channels = "[";
+ for (u32 ch = 0; ch < 7; ch++)
+ {
+ if (ch > 0) channels += ",";
+ u32 base = ch * 16;
+ u32 madr = DMA::ReadRegister(base + 0);
+ u32 bcr = DMA::ReadRegister(base + 4);
+ u32 chcr = DMA::ReadRegister(base + 8);
+ u32 enabled = (dpcr >> (ch * 4 + 3)) & 1;
+ channels += fmt::format(
+ R"({{"ch":{},"name":"{}","madr":"0x{:08X}","bcr":"0x{:08X}","chcr":"0x{:08X}","enabled":{}}})",
+ ch, dma_names[ch], madr, bcr, chcr, enabled);
+ }
+ channels += "]";
+ SendJSON(fmt::format(
+ R"({{"id":{},"ok":true,"dpcr":"0x{:08X}","dicr":"0x{:08X}","channels":{}}})",
+ s_request_id, dpcr, dicr, channels));
+ }
+ // ---- irq_state ----
+ else if (std::strcmp(cmd, "irq_state") == 0)
+ {
+ u32 i_stat = InterruptController::ReadRegister(0x00);
+ u32 i_mask = InterruptController::ReadRegister(0x04);
+ SendJSON(fmt::format(
+ R"({{"id":{},"ok":true,"i_stat":"0x{:08X}","i_mask":"0x{:08X}","chains":[]}})",
+ s_request_id, i_stat, i_mask));
+ }
+ // ---- event_state (BIOS concept — stub for DS) ----
+ else if (std::strcmp(cmd, "event_state") == 0)
+ {
+ SendJSON(fmt::format(
+ R"({{"id":{},"ok":true,"evcb_base":0,"evcb_count":0,"events":[]}})",
+ s_request_id));
+ }
+ // ---- sio_state (read SIO/Pad registers) ----
+ else if (std::strcmp(cmd, "sio_state") == 0)
+ {
+ // Read SIO registers via Pad namespace (offsets from SIO base 0x1F801040)
+ u32 rx_data = Pad::ReadRegister(0x00); // SIO_RX_DATA
+ u32 stat = Pad::ReadRegister(0x04); // SIO_STAT
+ u32 mode = Pad::ReadRegister(0x08); // SIO_MODE
+ u32 ctrl = Pad::ReadRegister(0x0A); // SIO_CTRL
+ u32 baud = Pad::ReadRegister(0x0E); // SIO_BAUD
+ SendJSON(fmt::format(
+ R"({{"id":{},"ok":true,"regs":{{"rx_data":"0x{:02X}","stat":"0x{:04X}","mode":"0x{:04X}","ctrl":"0x{:04X}","baud":"0x{:04X}"}},"note":"DS exposes raw registers only, not protocol state"}})",
+ s_request_id,
+ rx_data & 0xFF, stat & 0xFFFF, mode & 0xFFFF, ctrl & 0xFFFF, baud & 0xFFFF));
+ }
+ // ---- screenshot ----
+ else if (std::strcmp(cmd, "screenshot") == 0)
+ {
+ char path[256] = {};
+ json_get_str(json, "path", path, sizeof(path));
+ if (path[0] == '\0')
+ {
+ SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"missing path"}})", s_request_id));
+ }
+ else
+ {
+ u16 dx = g_gpu.GetDisplayVRAMLeft();
+ u16 dy = g_gpu.GetDisplayVRAMTop();
+ u16 dw = g_gpu.GetDisplayVRAMWidth();
+ u16 dh = g_gpu.GetDisplayVRAMHeight();
+ if (dw == 0) dw = 320;
+ if (dh == 0) dh = 240;
+
+ Image img(dw, dh, ImageFormat::RGBA8);
+ for (u16 row = 0; row < dh; row++)
+ {
+ u32* dst = reinterpret_cast<u32*>(img.GetRowPixels(row));
+ for (u16 col = 0; col < dw; col++)
+ {
+ u16 vx = (dx + col) & (VRAM_WIDTH - 1);
+ u16 vy = (dy + row) & (VRAM_HEIGHT - 1);
+ u16 pixel = g_vram[vy * VRAM_WIDTH + vx];
+ u8 r = static_cast<u8>((pixel & 0x1F) << 3);
+ u8 g = static_cast<u8>(((pixel >> 5) & 0x1F) << 3);
+ u8 b = static_cast<u8>(((pixel >> 10) & 0x1F) << 3);
+ dst[col] = static_cast<u32>(r) | (static_cast<u32>(g) << 8) | (static_cast<u32>(b) << 16) | 0xFF000000u;
+ }
+ }
+
+ Error error;
+ if (img.SaveToFile(path, 85, &error))
+ SendJSON(fmt::format(R"({{"id":{},"ok":true,"path":"{}","w":{},"h":{}}})", s_request_id, path, dw, dh));
+ else
+ SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"save failed: {}"}})", s_request_id, error.GetDescription()));
+ }
+ }
+ // ---- unknown ----
+ else
+ {
+ SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"unknown command: {}"}})", s_request_id, cmd));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+bool PSXRecompDebug::Initialize(u16 port)
+{
+ Error error;
+ Assert(!s_listen_socket);
+
+ const std::optional<SocketAddress> address =
+ SocketAddress::Parse(SocketAddress::Type::IPv4, "127.0.0.1", port, &error);
+ if (!address.has_value())
+ {
+ ERROR_LOG("Failed to parse address: {}", error.GetDescription());
+ return false;
+ }
+
+ SocketMultiplexer* multiplexer = System::GetSocketMultiplexer();
+ if (!multiplexer) return false;
+
+ s_listen_socket = multiplexer->CreateListenSocket<ClientSocket>(address.value(), &error);
+ if (!s_listen_socket)
+ {
+ ERROR_LOG("Failed to create listen socket: {}", error.GetDescription());
+ System::ReleaseSocketMultiplexer();
+ return false;
+ }
+
+ s_frame_counter = 0;
+ s_paused = false;
+ s_step_frames = 0;
+ s_run_to_frame = 0;
+ s_history_count = 0;
+ std::memset(s_watchpoints, 0, sizeof(s_watchpoints));
+ std::memset(s_pc_breaks, 0, sizeof(s_pc_breaks));
+ s_pc_hit_last.valid = false;
+ std::memset(s_history, 0, sizeof(s_history));
+
+ INFO_LOG("PSXRecomp debug server listening on 127.0.0.1:{}", port);
+ return true;
+}
+
+bool PSXRecompDebug::HasAnyClients() { return !s_clients.empty(); }
+
+void PSXRecompDebug::Shutdown()
+{
+ if (!s_listen_socket) return;
+ INFO_LOG("Shutting down PSXRecomp debug server ({} clients)", s_clients.size());
+ s_clients.clear();
+ s_listen_socket.reset();
+ System::ReleaseSocketMultiplexer();
+}
+
+void PSXRecompDebug::OnFrameDone()
+{
+ s_frame_counter++;
+
+ // ---- Record frame into ring buffer ----
+ u32 idx = s_frame_counter % FRAME_HISTORY_CAP;
+ FrameRecord& r = s_history[idx];
+ r.frame_number = s_frame_counter;
+
+ // CPU
+ for (int i = 0; i < 32; i++)
+ r.gpr[i] = CPU::g_state.regs.r[i];
+ r.pc = CPU::g_state.pc;
+ r.hi = CPU::g_state.regs.hi;
+ r.lo = CPU::g_state.regs.lo;
+
+ // GPU
+ r.display_vram_left = g_gpu.GetDisplayVRAMLeft();
+ r.display_vram_top = g_gpu.GetDisplayVRAMTop();
+ r.display_vram_width = g_gpu.GetDisplayVRAMWidth();
+ r.display_vram_height = g_gpu.GetDisplayVRAMHeight();
+ r.display_disable = g_gpu.IsDisplayDisabled() ? 1 : 0;
+ r.interlaced = g_gpu.IsInterlacedDisplayEnabled() ? 1 : 0;
+ r.pal_mode = g_gpu.IsInPALMode() ? 1 : 0;
+ r._gpu_pad = 0;
+
+ // Pad
+ Controller* ctrl = System::GetController(0);
+ r.pad1_buttons = ctrl ? static_cast<u16>(ctrl->GetButtonStateBits()) : 0xFFFF;
+
+ r._reserved = 0;
+
+ // RAM snapshots
+ for (int si = 0; si < RAM_SNAPSHOT_REGIONS; si++)
+ {
+ r.snapshot_addr[si] = s_snapshot_addrs[si];
+ if (s_snapshot_addrs[si] != 0 && s_snapshot_addrs[si] + RAM_SNAPSHOT_SIZE <= Bus::g_ram_size)
+ std::memcpy(r.snapshot_data[si], Bus::g_ram + s_snapshot_addrs[si], RAM_SNAPSHOT_SIZE);
+ else
+ std::memset(r.snapshot_data[si], 0, RAM_SNAPSHOT_SIZE);
+ }
+
+ s_history_count = static_cast<u64>(s_frame_counter) + 1;
+
+ // ---- Input override ----
+ if (s_input_override >= 0)
+ {
+ Controller* pad = System::GetController(0);
+ if (pad)
+ {
+ u16 mask = static_cast<u16>(s_input_override);
+ for (u32 bit = 0; bit < 16; bit++)
+ {
+ // PS1 bitmask: 0 = pressed, 1 = released
+ float value = ((mask >> bit) & 1) == 0 ? 1.0f : 0.0f;
+ pad->SetBindState(bit, value);
+ }
+ }
+ }
+
+ // ---- Watchpoints ----
+ for (int i = 0; i < MAX_WATCHPOINTS; i++)
+ {
+ if (!s_watchpoints[i].active || s_watchpoints[i].addr >= Bus::g_ram_size)
+ continue;
+ u8 cur = Bus::g_ram[s_watchpoints[i].addr];
+ if (cur != s_watchpoints[i].prev_val)
+ {
+ BroadcastJSON(fmt::format(
+ R"({{"event":"watchpoint","addr":"0x{:08X}","old":"0x{:02X}","new":"0x{:02X}","frame":{}}})",
+ s_watchpoints[i].addr | 0x80000000u, s_watchpoints[i].prev_val, cur, s_frame_counter));
+ s_watchpoints[i].prev_val = cur;
+ }
+ }
+
+ // ---- Step mode ----
+ if (s_step_frames > 0)
+ {
+ s_step_frames--;
+ if (s_step_frames == 0)
+ {
+ s_paused = true;
+ System::PauseSystem(true);
+ BroadcastJSON(fmt::format(R"({{"event":"step_done","frame":{}}})", s_frame_counter));
+ }
+ }
+
+ // ---- Run-to-frame mode ----
+ if (s_run_to_frame > 0 && s_frame_counter >= s_run_to_frame)
+ {
+ s_run_to_frame = 0;
+ s_paused = true;
+ System::PauseSystem(true);
+ BroadcastJSON(fmt::format(R"({{"event":"run_to_done","frame":{}}})", s_frame_counter));
+ }
+}
diff --git a/src/core/psxrecomp_debug_server.h b/src/core/psxrecomp_debug_server.h
new file mode 100644
index 0000000..8aa466c
--- /dev/null
+++ b/src/core/psxrecomp_debug_server.h
@@ -0,0 +1,20 @@
+// PSXRecomp TCP debug server for DuckStation
+// JSON-over-newline protocol on localhost:4371
+// Same protocol as the PSXRecomp runner debug server.
+// Includes 36000-frame ring buffer for retroactive state queries.
+
+#pragma once
+
+#include "common/types.h"
+
+namespace PSXRecompDebug {
+
+bool Initialize(u16 port = 4371);
+void Shutdown();
+bool HasAnyClients();
+
+/// Call once per frame from System::FrameDone(). Records frame state into
+/// the ring buffer, checks watchpoints, handles step-N logic.
+void OnFrameDone();
+
+} // namespace PSXRecompDebug
diff --git a/src/core/system.cpp b/src/core/system.cpp
index a36ad75..1d23daf 100644
--- a/src/core/system.cpp
+++ b/src/core/system.cpp
@@ -102,6 +102,7 @@ LOG_CHANNEL(System);
#define ENABLE_GDB_SERVER 1
#define ENABLE_SOCKET_MULTIPLEXER 1
#include "gdb_server.h"
+#include "psxrecomp_debug_server.h"
#endif
// #define PROFILE_MEMORY_SAVE_STATES 1
@@ -2018,6 +2019,9 @@ bool System::BootSystem(SystemBootParameters parameters, Error* error)
GDBServer::Initialize(g_settings.gdb_server_port);
#endif
+ // PSXRecomp debug server — always enabled on port 4371
+ PSXRecompDebug::Initialize(4371);
+
Host::OnSystemStarted();
if (parameters.load_image_to_ram || g_settings.cdrom_load_image_to_ram)
@@ -2136,6 +2140,8 @@ void System::DestroySystem()
GDBServer::Shutdown();
#endif
+ PSXRecompDebug::Shutdown();
+
VideoThread::RunOnThread([]() {
VideoThread::SetRunIdleReason(VideoThread::RunIdleReason::SystemPaused, false);
Host::ClearOSDMessages();
@@ -2278,6 +2284,8 @@ void System::FrameDone()
Achievements::FrameUpdate();
}
+ PSXRecompDebug::OnFrameDone();
+
#ifdef ENABLE_DISCORD_PRESENCE
PollDiscordPresence();
#endif
diff --git a/src/duckstation-qt/CMakeLists.txt b/src/duckstation-qt/CMakeLists.txt
index acb8e40..b7f7b8e 100644
--- a/src/duckstation-qt/CMakeLists.txt
+++ b/src/duckstation-qt/CMakeLists.txt
@@ -210,7 +210,7 @@ foreach(src ${SRCS})
list(APPEND UI_FILES ${src})
endif()
endforeach()
-qt_wrap_ui(UI_SRCS SOURCES ${UI_FILES})
+qt_wrap_ui(UI_SRCS ${UI_FILES})
target_sources(duckstation-qt PRIVATE ${UI_SRCS})
target_include_directories(duckstation-qt PRIVATE "$<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}>")