Report emulated time in cpu.status, and note headless/wsdbg traps in AGENTS.md

cpu.status only reported raw CPU ticks, which a client can't turn into a time:
the PSP's clock frequency is changeable and games do change it, so the
ticks-per-second ratio isn't fixed over a run. CrossCraft Classic runs at
333MHz, so assuming the default 222MHz reads 9.1s where the truth is 6.3s -
enough to put scripted input injection in the wrong place entirely.

Adds "us" (emulated microseconds) and "clockHz" alongside "ticks".
CoreTiming::GetGlobalTimeUs() can't be used directly for this: it rebases its
own internal counters as a side effect, and cpu.status is deliberately served
straight from the WebSocket thread rather than queued to the CPU thread (it's
meant to be cheap and frequently pollable). So PeekGlobalTimeUs() computes the
same value without the rebasing.

AGENTS.md picks up the things that cost time while driving headless over the
websocket API: --sync silently desynchronises on raw JSON lines because only
wsdbg's key=value shorthand gets a ticket; headless reports HAS_DEBUGGER as
false so anything gated on it silently does nothing there; the memstick is
hardcoded next to the executable; a leftover headless process turns a build
into an LNK1168 that looks like a compile error; wrapping the launcher in
`timeout` kills the emulator along with it, losing the crash you stopped at;
response field names aren't uniform (value vs uintValue); broadcast.config.set
rejects two of the four keys the docs list.

Also writes down the ELF-as-oracle technique that cracked the relocation bug -
when homebrew ships app.elf next to app.prx, the pre-link ELF still has the
symbols and the relocation symbol indices the PRX format discards, so the
loader's output can be checked exhaustively offline instead of by re-running.

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-17 18:51:54 +02:00
1 parent 05f5668dfe
commit 59eb8a9a81
5 files changed
+54 -1

No files matched your search

+35
View File
@@ -310,6 +310,41 @@ A working invocation, and the traps around it:
- Keep wsdbg scripts in files and pipe them in, rather than building JSON inline in a shell command - inline
`{"event":...}` in a bash heredoc trips Claude Code's command analyzer ("brace with quote character") and forces a
manual approval prompt for every single invocation.
- **`--sync` can only match a response to a request that carries a ticket**, and wsdbg only assigns tickets to its
`key=value` shorthand. A raw JSON line (needed for nested params) gets no ticket, so `--sync` just waits for the
next message and treats whatever broadcast arrives first as the answer, silently desynchronising the rest of the
script. Use the shorthand wherever the parameters are flat. Hex works there: `memory.disasm address=0x08804000`.
- **Headless reports `SYSPROP_HAS_DEBUGGER` as false** (only `Windows/main.cpp` implements it), so anything gated on
it does nothing there - `LoadSymbolsIfSupported()` in `Core/System.cpp`, for instance, doesn't load `.ppmap`/`.sym`
at all under headless. Gate new debugger-adjacent features on their own config flag, not on that property.
- The headless memstick is hardcoded to `memstick` next to the executable (`Headless.cpp`), with no `--memstick`
flag, so a game has to be copied under `Windows/x64/Debug/memstick/PSP/GAME/` to be bootable there.
- Kill leftover instances (`taskkill //F //IM PPSSPPHeadless.exe`) before building - a running one makes the link
step fail with `LNK1168: cannot open ... for writing`, which looks like a build problem and isn't.
- Don't wrap a script that starts headless in `timeout` - when it fires it takes the emulator down with it, and if
the emulator was stopped at the crash you were investigating, that state is gone. Let the launcher exit and leave
the process running; wsdbg can reconnect to the same port as many times as you like.
- Response field names are not uniform: `memory.read_u32` answers with `value`, while `cpu.getReg` answers with
`uintValue`. A parser defaulting a missing key to 0 will quietly report zeroes - read the handler's comment in
`Core/Debugger/WebSocket/*Subscriber.cpp` rather than guessing.
- `broadcast.config.set` currently rejects the `game` and `stepping` keys that `docs/WebSocketDebugger.md` lists,
erroring with "Unsupported 'disallowed' object key". Only `logger` and `input` work.
- **To line input injection up with a wall-clock repro, use `cpu.status`'s `us` field** (emulated microseconds), not
`ticks`. The PSP's clock frequency is changeable and games do change it - CrossCraft Classic runs at 333MHz, so
`ticks / 222000000` is off by a factor of 1.5. `clockHz` is reported alongside.
## Debugging a game that works on hardware but not in PPSSPP
One technique paid for itself twice over here. When a homebrew ELF is shipped next to the EBOOT (`app.elf`
alongside `app.prx`, common for Zig/Rust/SDK homebrew), **the pre-link ELF is a ground-truth oracle for anything the
loader computes**. It still has the symbol table (so an address can be turned into a
function name) and the full `.rel.*` sections *with symbol indices*, which the PRX format throws away. That makes it
possible to check the emulator's work exhaustively offline - for the HI16/LO16 relocation bug, "does the address
this pairing produces land inside the section its symbol belongs to" turned a guess into a measurement over 8589
relocations, and immediately showed that the first fix attempt scored worse than the code it replaced.
Reach for that before trying to reason a fix out of a disassembly. A few dozen lines of Python over the ELF beats
re-running the game.
## Debugger threading model (Core_RunOnCPUThread / g_frameMutex)
+8
View File
@@ -105,6 +105,14 @@ u64 GetGlobalTimeUs() {
return lastGlobalTimeUs + usSinceLast;
}
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
// happens often enough on the CPU thread that ticksSinceLast stays small here.
const s64 ticksSinceLast = GetTicks(currentMIPS) - lastGlobalTimeTicks;
return lastGlobalTimeUs + ticksSinceLast * 1000000 / GetClockFrequencyHz();
}
const Event *GetFirstEvent() {
return first;
}
+3
View File
@@ -96,6 +96,9 @@ namespace CoreTiming {
u64 GetIdleTicks();
u64 GetGlobalTimeUs();
u64 GetGlobalTimeUsScaled();
// GetGlobalTimeUs() without the internal rebasing, so it's safe to call off the CPU thread -
// for the debugger's status poll. Same value, just doesn't help the next call along.
u64 PeekGlobalTimeUs();
// Returns the event_type identifier.
int RegisterEvent(const char *name, TimedCallback callback);
@@ -112,6 +112,11 @@ void WebSocketCPUResume(DebuggerRequest &req) {
// - paused: boolean, CPU paused or not started yet.
// - pc: number value of PC register (inaccurate unless stepping.)
// - ticks: number of CPU cycles into emulation.
// - us: microseconds of emulated time. Not derivable from ticks on the client side: the PSP's
// clock frequency is changeable (scePowerSetCpuClockFrequency), and games do change it, so the
// ticks-per-second ratio isn't fixed over a run. This is what to use to answer "how far into
// the game am I", e.g. to line up input injection with a wall-clock repro.
// - clockHz: the CPU clock frequency right now, for reference.
// Deliberately not routed through Core_RunOnCPUThread(): this is meant to be a cheap, frequently-pollable
// status check, and the "pc" field is already documented as inaccurate unless stepping - queuing it would
// add up to a frame of latency to every poll for no real accuracy benefit. Matches how SteppingBroadcaster
@@ -127,6 +132,8 @@ void WebSocketCPUStatus(DebuggerRequest &req) {
json.writeUint("pc", pspInited ? currentMIPS->pc : 0);
// A double ought to be good enough for a 156 day debug session.
json.writeFloat("ticks", pspInited ? CoreTiming::GetTicks(currentMIPS) : 0);
json.writeFloat("us", pspInited ? (double)CoreTiming::PeekGlobalTimeUs() : 0);
json.writeUint("clockHz", pspInited ? CoreTiming::GetClockFrequencyHz() : 0);
}
// Retrieve all regs and their values (cpu.getAllRegs)
+1 -1
View File
@@ -107,7 +107,7 @@ file - this is just an index.
| Category | Events | File |
|---|---|---|
| Game/version | `game.reset`, `game.status`, `version` | `GameSubscriber.cpp` |
| CPU core | `cpu.stepping`, `cpu.resume`, `cpu.status`, `cpu.getAllRegs`, `cpu.getReg`, `cpu.setReg`, `cpu.evaluate` | `CPUCoreSubscriber.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` |
| 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` |