Add cpu.runUntilTime: run to a point in emulated time and break there

Lining a scripted repro up with a bug report ("about five seconds in, press
X") had no support at all. The only way to do it was to poll cpu.status in a
loop from the client, which is slow - a process spawn per poll, minutes for a
single run - and lands somewhere different every time, so the repro isn't one.

cpu.runUntilTime takes either an absolute `us` (as reported by cpu.status) or
`relativeUs` from now, resumes, and breaks when emulated time gets there. It
answers immediately with the target, and the usual cpu.stepping event follows
when it arrives. Anything else that stops the CPU first - a breakpoint, an
exception - cancels the deadline, the same way it cancels a pending step.

The deadline is held in microseconds, not ticks, and recomputed whenever
SetClockFrequencyHz() runs. Converting to a tick count once up front looks
right and isn't: games change the CPU clock while running, and CrossCraft
Classic goes 222 -> 333MHz during startup, which made a request for 3.0s stop
at 2.24s. With the recompute it stops at exactly 3000000us. Advance() also
shortens its slice to land on the deadline instead of up to a slice past it,
so repeated runs stop at the same instruction rather than somewhere in the
following frame.

Nothing is added to CoreTiming's event list, so savestates are unaffected -
the deadline is debugger session state and isn't serialized.

Also adds DebuggerRequest::ParamF64, since microseconds outgrow 32 bits after
about 71 minutes. Like the other Param* helpers it fails loudly on a missing
or unparseable value rather than defaulting.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
This commit is contained in:
Henrik RydgårdandClaude Opus 5 committed 2026-08-18 09:32:04 +02:00
1 parent 38cdc8bf68
commit 8120338530
7 files changed
+160 -1

No files matched your search

+3
View File
@@ -594,6 +594,9 @@ void Core_Break(BreakReason reason, u32 relatedAddress) {
// breakpoint, or lldb discarding the thread plan, on any stop.
g_breakpoints.ClearTempBreakPoint();
// Same reasoning for a cpu.runUntilTime deadline - it belonged to the run that just ended.
CoreTiming::SetBreakDeadlineUs(0);
g_breakReason = reason;
g_cpuStepCommand.type = CPUStepType::None;
g_cpuStepCommand.reason = reason;
+49
View File
@@ -60,6 +60,11 @@ alignas(16) static s64 globalTimer;
static s64 idledCycles;
static s64 lastGlobalTimeTicks;
static s64 lastGlobalTimeUs;
// See SetBreakDeadlineUs. 0 = none. Deliberately not saved in savestates - it belongs to a
// debugger session, not to the emulated machine.
static s64 breakDeadlineUs;
static s64 breakDeadlineTicks;
static void RecomputeBreakDeadline();
bool SetClockFrequencyHz(int cpuHz) {
if (cpuHz <= 0) {
@@ -79,6 +84,9 @@ bool SetClockFrequencyHz(int cpuHz) {
CPU_HZ = cpuHz;
// The remaining time to a debugger deadline is now a different number of ticks.
RecomputeBreakDeadline();
// TODO: Rescale times of scheduled events?
__AudioCPUMHzChange();
return true;
@@ -105,6 +113,26 @@ u64 GetGlobalTimeUs() {
return lastGlobalTimeUs + usSinceLast;
}
// Turns the microsecond deadline into the tick count Advance() compares against. Has to be redone
// whenever the clock frequency changes, since that changes how many ticks the remaining time is.
static void RecomputeBreakDeadline() {
if (!breakDeadlineUs) {
breakDeadlineTicks = 0;
return;
}
const s64 remainingUs = breakDeadlineUs - (s64)GetGlobalTimeUs();
breakDeadlineTicks = (s64)GetTicks(currentMIPS) + (remainingUs > 0 ? usToCycles(remainingUs) : 0);
}
void SetBreakDeadlineUs(u64 us) {
breakDeadlineUs = (s64)us;
RecomputeBreakDeadline();
}
u64 GetBreakDeadlineUs() {
return (u64)breakDeadlineUs;
}
u64 PeekGlobalTimeUs() {
// Same sum as above without the rebasing, so this stays callable from a thread that isn't the
// CPU thread. The rebasing exists purely to keep the multiply below from overflowing, and it
@@ -193,6 +221,8 @@ void Init(MIPSState *mips) {
idledCycles = 0;
lastGlobalTimeTicks = 0;
lastGlobalTimeUs = 0;
breakDeadlineUs = 0;
breakDeadlineTicks = 0;
CPU_HZ = initialHz;
}
@@ -393,6 +423,14 @@ void Advance(MIPSState *mips) {
globalTimer += cyclesExecuted;
mips->downcount = slicelength;
// Debugger deadline - see SetBreakDeadlineTicks. Checked before the events so the break lands
// on the requested tick rather than after whatever the events do.
if (breakDeadlineTicks && globalTimer >= breakDeadlineTicks) {
breakDeadlineTicks = 0;
breakDeadlineUs = 0;
Core_Break(BreakReason::DebugBreak, mips->pc);
}
ProcessEvents();
if (!first) {
@@ -411,6 +449,17 @@ void Advance(MIPSState *mips) {
slicelength += diff;
mips->downcount += diff;
}
// Shorten the slice so we come back exactly on the deadline instead of up to a whole slice
// past it - the point of cpu.runUntilTime is that it stops at a reproducible place.
if (breakDeadlineTicks) {
const s64 remaining = breakDeadlineTicks - globalTimer;
if (remaining > 0 && remaining < slicelength) {
const int diff = (int)remaining - slicelength;
slicelength += diff;
mips->downcount += diff;
}
}
}
void LogPendingEvents() {
+9
View File
@@ -100,6 +100,15 @@ namespace CoreTiming {
// for the debugger's status poll. Same value, just doesn't help the next call along.
u64 PeekGlobalTimeUs();
// Debugger support (cpu.runUntilTime): break as soon as emulated time reaches this many
// microseconds. Advance() shortens its slice to land exactly on it rather than overshooting,
// so this stops at a reproducible point rather than "somewhere in the next frame". 0 clears it.
// Core_Break() clears it too, so a deadline can't outlive the run it belonged to.
// Deliberately in microseconds and not ticks: games change the CPU clock mid-run (CrossCraft
// Classic goes 222 -> 333MHz during startup), so a tick count fixed up front drifts.
void SetBreakDeadlineUs(u64 us);
u64 GetBreakDeadlineUs();
// Returns the event_type identifier.
int RegisterEvent(const char *name, TimedCallback callback);
@@ -21,6 +21,7 @@
#include "Core/Debugger/WebSocket/SteppingSubscriber.h"
#include "Core/Debugger/WebSocket/WebSocketUtils.h"
#include "Core/Core.h"
#include "Core/CoreTiming.h"
#include "Core/HLE/HLE.h"
#include "Core/HLE/sceKernelThread.h"
#include "Core/MIPS/MIPSDebugInterface.h"
@@ -40,6 +41,7 @@ struct WebSocketSteppingState : public DebuggerSubscriber {
void Over(DebuggerRequest &req);
void Out(DebuggerRequest &req);
void RunUntil(DebuggerRequest &req);
void RunUntilTime(DebuggerRequest &req);
void HLE(DebuggerRequest &req);
protected:
@@ -54,6 +56,7 @@ DebuggerSubscriber *WebSocketSteppingInit(DebuggerEventHandlerMap &map) {
map["cpu.stepOver"] = [p](DebuggerRequest &req) { p->Over(req); };
map["cpu.stepOut"] = [p](DebuggerRequest &req) { p->Out(req); };
map["cpu.runUntil"] = [p](DebuggerRequest &req) { p->RunUntil(req); };
map["cpu.runUntilTime"] = [p](DebuggerRequest &req) { p->RunUntilTime(req); };
map["cpu.nextHLE"] = [p](DebuggerRequest &req) { p->HLE(req); };
return p;
}
@@ -275,6 +278,62 @@ void WebSocketSteppingState::RunUntil(DebuggerRequest &req) {
});
}
// Run until a point in emulated time (cpu.runUntilTime)
//
// The counterpart to cpu.runUntil for "let the game get N seconds in", which is what lining a
// scripted repro up with a wall-clock description of a bug needs. Polling cpu.status in a loop
// does the same job far more slowly and lands somewhere different every run; this stops on the
// requested tick, so the same script reaches the same place every time.
//
// Parameters (exactly one of):
// - us: absolute emulated microseconds to run until, as reported by cpu.status.
// - relativeUs: microseconds to run for, measured from now.
//
// Response (same event name):
// - targetUs: the absolute emulated time it will stop at.
// - 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.
void WebSocketSteppingState::RunUntilTime(DebuggerRequest &req) {
if (!currentDebugMIPS->isAlive()) {
return req.Fail("CPU not started");
}
const bool absolute = req.HasParam("us");
if (absolute == req.HasParam("relativeUs")) {
return req.Fail("Pass exactly one of 'us' or 'relativeUs'");
}
double requested = 0.0;
if (!req.ParamF64(absolute ? "us" : "relativeUs", &requested)) {
// Error already sent.
return;
}
if (requested < 0.0) {
return req.Fail("Time must not be negative");
}
// Route the actual stepping manipulation to the CPU thread instead of poking at it directly
// from this WebSocket handler thread - see Core_RunOnCPUThread() in Core.h.
Core_RunOnCPUThread([&] {
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;
}
CoreTiming::SetBreakDeadlineUs(targetUs);
PrepareResume();
Core_Resume();
JsonWriter &json = req.Respond();
json.writeFloat("targetUs", (double)targetUs);
json.writeFloat("us", (double)nowUs);
});
}
// Jump after the next HLE call (cpu.nextHLE)
//
// No parameters.
@@ -16,6 +16,7 @@
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
#include <cmath>
#include <cstdlib>
#include <limits>
#include "Common/Data/Text/Parsers.h"
@@ -173,6 +174,41 @@ bool DebuggerRequest::ParamU32(const char *name, uint32_t *out, bool allowFloatB
return false;
}
bool DebuggerRequest::ParamF64(const char *name, double *out, DebuggerParamType type) {
const bool required = type == DebuggerParamType::REQUIRED || type == DebuggerParamType::REQUIRED_LOOSE;
const JsonNode *node = data.get(name);
if (!node) {
if (required)
Fail(StringFromFormat("Missing '%s' parameter", name));
return !required;
}
const auto tag = node->value.getTag();
if (tag == JSON_NUMBER) {
*out = node->value.toNumber();
return true;
}
if (tag == JSON_STRING) {
// Same escape hatch as the integer params: a string for values a JSON number would mangle.
const char *s = node->value.toString();
char *end = nullptr;
const double val = strtod(s, &end);
if (end != s && *end == '\0') {
*out = val;
return true;
}
Fail(StringFromFormat("Could not parse '%s' parameter: number required", name));
return false;
}
if (tag == JSON_NULL && !required) {
return false;
}
Fail(StringFromFormat("Invalid '%s' parameter type", name));
return false;
}
bool DebuggerRequest::ParamBool(const char *name, bool *out, DebuggerParamType type) {
bool allowLoose = type == DebuggerParamType::REQUIRED_LOOSE || type == DebuggerParamType::OPTIONAL_LOOSE;
bool required = type == DebuggerParamType::REQUIRED || type == DebuggerParamType::REQUIRED_LOOSE;
+3
View File
@@ -94,6 +94,9 @@ struct DebuggerRequest {
bool HasParam(const char *name, bool ignoreNull = false);
bool ParamU32(const char *name, uint32_t *out, bool allowFloatBits = false, DebuggerParamType type = DebuggerParamType::REQUIRED);
// For quantities that don't fit in 32 bits, like emulated microseconds - JSON numbers are
// doubles anyway, which is exact well past any plausible session length.
bool ParamF64(const char *name, double *out, DebuggerParamType type = DebuggerParamType::REQUIRED);
bool ParamBool(const char *name, bool *out, DebuggerParamType type = DebuggerParamType::REQUIRED);
bool ParamString(const char *name, std::string *out, DebuggerParamType type = DebuggerParamType::REQUIRED);
+1 -1
View File
@@ -108,7 +108,7 @@ file - this is just an index.
|---|---|---|
| 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.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), `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` | `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` |