diff --git a/AGENTS.md b/AGENTS.md index 61fc752a2c..a2c6420d98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/Core/CoreTiming.cpp b/Core/CoreTiming.cpp index b8a775b6a6..572545c5d0 100644 --- a/Core/CoreTiming.cpp +++ b/Core/CoreTiming.cpp @@ -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; } diff --git a/Core/CoreTiming.h b/Core/CoreTiming.h index 725ef6dd72..6e62aa4a59 100644 --- a/Core/CoreTiming.h +++ b/Core/CoreTiming.h @@ -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); diff --git a/Core/Debugger/WebSocket/CPUCoreSubscriber.cpp b/Core/Debugger/WebSocket/CPUCoreSubscriber.cpp index b2947bc61f..2fd0c62ae8 100644 --- a/Core/Debugger/WebSocket/CPUCoreSubscriber.cpp +++ b/Core/Debugger/WebSocket/CPUCoreSubscriber.cpp @@ -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) diff --git a/docs/WebSocketDebugger.md b/docs/WebSocketDebugger.md index f16eaa0903..4599bf883b 100644 --- a/docs/WebSocketDebugger.md +++ b/docs/WebSocketDebugger.md @@ -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` |