mirror of
https://github.com/hrydgard/ppsspp.git
synced 2026-09-01 10:15:21 +02:00
Adds the source location column to the breakpoint list, the last of the places worth surfacing line info. Exec breakpoints get a real location; memchecks and register breakpoints get "-", since a watched data range and a register aren't tied to a code address. Fixed a pre-existing misalignment found while adding the column: the register breakpoint row never emitted a cell for the Log column, so every following cell sat one to the left - the register name appeared under "Type", the condition under "OpCode" and the hit count under "Cond", with the last column left blank. It has a Log checkbox now like the other two rows (register breakpoints do support the log action), and the register name moved to Size/Label where the memcheck row puts its size. AGENTS.md gets what this stretch of work turned up: - Most files here are CRLF, including every .vcxproj, Android.mk, Makefile.common and AGENTS.md itself. Patching one with a script that reads with universal newlines and writes with newline='' silently rewrites the whole file - it turned a two-line addition into a 5000-line diff, which is invisible in an editor and obvious in git diff --stat. - Don't pipe Python containing backslashes through a bash heredoc; the quoting mangles them and anchors just fail to match for no visible reason. - Headless registers its own debug-output listener, so exception and crash messages never reach the log - they go to stdout, block-buffered when redirected, and taskkill //F discards the buffer instead of flushing. Give the run a short --timeout and let it exit if you need to read a crash trace. - 0xFFFFFFFF decodes to vflush, a real VFPU instruction, so it's useless as "garbage" for testing illegal-instruction handling. - A wsdbg script has to stay connected long enough for what it asked for; ending with :quit straight after cpu.runUntilTime looks exactly like a broken feature. - Where DWARF line info is and isn't available, since it's much narrower than it sounds. - Corrected the note claiming broadcast.config.set only accepts logger and input; it takes all five categories now. UnitTest 55/55. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
547 lines
40 KiB
Markdown
547 lines
40 KiB
Markdown
# PPSSPP Agent instructions
|
|
|
|
These rules apply to this repository by default.
|
|
|
|
Ignore the folder ai_instructions in the root directory, it's old stuff from contributors.
|
|
|
|
## General instructions
|
|
|
|
1. Keep style changes minimal unless requested. Follow existing code patterns and conventions.
|
|
2. Keep cross-platform parity in mind when changing shared code. See below for more multiplatform tips
|
|
3. Never `git push` (to any remote) without asking the user first. Committing locally is fine when asked; pushing requires explicit approval.
|
|
4. **Most files in this repo are CRLF** - `.vcxproj`, `.vcxproj.filters`, `android/jni/Android.mk`,
|
|
`libretro/Makefile.common`, `AGENTS.md`, and much of the source. If you patch one with a script, read *and*
|
|
write with `newline=''`; reading with Python's default universal-newline translation and writing with
|
|
`newline=''` silently converts the whole file, turning a two-line addition into a 5000-line diff. Check
|
|
`git diff --stat` before committing - a whole-file rewrite is obvious there and invisible in the editor.
|
|
Prefer the Edit tool, which does exact string replacement and can't do this.
|
|
5. **Don't feed Python to `bash -c` via a heredoc when the code contains backslashes.** The quoting mangles them,
|
|
and an anchor string like `'...MemBlockInfo.cpp \\\r\n'` silently fails to match, so the patch reports
|
|
"anchor missing" for reasons that aren't visible. Write the script to a file and run that instead, building
|
|
separators with `chr(92)` if need be.
|
|
|
|
## Core Safety Checks
|
|
|
|
1. For HLE, CPU, GPU, timing, threading, and memory changes, call out regression risks explicitly.
|
|
2. Consider savestate compatibility when changing serialized state.
|
|
|
|
## Build and Validation
|
|
|
|
To verify that things build on Linux/Mac, use ./b.sh --debug. For Windows, use the Visual Studio solution in the Windows subdirectory
|
|
(`Windows/PPSSPP.sln`) - always build through it, even if a stray CMake-generated `build/` directory exists at the repo root (e.g.
|
|
left over from WSL/MSYS2 experimentation); that directory is not the supported Windows build path and may not have a working
|
|
compiler toolchain wired up.
|
|
|
|
An agent can drive the VS solution non-interactively with `MSBuild.exe` instead of opening the `devenv` GUI. Locate it via
|
|
`vswhere.exe` (same tool/gotchas as described in the libretro section below) and build a specific project with `/t:`, e.g.:
|
|
|
|
```powershell
|
|
$installPath = & "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath
|
|
$msbuild = "$installPath\MSBuild\Current\Bin\MSBuild.exe"
|
|
& $msbuild "Windows\PPSSPP.sln" /t:UnitTest /p:Configuration=Debug /p:Platform=x64 /m
|
|
```
|
|
|
|
(swap `/t:UnitTest` for `/t:PPSSPPWindows` or another project name as needed; drop it entirely to build the whole solution).
|
|
|
|
In addition to the pspautotests runner (test.py), there is a separate binary with C++ unit tests
|
|
in the /unittest subdirectory. After substantial changes (at the end of a chunk of work, not
|
|
necessarily after every edit), run these too:
|
|
|
|
- Windows: build the `UnitTest` project (unittest/UnitTests.vcxproj), then run `Windows/x64/Debug/UnitTest.exe all`
|
|
- Linux/Mac: configure with `-DUNITTEST=ON`, then run `build/PPSSPPUnitTest all`
|
|
|
|
This runs all tests in `availableTests` in unittest/UnitTest.cpp. You can run one or more
|
|
specific tests by passing their names instead of `all` (space-separated, e.g. `UnitTest.exe
|
|
CmdLine Path Utf8`); no arguments lists the available tests.
|
|
|
|
After a `git stash`/`git stash pop` that touches a **header**, do a `/t:Rebuild` rather than trusting
|
|
the incremental build. Stashing rewrites files with timestamps that can leave objects looking newer
|
|
than the header they were compiled against, so adding or removing a class member produces a binary
|
|
where translation units disagree on the object layout. That shows up as `UnitTest.exe` segfaulting
|
|
before it prints anything, for every test including ones unrelated to the change - which looks like
|
|
a catastrophic code bug and is not one. If a build starts crashing inexplicably right after a stash
|
|
cycle, rebuild before investigating anything else.
|
|
|
|
Known environment-specific issue: in at least one sandboxed dev environment, the `Jit` test
|
|
(`unittest/JitHarness.cpp`) hangs indefinitely specifically during the `CPUCore::JIT_IR`
|
|
phase - confirmed unrelated to source changes (reproduces identically on unmodified checkouts)
|
|
and not a memory-access fault (`Memory::HandleFault` is never entered). Root cause wasn't
|
|
pinned down further (would need a native debugger attached to the hung process, not available
|
|
in that environment) but is very likely specific to that sandbox rather than a real PPSSPP
|
|
bug, since CI runs the equivalent of `UnitTest.exe all` on every commit across multiple
|
|
platforms without apparent issue. If `all`/`Jit` hangs in your environment, run every other
|
|
test by name instead (skip `Jit`) to still get real coverage.
|
|
|
|
## Multiplatform considerations
|
|
|
|
The emulator has multiple platform-specific entry points. Some of these will be merged or removed in the future, but are all
|
|
still there. To verify that a change works, technically we need to compile for all these systems, but in practice we'll
|
|
just compile locally and test the platform we are currently on, and let CI handle the cross platform considerations.
|
|
|
|
System_-prefixed wrapper functions implement kind of a platform wrapper for some functionality, and are implemented in
|
|
the following list of files for each system. If we change one, we need to change them all.
|
|
|
|
Windows/main.cpp
|
|
ios/main.cpp
|
|
SDL/SDLMain.cpp
|
|
UWP/PPSSPP_UWPMain.cpp
|
|
android/jni/app-android.cpp
|
|
libretro/libretro.cpp
|
|
|
|
## Legacy Android build (android/jni)
|
|
|
|
There is a legacy Android build using the raw NDK build system (`android/jni/Android.mk` + `ndk-build`), separate from the
|
|
gradle build in `android/`. It's hooked up on CI (see `.github/workflows/build.yml`, the `android` matrix entries) and is
|
|
useful for quick test builds (it can build `ppsspp_headless` and the unit tests for Android). You do not need to build it
|
|
by default, but if you want to test-build it locally:
|
|
|
|
- The NDK path is hardcoded in `android/ab.cmd` (Windows) or passed via the `NDK` env var to `android/ab.sh` (POSIX).
|
|
It should match the `ndkVersion` in `android/build.gradle.kts`. The scripts copy assets first, then run ndk-build with
|
|
a core count derived from the machine (nproc / %NUMBER_OF_PROCESSORS%).
|
|
- Example (POSIX): `cd android && NDK=/path/to/ndk ./ab.sh APP_ABI=arm64-v8a HEADLESS=1`
|
|
- The `ppsspp_headless` executable ends up in `android/libs/<abi>/`.
|
|
|
|
## libretro core build (Windows)
|
|
|
|
Canonical instructions are in `libretro/README_WINDOWS.txt` - read that first, this is a summary plus
|
|
agent-specific gotchas. The libretro core (`ppsspp_libretro.dll`) is built with a real `make`, not the
|
|
Visual Studio solution, even on Windows - it uses `cl.exe`/`link.exe` as the compiler/linker (via
|
|
`platform=windows_msvc2019_desktop_x64`), but orchestrated through GNU Make running inside an MSYS2
|
|
shell (a plain MSYS2 install, not "Git Bash" - typically at `C:\msys64`, needs `pacman -S make`).
|
|
|
|
```sh
|
|
cd libretro
|
|
make DEBUG=1 platform=windows_msvc2019_desktop_x64 -j32
|
|
```
|
|
|
|
(drop `DEBUG=1` for a release build; `-j` count doesn't need to match logical CPUs exactly). To test the
|
|
result, copy `ppsspp_libretro.*` into wherever the local RetroArch install reads cores from (e.g. its
|
|
`cores/` directory) and load it from within RetroArch.
|
|
|
|
An agent can drive this non-interactively by invoking `C:\msys64\usr\bin\bash.exe -lc "..."` directly
|
|
as a subprocess (the `-l` login-shell flag matters - it's what sets up MSYS2's own `PATH`, `make`,
|
|
`cygpath`, etc. correctly). In a sandboxed/agentic invocation (as opposed to a normal interactive MSYS2
|
|
terminal a human opens), two Windows environment variables the Makefile's VS-detection logic depends on
|
|
may not be inherited by the spawned process - `COMSPEC` (breaks the `cmd //c "bash VSWhere.sh ..."` call
|
|
used to locate Visual Studio) and `ProgramFiles(x86)` (which `VSWhere.sh` itself needs to find
|
|
`vswhere.exe`). If VS auto-detection fails this way, skip it by overriding `VsInstallRoot` directly on
|
|
the `make` command line (GNU Make command-line variables take precedence over the Makefile's own `:=`
|
|
assignment of the same name):
|
|
|
|
```sh
|
|
make VsInstallRoot="/c/Program Files/Microsoft Visual Studio/<year>/<edition>" DEBUG=1 platform=windows_msvc2019_desktop_x64 -j32
|
|
```
|
|
|
|
(path in MSYS2/cygpath POSIX form, not a raw Windows path; find the real value via `vswhere -latest
|
|
-property installationPath` if unsure of `<year>/<edition>`). This is a real full compile+link - prefer
|
|
it over trying to syntax-check libretro-specific files with a standalone `cl.exe /Zs` invocation, which
|
|
can miss real bugs (e.g. an include-order issue that leaves a platform macro like
|
|
`VK_USE_PLATFORM_WIN32_KHR` undefined before `vulkan.h`'s first, include-guarded inclusion, since a
|
|
narrower manual include-path/define set used for a syntax-only check may not reproduce the actual build
|
|
step's ordering).
|
|
|
|
## Command-line parsing
|
|
|
|
All command-line parsing for both the main app and headless builds belongs in `Core/CmdLine.cpp` /
|
|
`Core/CmdLine.h` (`CommandLineOptions`), not in the platform entry points (`Windows/main.cpp`, `headless/Headless.cpp`,
|
|
`UI/NativeApp.cpp`, etc.). Don't re-parse `argv` manually in those files - add a field to `CommandLineOptions` instead.
|
|
|
|
- Most options are declared in the `g_autoParams` table in `CmdLine.cpp` as `{offsetof(...), type, longName,
|
|
shortName, docString, mode}`. `mode` gates the option to `CmdLineMode::Application`, `::Headless`, or `::Both`
|
|
(the default if the field is omitted from the initializer) - the same long name can be reused for both modes with
|
|
different types/meanings (e.g. `--log` is a `String` "log to FILE" option in Application mode but a `Bool` "full
|
|
log output" option in Headless mode; they don't collide because a given `Parse()` call only matches params whose
|
|
mode is `Both` or equal to the current mode).
|
|
- Options that can be repeated (e.g. `--ignore TESTNAME`, collected into a `std::vector<std::string>`) or that don't
|
|
fit the generic single-value table need manual handling in the `else if` chain inside `CommandLineOptions::Parse()`,
|
|
similar to how `--graphics=` and `boot Filenames` are handled.
|
|
- `ApplyToConfig()` is where parsed options get pushed into `g_Config`/`g_logManager`; prefer wiring a new option
|
|
through there so all platforms get it for free, rather than reading `CommandLineOptions` fields ad-hoc at each
|
|
call site.
|
|
- `NativeInit()` in `UI/NativeApp.cpp` still takes `argc`/`argv` (several platform entry points pass them in), but
|
|
it shouldn't read them directly - by the time `NativeInit()` runs, `CommandLineOptions` should already have
|
|
everything.
|
|
|
|
## File formats, codecs, and other format handlers
|
|
|
|
Before implementing any file format handler, decompressor, codec, or similar from scratch, search the
|
|
codebase first - PPSSPP already has implementations of many formats (CSO, LZRC, zlib-based loaders, ISO
|
|
handlers, PBP, SevenZip, etc.), possibly in several places. Reuse or extend an existing one instead of
|
|
writing a new one (e.g. there is an LZRC decompressor in Core/FileSystems/tlzrc.cpp).
|
|
|
|
## Headless and unittest builds
|
|
|
|
We have additional PPSSPPHeadless and unit test builds (/headless and /unittest), that have their own separate
|
|
main functions (and also stub out most of the System_ functions as needed). Take these into account
|
|
when making cross platform changes.
|
|
|
|
New unit tests are added by listing them in availableTests in unittest.cpp. If they are large, put them in
|
|
separate files in the unittest subdirectory. Remember to update both CMakeLists.txt and the visual studio project.
|
|
|
|
A unit test is often the first thing to call a given function from outside its own .cpp, which makes the
|
|
`ppsspp_unittest` target in the legacy Android build (`android/jni/Android.mk`, see above) the strictest check
|
|
we have: MSVC links an `inline` function defined in a .cpp anyway, clang correctly does not. So a test can
|
|
build and pass on Windows and fail to link only on Android CI, with an undefined symbol pointing at a header
|
|
line. Fix it by dropping the bogus `inline` from the definition, not by avoiding the call.
|
|
|
|
pspautotests are a large set of tests of the PSP OS's API surface, and thus tests our HLE implementation.
|
|
|
|
**To check for regressions, run them exactly the way CI does** (see `.github/workflows/build.yml`):
|
|
|
|
```bash
|
|
python test.py -g --graphics=software
|
|
```
|
|
|
|
**The `-g` matters.** `test.py` keeps two lists: `tests_good` (the regression set - these pass and must keep
|
|
passing, ~314 of them) and `tests_next` (work-in-progress tests that are *expected* to fail, i.e. the to-do list).
|
|
`-g` runs only `tests_good`; with no flag you get `tests_next + tests_good` and around a hundred failures that mean
|
|
nothing is wrong. Don't go hunting those, and don't report them as regressions - the only meaningful result from
|
|
`-g` is `0 tests failed`. (`-b` runs only `tests_next`; `-m` prefix-filters whichever list is selected.)
|
|
|
|
Note the runner prints a debug-CRT "Detected memory leaks!" dump after the summary line on Windows debug builds.
|
|
That's normal and not a test failure - read the `N tests passed, N tests failed` line, which comes before it.
|
|
|
|
See docs/pspautotests.md for a workflow for running pspautotests and improving PPSSPP with the results.
|
|
|
|
## Framedump rendering tests (frametests)
|
|
|
|
There is a rendering test system that replays GE frame dumps (`.ppdmp`) through PPSSPPHeadless and compares
|
|
the output against reference images, driven by the `frametests.py` script and a JSON config per test set.
|
|
When changing rendering code, consider running these tests. See docs/frametest.md for full documentation.
|
|
|
|
Note: `headless/Compare.cpp` reads back framebuffers top-down; the flip to bottom-up is only applied when
|
|
writing BMPs (and when reading BMP references). `TranslateDebugBufferToCompare` also exists as a copy in
|
|
`libretro/LibretroGraphicsContext.cpp` - keep the two in sync.
|
|
|
|
## Adding HLE modules
|
|
|
|
HLE module implementations live in `Core/HLE/sce<ModuleName>.cpp` / `.h` (e.g. `sceOpenPSID.cpp`, `scePauth.cpp` are good
|
|
small examples to copy from). A module is a `const HLEFunction <name>[]` table of
|
|
`{nid, &WrapX_YYY<func>, "funcName", retChar, argString}` entries, registered via
|
|
`RegisterHLEModule("<name>", ARRAY_SIZE(table), table)` inside a `Register_<name>()` function declared in the header.
|
|
|
|
- `FunctionWrappers.h` has generic `WrapX_YYY<func>` templates for common signatures (return type X, args YYY) - add new
|
|
wrappers there if you need a new signature.
|
|
- Format string legend for the `retmask`/argmask chars: `x` = u32 (shown as hex), `i` = int/s32, `f` = float, `X` = u64,
|
|
`I` = s64, `v` = void.
|
|
- For functions of genuinely unknown purpose (only known by NID), name them `<moduleName>_<NID>` and stub them with
|
|
`return hleLogError(Log::HLE, 0, "UNIMPL");` - an established pattern (see `scePauth.cpp`, `sceOpenPSID.cpp`).
|
|
- **New modules must be registered at the very end** of the registration function in `Core/HLE/HLETables.cpp` (look for
|
|
the `// add new modules here.` comment near the end of that function) - not inserted alphabetically/logically among
|
|
the existing `Register_*()` calls. Module registration order affects numeric IDs used in savestates, so inserting a
|
|
new module earlier in that list would break save-state compatibility for saves made with older builds.
|
|
- Remember to add any new `.cpp`/`.c` file to **seven** places: `Core/CMakeLists.txt`, `Core/Core.vcxproj`,
|
|
`Core/Core.vcxproj.filters`, `UWP/CoreUWP/CoreUWP.vcxproj`, `UWP/CoreUWP/CoreUWP.vcxproj.filters`,
|
|
`android/jni/Android.mk`, and `libretro/Makefile.common`. New `.h` files need the first five (everything except
|
|
`Android.mk`/`Makefile.common`, which are plain compiled-source lists so headers don't go in them). Double check
|
|
each by hand against how an existing neighboring file (e.g. `sceVaudio.cpp`) is listed. Forgetting the UWP entries
|
|
is easy to miss - the CMake and MSBuild (`Core.vcxproj`) builds both succeed silently, and it only surfaces as a
|
|
UWP-only build failure (this has happened for real: `Core/MIPS/InterpreterDispatch.cpp` landed without its UWP
|
|
entries, and the omission wasn't caught until someone actually built the UWP project). Note: New files in the
|
|
unittest project have to be updated in the unittest part in android/jni/Android.mk.
|
|
|
|
Both the CMakeLists.txt change (via a Linux/Mac build) and the `Core.vcxproj`/UWP changes (via MSBuild on Windows)
|
|
can actually be build-tested, not just eyeballed - see "Build and Validation" above for the main Windows solution,
|
|
and for UWP specifically:
|
|
```powershell
|
|
$installPath = & "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath
|
|
$msbuild = "$installPath\MSBuild\Current\Bin\MSBuild.exe"
|
|
& $msbuild "UWP\PPSSPP_UWP.sln" /t:CoreUWP /p:Configuration=Debug /p:Platform=x64 /m
|
|
```
|
|
(only `android/jni/Android.mk` and `libretro/Makefile.common` genuinely can't be build-tested here - see their
|
|
respective sections above for what verification is possible for those.)
|
|
|
|
## WebSocket debugger
|
|
|
|
PPSSPP has a JSON/WebSocket debugger and automation API (connect, read/write memory, search memory for values or byte
|
|
patterns, set breakpoints, step the CPU, label data symbols, read GPU state, inject input, tail logs, etc.), served on
|
|
the same port as Remote ISO sharing at `/debugger` with subprotocol `debugger.ppsspp.org`. Implementation is in
|
|
`Core/Debugger/WebSocket.cpp` and `Core/Debugger/WebSocket/*Subscriber.cpp` (one file per feature area, each
|
|
documented at the top). Enable it via Settings > Tools > Developer Tools > "Allow remote debugger",
|
|
`RemoteDebuggerOnStartup` in the config, or `--debugger=PORT` on the command line (`0` = pick a port automatically) -
|
|
works on both the application and headless builds. On headless it also forces a break at start (`startBreak`), so the
|
|
CPU halts before anything runs. The bundled web GUI at `/debugger/` comes from the `assets/debugger` submodule
|
|
(`unknownbrackets/ppsspp-debugger`, `bundled` branch).
|
|
|
|
The `bundled` branch only holds built output, so `assets/debugger/static/js/main.*.js` in this tree is minified -
|
|
don't try to answer "does the web GUI use this event/parameter?" by reading it, the identifiers are mangled and you
|
|
will guess wrong. **The unminified source is at https://github.com/unknownbrackets/ppsspp-debugger** (default branch,
|
|
not `bundled`) - fetch or grep that when you need to know what the official client actually sends, e.g. before
|
|
changing or removing part of the protocol.
|
|
|
|
**Before touching this interface, read `docs/WebSocketDebugger.md`** - it has the full protocol reference and event
|
|
catalog (including which events are read-only vs. require `cpu.stepping` first). Don't guess event names or
|
|
parameters from memory; the doc (and each `*Subscriber.cpp` file's per-handler comments) is the source of truth, and
|
|
new events get added over time (e.g. `memory.search`, `hle.data.*`).
|
|
|
|
When adding new commands, don't forget to update `docs/WebSocketDebugger.md`,
|
|
|
|
To quickly get a live session going for manual testing (e.g. after adding/changing an event): build `PPSSPPWindows`
|
|
(see Build and Validation above), then run it with `--debugger=PORT` and something that keeps running/looping so the
|
|
CPU stays alive, so requests get a response instead of "CPU not started"/"CPU not active" errors. Any homebrew or
|
|
game works; PSP homebrew isn't checked into this repo, so if you don't already have something installed under
|
|
`memstick/PSP/GAME/`, ask the user for a `.iso`/`.cso`/`.elf`/`EBOOT.PBP` to boot, or to install one via the in-app
|
|
Homebrew Store. Watch the log output (`--log=somefile.log`) for the line `Listening on port N`, then point
|
|
`Tools/wsdbg/` at that port (`cargo run -- N <event> [key=value...]` for one-shot, or `cargo run -- N` for a REPL).
|
|
Most mutating events (`hle.func.*`, `hle.data.*`, memory writes while paused, etc.) require the CPU to be stopped
|
|
first - send `cpu.stepping` and `cpu.resume` to pause/unpause.
|
|
|
|
Alternatively use the headless build, Windows/{arch}/Debug/PPSSPPHeadless.exe or build/PPSSPPHeadless on CMake-based
|
|
platforms. Where arch is x64 or ARM64.
|
|
|
|
### Driving a headless debugger session (gotchas)
|
|
|
|
A working invocation, and the traps around it:
|
|
|
|
```bash
|
|
./Windows/x64/Debug/PPSSPPHeadless.exe -i --debugger=34567 --timeout=100000 --graphics=software --log \
|
|
--root pspautotests/tests/../ pspautotests/tests/cpu/cpu_alu/cpu_alu.prx > hl.log 2>&1 &
|
|
# wait for "Listening on port" in hl.log, then:
|
|
./Tools/wsdbg/target/release/wsdbg.exe 34567 --sync --sync-timeout 15 < script.txt
|
|
```
|
|
|
|
- **`--timeout` is wall-clock seconds for the whole session**, not per test - the default is infinity, but as soon as
|
|
you pass one it applies to your whole interactive debugging session too. Pass something huge (`--timeout=100000`);
|
|
otherwise the process prints `TIMEOUT` and exits out from under you mid-session. (There's an escape hatch: the
|
|
deadline check is skipped while `IsDebuggerPresent()`, i.e. under a native debugger.)
|
|
- **Prefer `--debugger=0` and scrape `Listening on port N` from that run's own log** over hardcoding a port. Also
|
|
`taskkill //F //IM PPSSPPHeadless.exe` between runs for hygiene (Git Bash here has no `pkill`) - leftover
|
|
instances are easy to accumulate when a script leaves the CPU stopped at a breakpoint.
|
|
|
|
Some history, because it silently produced a round of bogus results before it was fixed: `Common/Net/HTTPServer.cpp`
|
|
used to set `SO_REUSEADDR`, which on Winsock means "allow binding a port someone else is already listening on"
|
|
(unlike POSIX, where it only covers TIME_WAIT). Two instances would *both* bind the same explicit port and *both*
|
|
log `Entering web server loop. Listening on port 34567`, with the winner of any given connection undefined - so a
|
|
client aimed at a fixed port could end up driving a leftover process running a different binary, CPU backend, or
|
|
game. It's `SO_EXCLUSIVEADDRUSE` on Windows now, so the second bind fails honestly, and a non-zero `--debugger=PORT`
|
|
that can't be honored is fatal in headless (exit 1) instead of falling back to a random port. If you still suspect
|
|
you're talking to the wrong process, the `version` response carries `pid` and `path` - check them.
|
|
- **The headless build defaults to JIT** (`Headless.cpp`, `CPUCore cpuCore = CPUCore::JIT`), despite
|
|
`g_Config.iCpuCore` being force-set to INTERPRETER just above - `ApplyToConfig()` has the final say. Pass `-i` for
|
|
the interpreter.
|
|
- **`-r` is ambiguous in headless**: it's both "use IR interpreter" (legacy short cpu-core flag) and `--root`'s short
|
|
form. Passing `-r` makes it eat the *next* argument as the root path, silently dropping e.g. `--debugger=PORT` so
|
|
the server never starts. Use `--cpu=ir` instead. (`-i`, `-j`, `-J` are unambiguous.)
|
|
- When a test finishes, headless exits and the WebSocket connection closes (`CloseFrame { code: Away }`). So "the
|
|
connection just closed" after a `cpu.resume` normally means **the breakpoint you were counting on never tripped**
|
|
and the game ran to completion - not a transport problem.
|
|
- Some events deliberately never respond while the CPU is stepping, so `--sync` will burn its full timeout on them:
|
|
`gpu.stats.get` and `gpu.stats.feed` (documented - they answer after the next flip), `gpu.record.dump`, and
|
|
`input.buttons.press` (waits for N frames). Resume the CPU first, or skip them in scripted runs.
|
|
- Log broadcasts drown scripted output. Send this first:
|
|
`{"event":"broadcast.config.set","disallowed":{"logger":true,"input":true}}`. Note `wsdbg`'s `key=value` shorthand
|
|
can't build nested objects - paste raw JSON lines (any line starting with `{` is sent verbatim) for those.
|
|
- 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.
|
|
- Headless defaults its memstick to `memstick` next to the executable (`Headless.cpp`). Pass `--memstick=DIR` to
|
|
point it at a real one instead - e.g. the same directory the app build uses - rather than copying a game in.
|
|
- 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` accepts all five categories now (`logger`, `input`, `game`, `stepping`, `breakpoint`);
|
|
it used to reject `game` and `stepping` until each had happened to fire once.
|
|
- **A script has to keep the connection open long enough for what it asked for to happen.** `cpu.runUntilTime`
|
|
followed immediately by `:quit` disconnects before the run even starts, and it looks exactly like the feature
|
|
not working. End with a `:wait cpu.stepping <seconds>`.
|
|
- **Exception and crash messages do not reach the log in headless.** It registers its own debug-output listener
|
|
(`SendDebugOutput` in `headless/Headless.cpp`) that `fwrite`s to stdout, which is block-buffered when you
|
|
redirect it to a file - so the output sits in the CRT buffer while the process runs, and `taskkill //F` throws
|
|
it away rather than flushing. To actually read a crash trace, give that run a short `--timeout` and `wait` for
|
|
the process to exit on its own.
|
|
- **`0xFFFFFFFF` is not an invalid instruction** - it decodes to `vflush`, a real Allegrex VFPU op, so writing it
|
|
over code to test illegal-instruction handling just runs it. Check what an encoding actually is with
|
|
`memory.disasm` before assuming it's garbage; the interpreter raises `ExecExceptionType::ILLEGAL` only when
|
|
`MIPSGetInstruction` has no interpreter for it (`tge`/`tlt`/`teq` and friends).
|
|
- **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
|
|
|
|
First, turn on `bAutoSaveLoadSymbols` (`--auto-save-load-symbols` in headless): when homebrew ships its
|
|
unstripped ELF next to the EBOOT (`app.elf` alongside `app.prx`, common for Zig/Rust/SDK homebrew), PPSSPP loads
|
|
the function and data names out of it, so the disassembly reads `world.init_empty` instead of `z_un_088c00f0`.
|
|
prxgen strips the symbol table on the way to the PRX, which is why the loaded module has none of its own.
|
|
|
|
The same flag also loads DWARF line info from that ELF (`Core/Debugger/LineInfo.h`), so addresses turn into
|
|
`mesh.zig:163` in backtraces, crash traces, breakpoint hits and log lines, both call stack views, and the
|
|
disassembly status bar. Availability is narrow and worth knowing before relying on it: **PRX conversion strips
|
|
every `.debug` section**, verified across all 437 pspautotests `.prx` and CrossCraft's own `app.prx`, and of 24
|
|
installed homebrew EBOOTs *none* carry debug info - CrossCraft only does because it ships `app.elf` separately.
|
|
So it's there for homebrew you're developing (or a plain `.elf` you built), never for a commercial game. DWARF 2
|
|
through 4 are decoded (psp-gcc emits 2, Zig 4); v5 re-encoded the file table and its units are skipped with a
|
|
warning rather than mis-parsed.
|
|
|
|
That same file is also 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)
|
|
|
|
CPU-thread-owned debugger state (`g_breakpoints`, `g_symbolMap`, `g_disassemblyManager`, registers,
|
|
memory, kernel threads) used to be touched directly from other threads (the WebSocket handler
|
|
thread, and the legacy Win32 debugger's message-pump thread) with no synchronization. Two
|
|
mechanisms now exist for doing this safely - pick based on whether you're mutating or just reading:
|
|
|
|
- **`Core_RunOnCPUThread(func)`** (`Core.h`/`Core.cpp`) - queues `func` to run on the CPU thread,
|
|
blocking the caller until it's done. Use for *mutations* (breakpoint add/remove, register/memory
|
|
writes, symbol map edits, stepping requests, thread wake/kill). Drained at the top of every
|
|
`Core_RunLoopUntil()` iteration, so it's reached whether the CPU is running or stepping/paused;
|
|
runs immediately if already called from the CPU thread. Two hard rules learned the hard way:
|
|
never put a modal Win32 dialog call (`MessageBox`, `DialogBoxParam`, `InputBox_GetString`,
|
|
`SomeDialog::exec()`) inside the queued lambda - it would block the CPU thread on user input, so
|
|
split the function into "read/decide", "show modal", "mutate" pieces instead. And never call
|
|
`SendMessage()` targeting one of your own GUI-thread windows from inside the lambda - the calling
|
|
GUI thread is blocked waiting on the CPU thread rather than pumping messages, so a cross-thread
|
|
`SendMessage()` back to it deadlocks; keep such calls outside the lambda instead.
|
|
- **`g_frameMutex`** (`Core.h`/`Core.cpp`) - a plain `std::mutex`, held by `NativeFrame()`
|
|
(`UI/NativeApp.cpp`) only across the span where it actually touches that state
|
|
(`g_breakpoints.Frame()` through `g_screenManager->render()` - where `Core_RunLoopUntil()`/actual
|
|
CPU stepping happens - through `runImDebugger`/`renderImDebugger`), not across input handling or
|
|
the present/frame-pacing waits. Use for *reads* invoked very frequently (`WM_PAINT`, a list
|
|
reload triggered on every debugger-state-changed notification) where routing through
|
|
`Core_RunOnCPUThread` would be too slow/heavy. The legacy Win32 debugger windows do this now -
|
|
see `CtrlRegisterList::onPaint`, `CtrlDisAsmView::onPaint`, `CtrlMemView::onPaint`,
|
|
`CtrlBreakpointList::reloadBreakpoints`, `CtrlThreadList::reloadThreads`, etc. in
|
|
`Windows/Debugger/*.cpp`.
|
|
|
|
Architecture fact that makes `g_frameMutex` correct: regardless of graphics backend, `NativeFrame()`
|
|
(and thus CPU emulation via `Core_RunLoopUntil()`, and the Dear ImGui debugger) always runs on the
|
|
*same* thread - see `Core/EmuThread.cpp`. When a backend needs its own thread for actual graphics
|
|
API calls (`GraphicsContext::NeedsSeparateEmuThread()` - true for OpenGL, SDL, headless, libretro,
|
|
Qt; false for D3D11/Vulkan on Windows), the *original* thread stays behind purely to pump
|
|
`graphicsContext->ThreadFrame()` (i.e. just executes queued graphics API calls), and a *newly
|
|
spawned* thread takes over `NativeFrame()`/game logic/CPU duty. So `UI/ImDebugger/*.cpp` is always
|
|
safe to read/write this state directly, without either mechanism - it's always on the same thread
|
|
as `Core_RunLoopUntil()`. The legacy Win32 debugger is different: its dialogs are pumped by the
|
|
*original* `WinMain` message-loop thread, which is a genuinely separate OS thread from whichever
|
|
thread ends up running `NativeFrame()`/CPU, regardless of backend (this split happens one level
|
|
above the `NeedsSeparateEmuThread()` branch) - that's the whole reason it needed this mechanism.
|
|
|
|
**Update (2026-08-08): both `BreakpointManager`'s and `SymbolMap`'s internal mutexes have been
|
|
removed** after auditing every touchpoint across the codebase (WebSocket subscribers,
|
|
`Windows/Debugger/*.cpp`, `Windows/MainWindowMenu.cpp`/`Windows/MainWindow.cpp` main-window menu
|
|
items, `Core/Core.cpp`'s free-threaded `Core_Break`/`Core_Resume`, `UI/ImDebugger/*.cpp`,
|
|
JIT/interpreter backends, `Core/Debugger/MemBlockInfo.cpp`) and confirming each is covered by one of
|
|
the two mechanisms above or is already on the CPU/NativeFrame thread. Notably, `Windows/main.cpp`'s
|
|
`SortSymbols()` calls (fired from `System_Notify(BOOT_DONE)`/`System_Notify(SYMBOL_MAP_UPDATED)`)
|
|
turned out to already be safe without any change - both notifications are only ever fired from the
|
|
CPU/NativeFrame thread (`UI/EmuScreen.cpp`, `Core/HLE/sceKernelModule.cpp`), despite an old comment
|
|
there claiming reliance on the (now-removed) internal lock. `Qt/mainwindow.cpp`/`Qt/QtMain.cpp`
|
|
still poke at `g_symbolMap` directly and unguarded on the Qt UI thread - a pre-existing issue,
|
|
deliberately left alone since Qt isn't a maintained backend and is slated for removal; removing the
|
|
lock doesn't change `SymbolMap`'s public API, so Qt still builds, just without that safety net.
|
|
`GPU/Common/GPUDebugInterface.cpp` (GE debugger expression evaluation) and `Core/MemFault.cpp`
|
|
(crash-time diagnostics) also touch `g_symbolMap` and were deliberately not audited this round -
|
|
different subsystem / best-effort-by-nature respectively, follow up if they ever come up.
|
|
|
|
Two more things found while doing this:
|
|
- **`Core_RunOnCPUThread()`'s queue is only drained where `Core_RunLoopUntil()` runs, which requires
|
|
a game to be loaded** (it's called from `EmuScreen::render()`). Calling `Core_RunOnCPUThread()`
|
|
while at the main menu with nothing loaded used to hang forever. Fixed by also calling the
|
|
(now public) `Core_ProcessCPUQueue()` directly from `NativeFrame()`, right before
|
|
`g_screenManager->render()`, inside the same `g_frameMutex`-locked span - so it always runs, not
|
|
just while a game is active.
|
|
- **Lock-ordering rule**: because `Core_ProcessCPUQueue()` is called from inside `NativeFrame()`'s
|
|
`g_frameMutex`-locked span, any `Core_RunOnCPUThread()` lambda that itself tries to lock
|
|
`g_frameMutex` (directly, or indirectly - e.g. by calling something like
|
|
`CDisasm::NotifyMapLoaded()`, which locks it internally) will deadlock. Keep such calls outside
|
|
the queued lambda, same as the modal-dialog and `SendMessage()` rules above.
|
|
|
|
### Lock ordering: `g_frameMutex` before `Core_LockAgainstShutdown()`, always
|
|
|
|
`Core_LockAgainstShutdown()` / `CoreShutdownLock` (a `recursive_mutex`, `g_shutdownLock` in
|
|
`Core/Core.cpp`) is held across `CPU_Shutdown()` and `Memory::Reinit()`, i.e. while the core is
|
|
going away. Take it on any thread other than the CPU thread before reading core state - emulated
|
|
memory, the symbol map, kernel objects - so none of it is freed mid-read. It was called
|
|
`Memory::Lock()` and only covered the memory map; the name misled people into thinking it was about
|
|
memory access. When a function needs both it and `g_frameMutex`, **take `g_frameMutex` first**.
|
|
|
|
The CPU thread's order is structural and can't be changed: `NativeFrame()` wraps everything below it
|
|
in `g_frameMutex`, and several things under there lock memory - `Core_ProcessCPUQueue()` running a
|
|
queued WebSocket handler, and `runImDebugger()` -> `ImMemView` -> `DisassembleRange()`. So the
|
|
GUI-thread side is the one that has to match. (Getting it backwards deadlocked for real: a paint
|
|
handler held the memory lock and waited for `g_frameMutex` while the CPU thread did the reverse.)
|
|
|
|
Also: **a `Core_RunOnCPUThread()` callback does not need `Core_LockAgainstShutdown()`** - teardown only happens
|
|
on the CPU thread itself (`Memory::Shutdown()` via `CPU_Shutdown()` <- `PSP_Shutdown()`, all callers
|
|
on that thread; `Memory::Reinit()` from `Memory::DoState()` on savestate load). Don't add one.
|
|
|
|
**The general rule behind both of these: never make the CPU thread wait for a thread that is (or may
|
|
be) waiting on the CPU thread.** `Core_RunOnCPUThread()` blocks until the CPU thread drains the
|
|
queue, so anything the CPU thread might block on must not be held across such a call. The WebSocket
|
|
debugger's `lifecycleLock` hit exactly this - it was held across a whole event handler, and the CPU
|
|
thread took it in `Core_NotifyLifecycle(STOPPING)`, so stopping a game with a debugger request in
|
|
flight hung both threads. That lock is gone now; the rule is what's left of it.
|
|
|
|
**The WebSocket debugger no longer has any lock guarding it against startup/shutdown, and must not
|
|
grow one back.** The invariant instead is: a handler either does its emulator-state access inside
|
|
`Core_RunOnCPUThread()` - which serializes it against startup and teardown, since those run on the
|
|
CPU thread too - or touches only state that carries its own lock (the log ring buffer, `ctrlMutex`,
|
|
`GPUStepping`'s pause-action rendezvous). When adding a subscriber, put the core access in the
|
|
queued callback, including the `isAlive()`/`IsValidAddress()` checks: answering those outside it
|
|
just means acting on an answer that may already be stale.
|
|
|
|
`game.*` and `cpu.stepping`/`cpu.resume` are pushed rather than polled - `WebSocketDebuggerTick()`
|
|
(called from `Core_ProcessCPUQueue()`) notices the transition on the CPU thread, formats the event
|
|
there, and drops it in a per-connection mailbox. Don't add a broadcaster that reads emulator state
|
|
from the connection's own thread; produce the event on the CPU thread and push it instead.
|
|
|
|
The Win32 debugger's GUI-thread readers *do* still need it, so don't "simplify" those away:
|
|
teardown is not yet fully inside the `g_frameMutex` span. `EmuScreen::render()`'s `PSP_Shutdown()` is
|
|
inside it, but the ones in `EmuScreen::sendMessage()` (`REQUEST_GAME_RESET`, loading a new game) run
|
|
from `g_screenManager->sendMessage()` in `NativeFrame()`, which sits *above* where the guard is
|
|
taken. Closing that hole - moving those shutdowns inside the span, or deferring them to render time -
|
|
is the prerequisite for dropping the shutdown lock from the debugger entirely.
|
|
|
|
Painting-problem design history, in case a similar tradeoff comes up elsewhere: routing every paint
|
|
through `Core_RunOnCPUThread` was rejected as too slow for something invoked continuously. A
|
|
per-window snapshot/cache with a per-row-rechecked `Core_IsStepping()` guard was tried first and
|
|
worked, but still had a narrow TOCTOU race (the CPU could resume between the check and that row's
|
|
reads) and the per-row-recheck pattern itself wasn't liked. Settled on `g_frameMutex` instead -
|
|
simpler, and actually race-free rather than just lower-risk. `CtrlRegisterList` shows live values
|
|
always now, grayed out by color alone (not cached) while the core is running, since a
|
|
constantly-moving value isn't meaningful to read closely anyway.
|
|
|
|
## Debugging and breakpoint considerations
|
|
|
|
It might be worth trying the interpreter - all types of breakpoints are the most reliable with this CPU backend.
|
|
The JITs are much, much faster and in theory also support breakpoints, and we're trying to make the JITs
|
|
as reliable, but are maybe not quite there.
|
|
|
|
Concretely, as measured against the headless build (2026-08-16), per CPU backend:
|
|
|
|
| | interpreter (`-i`) | JIT (`-j`) / IR JIT (`-J`) |
|
|
|---|---|---|
|
|
| `cpu.breakpoint.*` (exec) | works | works |
|
|
| `cpu.stepInto/Over/Out`, `runUntil`, `nextHLE` | works | works |
|
|
| `memory.breakpoint.*` (memchecks) | works | **only for constant addresses** - see below |
|
|
| `cpu.regBreakpoint.*` | works | never trips (as documented) |
|
|
|
|
## Quick rebuild on Linux
|
|
|
|
You don't need to do ./b.sh --debug to verify every single little change, instead use this shortcut:
|
|
|
|
```bash
|
|
cd build ; make -j32; cd ..
|
|
```
|