From f1274c1889b7045b079486c4c4e9f0f08e4d7db3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 29 Oct 2026 00:20:14 +0100 Subject: [PATCH 1/5] TextureCache: Never rehash the PPGe atlas A HASH_RECHECK forced a rehash even of the RELIABLE atlas, which hashed its fake address as if it were in emulated memory: a memory exception in the middle of the game's next system dialog update. Loading a state saved after one had drawn text was enough to hit it. Co-Authored-By: Claude Opus 5.5 --- GPU/Common/TextureCacheCommon.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/GPU/Common/TextureCacheCommon.cpp b/GPU/Common/TextureCacheCommon.cpp index 78f64d2e7c..83aff9e4bf 100644 --- a/GPU/Common/TextureCacheCommon.cpp +++ b/GPU/Common/TextureCacheCommon.cpp @@ -718,6 +718,11 @@ TextureApplyResult TextureCacheCommon::ApplyTexture(bool doBind) { rehash = true; } + // The PPGe atlas is host data, not in emulated memory, and never changes. + if (entry->status & TexStatus::IS_PPGE_ATLAS) { + rehash = false; + } + // got one! VERBOSE_LOG(Log::TexCache, "%08x: Texture found in cache. Rehash flag = %d", texaddr, (int)rehash); From b719c7e02712912c4d97390af2ac40eddc140cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 29 Oct 2026 00:05:58 +0100 Subject: [PATCH 2/5] PPGe: Load the atlas in headless too Headless skipped it, so system dialogs (savedata messages, the network dialogs) had no text, which made driving a game through them blind. Co-Authored-By: Claude Opus 5.5 --- Core/Util/PPGeDraw.cpp | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Core/Util/PPGeDraw.cpp b/Core/Util/PPGeDraw.cpp index 98d8ee7030..05ce9d8abd 100644 --- a/Core/Util/PPGeDraw.cpp +++ b/Core/Util/PPGeDraw.cpp @@ -239,10 +239,6 @@ void __PPGeSetupListArgs() { } void __PPGeInit() { - // PPGe isn't really important for headless, and LoadZIM takes a long time (well not that long, but unnecessary). - // TODO: Load it lazily. - const bool skipZIM = System_GetPropertyBool(SYSPROP_IS_HEADLESS); - u8 *imageData[12]{}; int width[12]{}; int height[12]{}; @@ -250,8 +246,8 @@ void __PPGeInit() { // TODO: Load the atlas on a thread! - bool loadedZIM = !skipZIM && LoadZIM("ppge_atlas.zim", width, height, &flags, imageData); - if (!skipZIM && !loadedZIM) { + bool loadedZIM = LoadZIM("ppge_atlas.zim", width, height, &flags, imageData); + if (!loadedZIM) { ERROR_LOG(Log::sceGe, "Failed to load ppge_atlas.zim.\n\nPlace it in the directory \"assets\" under your PPSSPP directory.\n\nPPGe stuff will not be drawn."); } From df72f3be815c4b6c9cdc1afd976c9290dbc3a3d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 29 Oct 2026 00:05:59 +0100 Subject: [PATCH 3/5] WebSocket debugger: Any savestate slot in headless game.state.save and game.state.load with a slot were limited to the app's slot count (5 by default), which only means how many the UI shows. Co-Authored-By: Claude Opus 5.5 --- Core/Debugger/WebSocket/GameSubscriber.cpp | 3 ++- docs/WebSocketDebugger.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Core/Debugger/WebSocket/GameSubscriber.cpp b/Core/Debugger/WebSocket/GameSubscriber.cpp index b430264df8..101d0242b9 100644 --- a/Core/Debugger/WebSocket/GameSubscriber.cpp +++ b/Core/Debugger/WebSocket/GameSubscriber.cpp @@ -115,7 +115,8 @@ void WebSocketGameState::StateOperation(DebuggerRequest &req, StateOp op) { if (useSlot) { if (!req.ParamU32("slot", &slot)) return; - if (slot >= (uint32_t)g_Config.iSaveStateSlotCount) + // The slot count is how many the app's UI shows. Headless has no UI to show them in. + if (slot >= (uint32_t)g_Config.iSaveStateSlotCount && !System_GetPropertyBool(SYSPROP_IS_HEADLESS)) return req.Fail("Invalid slot"); } else if ((op == StateOp::LOAD || op == StateOp::SAVE) && !req.ParamString("path", &path)) { return; diff --git a/docs/WebSocketDebugger.md b/docs/WebSocketDebugger.md index 8636984274..32908c5ba2 100644 --- a/docs/WebSocketDebugger.md +++ b/docs/WebSocketDebugger.md @@ -271,7 +271,7 @@ file - this is just an index. | Category | Events | File | |---|---|---| -| Game/version | `game.reset`, `game.status`, `game.state.load` (queue loading a `.ppst` file given as `path`, or a `slot` of the running game, 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` | +| 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` | | 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` | 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 4/5] 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` | From 8f6d7fcf94631d4f3a00960981e269e6b235a2eb 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 5/5] Log CPU exception reports in headless too Headless sets a debug output listener, which only passes the output on when collecting a test's output, so a game crashing in headless left nothing in the log. Co-Authored-By: Claude Opus 5.5 --- Core/Core.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Core/Core.cpp b/Core/Core.cpp index f7cc149cbc..c1d8b35c85 100644 --- a/Core/Core.cpp +++ b/Core/Core.cpp @@ -254,10 +254,11 @@ void Core_RegisterDebugOutputListeners(std::function