From 93b7dacc1e42c7cf96cbfed2a4db8f92e4811175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 29 Oct 2026 00:21:49 +0100 Subject: [PATCH] WebSocket debugger: runUntilTime that's already there, and across loads A target time that had already passed, relativeUs 0 for one, failed without a stop event, so a client waiting for cpu.stepping waited until its own timeout. Now it doesn't run, and reports the stop right away. A savestate loaded during the run kept the deadline's absolute time, which belongs to the timeline the load replaced: a state from later in the game stopped the run at once, one from earlier ran it for as long as the gap. Now the deadline keeps the time it had left. Co-Authored-By: Claude Opus 5.5 --- Core/Core.cpp | 16 ++++++++++++++++ Core/Core.h | 3 +++ Core/CoreTiming.cpp | 7 ++++++- Core/Debugger/WebSocket/SteppingSubscriber.cpp | 16 +++++++++------- docs/WebSocketDebugger.md | 2 +- 5 files changed, 35 insertions(+), 9 deletions(-) diff --git a/Core/Core.cpp b/Core/Core.cpp index e058a8573a..f7cc149cbc 100644 --- a/Core/Core.cpp +++ b/Core/Core.cpp @@ -712,6 +712,22 @@ void Core_Break(BreakReason reason, u32 relatedAddress, const BreakpointHit *hit System_Notify(SystemNotification::DEBUG_MODE_CHANGE); } +void Core_ReportBreakWithoutRunning(BreakReason reason, u32 relatedAddress) { + if (coreState != CORE_STEPPING_CPU) { + return; + } + std::lock_guard lock(g_stepMutex); + if (!g_cpuStepCommand.empty()) { + // A step is about to run, and its own stop will be reported. + return; + } + g_breakReason = reason; + g_breakHit = BreakpointHit{}; + g_cpuStepCommand.reason = reason; + g_cpuStepCommand.relatedAddr = relatedAddress; + steppingCounter++; +} + // Free-threaded (or at least should be) void Core_Resume() { // If the current PC is on a breakpoint, the user doesn't want to do nothing. diff --git a/Core/Core.h b/Core/Core.h index 0645b5f4b9..6581abaa28 100644 --- a/Core/Core.h +++ b/Core/Core.h @@ -117,6 +117,9 @@ struct BreakpointHit { // hit is optional detail for the breakpoint kinds, forwarded to the debugger. Only stored when // the break actually takes effect, so a rejected Core_Break() can't leave a stale one behind. void Core_Break(BreakReason reason, u32 relatedAddress = 0, const BreakpointHit *hit = nullptr); +// While stepping: reports a stop for reason without running, for a run whose stop condition already +// holds (cpu.runUntilTime with a time that's already passed), so a debugger waiting for the stop gets one. +void Core_ReportBreakWithoutRunning(BreakReason reason, u32 relatedAddress = 0); // Resumes execution. Works both when stepping the CPU and the GE. void Core_Resume(); diff --git a/Core/CoreTiming.cpp b/Core/CoreTiming.cpp index b64b877c84..a7f325e88f 100644 --- a/Core/CoreTiming.cpp +++ b/Core/CoreTiming.cpp @@ -567,6 +567,9 @@ void DoState(PointerWrap &p) { return; // States are saved between slices. otherCPUsRan = 0; + // A debugger's run-until deadline keeps the time it had left, not its absolute time, which + // belongs to the timeline being replaced. + const s64 deadlineLeftUs = p.mode == PointerWrap::MODE_READ && breakDeadlineUs ? breakDeadlineUs - (s64)GetGlobalTimeUs() : 0; int n = (int)event_types.size(); int current = n; @@ -632,7 +635,9 @@ void DoState(PointerWrap &p) { lastGlobalTimeUs = 0; } if (p.mode == PointerWrap::MODE_READ) { - // A debugger's run-until deadline is in emulated us, and the ticks it maps to just changed. + if (breakDeadlineUs) { + breakDeadlineUs = (s64)GetGlobalTimeUs() + std::max(deadlineLeftUs, (s64)0); + } RecomputeBreakDeadline(); } diff --git a/Core/Debugger/WebSocket/SteppingSubscriber.cpp b/Core/Debugger/WebSocket/SteppingSubscriber.cpp index e9f5493e99..954176aa61 100644 --- a/Core/Debugger/WebSocket/SteppingSubscriber.cpp +++ b/Core/Debugger/WebSocket/SteppingSubscriber.cpp @@ -293,6 +293,9 @@ void WebSocketSteppingState::RunUntil(DebuggerRequest &req) { // - us: emulated time right now. // A cpu.stepping event follows once it gets there. Note that anything else that stops the CPU // first - a breakpoint, an exception - cancels the deadline, same as it cancels a step. +// A time that has already passed (relativeUs 0, say) doesn't run at all: the response and the +// cpu.stepping event come right away. A savestate loaded during the run moves the deadline along +// with the clock, so it still runs for the time that was left. void WebSocketSteppingState::RunUntilTime(DebuggerRequest &req) { if (!currentDebugMIPS->isAlive()) { return req.Fail("CPU not started"); @@ -318,15 +321,14 @@ void WebSocketSteppingState::RunUntilTime(DebuggerRequest &req) { const u64 nowUs = CoreTiming::GetGlobalTimeUs(); const u64 targetUs = absolute ? (u64)requested : nowUs + (u64)requested; if (targetUs <= nowUs) { - req.Fail("Target time has already passed"); - return; + // Already there. A client waiting for the stop would otherwise never get one. + Core_ReportBreakWithoutRunning(BreakReason::RunUntilTime, currentMIPS->pc); + } else { + CoreTiming::SetBreakDeadlineUs(targetUs); + PrepareResume(); + Core_Resume(); } - CoreTiming::SetBreakDeadlineUs(targetUs); - - PrepareResume(); - Core_Resume(); - JsonWriter &json = req.Respond(); json.writeFloat("targetUs", (double)targetUs); json.writeFloat("us", (double)nowUs); diff --git a/docs/WebSocketDebugger.md b/docs/WebSocketDebugger.md index 32908c5ba2..56b2b1195f 100644 --- a/docs/WebSocketDebugger.md +++ b/docs/WebSocketDebugger.md @@ -273,7 +273,7 @@ file - this is just an index. |---|---|---| | Game/version | `game.reset`, `game.status`, `game.state.load` (queue loading a `.ppst` file given as `path`, or a `slot` of the running game (any slot in headless, otherwise one the app's slot count shows), applied at the end of the next frame the CPU runs; with `wait: true` the response comes once it's done, or as an error if it failed), `game.state.save` (the same for saving one), `game.state.rewind` (the same for the newest rewind snapshot, which it keeps, so only a rewind within 0.8 seconds of the last steps further back; needs `RewindSnapshotInterval` set, and the `wait` response's `metadata` says how far back that is in emulated time), `game.state.verify` (the same for checking that saving the current state and reading it back agree; all but this one are refused in a session booted into the XMB, whose discs come and go), `game.speed.get/set` (emulation speed - unlimited fast-forward, or a percentage of 60 FPS; see below), `game.umd.insert` / `game.umd.eject` (put a disc image, given as `path`, in an empty drive, or take it out: for the XMB booted with `--vsh` and no game, which then lists the disc and can launch it), `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` | +| 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; a time already passed, such as `relativeUs` 0, stops right away without running, and a savestate loaded during the run keeps the time that was left), `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` |