Technical Documentation: hrydgard/ppsspp
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (`README.md`)
PPSSPP - a fast and portable PSP emulator
Created by Henrik Rydgård
Additional code by many contributors, see the Credits screen
Originally released under the GPL 2.0 (and later) in November 2012
Official website: https://www.ppsspp.org/
Discord: https://discord.gg/5NJB6dD
No BIOS file required to play, PPSSPP is an "HLE" emulator. Default settings balance good compatibility and speed.
To contribute, see Introduction to development. This page also includes our new AI policy.
Help with testing, investigating, or fixing bugs is always welcome. See the list of issues.
For the latest source code, see our GitHub page.
For documentation of all kinds (usage, reference, development), see the documentation on the main website
For build instructions and other development tutorials, see the wiki.
To download fresh development builds for Android, Windows and Mac, go to the /devbuilds page
For game compatibility, see community compatibility feedback.
What's new in 1.20.4
1.20.4 is an unusually big followup release, but given the new multiplayer developments, we need something to cover the longer gap to 1.21.
During this cycle, I've mostly focused on UX improvements.
Ad-hoc
Texture upscaling
Rendering fixes
Misc UI improvements
- Instant type-to-search in game browser (#21559, #21565, #21630)
- Fix file picker problems on some Android devices (regression) (#21614, #21656)
- Fix crash in text edit fields on Mac/iOS (#21601)
- PSP DVD prototypes can now load directly (#21599, #21601)
- Cheat UI has been cleaned up and supports titles and comments natively (#21590)
- RetroAchievements subset display improvements (#21536)
- Basic deep link support on iOS (#21615)
- Soft keyboard has more symbols (#21625)
Other
- Fix important (but mostly rare) crash bug affecting games that draw a lot (#21669)
- More plugin zip files can now auto-install (#21556)
- Pause on lost-focus on Linux (#21517)
- Frameskipping no longer breaks analog stick auto-rotation in GoW, however manual rotation still broken (#21508)
- Android: Restore "Landscape Auto" (#21482), the "Auto" screen rotation mode now overrides the user rotation lock (#21507)
- Contribution by lrzlin: Loongarch improvements (#21499, #21595)
- Basic support for extracting ISO from 7zip files (#21493)
- Fix a bug that prevented loading certain texture packs (#21491)
- Fix music looping in Death JR (#21490)
- Hide the save-load indicator in the corner by default (#21528)
- Fix crash in the remote debugger, by Nemoumbra (#21652)
- Fix screenshot cropping bug (#21665)
- mfplat.dll is no longer required on Windows unless you need camera/microphone (#21660)
What's new in 1.20.3
- Fix issue preventing ad hoc relay servers from working when RetroAchievements are enabled on Windows (#21420)
- Some ad hoc UI improvements (#21432, #21433)
- Fix crash/failure setting the background on iOS (#21409)
- Fix logging to file (#21412)
- Networking settings cleanup (#21418)
- Some fixes for assorted rare crashes (#21422)
- Fix issues when unpausing using a controller binding (#21424), DualSense Edge detection on Windows (#21426)
- Fix missing savestate undo button (#21425)
- MMPX texture upscaling algorithm has been restored, the new one has been improved (#21376) and renamed MMPX Advanced (#21421)
- Fix support for custom game config for homebrew apps (#21434)
- Improve insets handling on Android (#21437)
What's new in 1.20.2
- Improved server list for ad hoc multiplayer, dynamically updated and you can now add/remove entries (#21326, #21389, #21351)
- Fix broken multitouch on iOS with OpenGL (#21350)
- Ad hoc relay connection improvements (#21352)
- Fix a lot of minor UI issues (#21400, #21362)
- Fix background image selection on Android
and iOS(#21345, #21384, #21371) - Fix file permission issue on iOS (#21374)
- Add a "hold" version of axis swap toggle (#21357)
- Fix regression in Gripshift (#21377)
- Fix crash on audio device switch on Windows (#21341)
- Fix timing glitches in gamepad input on Windows (#21393)
- And other assorted fixes.
What's new in 1.20
User interface
- New feature: Proper support for Portrait Mode UI - you can now rotate your phone to vertical mode, and things will mostly work fine! (#21185, #21044...)
- New feature: Separate touch controls and display layout configs for landscape/portrait, new default touch control layout for portrait mode
- New feature: Upload files via HTTP from devices on the same network
- Savestate count limit can now be configured (#21112)
- UI icons are now vector-based, making them look sharper on high-DPI screens (#20824, #20870)
- Better handling of unsupported ISOs (#21169, #21166)
- Misc: Improved text rendering, chat box improvements (#20653), PIC0 is now visible on the game screen (#20686), (#20611), rotation fixes (#21145), input fixes (#21248), fixes for XBox FullScreen Experience, background stretch (#21211), handle savedata in main game browser (#21277), Discord Presence improvement (#21246)
- Show ICON1.PMF on the game info screen (#21303)
Regression fixes
- In 7th Dragon, voice-related crashes are fixed (#20837)
- Soundtrack is working again in Tony Hawk's Project 8 (#20775)
- Fix crash in StormBasic homebrew apps (#20715)
- Built-in songs in Beats work again (#20662)
- Fix adhoc in Syphon Filter games (#20642)
- Fix strange cutscene speedups in Power Stone 2 (Power Stone Collection) (#2124)
- Work around game bug to help more custom songs play again in GTA LCS (#20692)
Multiplayer
Controller and touch screen input
- Native support for DualSense (#20580, #20620, #21191), DualShock and Switch Pro (#20647) controllers on Windows - including tilt controls for DualSense and Switch Pro
- Misc touch screen and controller mapping improvements (#21195, #21197, #21258, #21325)
- Add simple virtual keyboard for text input for platforms that don't have one (#21306)
Graphics
- Fixed multiple graphical effects in Tales of Phantasia X
- Reworked the VSync setting to be more intuitive. It's now pretty much never a bad idea to turn it on.
- Fixes for various glitches in Tales of Phantasia X (#21173, #21141, #21205)
- Some more framebuffer readback workarounds (#20640, #20631, #20632)
- MMPX upscaling enhancements by crashGG (#20622, #20541)
- Huge rendering speedup in Brave Story (#21151)
- Fix rendering bug caused by CPU emulation bug in ATV Offroad Fury: Blazing Trails (#21238)
- Partially fix rendering problem in Mahjong Artifacts (#21244) and The Mystery of the Crystal Portal (#21236)
- Boost the GPU in Outrun 2006 to avoid unnecessary slowdowns (#21304)
Other game fixes
- Fix savedata problem in Silent Hill: Shattered Memories (#21294)
Audio
Platform support
- Windows
- Android
- The minimum Android version has been raised for technical reasons, KitKat is no longer supported (#19658)
- Support for Android devices with 16KB page size. This is a technical change, required by Google Play, that unfortunately forced us to drop compatibility with the very oldest devices (Android < 5). (#20788)
- Shortcut widgets can now be created even if the app isn't running (#20798)
- Removed support for classic Moga controllers, these were only a thing on early Android (#20762)
- iOS
- Add support for screen rotation
- Linux
- UWP
- Migrate code base to C++/WinRT (#21100)
Debugger
Other
What's new in 1.19.3
- Fixed crash on startup in Tony Hawk's Underground 2 (#20573)
- Fixed crash/hang when accessing the menu in FFII if readbacks were configured to copy-to-tex (#20573)
- Fix issue with detection of some types of saving, for the save reminders on exit (#20623)
- Fixed music and other audio in modded games (various football games, Crazy Taxi w/ original soundtrack, etc) (#20566, #20571)
- Fix exiting not functioning correctly on Windows in some circumstances (#20607)
- Minor UI and key binding fixes (#20604)
- Fix crash/blackscreen when switching from skip buffer effects to auto-frameskip rendering (#20605)
- Fix bug in Mac/Linux builds where the mouse got hidden and stuck if you enabled mouse input mapping (#20612)
- Fix black screen on save/load in the Football Manager Handheld games (#20616)
- Translation improvements
What's new in 1.19.2
- In-game save/load not properly detected for saving reminder (#20500)
- Install savedata from ZIP was partially broken (#20498)
- Fixed module loader bug affecting a few games (#20513)
- Fixed some hangs in the Windows debugger (#20510)
- Fix performance problem in texture replacement (#20520)
- Assorted fixes (#20518, #20514, #20502, #20515)
What's new in 1.19.1
- Fix selecting background image on Android (#20477)
- Fix RetroAchievements regression for multi-executable games (#20469)
- Possible fix for Mac audio device selection issue (#20482)
- Add workaround for Dragon's Lair not working with LLE scePsmf (#20468)
- Prevent trying to load obviously-corrupt CSO/CHD files (#20466)
- Fix regression for homebrew apps that request extra memory (#20457)
- Fix grid drawing in the various Robot Taisen games (#20456)
- Fix crash in UI when viewing a directory with multiple NPDRM ISOs (#20453)
What's new in 1.19
Audio
- Brand new sceAtrac implementation, fixing Flatout and multiple other long-standing issues! (#20098, #20136, #20138, #20162)
- More correctly implemented sceAtrac-through-sceSas functionality, fixing voice in Sol Trigger and multiple minis (#20156)
- Better support for using sceAudiocodec directly, fixing music in some homebrew apps (#20199, #20246, #20253, #20209)
- Volume control improvement (#19969, #19971)
Rendering fixes and performance
- Important: This will be the last major version with D3D9 support. D3D11 will of course continue to be supported, along with Vulkan and OpenGL.
- Fix smoke effects in Jak'n'Daxter by re-using the fix from Ratchet & Clank (#20032)
- Software depth buffer rendering added to fix lens flares in multiple games efficiently (Midnight Club LA, Wipeout, Syphon Filter etc) (#19748, #19764, #20231, #19955, etc)
- Multiple rendering-related crash fixes (#20270, #20346, #20344)
- Texture replacement load speed corrected and can now be controlled (#20286), regression fix for zipped packs (#19860)
- Additional assorted compatibility and other fixes (#20376, #20070, #19685)
- Persona 1/2 readback fixes (#20067, #20068)
- Other correctness fixes (#20233, #20255, #19686)
General emulation
- Allow disabling HLE on a per-module basis (if games ship the module so we can load it). Enabled for sceCcc (#20218), and more importantly scePsmf (#20208), fixing multiple issues.
- Additional module loading improvements (#20114)
- More precise sleep function on Windows (#20054)
- More replacement texture improvements, allow replacing game icons (#19975, #19978)
- Disable certain features like fast-forward when networking is on, to avoid de-syncs (#20311)
- Fixes mono Atrac3 as video soundtrack (#19800)
- Improvements to RISC-V support (#20352)
Control and input
New "Dear ImGui"-based PSP debugger: ImDebugger
- Allows very rapid development of debugging features as-needed, and also implements a pretty good Ge debugger for stepping through draws.
- Unlike the old Win32 debugger (but similar to the unmaintained web debugger), works on all platforms (though cumbersome on touchscreens)
- Major commits (though there are many more): (#20242, #20240, #20294)
Multiplayer
- Added Infrastructure multiplayer support, with automatic DNS configuration (#19865, #19869, #20221, #20220, #19875)
- Note: Only games that people have made "revival servers" for will work. See here.
- Implement more of sceNet (prerequisite for the former feature).
- Socket re-mapping implemented (#19827)
- Assorted bugfixes, thanks ANR2ME (#20247, #20245, #19843, #19849, #19836)
- Added Infrastructure multiplayer support, with automatic DNS configuration (#19865, #19869, #20221, #20220, #19875)
UI
- Loading spinner now actually spins properly, the app no longer appears to hang (#20341)
- Minor features: Asks for confirmation on exit in most scenarios (#19996, #20023), DPI scaling (#20013), can pause without menu (#19883)
- As usual a lot of tweaks, perf fixes, and fixes for hangs and crashes (#20343, #20332, #20305, #20303, #20299, #20163, #20152, #20143, #20079, #20137, #20374)
- Two new color themes (#20334, #20335), related themability fixes (#19984, #19995, #20308)
- Improvements and bug fixes in the savedata manager (#19771, #20170)
- Add "Move to trash" deletion functionality to multiple platforms (#20230, #20261)
- Add ability to take "raw" screenshots of gameplay (#20029)
- More files can be loaded directly from ZIP (#20243)
- Developer Settings are now tabbed for easier access (#20228)
- Switch to the full libpng API so we can disable gamma correction, like the real PSP (#20033)
- Support displaying the battery percentage on more platforms (#19973, #19967)
- Allow picking a background image on iOS (#20370)
Platform compatibility
- Exclude older Macs from using Vulkan (too many black screens, hangs) (#20236)
- Use portable-file-dialogs to provide file-open dialogs on Linux (#20175)
- "Cache full ISO in RAM" is now correctly hidden where it doesn't work (#20165)
- Now rendering at proper resolution on newer Macs (#20011)
- Mouse input improved on Android, including separate button mapping (#19915)
- Use the correct font again on Mac/iOS (#19874)
- Multiple file access optimizations made to make the most out of the flawed foundation called Android Scoped Storage (#19668)
What's new in earlier versions
See history.md.
Overview (`headless/README.md`)
PPSSPPHeadless
Non-interactive, headless build of PPSSPP. It boots a PSP executable, PRX, or GE frame dump (.ppdmp) without a GUI, forwards what the program prints to the console, optionally captures and compares text output or screenshots, and exits.
Primarily intended for:
- Automated regression testing (via pspautotests)
- Replaying GE frame dumps (
.ppdmp) to compare rendering output across GPU backends or settings - CI pipelines that need to verify emulation correctness
Building
Windows (Visual Studio)
The PPSSPPHeadless project is part of the Windows/PPSSPP.sln solution. Build the PPSSPPHeadless target in your preferred configuration.
Example:
msbuild Windows\PPSSPP.sln /p:Configuration=Debug /p:Platform=x64 /t:PPSSPPHeadlessLinux / macOS (CMake)
cmake -DHEADLESS=ON -B build-headless
cmake --build build-headlessUsage
PPSSPPHeadless file.elf|file.prx|file.ppdmp [...] [options]Options
| Argument | Description |
|---|---|
file.elf / file.prx / file.ppdmp |
Executable or frame dump to run. Directories followed by ... recurse for .prx files. |
@file |
Read list of test filenames from a text file (@- for stdin). |
-m, --mount <file.cso> |
Mount an ISO/CSO on umd1:. |
-r, --root |
Mount a path on host0: (ELF/PRX files must be under this). |
-l, --log |
Full emulator log output, on top of the program's own output (see Program Output below). |
-o, --odslog |
Write log to OutputDebugString (Windows only). |
--graphics= |
GPU backend: software, gles, directx11, vulkan. |
--screenshot=<file> |
Compare the rendered output against a reference screenshot. |
--screenshot-save=<file> |
Save the rendered output to a file (PNG if the path ends in .png, BMP otherwise). |
--screenshot-dir=<dir> |
Save each test's output as <dir>/<test path>.png, so one run can render many dumps. |
--screenshot-diff=<file> |
When comparing screenshots, save a visual comparison image to this file (always, regardless of pass/fail). |
--screenshot-keep-alpha |
Preserve the alpha channel when saving PNG screenshots (default: alpha is forced to 255, since games often use it for non-visual purposes). |
--max-mse=<number> |
Maximum allowed Mean Squared Error for screenshot comparison (default: 0 = exact). |
--compare / -c |
Compare test output with .expected text file and/or screenshot (see below). |
--timeout=<seconds> |
Abort test if it takes longer than this. |
-v, --verbose |
Print full pass/fail details. |
--bench |
Run multiple times and report average speed. |
--cpu=<core> |
CPU core: interpreter, ir, jit (default), jit-ir. |
--debugger= |
Enable WebSocket debugger and break at start. |
--state=<file> |
Load a save state before running. |
--old-atrac |
Use the old Atrac3+ audio decoder. |
--ignore <file> |
Skip the specified test file. |
--help / -h |
Show usage information. |
Program Output
Running a homebrew with no options at all prints what it prints, and nothing else. Two separate
channels feed the console, both on by default:
- The emulated program's
stdoutandstderr-sceIoWrite()to fd 1 and 2 (which is where a
PSPSDKprintf()ends up), and writes to a tty device. These go to ourstdoutandstderr
respectively, byte for byte, with no prefix or sanitization. - The
emulator:devctl channel -sceIoDevctl("emulator:", SEND_OUTPUT, ...), a PPSSPP
extension. This goes tostdout. pspautotests uses this channel exclusively, and it is what--comparecompares against the.expectedfile.
--compare and --bench turn both off, so the only thing a test run prints is the comparison
result. Emulator log output is separate again, and stays off unless you pass -l.
GPU Backends
The --graphics option selects the rendering backend:
| Backend | Description |
|---|---|
software |
Software rasterizer (most deterministic, recommended for tests) |
gles |
OpenGL ES (desktop OpenGL on non-Windows) |
directx11 |
Direct3D 11 (Windows only) |
vulkan |
Vulkan |
The software backend produces deterministic output across runs and is the default. Hardware backends may produce slightly different pixels due to precision differences in shaders and rasterization.
Frame Dump Replay (`.ppdmp`)
GE frame dumps are recordings of a single frame's GE graphics commands. When a .ppdmp file is passed:
- The file is identified by the
PPSSPPGEmagic header. - The GE commands are replayed through the selected GPU backend.
- The framebuffer is captured automatically (512×272 stride, 480×272 visible).
- A screenshot is sent for comparison/saving via
--compare,--screenshot, or--screenshot-save.
For batch rendering tests over sets of frame dumps, see docs/frametest.md (the frametests.py runner).
Example: Generate a reference screenshot
# BMP:
PPSSPPHeadless.exe --graphics=software --screenshot-save=reference.bmp frame.ppdmp
# PNG (lossless, recommended for storing references):
PPSSPPHeadless.exe --graphics=software --screenshot-save=reference.png frame.ppdmpThe BMP output is 512×272 with 32-bit BGRA pixel data. The file size is always 557,110 bytes (54-byte header + 512 × 272 × 4 bytes). PNG output is 512×272 RGBA.
Example: Compare against a reference
# With --compare, auto-derives the screenshot path:
# frame.ppdmp → looks for frame.png (next to the .ppdmp)
PPSSPPHeadless.exe --graphics=software --compare frame.ppdmp
# Explicit reference file (saves a visual comparison to diff.png):
PPSSPPHeadless.exe --graphics=software --screenshot=reference.bmp --screenshot-diff=diff.png --max-mse=0.5 frame.ppdmpWhen the MSE exceeds --max-mse, the following files are saved in the working directory:
| File | Contents |
|---|---|
__testfailure.bmp |
The actual rendered output (512×272 BMP). |
__testcompare.png |
Visual comparison: left column = actual, right column = reference on top, diff map on bottom. |
Screenshot Comparison Details
- Resolution: Always 480×272 display captured from a 512-pixel-wide framebuffer.
- Format: MSE (Mean Squared Error) calculated per-pixel across R, G, B channels (alpha ignored).
- MSE formula:
MSE = sum((actual - reference)^2) / (width × height × 3) - Reference formats: BMP (32-bit BGRA) or PNG (auto-detected by header).
--compareauto-path: For.ppdmpfiles, it replaces the extension with.png. For.prx/.elffiles, it appends.expected.bmp.- Failure output: Off by default in GitHub Actions CI; controllable via
SetWriteFailureScreenshot().
Text Output Comparison
The --compare flag also compares emulated debug output (printf / sceIoWrite to the emulator channel) against a .expected text file:
- For
.prxfiles: same path with.expectedextension. - The comparison is line-based with a diff algorithm that highlights mismatches and insertions/deletions, although the support for the
latter is limited, it's not a full-on diff.
If only a screenshot reference exists and no .expected file, the test passes as long as there is no unexpected text output.
Batch Testing
Tests can be listed in a text file or piped via stdin:
# List file
PPSSPPHeadless.exe --compare --timeout=5 @testlist.txt
# Stdin
echo "test1.prx test2.prx test3.ppdmp" | PPSSPPHeadless.exe --compare --timeout=5 @-
# Recursive directory scan (append /...)
PPSSPPHeadless.exe --compare tests/cpu/...Exit code is 0 if all tests pass, 1 if any fail.
CI Integration
GitHub Actions is auto-detected via the GITHUB_ACTIONS environment variable and will print error annotations inline.
Example workflow step:
- name: Headless tests
run: |
PPSSPPHeadless.exe --graphics=software --compare --timeout=10 @testlist.txtConfiguration
The following configuration is hardcoded for headless mode (config file is never saved):
(For actual up-to-date hardcoded config, see the headless.cpp file).
| Setting | Value | Notes |
|---|---|---|
| Internal resolution | 1× (480×272) | Fixed. |
| Hardware transform | Enabled | |
| Vertex decoder JIT | Enabled | |
| Software renderer JIT | Enabled | |
| Ignore bad mem access | Enabled | |
| Firmware version | 6.60 | |
| PSP model | Slim |
Overview (`ios/README.md`)
Updating with a self-built MoltenVK
Probably won't be needed again.
cp -r ../dev/build-molten/MoltenVK/Package/Release/MoltenVK/static/MoltenVK.xcframework ios/MoltenVK
The Old iOS Build Instructions
Important Notes
These instructions have not been updated for a long time and may be outdated.
Prerequisites:
- Xcode (from the Mac App Store) with command line tools installed
- MacPorts (from macports.org); easiest to install with their package installers
- cmake build system (from MacPorts); run "sudo port install cmake" from the command line
If you need to build ffmpeg yourself too, then you'll also need:
gas-preprocessor; download the zip from https://github.com/mansr/gas-preprocessor, unzip and from the command line run:
textsudo cp gas-preprocessor.pl /usr/bin/ sudo chmod +rw /usr/bin/gas-preprocessor.plyou may need pkg-config (from MacPorts); run "sudo port install pkgconfig" from the command line
Most of this is done from the command line:
Change directory to wherever you want to install ppsspp (eg. "cd ~"), and then clone the main ppsspp repository:
git clone https://github.com/hrydgard/ppsspp.gitChange directory to the newly created ppsspp directory and run:
git submodule update --initThe above command will pull in the submodules required by PPSSPP, including the native, ffmpeg, and lang directories. Included in the ffmpeg directory should be the necessary libs and includes for ffmpeg, so most people can skip the next command. However, if you need to recompile ffmpeg for some reason, change directory into ffmpeg and run (this will take a while):
./ios-build.shChange directory back up to the main ppsspp directory and do the following:
mkdir build-ios
cd build-ios
cmake -DCMAKE_TOOLCHAIN_FILE=../cmake/Toolchains/ios.cmake -GXcode ..You now should have an Xcode project file in the build-ios directory named PPSSPP.xcodeproj. Open it up in Xcode and do Product->Build to build the debug version, or Product->Build For->Archiving to build the release version (which is much faster). If your iOS device is plugged in, you may be able to just Run in Xcode to install and test it. Otherwise, copy the PPSSPP app from build-ios/Debug-iphoneos/PPSSPP.app or build-ios/Release-iphoneos/PPSSPP.app to the /Applications directory on your device and from ssh or MobileTerminal do a "chmod +x PPSSPP" inside the PPSSPP.app directory. If this is the first time you've installed the PPSSPP app, you'll have to respring or restart your device for the icon to show up.
Overview (`macOS/README.md`)
MacOS notes
Entitlements.plist is to be used for code signing on macOS.
We enable "hardened runtime" in order to make notarization happy, but we need some exceptions.
MoltenVK on Mac
To update MoltenVK, download the latest version and get the files from dylib/macOS and put them in ext/vulkan/macOS/Frameworks.
Overview (`Tools/langtool/README.md`)
Language ini file processor tool
To install Rust and cargo, go here.
To run, with rust installed, change to this Tools/langtool directory, then:
cargo run(Or just move the binary out of target/ and run it directly).
To see command line usage, type:
cargo run -- --helpValidating
cargo run -- validateChecks every translated string in every language file against the English one it came from:
placeholders (%1, %d, ...) must survive translation, no line breaks or stray quotes, nothing
empty. Exits with a non-zero code if it finds anything, so it can be used as a check in a script.
The same checks run on anything the AI produces, before it gets written to a file.
AI translation
Some commands (add-new-key-ai, add-new-key-value-ai, finish-language-with-ai) use an LLM to
generate translations. Two providers are supported, pick one by setting its API key in the
environment:
- Claude (Anthropic):
ANTHROPIC_API_KEY - OpenAI:
OPENAI_API_KEY
If both are set, Claude is used unless you say otherwise with --provider openai. To pick a
specific model, use --model, for example:
cargo run -- --model claude-sonnet-5 finish-language-with-ai sv_SE
cargo run -- --model gpt-4o finish-language-with-ai sv_SEThe provider is inferred from the model name, so --provider is only needed to disambiguate.
Strings that are deliberately the same as the English one (terms like "Vsync") are marked with a# same as English comment. finish-language-with-ai skips those instead of paying to have them
"translated" again on every run, and writes the comment itself when the AI hands the English string
back unchanged. Delete the comment if you disagree with it and it'll be picked up again next time.
To autoformat the code, use:
cargo fmtor set up your IDE to do that (it's already pre-set-up for VS Code, if you open the langtool folder directly).
Overview (`Tools/wsdbg/README.md`)
wsdbg - WebSocket debugger CLI
A small standalone client for talking directly to PPSSPP's WebSocket debugger
interface (see docs/WebSocketDebugger.md in the root of the repo for the
full protocol reference and event catalog).
To install Rust and cargo, go here.
To run, with rust installed, change to this Tools/wsdbg directory, then:
cargo run -- <port>(Or build once with cargo build --release and run the binary directly fromtarget/release/.)
Usage
Start PPSSPP with the remote debugger enabled (--debugger on the command
line, or Settings > Tools > Developer Tools > Allow remote debugger) and note
the port it's listening on (shown in that same settings screen, and logged at
startup).
Letting wsdbg start PPSSPP
For headless scripting, --launch removes all of that. It starts PPSSPP, learns the debugger port
from its output, connects once the socket accepts, and kills it again on the way out:
wsdbg --sync --compact --quiet --launch ./PPSSPPHeadless.exe --vsh --cpu=interpreter --graphics=vulkan < script.txt--launch takes the executable and all of its arguments, so it has to come last - everything after
it belongs to PPSSPP, and wsdbg's own flags go before it. --debugger=0 is appended unless the
arguments already ask for a debugger port, so the OS picks a free one and nothing has to agree on a
port number in advance. PPSSPP's output is forwarded to wsdbg's stderr, separate from the protocol
messages on stdout, so 2>emu.log keeps them apart.
This replaces the wrapper script the same job used to need: no launching in the background, no
polling a log file for the port (a race - you can attach to a previous run that still holds one),
no fixed sleep before connecting, and no leftover emulator processes, which --timeout's
wall-clock budget otherwise leaves running for as long as it says.
Interactive REPL - type an event name and key=value params, get responses
and broadcasts (log messages, stepping notifications, etc.) printed as they
arrive:
cargo run -- 12345
> game.status
-> (ticket 2) {"event":"game.status","ticket":2}
<- {
"event": "game.status",
"game": null,
"paused": false
}One-shot mode - send a single event and exit as soon as its reply arrives, handy
for scripting:
cargo run -- 12345 gpu.stats.get
cargo run -- 12345 cpu.setReg thread=0 name=4 value=1000
cargo run -- 12345 --raw '{"event":"cpu.evaluate","expression":"pc"}'The reply is matched by ticket, so this returns in milliseconds rather than
padding every invocation with a fixed sleep. --wait (default 10s) is only the
upper bound before it gives up and exits non-zero. Pass --wait-all to go back
to "print everything that arrives for --wait seconds", which is what you want
when watching broadcasts (log lines, gpu.stats.feed) rather than asking a
question.
An error reply carrying the request's ticket makes it exit non-zero too (with --wait-all,
once the interval is up).
Type :help in the REPL for a quick reminder, :quit to disconnect.
Scripting a multi-step sequence
Piping several commands into the REPL ((echo cmd1; echo cmd2; ...) | wsdbg PORT) sends them all
immediately by default - nothing waits for a response before moving to the next line, so scripts
traditionally needed sleep N between commands to guess how long each one takes. Pass --sync
to remove the guessing: each line blocks until its own response arrives (matched by ticket) before
the next line is read, and for cpu.resume/cpu.stepInto/cpu.stepOver/cpu.stepOut/cpu.runUntil/cpu.runUntilTime/cpu.nextHLE it also waits for the following cpu.stepping
broadcast - the actual "the CPU stopped again" signal those commands imply. Everything still
prints as it arrives; this only changes when the next line gets sent. A breakpoint that never
trips would otherwise hang the script forever, so it gives up after --sync-timeout seconds
(default 30), reports it, and makes the run exit non-zero.
An error reply to a line doesn't stop the script, but makes the run exit non-zero at the end.
Matching is by ticket, always - --sync never waits for "whatever message arrives next", which is
what used to quietly desynchronise a script. So prefer the key=value form for nested parameters
too: values are parsed as JSON, and single quotes keep the inner double quotes intact, as ininput.buttons.send buttons='{"cross":true}'. A raw JSON line is sent exactly as written, so it's
waited for only if you gave it a ticket;
without one there is nothing to match and --sync moves straight on to the next line. Raw lines
are rejected up front, rather than sent and left to fail somewhere downstream, if they aren't valid
JSON, aren't an object, have no string event, or carry a ticket that isn't an integer.
(
echo 'cpu.breakpoint.add address=134348800 enabled=true'
echo 'cpu.resume'
echo 'cpu.getAllRegs'
) | cargo run -- 12345 --syncScript directives
A script often needs to wait for something that isn't a direct response to the line before it.
Doing that by splitting the script across several wsdbg invocations costs a process, a TCP
connection and a handshake per pause, which is slow enough to matter - a polling loop built that
way took minutes per run. These run inside the one session instead:
| Directive | What it does |
|---|---|
:sleep <seconds> |
Wall-clock pause. Keeps draining and printing messages while it waits. |
:wait <event> [timeout] |
Blocks until a message with that event name arrives. Exits non-zero if it never does. |
:screenshot <file.png> |
Saves gpu.buffer.screenshot as a PNG, without printing the image data. Needs the CPU stopped. Use a native path (C:/...) on Windows. Works in headless with --graphics=vulkan. |
:nowait <command> |
Sends the command and moves straight on, even under --sync; its reply prints whenever it arrives. For requests that only answer once the CPU has run, like input.buttons.press. |
:echo <text> |
Prints text, for marking up a script's output. |
# comment |
Ignored. |
--compact prints one line per message (<- event {json}) instead of pretty-printed JSON, and
drops the banner and prompt - much easier for a shell to grep. wsdbg exits non-zero if a :wait
timed out, so a script can be checked without parsing its output at all.
A complete repro - boot, get several seconds in, step, inspect - in one file and one connection:
# hand this to: wsdbg PORT --sync --compact --quiet < script.txt
:echo === run to 1.5s of emulated time ===
cpu.runUntilTime us=1500000
cpu.status
:echo === step twice ===
cpu.stepInto
cpu.stepInto
:quitDon't add :wait cpu.stepping after cpu.runUntilTime there (an earlier version of this
example did). Under --sync those commands already wait for the cpu.stepping that follows, so an
explicit :wait blocks for a second one that never arrives and burns the entire--sync-timeout. It is a nasty failure to diagnose, because the emulator has already stopped
exactly where you asked: the run just sits there, and a script written with --sync-timeout 400
takes seven minutes instead of three seconds while looking like a slow boot. :wait is for events
nothing else is waiting on - a breakpoint hit during a free run, say.
For the same reason, never put "stepping":true in broadcast.config.set's disallowed:cpu.stepping is a broadcast with no ticket of its own, and muting it means nothing can ever
observe that a run finished. --quiet above is the safe way to get the same noise reduction - it
disables logger and input only, and wsdbg warns if a hand-written broadcast.config.set mutesstepping.
cpu.runUntilTime (see docs/WebSocketDebugger.md) is what makes that reproducible: it stops on
the requested emulated microsecond, so the same script reaches the same instruction every run.
Polling cpu.status in a loop instead lands somewhere different each time.
Overview (`assets/lang/README.md`)
lang
PPSSPP language ini repository
Thanks for your interest in translating PPSSPP!
- Simply copy en_US.ini file to a new ini file with your language code, or use it to update an existing file with that name.
- To see a list of codes, view this page.
Please note, while translating:
- Ampersands
&on the RIGHT side of an equals sign denote an underlined keyboard hotkey. - The hotkeys are only supported currently in the DesktopUI section, however.
- Example:
&File. This will make it so when you press ALT + F on Windows, it'll open the File menu.
Tools
To remove a translation, use the following (where KeyWord is the key):
find . -type f -print0 | xargs -0 sed -i /^KeyWord/d
To change a translation key, use something like this:
find . -type f -print0 | xargs -0 sed -i /^Key/NewKey
Before you commit, use git diff to check that you don't delete too much
or some unrelated key with the same prefix.
Happy translating!
Overview (`docs/emu-api/README.md`)
PPSSPP Emulator API
ppsspp_emu_api.h is a small, header-only C wrapper around the emulator-only tricks PPSSPP exposes
to homebrew through the "emulator:" device of sceIoDevctl (implemented in Core/HLE/sceIo.cpp,
search for "emulator:").
None of this works on real PSP hardware - always check ppsspp_is_emulator() before relying on
anything else in the header, and keep a working fallback path for real hardware.
C API documentation: https://www.ppsspp.org/docs/development/ppsspp-internals/emu-api/
Raw sceIoDevctl protocol (commands, layouts, quirks): see protocol.md.
Usage
Just copy ppsspp_emu_api.h into a PSPSDK-based homebrew project and #include it. It's entirelystatic inline functions over sceIoDevctl, so there's no library to build or link.
#include "ppsspp_emu_api.h"
if (ppsspp_is_emulator()) {
ppsspp_send_output_str("Hello from homebrew, running under PPSSPP!\n");
}Overview (`ext/jpge/README.md`)
jpeg-compressor
This directory contains code from
https://github.com/richgel999/jpeg-compressor/tree/aeb7d3b463aa8228b87a28013c15ee50a7e6fcf3
with the following patches:
License
The license of jpgd.cpp/.h and jpge.cpp/.h is either Public Domain or Apache 2.0. Choose whatever you want.
The license for the optional file jpgd_idct.h (and ONLY this file) is Copyright 2009 Intel Corporation:
Permission is granted to use, copy, distribute and prepare derivative works of
this software for any purpose and without fee, provided, that the above
copyright notice and this statement appear in all copies. Intel makes no
representations about the suitability of this software for any purpose. THIS
SOFTWARE IS PROVIDED "AS IS." INTEL SPECIFICALLY DISCLAIMS ALL WARRANTIES,
EXPRESS OR IMPLIED, AND ALL LIABILITY, INCLUDING CONSEQUENTIAL AND OTHER
INDIRECT DAMAGES, FOR THE USE OF THIS SOFTWARE, INCLUDING LIABILITY FOR
INFRINGEMENT OF ANY PROPRIETARY RIGHTS, AND INCLUDING THE WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. Intel does not assume any
responsibility for any errors which may appear in this software nor any
responsibility to update it.
Overview (`ext/naett-lib/README.md`)
naett /nɛt:/
Tiny HTTP client library in C.
Wraps native HTTP client functionality on macOS, Windows, Linux, iOS and Android in a single, simple non-blocking API.
Using `naett`
Get the naett.c and naett.h files and throw them into your project. Check out the example for a basic Makefile - based setup.
The library needs to be initialized by a call to naettInit(). On Android, you need to provide a JavaVM* handle in the call to naettInit().
On the other platforms, call with NULL.
See naett.h for reference docs.
Platform implementations
naett uses the following HTTP client libraries on each platform:
| Platform | Library / component | Build with |
|---|---|---|
| macOS, iOS | NSURLRequest | -framework Foundation |
| Windows | WinHTTP Sessions | -lwinhttp |
| Android | java.net.URL | NDK |
| Linux | libcurl | -lcurl -lpthread |
Example
#include "naett.h"
#include <unistd.h>
#include <stdio.h>
int main(int argc, char** argv) {
naettInit(NULL);
naettReq* req =
naettRequest("https://foo.site.net", naettMethod("GET"), naettHeader("accept", "application/json"));
naettRes* res = naettMake(req);
while (!naettComplete(res)) {
usleep(100 * 1000);
}
if (naettGetStatus(res) < 0) {
printf("Request failed\n");
return 1;
}
int bodyLength = 0;
const char* body = naettGetBody(res, &bodyLength);
printf("Got %d bytes of type '%s':\n", bodyLength, naettGetHeader(res, "Content-Type"));
printf("%.100s\n...\n", body);
}CodeWarriorMangling (`docs/CodeWarriorMangling.md`)
Metrowerks CodeWarrior C++ symbol mangling (PSP)
Some PSP titles were built with Metrowerks CodeWarrior rather than the SDK's GCC, so their
symbols aren't Itanium-mangled and _Z-based demanglers do nothing with them. PPSSPP
demangles them in Common/Data/Text/Demangle.cpp (DemangleCodeWarrior), which is what makes
the symbol map readable for those games.
The scheme is a descendant of the AT&T cfront mangling, and the basics match the Macintosh C++
ABI. It deviates in enough places that following that document alone produces wrong answers, so
this is a description of what PSP binaries actually contain.
How this was worked out
From two PSP executables that shipped with intact symbol tables - one small C++ test program,
one large application of about 10,000 symbols - by demangling every symbol and checking the
result against the disassembly and against the bytes each data symbol points at.
Constructs the corpus did not contain are marked (inferred) below. They come from the cfront
scheme, are implemented, and are believed right, but nothing here proves them.
Overall shape
A mangled symbol is:
<basename> __ [<class>] [<cv>] F <parameters> [_ <return type>]with the class, the cv-qualifiers, the F and everything after it all optional. A few examples,
building up:
| Mangled | Demangled |
|---|---|
run_tests__Fv |
run_tests() |
getDistance__6KzUtilFP7st_unitP7st_unit |
KzUtil::getDistance(st_unit *, st_unit *) |
what__Q23std9exceptionCFv |
std::exception::what() const |
count__Q23foo3bar |
foo::bar::count |
The last one has no F, so it isn't a function at all - it's a static data member, and there is
no parameter list to print. A name with neither a class nor an F is not mangled.
Finding the separator
The __ separator is genuinely ambiguous. A base name can itself start with underscores
(__SetupFrameInfo__FP12ThrowContext...), contain a __ of its own
(__TableUnit__SetValue__5shTbbF...), and plain C code in the same binary is full of names
like I3dCacheManager__GarbageCollect that are not mangled at all.
There is no way to resolve this from the grammar; PPSSPP tries every __ in the symbol and keeps
the first split whose right-hand side parses completely. Only if none does will it accept a split
whose parameters didn't decode - and then only when a class qualifier is present, because
otherwise every C name with a double underscore in it decodes as a garbage function signature.
Names
An identifier is written as its length in decimal followed by its characters: 6KzUtil.
A qualified name is Q<count> followed by that many identifiers: Q23std9exception isstd::exception (2 components, 3std and 9exception). The count is a single digit; ten or
more components are written Q_<count>_ (inferred).
Template arguments
Template arguments are written literally between < and >, inside the length-prefixed name -
the length covers the whole thing, brackets included. (The Macintosh ABI document describes a__PT prefix instead. PSP binaries do not use it.)
39CList<Q38hlScreen5Brwsr13CContentsUnit> shList::CList<hlScreen::Brwsr::CContentsUnit>Here 39 is the length of CList<Q38hlScreen5Brwsr13CContentsUnit>. Each argument is either a
mangled type, or a plain integer for a non-type parameter, separated by commas:
| Mangled | Demangled |
|---|---|
15CSimpleChar<24> |
CSimpleChar<24> |
61ForwardIterator<16TABLE_VAR_MEMBER,21TABLE_VAR_SECT_HEADER,v> |
ForwardIterator |
47CList<Q26ssTool29tag_<Q26shFont12SysCmdString>> |
CList<ssTool::tag_<shFont::SysCmdString>> |
They nest, so a parser must match brackets rather than scanning for the next >, and split
arguments only on top-level commas.
A function template puts its arguments in the base name instead, and - unlike a normal function -
encodes its return type after a trailing _:
sort<Pf>__3stdFPfPf_v void std::sort<float *>(float *, float *)Special base names
| Base name | Meaning |
|---|---|
__ct |
Constructor; the name is taken from the class |
__dt |
Destructor |
__op<type> |
Conversion operator, e.g. __opCi is operator const int() |
__<code> |
Overloaded operator, see below |
Operator codes are the cfront set: nw dl nwa dla for new/delete, pl mi ml dvmd for arithmetic, apl ami amu adv amd aad aor aer als ars for the compound
assignments, eq ne lt gt le ge for comparisons, aa oo nt for logic, ad orer co ls rs for bitwise, and as pp mm cl vc rf rm cm for= ++ -- () [] -> ->* ,.
So __as__9ANIMEDataFRC9ANIMEData is ANIMEData::operator=(const ANIMEData &).
Types
Type codes are read left to right, each one modifying what follows.
| Code | Type | |
|---|---|---|
v |
void |
|
b |
bool |
|
c |
char |
|
s |
short |
|
i |
int |
|
l |
long |
|
x |
long long |
|
f |
float |
|
d |
double |
|
r |
long double |
(inferred) |
w |
wchar_t |
(inferred) - a Metrowerks addition to the cfront set |
e |
... |
varargs, always last |
P |
pointer to | |
R |
reference to | |
C |
const |
|
V |
volatile |
|
U |
unsigned |
|
S |
signed |
(inferred) |
A<n>_ |
array of n |
(inferred) |
F_<ret> |
function | |
<len><name> |
class | |
Q<n>... |
qualified class |
PCc is const char *; CPc is char * const. A pointer to a function or an array needs the
declarator wrapped rather than a * appended - PFi_i is int (*)(int), and PPFv_v isvoid (**)().
Two forms back-reference an earlier parameter of the same function (inferred):
| Form | Meaning |
|---|---|
T |
Same type as parameter ``, 1-based |
N<count> |
<count> more parameters, each the type of parameter `` |
So foo__FPCcUiN21 is foo(const char *, unsigned int, const char *, const char *).
cv-qualifiers on the function
A C or V sits between the class and the F, and qualifies the member function rather than a
parameter: InitRun__Q28shCamera11TStillParamVFv isshCamera::TStillParam::InitRun() volatile.
Compiler-generated symbols
CodeWarrior emits a family of symbols for things with no C++ name of their own. These wrap a
mangled name rather than being one, and none of them are described in the ABI document.
| Form | Meaning | Example |
|---|---|---|
__vt__<class> |
Vtable | __vt__Q23std9exception |
__RTTI__<class> |
Typeinfo record | __RTTI__Q23std9exception |
__sinit_<file> |
Static initializers for a translation unit | __sinit_hl_app.cpp |
__sterm_<file> |
Static destructors | |
@<n>@<symbol> |
this-adjusting thunk, n bytes |
@12@__dt__3SonFv |
@STRING@<symbol>[@<n>] |
A string literal used inside that function | @STRING@Get_BGM__Q25hlBHC4SBhcFi@0 |
@LOCAL@<symbol>@<var>[@<n>] |
A function-local static | @LOCAL@sort__3stdFPfPf_v@shuffle@0 |
@GUARD@<var>$<n> |
Its "already constructed" flag | @GUARD@app$16079 |
@<n> |
An anonymous string constant | @10046 |
The trailing @<n> on @STRING@ and @LOCAL@ is a discriminator, present only when a function
has more than one of them - so a parser has to treat it as optional, and must not mistake the@ before a @LOCAL@ variable name for it.
@<n>@ thunks really are thunks: the one above is 8 bytes of code that does addiu a0, a0, -12
and jumps, i.e. adjusts this for a secondary base. @STRING@ symbols point at ordinary string
data - usually the __FILE__ an assertion expanded to.
Other decorations
Two more show up inside otherwise ordinary names. Neither needs decoding, but both are worth
recognising:
<name>$<digits><file>- a class or variable with internal linkage, tagged with where it was
declared:ClutScreen$11229hl_cplayer_effect_renderer_cpp. These make for very long symbols,
since the tag repeats everywhere the type appears.<len>@unnamed@<file>@- used as a qualifier for file-scope statics, i.e. an unnamed namespace.ARWMENU_MODEL_NAME__34@unnamed@hl_editor_arrow_menu_cpp@is a data symbol in one.
The hidden destructor parameter
The mangling describes the source-level signature, not the generated one. __dt__3SonFv
demangles to Son::~Son(), and that is the right answer for a demangler - but the function the
compiler emitted takes a second argument, and anything that turns a demangled name into a
function signature (a decompiler, a debugger's parameter view) will be wrong about it.
CodeWarrior emits one destructor per class rather than the two or three the Itanium ABI clones,
and distinguishes the cases with a flag. On PSP that flag is a short in a1, and the
destructor returns this in v0. So the real signature is:
Son *__dt__3SonFv(Son *this, short flag);Decompiled from a real one, the body is:
Son *__dt__3SonFv(Son *this, short flag) {
if (this) { // destructors are null-safe
... restore vtable pointers, destroy members ...
__dt__3DadFv(this + 12, 0); // base subobjects get 0
__dt__3MomFv(this, 0);
if (flag > 0) // "blez" - only a positive flag frees
__dl__FPv(this); // operator delete
}
return this;
}The values that actually get passed, counted over the call sites in a retail binary:
| Flag | Meaning | Frequency |
|---|---|---|
-1 |
Destroy a complete object without freeing it - a stack object, a member, an explicit ~T() call |
Most calls |
1 |
Destroy and free - what delete compiles to |
A handful |
0 |
Destroy a base-class subobject | A handful |
0 and -1 are a caller-side distinction: the destructors examined here only test the sign, so
both simply mean "don't free", but the compiler still passes 0 specifically when destroying a
base subobject and -1 everywhere else. Treat 0 as "this call is destroying me as somebody's
base" when reading a call site, even though the callee can't tell.
Constructors in these binaries take only this (and also return it).
Note that the vtable slot holds this same function, flag and all, which is how CodeWarrior gets
away without separate deleting and non-deleting destructors: a virtual delete passes 1 through
the vtable.
Hazards
- A truncated name is unrecoverable. Some symbol tables cap names (127 characters is a common
limit), and templates blow past that easily. Once a name is cut, the length prefixes no longer
match what's left and nothing after the cut can be trusted - better to reject the symbol than
to print a plausible-looking guess. - Not everything with a
__is mangled. C code linked into the same binary uses__as a
word separator freely. Requiring a class qualifier before accepting a partial parse is what
keepsI3dClut__FlushCachefrom turning intoI3dClut(long, ...). eis a real parameter, not a parse failure.Printf__Q26shFont4FontFiiPCceends in
varargs;(int, int, const char *, ...)is the correct answer, not a partial one.
DebuggerThreading (`docs/DebuggerThreading.md`)
Debugger threading model (Core_RunOnCPUThread / g_frameMutex)
Which thread may touch CPU-thread-owned debugger state (breakpoints, symbol map, registers, memory,
kernel threads), and in which order the locks have to be taken. Read this before touchingWindows/Debugger/*, Core/Debugger/WebSocket* or anything else that reaches into core state from
outside the CPU thread.
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) - queuesfuncto 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 everyCore_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 callSendMessage()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-threadSendMessage()back to it deadlocks; keep such calls outside the lambda instead.g_frameMutex(Core.h/Core.cpp) - a plainstd::mutex, held byNativeFrame()
(UI/NativeApp.cpp) only across the span where it actually touches that state
(g_breakpoints.Frame()throughg_screenManager->render()- whereCore_RunLoopUntil()/actual
CPU stepping happens - throughrunImDebugger/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 throughCore_RunOnCPUThreadwould be too slow/heavy. The legacy Win32 debugger windows do this now -
seeCtrlRegisterList::onPaint,CtrlDisAsmView::onPaint,CtrlMemView::onPaint,CtrlBreakpointList::reloadBreakpoints,CtrlThreadList::reloadThreads, etc. inWindows/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 RunGraphicsLoop() in Core/EmuThread.cpp, which every platform goes through
(Windows and headless via MainThreadFunc(), Android from its render loop thread). 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'sSortSymbols() 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 whereCore_RunLoopUntil()runs, which requires
a game to be loaded (it's called fromEmuScreen::render()). CallingCore_RunOnCPUThread()
while at the main menu with nothing loaded used to hang forever. Fixed by also calling the
(now public)Core_ProcessCPUQueue()directly fromNativeFrame(), right beforeg_screenManager->render(), inside the sameg_frameMutex-locked span - so it always runs, not
just while a game is active.- Lock-ordering rule: because
Core_ProcessCPUQueue()is called from insideNativeFrame()'sg_frameMutex-locked span, anyCore_RunOnCPUThread()lambda that itself tries to lockg_frameMutex(directly, or indirectly - e.g. by calling something likeCDisasm::NotifyMapLoaded(), which locks it internally) will deadlock. Keep such calls outside
the queued lambda, same as the modal-dialog andSendMessage()rules above.
Lock ordering: `g_frameMutex` before `Core_LockAgainstShutdown()`, always
Core_LockAgainstShutdown() / CoreShutdownLock (a recursive_mutex, g_shutdownLock inCore/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 calledMemory::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 insideCore_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.
HLEModules (`docs/HLEModules.md`)
Adding HLE modules
How the HLE module tables work, the savestate-compatibility rules for adding to them, and the list of
build files a new source file has to be added to.
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 viaRegisterHLEModule("<name>", ARRAY_SIZE(table), table) inside a Register_<name>() function declared in the header.
FunctionWrappers.hhas genericWrapX_YYY<func>templates for common signatures (return type X, args YYY) - add new
wrappers there if you need a new signature.A function that needs the CPU (registers beyond its arguments, the PC for a log line,
PARAM()/RETURN()) takesMIPSState *mipsas an extra first parameter, and the wrapper passes in the state the syscall runs on. Use that
rather thancurrentMIPS. Calling such a function directly, or throughhleCall, means passing a state yourself.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 withreturn hleLogError(Log::HLE, 0, "UNIMPL");- an established pattern (seescePauth.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 existingRegister_*()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.The same rule applies one level down: new entries in an existing module's
const HLEFunction <name>[]table must
go at the very end of that array too, never inserted alphabetically or next to a "related" existing entry. A
resolved import gets written directly into guest RAM as a syscall opcode that encodes the entry's array position
(modulenum<<18 | funcindex<<6, seeGetSyscallOp()inCore/HLE/HLE.cpp), and a savestate captures that opcode
verbatim - inserting a new entry before an existing one shifts every later entry's index, so loading an older
savestate after such a change resolves those later entries to the wrong function. Adding an all-new table (a new
module, including a new alias-module..._driver[]variant of an existing one) is unaffected, since no old
savestate could reference indices into a table that didn't exist yet - only existing tables need this care.Remember to add any new
.cpp/.cfile 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, andlibretro/Makefile.common. New.hfiles need the first five (everything exceptAndroid.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.cpplanded 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 Building and testing 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.mkandlibretro/Makefile.commongenuinely can't be build-tested here - see
Building and testing for what verification is possible for those.)
POPS (`docs/POPS.md`)
PS1 EBOOTs (POPS)
PS1 games on the PSP are EBOOT.PBPs run by the firmware's PS1 emulator. PPSSPP runs the firmware's own
modules for them, so it needs an installed 6.60 or 6.61 firmware (flash0:/kd/popsman.prx); without
one, booting a PS1 EBOOT fails with a message saying so. --unpack-updater DIR EBOOT.PBP installs one from an
official updater.
The code is Core/HLE/PopsBoot.cpp (the boot, the ME, the patches) and Core/HW/PopsMMIO.cpp (the
hardware registers the manager uses). The patches are for the 6.60 and 6.61 popsman.prx and pops_0Xg.prx,
whose code is the same (and the same at the patched places for the PSP-1000 and PSP-2000 models). The
manager's is checked when it's loaded. Offsets below are from the module's base.
The boot
On hardware the kernel reboots into its POPS configuration, which starts the POPS manager
(popsman.prx) and a few user modules, and the kernel's init then calls scePopsManLoadModule(path, 0),
which loads pops_0Xg.prx (the emulator) for the PSP model. Load_PSP_POPS does the same without the
reboot: it loads the manager, the user modules (libfont_hv, pafmini, libpspvmc) and a helper thread
that makes the init's call. InitForKernel reports a memory stick boot in the POPS key configuration.
The emulator draws through the GE and plays through the Media Engine. The manager drives both directly:
- GE. The emulator keeps one display list stalled and the manager extends it each frame by writing
the stall register (0xBD40010C), then waits for the FINISH interrupt. These map onto the HLE GPU's
current list. - ME. A PS1 boot always gets the emulated Media Engine (
Core/MIPS/MeCore.cpp), which runs the
manager's code as the PSP does. The manager copies a bootstrap into the boot page (0xBFC00040),
releases the ME's reset throughsceSysreg_driverand waits for an acknowledgement in the mailbox
(0xBFC007F0). Sony's own ME firmware isn't booted for it. The ME loop (+2F88) sets up the audio block
(0xBE000000) and then, once per sample, sleeps inhaltuntil the FIFO has room (interrupt source 12,
a level: the loop never enables interrupts nor acks them), calls the emulator's mixer (on the ME) and
writes the sample. The FIFO plays at 44.1kHz in emulated time, and its samples are mixed into the
output (MeMMIO::AudioMix). The loop's calls to the cache functions do nothing on the ME.
Neither the lock the emulator shares with the ME (pops +5C) nor its SPU interrupt pacing is patched: with
two CPUs they should work as on hardware. Vib-Ribbon, which streams its stage music through the SPU's
capture buffers and asserts when those interrupts come at the wrong PS1 time, hasn't been tried.
Unresolved imports remain, mostly sceSyscon_driver and power management. The manager carries on
without them.
Rendering
The emulator keeps the PS1's 1024x512 VRAM at 0x04000000 as one 16-bit framebuffer, and reads and writes
it outside where it draws. It's a fixed framebuffer: created whole, from memory, and uploaded without
filtering. Its 24-bit video is unpacked three bytes per pixel into 0x04100000, which is kept at 1x. It
draws sprites on pixel centers, which above 1x read their neighbours' texels, so those are moved back half a
pixel. And it builds video frames by ORing passes together, so draws whose logic op is done in the shader
are drawn one at a time.
Store and converted games
A store game's disc image is a PGD. The manager's license check (+266C) needs the game's license and
activation, so it's replaced: the version key comes from the PGD header, as CFW's popcorn plugin does.
The MACs are checked with the real algorithms (sceAmctrl_driver, on libkirk).
A converted EBOOT (popstation and similar) has a plain disc image of DEFLATE blocks and no MACs. As popcorn
does, the emulator's decoder calls go to an inflate, its CD audio index check takes the full-length branch,
and the two MAC checks (+0A28, +0AB8) pass.
For any EBOOT, also as popcorn does: the manual's name check passes, and an ICON0 the emulator can't use
(anything but an 80x80 PNG, such as a missing one or a PSP game's 144x80 one, which crashes it) reads as a
blank 80x80 one.
Testing
PPSSPPHeadless --memstick=MEMSTICK --graphics=vulkan --timeout-wall=60 --timeout-emulated=60 \
--press=30:0x8,40:0x4000 --screenshot-at=55:shot.png --dump-audio=out.wav EBOOT.PBPThe HOME button opens the emulator's menu. Savestates work.
Not done
- Other firmware versions. CFW's popsloader runs POPS from 3.02 to 6.60, and its tables show what differs
between them: the offsets patched here, and the kernel NIDs the modules import. - The anti-LibCrypt word some PAL games need in a converted image (PSISOIMG + 0x12B0). ARK-4's popcorn has
a table of them by disc ID, switched off. Converting tools can patch the image instead. - ARK-4's per-game CONFIG.BIN next to the EBOOT, copied over the image's config (PSISOIMG + 0x420).
- A byte popcorn changes in reads of converted images (0x41B, from 0x27 to 0x55 when 0x41B-0x41E read
27 19 22 41) without knowing why.
PsarFileFormat (`docs/PsarFileFormat.md`)
The PSAR archive format (PSP firmware updaters)
An official PSP firmware updater carries the whole firmware image in a PSAR archive. PPSSPP can
walk one and extract the files out of it without emulating the updater, which is whatCore/Util/PSARUnpack.cpp does. The main use is getting flash0:/font - the PGF system fonts -
out of an updater the user already has, since most UMDs carry one.
This document is what was learned reading 43 different firmware versions, from 1.50 to 6.61. It's
a description of the format rather than of the code; the code is the same shape but has the error
handling.
Where an updater lives
The archive turns up in three shapes, all of them the same bytes once you find them:
| Shape | Where | Notes |
|---|---|---|
| Downloaded updater | EBOOT.PBP, subfile 7 (DATA.PSAR) |
The one you get from Sony's site |
| Disc updater | PSP_GAME/SYSDIR/UPDATE/DATA.BIN |
Not wrapped in a PBP - the bare archive |
| Loose | any file starting with PSAR |
Whatever someone already pulled out |
A disc updater directory also holds EBOOT.BIN (the updater executable, a bare ~PSP PRX, of no
use to us) and PARAM.SFO. That SFO is worth knowing about: its TITLE reads"PSP™ Update ver 3.95", so the firmware version can be read without decrypting anything,
which is cheap enough to check a whole game library with. DISC_ID is MSTKUPDATE and CATEGORY
is MG.
Of 589 discs scanned across two libraries, 473 carried an updater.
Archive header
+0x00 u32 "PSAR" magic (0x52415350)
+0x04 u8 version. 1 = "oldschool" (see below), 2 and 3 are the common ones
+0x08 u32 total length of the records
+0x20 u32 0x2C333333 if this archive is already decrypted, otherwise part of the ciphertextThe length at +0x08 matters: archives have a few bytes of padding after the last record (16 and 32
bytes in the two checked), and a walk that goes by file size instead will try to decode that
padding as a record and report a spurious failure at the very end.
The marker at +0x20 only appears in archives some other tool has already decrypted. In an original
updater those bytes are ciphertext and will not match.
Records
The archive is a flat sequence of records starting at +0x10. There is no index - you find entry
n+1 by decoding entry n and adding up its sizes. Each record is:
[ 0x150 bytes encryption header ] <- absent if the archive is pre-decrypted
[ 0x110 bytes entry description ]
[ variable file contents ] <- has its own 0x150 header, same as aboveCall the header size overhead: 0x150 normally, 0 for a pre-decrypted archive.
Decoding a block
Every encrypted block - entry descriptions and file contents alike - is decoded the same way:
Demangle. The 0x130 bytes at block+0x20 are AES-128-CBC encrypted on top of everything
else. Decrypt them with KIRK command 7 (KIRK_CMD_DECRYPT_IV_0), key seed 0x55, IV zero,
writing the result back over block+0x20. This step is skipped for version 1 archives.The point of it is that it hides the PRX tag: you cannot tell how to decrypt the block until
you have done this.PRX decrypt. The block is now an ordinary PRX blob, with its tag at block+0xD0. Run it
through the normal PRX decrypter (pspDecryptPRX). The tag official updaters use is
0x0E000000;0x06000000also appears in the wild.Note that
0x0E000000's key is stored unscrambled, unlike almost every other key in
PPSSPP's tag table, so it needs thekirk7pass applied before use - that's what thescrambleKeyflag onTAG_INFOis for.
One wrinkle worth knowing: the decrypter reads a little past the end of the block and uses what
it finds there, so a decoder should hand it about 16 bytes of slack from the archive. The last
record ends flush with the end of the archive and has no slack to give; zero-filling it there is
fine, but zero-filling it everywhere breaks every archive that does have those bytes.
Entry description
0x110 bytes once decoded:
+0x004 char[] entry name, NUL padded (see "Naming" below)
+0x100 u32 always zero. If it isn't, you've lost your place in the archive
+0x104 u32 size of the contents block that follows, including its 0x150 header
+0x108 u32 uncompressed size of the file. 0 means this entry is a directoryA directory entry still has a contents block to skip over - advance by +0x104 either way.
The first two records
The first record (at +0x10) is not a file. Decoded, it holds a text string at +0x10 whose last
comma-separated field is the firmware version, e.g. ...,6.61. That's the authoritative version
for choosing decryption keys further down.
There is a second record after it whose length isn't recorded anywhere. In practice it isoverhead + 100, or overhead + 144 on 2.7x, or overhead + u16 at +0x90 of the first record.
Try them in that order and take the first that decodes. Round the result up to a multiple of 16
before advancing. Version 1 archives don't have this record at all.
Contents
The contents block decodes to a compressed stream. Sniff the first bytes:
| Magic | Format |
|---|---|
78 9C |
zlib |
KL4E |
KL4E - Sony's own, not implemented in PPSSPP |
KL3E |
KL3E - likewise |
2RLZ |
LZR |
Across all 43 firmware versions examined, every file in every archive was zlib. KL4E does turn
up on the PSP, but a layer further in: two kernel modules (memlmd_01g.prx and loadexec_01g.prx)
are themselves KL4E-compressed inside their own PRX, which is a separate problem from this one.
Naming
This is the part that changed most across firmware generations, and the part most likely to catch
out an implementation tested against only one updater.
1.x and 2.x - real paths
Entries are named outright: flash0:/vsh/resource/opening_plugin.rco, flash1:/registry,ipl:/psp_nandipl.bin. Nothing else is needed. Note ipl: - it is not enough to recogniseflash0: and flash1:; better to treat any <device>:/ shape as a real path.
3.x - grouped short names
Entries are named <group>:<5 digits>, where the group is com for files every model gets, or01g, 02g, ... for one model's. <group>:00000 is that group's file list, and the rest of
that group's entries are looked up in it by their number alone (entry com:00004 is key 00004).
5.x and 6.x - flat short names with per-model lists
Entries are a bare 5-digit number. The low numbers are file lists, one per PSP model
(00001 = 01g, 00002 = 02g, ...) and the rest are files that any of the lists may name.
Which numbers are lists is not fixed. 6.61 uses 1-11; 6.00 has real files at 00010-00012.
Since a list always decrypts successfully (the PRX layer inside validates a hash), the robust rule
is: try to decrypt a candidate as a list, and if that fails, treat it as an ordinary file.
File lists
A list decodes to plain text, one shortname<separator>path per line. Both the separator and the
path style depend on the generation:
| Generation | Separator | Path written as |
|---|---|---|
| 3.x | | |
flash0/font/ltn0.pgf |
| 5.x, 6.x | , |
flash0:/font/ltn0.pgf |
Worth normalising to one form early - otherwise a filter like flash0:/font/ silently matches
nothing on a 3.x archive.
List encryption
The lists are encrypted on top of everything else, with DES - the tables in the reference
implementations are DES's own: a 56-entry PC-1, a 48-entry PC-2, eight 64-entry 4-bit S-boxes, a
32-entry P permutation, and textbook IP/FP bit-shuffling constants.
It is DES-CBC decrypt, with the key assembled from two 32-bit words as (high << 32) | low in
big-endian byte order, and an IV alongside them. There is one such set per firmware series, and
which one applies is chosen from the version string in the archive's first record:
| Firmware | Key set |
|---|---|
| 1.x, 2.x, 3.0 - 3.7 | 0 |
| 3.8, 3.9 | 1 |
| 4.x | 2 |
| 5.x | 3 |
| 6.x | 4 |
A sixth set exists that nothing selects by version. The values themselves are in kTableKeys inCore/Util/PSARUnpack.cpp and aren't repeated here.
Underneath the DES layer is an ordinary PRX blob, so run the result through pspDecryptPRX as
well; what comes out of that is the text.
A good check while implementing: after the DES pass but before the PRX pass, the u32 at +0xD0
should be a recognisable PRX tag. A wrong key gives a random value there, so that single word
tells you whether the cipher is right before anything else has to work.
What the archives look like in practice
Three structural eras, visible in the shape of the output:
| Firmware | Naming | Directory entries | Fonts |
|---|---|---|---|
| 1.50 - 3.52 | real paths | 0-16 | 17-20 |
| 3.71 - 4.05 | com:/NNg: groups |
50 | 21 |
| 5.01 - 6.61 | flat + per-model lists | 16-17 | 21 |
A 6.61 archive holds 435 records: 411 files, 17 directories and 7 file lists. Among the files are
295 ~PSP modules, 61 PRF files, 7 encrypted XMB indices (PSPsysGP), and 21 files inflash0:/font - 19 of them PGF, plus gb3s1518.bwfon and imagefont.bin.
The "Fonts" column above counts files in flash0:/font, not PGFs specifically.
Gotchas, collected
- Walk by the length at +0x08, not the file size, or the trailing padding decodes as a bad record.
- Stop when fewer than
overhead + 0x110bytes remain rather than when the position passes the
end - the two differ by exactly one spurious record. - Give the block decrypter its 16 bytes of slack, but don't require them for the last record.
- Don't assume which entry numbers are file lists; find out by trying.
- Don't assume there are file lists at all - 1.x and 2.x have none.
ipl:is a device too.- A 3.x path has no colon; a 6.x path does.
See also
Core/Util/PSARUnpack.{cpp,h}- the implementation, and the only consumer of most of this.Core/ELF/PrxDecrypter.cpp- the PRX layer every block goes through.ext/libkirk-kirk7()is the demangle step.PPSSPPHeadless --unpack-updater=DIRunpacks one from the command line;--unpack-updater-model=01g..12gpicks a model.
SNSystemsMangling (`docs/SNSystemsMangling.md`)
SN Systems (SNC / ProDG) C++ symbol mangling (PSP)
A number of PSP titles were built with SN Systems' compiler rather than the SDK's GCC. Its C++
symbols look nothing like the Itanium mangling, and nothing like the cfront-derived one
CodeWarrior uses either - they are much more compact, and encode lengths as letters rather than
digits. PPSSPP demangles them in Common/Data/Text/Demangle.cpp (DemangleSNSystems).
How this was worked out
From a single PSP executable that shipped with its symbol table intact: 4,662 mangled symbols
out of 11,338, covering an application, an STL, a JPEG decoder and a sound library. Everything
below is attested there except where marked (inferred), which means the code implements it
by analogy with the codes that are attested and nothing in that binary proves it.
The description is complete enough to decode 4,661 of the 4,662. The one holdout is an STL
symbol whose template argument is a reference to a member of another template.
Overall shape
__0 <kind> <name…> <parameters> [_ <return type>] [<qualifier>]The kind character says what the symbol is, and - importantly - how many name components to
read, since nothing else delimits them:
| Kind | Meaning | Name components |
|---|---|---|
f |
Member function | class, then function |
F |
Free function | function |
o |
Member operator, constructor or destructor | class, then a two-letter code |
O |
Global operator | just the two-letter code |
d |
Data | class, then variable |
__0fLCHeapMemoryFAlloci CHeapMemory::Alloc(int)
__0FHGetRandv GetRand()
__0oLCHeapMemorydtv CHeapMemory::~CHeapMemory()
__0OnwUi operator new(unsigned int)
__0dLCHeapMemoryG__vtbl CHeapMemory::__vtblNames
An identifier is a letter giving its length, then that many characters. A is 0, so F is
5 and L is 11; lowercase continues the run, with a = 26 up to z = 51.
LCHeapMemory -> L = 11 -> "CHeapMemory"
FAlloc -> F = 5 -> "Alloc"
bMatrixScaleRotationXZYTrans -> b = 27A 5 before a component makes it an enclosing scope, and any number of them can stack up
before the component the kind character called for:
__0f5FsoundMCSoundPlayerPGetPlayerObjectUi
^ namespace "sound", class "CSoundPlayer", function "GetPlayerObject"
sound::CSoundPlayer::GetPlayerObject(unsigned int)Template arguments
7 opens a template argument list and _ closes it. Each argument is a type, or 4 and an
integer for a non-type parameter. They nest.
6KTFixString748D512_ TFixString<512>
6FTRect7f_ TRect<float>
6KTFixVector76KTFixString7400Y_4g_ TFixVector<TFixString<128>, 32>9A refers back to one of the enclosing name's template arguments, 1-based, so 9BA is
the first. The trailing letter appears to be a nesting level and is always A in practice.
__0FDmax7f_RC9BATB_RC9BA const float & max<float>(const float &, const float &)Integers
Non-type template arguments and array bounds use a compact encoding: a run of 0s, each worth
52, followed by a letter for the remainder.
| Written | Value | |
|---|---|---|
Q |
16 | |
g |
32 | (lowercase, so 26 + 6) |
0M |
64 | 52 + 12 |
00Y |
128 | 104 + 24 |
0000w |
256 | 208 + 48 |
Values that would need too many 0s switch to an escape: 8, a length letter, then that many
decimal digits. 8D512 is 512 and 8E1024 is 1024.
Operators
For kinds o and O the name ends with a two-letter code instead of a length-prefixed
identifier. ct and dt are the constructor and destructor - the class name isn't repeated,
so it has to be taken from the qualifier. nw and dl take an optional trailing a for the
array forms.
Attested: ct dt as eq ne ls nw nwa dl dla. The rest of the usual set
(pl mi ml dv md, the compound assignments, comparisons, cl, vc, rf, …) is
implemented by analogy (inferred).
Types
| Code | Type | |
|---|---|---|
v |
void |
|
b |
bool |
|
c |
char |
|
s |
short |
|
i |
int |
|
l |
long |
|
x |
long long |
|
f |
float |
|
d |
double |
|
r |
long double |
(inferred) |
w |
wchar_t |
(inferred) |
e |
... |
varargs |
P |
pointer to | |
R |
reference to | |
C |
const |
|
V |
volatile |
(inferred) |
U |
unsigned |
|
S |
signed |
(inferred) |
A<n> |
array of n |
|
F_<ret> |
function | |
M<class> |
pointer to member of <class> |
|
6<name> |
class, struct or enum | |
9A |
template argument `` |
Note that a class type in a parameter is introduced by 6, while the symbol's own name isn't -
the kind character has already said a name is coming.
Declarators need wrapping rather than suffixing: PFv_v is void (*)(), PPA0Ms isshort (**)[64], and PM<class>F…_… is ret (Class::*)(args).
Parameter back-references
Two forms repeat an earlier parameter of the same function, both indexed 1-based with letters:
| Form | Meaning |
|---|---|
T |
Same type as parameter `` |
N<count> |
<count> more parameters, each the type of parameter `` |
__0FKRadixSortSPUiNCBUi
RadixSortS(unsigned int *, unsigned int *, unsigned int *, unsigned int)NCB is "two more of parameter 1". Note that T is also the static marker below, so a T
whose following letter isn't a valid parameter index ends the list rather than repeating one.
Return types and qualifiers
A _ after the parameters introduces the return type. Only templates carry one - the same rule
as the other manglings, since two instantiations can differ in return type alone.
After that, a trailing K marks a const member function. A trailing T marks a static
member function: it appears only on kinds f and o, never on a free function (which wouldn't
need marking), and every member operator new/operator delete in the corpus carries it,
which is exactly the set of member functions that are implicitly static.
One more marker, __S, turns up between the name and the parameters on a couple of template
members. What it means is unknown; it doesn't affect the name, so the demangler skips it.
Compiler-generated symbols
| Form | Meaning |
|---|---|
__TID_<type> |
Type id - a one-byte object whose address is the type's identity |
__T_<type> |
Typeinfo record - 20 bytes, pointing at the name and the base class |
__sti__<file>_cpp |
Static initializers for a translation unit |
<class>::__vtbl |
Vtable, as an ordinary __0d data symbol |
__TID_ and __T_ are the two halves of the RTTI for one class, and which is which is
inferred from their sizes rather than from anything that names them. Both take a type rather
than a symbol, and - unlike everything above - spell an enclosing namespace as just another
component instead of marking it with a 5:
__TID_DstdIbad_cast type id for std::bad_cast
__TID_v type id for voidSecondary vtables for multiple inheritance use a different scheme again, with decimal lengths:__vtbl___10CRefObject__5FsoundNCSoundRequest. Only a handful exist and PPSSPP leaves them
alone.
Hazards
- A lot of C runtime code links into the same binary, and much of it starts with a double
underscore (__adddf3,__malloc_lock,__sti). Rejecting a symbol that doesn't parse
completely is what keeps those intact. - The kind character is the only thing that says where the name ends. There is no separator
between the last name component and the first parameter, and uppercase letters are both
lengths and type codes, so a parser that guesses will read the wrong thing and still consume
a plausible number of characters. Tis overloaded - a parameter back-reference in the middle of the list, and the static
marker at the end.
VSHBootInvestigation (`docs/VSHBootInvestigation.md`)
VSH Boot Investigation
Notes on booting the PSP's Visual Shell (VSH / vshmain.prx) in PPSSPP via the direct HLE boot
path (--vsh), using a real Sony firmware dump (not checked into the repo - placed locally underassets/flash0/, with assets/flash1/ alongside it if you have one). This is exploratory
reverse-engineering of Sony's shipped, undocumented System Software, not a supported PPSSPP
feature - treat conclusions here as best-effort, not verified against hardware.
Start here
One command boots VSH, runs to a fixed point, and tells you where it got. It starts the emulator,
finds the debugger port, connects, and cleans up after itself - no wrapper script needed:
printf 'cpu.runUntilTime us=6000000\ncpu.status\n:quit\n' | \
Tools/wsdbg/target/release/wsdbg.exe --sync --compact --quiet \
--launch Windows/x64/Debug/PPSSPPHeadless.exe --vsh --cpu=interpreter --graphics=vulkan --loglevel=3 2>emu.logThat takes a few seconds. Protocol replies come back on stdout, the emulator's log on stderr.
Put more commands in the script to inspect state once it stops. Three things to know:
- Don't add
:wait cpu.steppingaftercpu.runUntilTime(or any resume/step command).--syncalready waits for it; a second one never arrives and burns the whole timeout. This is
the single most likely reason a run seems to take minutes - emulation itself is ~real time. --cpu=interpreteris what this path is known to work with, and breakpoints are most
reliable there.- Boot needs a real firmware dump in
assets/flash0/(and optionallyassets/flash1/), neither
of which is in the repo.
Why it opens on a disc error
The shell reaches the interactive XMB, but first shows "This disc cannot be started. The region
code is not correct." Cancel it with circle and the menu is there and usable.
Two separate things:
It thought a disc was inserted, because nothing in PPSSPP modelled an empty drive. Now a shell
booted without a game starts with an empty drive:sceUmdActivatetells the shell's drive callback
(SceVshMediaDetectUMD) there's no disc, andsceIoDevctl0x01F20001(get disc type) answers0,
as JPCSP does. Activation used to report a readable disc regardless, so the shell asked for its type,
got nothing it knew, and showed "The disc could not be read."A disc goes in with the WebSocket debugger's
game.umd.insert(path), which mounts the image
and calls the drive callback the way a PSP does when a disc is inserted, and comes out withgame.umd.eject. The shell then lists the disc in the Game column with its icon, background and
animated preview, and launching it boots the game throughsceKernelLoadExecVSHDisc(Daxter on
6.61, to its title menu). In the app, the pause screen offers "Insert UMD" and "Eject UMD" while
the XMB is running.UMD Video discs work too: the disc type comes from what's on the disc (
PSP_GAME,UMD_VIDEO,UMD_AUDIO), the shell lists a video disc in the Video column, and selecting it hands it to the
firmware's own player (video_plugin,video_main_plugin, withmpeg_vsh.prx). Bourne Supremacy
and the Spider-Man 2 trailer on the EUR Video Demo Disc play with sound. What that player needed
from our Media Engine HLE (per-picture SEI records, keyframe flags, field conversion insceMpegBaseCscAvc) is in pspautotestsvideo/mpeg/avcdetailandvideo/mpeg/cscavc.It then asked whether the region matched and we didn't answer.
sceIoDevctl0x01E18030is
the region check, and unusually it answers through the return value (1 matches, 0 doesn't)
rather than an output buffer. It was unimplemented, so the VSH read the failure as a mismatch and
retried 12 times. Implemented now - PPSSPP has no region-locked discs, so anything it can load is
something it should run - and the call drops to one.
Around the XMB (6.61)
- Game: the memory stick list shows PSP games, PS1 EBOOTs and homebrew with icons and info.
Homebrew and PS1 games launch from it; PSN titles stop at "The copyright protection information
is invalid (8010850E)", since there's no licence (PSP/LICENSE/*.RIF). A game that exits goes
back to the XMB, as on a PSP. The Saved Data Utility lists saves: it needed the raw stick devicemsstor0p1:/(pspautotestsio/msstor/msstor) andsceSdGetLastIndexwithout a key. - Photo: folders under
PICTURE/list with thumbnails, and JPEG and PNG open full screen (R
for the next one). - Music: MP3 plays. SensMe stops at 8002013A.
- Settings: the menus open. System Information shows "-" for the MAC address: wlan.prx reads it
from IdStorage key 0x44, and IdStorage is left failing on purpose (see below). - Network: the browser and the online manuals reach their Network Connection screen once
sceNetApctl_libis there, then stop at 8002013A onsceHttpfunctions only the NetFront browser
uses (06488A1C, 2C3C82CF, 3A67F306, 739C2D79, 77EE5319, 8046E250, A461A167, B0257723).
Current status
--vsh boots VSH and renders real XMB frames. Measured on the GE command stream after 25
emulated seconds: 8 draw commands (two DRAW SPLINE 16x9, a DRAW BEZIER 4x4, four texturedDRAW PRIM TRI_STRIP - the wave background and icon quads), 41 texture binds, and full
PROJECTION/World/VIEW transform setup. opening_plugin_module, sceMpegVsh_library andimpose_plugin_module load and start.
It reaches the interactive XMB. Up to 2026-08-19 it showed the logo and wave animation and then
a full-screen red error screen, which turned out to be the XMB index failing to decrypt (below).
With that fixed, and with the disc region check answered (above), the shell opens on a dismissable
disc error and the menu behind it works.
Note that framebuffer readback does not work under headless on either backend -gpu.buffer.screenshot and gpu.buffer.renderColor both answer "Could not download output" - so
checking what is actually on screen means running the app build. From headless, the useful proxy is
the per-frame display list signature (see the red error screen section): a screen that is finished
changing repeats byte-identically, and a live menu does not.
Which firmware versions work
All 40 of them. Every version that ships on a UMD - 1.50, 1.52, 2.00, 2.50, 2.60, 2.71, 2.80,
2.81, 2.82, 3.03, 3.11, 3.30, 3.40, 3.50, 3.51, 3.52, 3.71, 3.72, 3.73, 3.80, 3.90, 3.95, 3.96,
4.01, 4.05, 5.01, 5.02, 5.03, 5.50, 5.55, 6.00, 6.10, 6.20, 6.30, 6.31, 6.35, 6.37, 6.39, 6.60 -
plus the download-only 6.61, reaches an interactive XMB with no error on screen.
An earlier pass through this also concluded "all of them", but what it measured was that the shell
renders, and several versions did that while showing nothing but the wallpaper or a full-screen
error. The check now is a picture: boot with --vsh --graphics=software --screenshot-save and look
at it. Two things about that:
--timeoutis wall-clock seconds, not emulated ones. A shell drawing a full XMB runs far
slower than one stuck on a flat background, so a short timeout makes a working version look
stuck. 120 seconds is comfortable for all of them.- Judge by the image, not the file size - though the failure modes do have recognisable sizes,
and none of them is subtle.
What was in the way, in the order it was found
Five distinct causes, each of which stopped a whole band of versions:
Three kernel calls under NIDs we didn't have.
sceKernelLoadModuleVSHis how the shell loads
its own plugins, so without it nothing drawable ever loads and the screen stays black; it has six
NIDs across the range. Same story forsceKernelGetModel(seven) andsceImposeSetStatus(five).
An unresolved GetModel meant the shell read a garbage model number and went looking for PSP-3000
resources on a dump that has none.Finding them is mechanical, and worth writing down because it generalises. For each firmware,
disassemble the module that exports the function and look for the body you already know from
6.61:sceKernelLoadModuleVSHis themodulemgr.prxexport whose callees aresceKernelIsIntrContext,sceIoOpen/Ioctl/CloseandsceKernelGetUserLevel;sceKernelGetModelis whatevervshbridge.prxwraps in asceKernelGetUserLevel() < 4check
and re-exports. Several exports share that shape, so cross-check against the boot log: the one
that matters is the import the shell actually calls, which the "Unknown syscall (run)" lines name.Missing
sceResmgrkeys.flash0:/vsh/etc/index_XXg.datis the index of what the XMB shows
and is encrypted; a shell whose key is missing loads every resource successfully and still has
nothing to put in the menu, so it gives up with the red error screen. Each generation has its own
tag, one per PSP model from 5.03 on and a single unsuffixedindex.datbefore that. 1.50 through
2.50 don't use it at all.The keys are in each firmware's own
mesg_led*.prx, in a table of 24-byte entries - 4-byte tag,
16-byte key, 4 bytes of padding. Search the decrypted module for the tag as a little-endian word
and read the next 16 bytes. Validate the hit before believing it: locate a tag thatPrxDecrypter.cppalready has a key for and check it matches byte for byte. Extracting the 6.6x
triple this way reproduceskeys_9DC14891_1/2/3exactly, which is what established the layout in
the first place. Don't try to walk the table blind - guessing the grid alignment produces
plausible-looking garbage out of ordinary MIPS code, and the 2.5x-era table isn't on that grid
at all.sceImposeGetParam(0x40000000). A real setting in everyimpose.prxfrom 1.50 to 5.55, which
we rejected as not a parameter at all. 3.11's shell read the error back, blanked the display withsceDisplaySetFrameBuf(0, 0, 0)and never turned it on again - so the screen kept whatever
uninitialised VRAM held while a perfectly healthy frame loop ran behind it. 2.00, 3.03 and 3.11
all turned on this one value.category_versionin the registry. OursceRegserves a compiled-in snapshot of a 6.6x PSP's
registry,/REGISTRY/category_versionincluded. Every shell checks it and treats a version higher
than the schema it knows as a corrupt registry, offering to reset your settings instead of
booting - which is what 1.50 through 5.50 did. The check is one-directional, older is always
accepted, and nothing tried to migrate anything, so we report 1.sceRegCloseRegistrydropping categories another opener still owned. SeesceReg.cpp; the
VSH's alarm scan nests registry opens and this cost it its own open category.
Sony renumbered the kernel NIDs, and that is what blocked everything below 6.60
Not offsets - NIDs. A function PPSSPP HLEs under its 6.6x *_driver NID is unrecognized on an
older build, so the import resolves to the real firmware module instead, and the real module
goes places the emulator can't follow.
| Function | 6.60/6.61 | 6.31-6.39 | 6.00-6.20 | 5.03-5.55 | 3.95-4.05 | 3.72-3.90 | 3.71 | 1.50-3.51 |
|---|---|---|---|---|---|---|---|---|
sceRtc_driver sceRtcSetAlarmTick |
E09880CF |
54B9C589 |
68AED59A |
ADAF231F |
55AC1C23 |
827BCB3F |
329E8E3A |
7D1FBED3 |
sceHprm_driver sceHprmReadLatch |
E9B776BE |
A3A87975 |
5FC5E53B |
605DEA7A |
A6E8D4F0 |
8C728076 |
F0AA1FB9 |
40D2F9F0 |
The rtc one is the interesting failure. Without the HLE, the VSH's alarm call ran the realrtc.prx, which called on into syscon.prx and blocked forever on a SceSysconSync semaphore.
The symptom was a boot where every thread was parked and idle0 was running, and the tell was a
fourth SceSysconSync waiter that a healthy boot doesn't have (there are three, one each for
sceSYSCON_Driver, sceRTC_Service and SceWlanMac - that's their normal idle state).hle.backtrace thread=<SCE_VSH_GRAPHICS> named the whole chain: vsh_module -> vshbridge -> rtc ->
syscon -> wait. The hprm one is only a performance bug, but a loud one: the VSH reads the latch
once a frame, so an older firmware's 12-second boot logged ~20000 lines of the same import.
Two more of the same shape, for 1.50-2.xx: sceImpose_driver exports sceImposeGetParam as0x531C9778 and sceImposeChanges as 0xB415FC59 there, with no user-mode alias. Changes runs
once a frame too. And ModuleMgrForKernel numbers sceKernelLoadModuleVSH 0xA4370E7C on 1.x -
that's how the VSH loads its own plugins, so unresolved it got module id 0 back and thesceKernelStartModule after it failed, exactly the way 0xD5DDAB1F did on 6.61 before it was
implemented.
How to map a NID between versions. Disassemble the same module from both firmwares with--re-module and match by address. Don't compare raw addresses - the builds move code - anchor on
the plain user-mode export whose NID never changed (sceRtc/0x7D1FBED3 for SetAlarmTick,sceHprm/0x40D2F9F0 for ReadLatch) and read off the *_driver NID at the same address. Below
3.95 sceRtcSetAlarmTick isn't in the user-mode library at all, so anchor on a neighbour instead:
it is the driver export immediately below sceRtcIsAlarmed. Where even that fails, match the
function body - that is how the two impose calls and the 1.x LoadModuleVSH were identified.
sceRtcIsAlarmed also had to be implemented (it returns 0, as in JPCSP). Left as a null entry it
returned SCE_KERNEL_ERROR_LIBRARY_NOT_YET_LINKED, and the 3.0x-3.5x VSH read that as "ask the
hardware instead" and went back to blocking on syscon.
The scePaf heap pool, and the order modules start in
scePaf's allocator wants a pool base already written into one of its BSS slots. The module that
owns the allocator fills that slot in itself, from its own module_start. The kernel's bootstrap
starts modules one at a time and waits for each module_start to return, so on hardware that has
happened long before vshmain runs. We used to start all the modules as threads side by side, and
vshmain's start thread ran first: the pointer was still null and the shell wrote through it. For a
while the slot was pre-filled with an 8.3MB block (the compiled-in default size) to get past that.
The pre-fill made the boot work but not the game icons. vshmain's start asks paf for one block
for game_plugin's surface pools, 12MB when sceKernelTotalMemSize says more than 36MB (8MB
otherwise). paf's own start would have set up a 0xC50000 long-life heap that holds it, but hadn't
run yet, so the request went to the 8.3MB pre-filled heap and failed. game_plugin then made
surfaces with no memory behind them, decoded every ICON0 and PIC1 correctly, and threw them away
(white tiles on 3.80-5.05, grey placeholders from 5.50).
LoadVshKernelModules() now starts the shell's own modules (heaparea1 where it exists, vshbridge,
paf, common_gui, common_util) one at a time, each when the previous module_start returns, and
vshmain waits for the last. The kd/ drivers still start side by side: syscon's module_start blocks
on a semaphore its hardware interrupt would signal. With the order right the pre-fill went away,
and every version from 1.50 to 6.61 shows game icons.
Which module owns the allocator moved. Up to 4.05 it is a separate flash0:/vsh/module/heaparea1.prx,
which paf imports as scePafHeaparea and cannot allocate a byte without; from 5.01 it is compiled
into paf.prx and heaparea1 is gone. Loading heaparea1 when it's present was the missing piece for
every 3.x and 4.x version - without it scePafHeaparea_ACCE25B2 was an unresolved import, paf
built a heap out of an uninitialized stack pair, and vshmain died storing the null the allocator
handed back.
In both modules the slot is filled by the one function that calls sceKernelTotalMemSize: a> 0x2400000 test picks 0xA00000/0xC50000 pool sizes over the compiled-in defaults, then twosceKernelCreateFpl + sceKernelTryAllocateFpl pairs write to adjacent slots.
1.50 declares no module attributes
g_runningVSH was set from PSP_MODULE_VSH_MODE in the module's attribute word. 1.50's
vshmain.prx has attribute 0000 - Sony only started setting the flag in 1.52 - so the entire VSH
bootstrap was skipped and the shell ran with none of its support modules loaded. The check also
accepts the module name vsh_module now.
The vsh_module alarm-category patch only 6.6x needed
6.60 and 6.61 used to crash in vsh_module's alarm-task scan on a "count" in rodata that nothing
initialized, and a patch zeroed it (only when the word was the 0x3F666666 it was derived from).
It was another effect of the start order above: with the shell's modules started in order, 6.60
and 6.61 reach the XMB without it, and it's gone.
Kernel modules with per-model builds
LoadVshKernelModules() asked for memlmd_01g.prx, loadexec_01g.prx andwlanfirm_01g.prx by name. A firmware unpacked for one model ships only that model's build, and
PPSSPP's own updater unpack defaults to 02g, so all three failed to load. ResolveVshModelModule()
now substitutes the emulated model's suffix when that file exists, falling back to _01g for a
dump unpacked with model any (which has every model's). Firmwares older than about 3.60 predate
the PSP-2000 and have no per-model split at all, so they are unaffected.
5.55 needed two PRX keys
Its flash0:/kd modules are tagged 0x4C941AF0/0x4C941BF0, and those two were the only entries
of JPCSP's PRX tag table PPSSPP was missing. The shell came up anyway - vshmain and paf are user
modules - but with not a single driver behind it.
The red error screen
What is known, all measured from a 40-emulated-second --vsh boot:
- Nothing fails to load. Every
sceIoOpenin the whole boot succeeds:flash0:/font/*.pgf,opening_plugin.rco,system_plugin.rco/_bg/_fg,topmenu_plugin.rco,topmenu_icon.rco,impose_plugin.rco, andflash0:/vsh/etc/index_02g.dat. The only I/O
failure in the entire trace isflash1:/registry/init.dat, which is correct (see above). So VSH
genuinely gets as far as building the top menu before giving up. - Where it freezes. Grouping
sceGeListUpdateStallAddrby display list id gives a per-frame
signature of what is being drawn. It grows 6 -> 10 -> 11 -> 15 -> 19 -> 33 -> 34 -> 38 stall
points (the dialog and its text being composed) and then repeats byte-identically for the
remaining ~2016 frames. That is the error screen locking in; everything before it is animation. - Nothing logs an error in the run-up. The window before the freeze is just the startup jingle
decoding (sceAtracDecodeData+sceAudioOutputPannedBlocking),vshCtrlReadBufferPositive,scePowerIsSuspendRequired,sceKernelGetUserLevelandsceImposeChanges. So the trigger is a
wrong return value we hand back, not a missing call - which makes it much harder to spot than
thesceKernelLoadModuleVSHbug was. sceImposeChangesis not a symptom of the error. It runs once a frame regardless; the
~10000 calls per boot are just the indicator refresh, and they start after the frame loop is
already steady. Don't read into it.
What actually triggers it: the XMB index can't be decrypted
Found by raising log channels for a narrow window around the transition (5.8s to 6.7s emulated,
~12K lines - log.channel.set, not a full-boot log). The frame loop's calls before and after the
transition differ sharply: before it there is resource loading, module starting and registry
access, after it only the clock and battery indicators. Inside that window:
sceIoOpen(flash0:/vsh/etc/index_02g.dat) -> fd 8
sceIoRead(8, 092a2d40, 496) -> the whole file
sceIoClose(8)
unresolved import sceResmgr/9dc14891, called from 'vsh_module' <- traps here
sceKernelExitDeleteThread(1) <- the ScePafJob building the menu gives upindex_02g.dat is the index of what the XMB shows, and it is encrypted (it starts PSPsysGP, not
the release: of an already-plaintext one). sceResmgr_9DC14891 is what decrypts it. With that
import unresolved the call trapped, the index stayed encrypted, and the job thread quit - hence a
shell that has loaded every resource successfully and still has nothing to show.
Fixed. Core/HLE/sceResmgr.cpp implements the call, the three tags it needs
(0x0B2B90F0/91F0/92F0, keys and code 0x5C from JPCSP) are in Core/ELF/PrxDecrypter.cpp, andpspDecryptType9() there decrypts it - 496 bytes in, 159 out, starting release:, which is the
check sceResmgr logs on every call.
Type 9 turned out to be type 6 with three differences, all because a type 9 file carries a real
ECDSA signature at 0x104..0x12C where type 6 has nothing:
- The "this region must be empty" check has to stop at
0x104rather than0x10C, or the first
8 bytes of the signature fail it (this was the original-2). - The signature is excluded from the hashed header rather than fed into it - JPCSP zeroes
buf2[0x34..0x5C), which is the same range after its header rearrangement, soPRXType9simply
leaves that field zero. ecdsa_hashin the KIRK CMD1 header stays 0. Type 6/7 set it; the branch type 9 takes writes
only the mode word. Setting it was the final-4.
The SHA1 check inside the decrypt is a good progress signal while porting: reaching -4 means the
header reconstruction and hash inputs were already right and only the KIRK call was wrong.
The root cause of the long-standing blank screen
ModuleMgrForKernel/0xD5DDAB1F (sceKernelLoadModuleVSH) was unimplemented. That is how VSH
loads its own plugins: the XMB's UI lives in flash0:/vsh/module/*_plugin.prx and vshmain
pulls those in through this kernel call, not the user-mode sceKernelLoadModule. The import went
unresolved, vshmain got no module id back, and the sceKernelStartModule that followed was called
with id 0 and failed with 0x8002012E UNKNOWN_MODULE. No plugin ever ran, so the scene had
its containers but nothing drawable in them - which is exactly the "render state set up 6 times
per frame, zero draws" symptom this investigation chased for a long time.
Implemented along with 0xD86DD11B sceKernelSearchModuleByName, the other unresolvedModuleMgrForKernel import.
Found by scanning a whole boot for any HLE call returning an error. Exactly one mattered, and it
had been missed on two earlier passes because it logs as : error 8002012e rather than as a8xxxxxxx=sceSomething( return value. Grep both shapes.
Solved along the way
- Real kernel driver modules running as genuine MIPS code needed scratchpad RAM kernel-mode
address mirrors (Core/MemMap.cpp), dummy COP0 instructions (Core/MIPS/Interpreter.cpp), and
a GPIO + Syscon serial MMIO model (Core/HW/GpioMMIO.{h,cpp}) sokd/syscon.prx's real
handshake completes instead of spinning. vsh_modulealarm-task SIGSEGV: was a targeted data patch zeroing an "alarm category count"
field nothing else initialized. Removed once the shell's modules started in order, which made
it unnecessary.SCE_KERNEL_ERROR_NO_MEMORYin the app build: the 4MB kernel pool was exhausted by PPGe's
~2MB overlay texture (now skipped when booting VSH) plus 11 driver modules each asking for a
256KB thread stack (now given 32KB).sceHprm_driver/sceHprmReadLatch(0xE9B776BE) implemented and its output struct actually
zeroed, matching JPCSP. Correct, but did not affect the blank screen.- flash1 is now mounted when a dump sits next to the flash0 one.
sceRegserves a compiled-in
snapshot of some real PSP's registry (Core/HLE/sceReg.cpp) rather than reading the volume, and
a genuine dump has onlyregistry/system.dregand.ireg.flash1:/registry/init.datmust
stay absent - an earlier note here said creating one "changes nothing", which is wrong. VSH
stats it twice during startup, and finding it means "reinitialize the registry": it then callssceRegRemoveRegistryandsceIoDevctl("flashfat1:", 0x5802), bothUNIMPL, and the boot dies
without starting a single plugin module. Its absence is the healthy path, and the resultingFILE NOT FOUNDin the log is expected, not a lead. - A diagnostic-tooling bug that produced a phantom lead. The "unresolved import" reporter
encoded identity in a 55-slot ring buffer, but a VSH boot registers ~1000 unresolved imports, so
the ring wrapped ~18 times and every report named whatever most recently reused that slot. It
madesceMgVideo_driverlook like one massively hot call when it was the misattributed sum of
many. Fixed by recoveringstubAddrviag_lastSyscallPCand looking identity up inmodule->importedFuncs, which already tracked exactly this (interpreter-only, matching the
original limitation). Lesson: when identity has to be recovered later by address, check
whether something already tracks it before adding a cache. Breakpoints were never at fault -
if one doesn't fire, suspect the address first.
Imports that must stay unresolved
Counterintuitive but repeatedly measured: implementing more of what the VSH imports can make the
boot worse, because a correct implementation lets a real flash0 driver finish its init and walk
on into hardware PPSSPP does not emulate. Leaving the import unresolved makes the driver fail early
and the boot continues. All three are commented in the source at the point where they'd be added:
ThreadManForKernelmutex NIDs (0xB7D098C6sceKernelCreateMutex,0xB011B11FsceKernelLockMutex,0x6B30100FsceKernelUnlockMutex) - we implement all three for user
mode already. Resolve them and the NAND and ID storage drivers init successfully, thensceIdStorage_Servicepolls the NAND controller at0xbd101300forever (470k reads in 25
emulated seconds), and only 4 modules start.sceKernelAllocateFpl(0xD979E9BF) used to be on
this list, but it's the mutexes that do it: with only the FPL resolved,sceIdStorage_Service
allocates from its pool and the boot still reaches the XMB.InterruptManagerForKernelinterrupt registration (0x58DD8978,0xF987B1F0,0x4D6E7305,0xD774BA45) - JPCSP can honour these because it emulates the interrupt controller as MMIO. We
dispatch the few interrupts we emulate ourselves (__RegisterIntrHandler), so success is a lie
the drivers act on. Measured: 31 such calls (interrupts 4, 12, 15-18, 20-24, 31), then a stall in
GE list execution with no plugin module started.
73 unresolved import hits over 37 distinct module/NID pairs remain in a boot. Most of the rest aresceSysEventForKernel, sceSuspendForKernel and *_driver modules that would need real hardware
behind them. Note the runtime ones log as Unknown syscall (run) at ...: unresolved import ...
(HLE.cpp:905) - a different string from the load-time Unknown syscall from known HLE module,
so grep for both or you will undercount badly.
How this boot path works
Real hardware boots VSH through the kernel's own module bootstrap, launched fromflash0:/reboot.bin. PPSSPP doesn't emulate that chain. --vsh loadsflash0:/vsh/module/vshmain.prx through the normal PRX loader, andLoadVshKernelModules() (Core/HLE/sceKernelModule.cpp) approximates the missing
bootstrap by loading and starting vshbridge.prx, paf.prx, common_gui.prx, common_util.prx
and 11 real kd/ drivers first, in the order JPCSP's own --vsh shortcut uses.
JPCSP's --vsh turns out to use the same shortcut rather than a real reboot.bin LLE path, so this
is a legitimate if approximate route to a working VSH.
Known differences from JPCSP's --vsh that have not been tried: it preloadsPSP_MODULE_AV_VAUDIO, AV_ATRAC3PLUS and AV_AVCODEC, and sets the io filepath toms0:/PSP/GAME. Its trick of forcing the root thread to kernel mode at priority 0x7E was
tried and reverted - it reorders scheduling enough that sceVshBridge_Driver runs before a
precondition is ready. Firmware version already matches (660 both sides).
Thread structure
There is no vsh_main thread and there shouldn't be. vshmain.prx's module_start runs on theroot thread, creates and starts SCE_VSH_GRAPHICS (entry 08818d14) and returns from module -
visible in the log as Context switch: root -> ... (returned from module) right after thesceKernelStartThread. SCE_VSH_GRAPHICS is VSH's main thread, and its entry is the
outermost frame of every render backtrace.
Steady state is 8 threads: SCE_VSH_GRAPHICS, ScePafThread, SceWaveMain, SceWlanMac,sceRTC_Service, sceSYSCON_Driver, idle0, idle1, plus transient ScePafJob threads that
load the .rco resources and exit. The frame loop is healthy: poll ScePafSyncCall,vshCtrlReadBufferPositive, render, sceDisplaySetFrameBuf, wait SceVblankSync.
The render call chain
Still worth having if individual widgets misbehave. Traced with a write memcheck on a display
list word (memory.breakpoint.add address=... write=true), which stops with a structured hit
and lets hle.backtrace name the whole chain - minutes of work, and the technique to reach for
first next time:
08818d14 SCE_VSH_GRAPHICS thread entry
08895544 6-iteration loop
08963cfc container: sets base render state, walks children at [obj+0x550], count [obj+0x554]
08950754 per-child render
089a1cdc / 089a36e4 / 089a38c0 ... GE state helpers08950754 gates on z_un_089501cc(child+0x450) == 1 and on [child+0x438] != 0, then computes
the scissor by intersecting [child+0x43c..+0x448] with a parent clip rect.
Tools and methodology
- Everything useful works on a live session:
hle.thread.list,hle.eventflag.list,hle.backtrace,memory.disasm,memory.readString,gpu.displaylist.disasm,cpu.evaluate
(expressions like[s0+0x438]read guest memory), and memory breakpoints. Seedocs/WebSocketDebugger.mdfor the catalog. cpu.runUntilTime us=Nis what makes a script reproducible - it stops on the requested emulated
microsecond, so every run reaches the same instruction. Ask for the time you need and no more;
the red screen locks in around 6 emulated seconds.- Raise log channels for a short window only -
sceGe/G3Dat debug produce ~200K lines in
three seconds of boot.log.channel.setis session-only, never persisted.--loglevel=3is a
good default for the whole run: bare--logon a VSH boot writes ~2.3GB, level 3 writes ~66KB. - Diffing against JPCSP (
../JPCSP, Java source, NIDs searchable by hex) repeatedly beat guessing
at missing HLE functions.assets/flash0has the decrypted PRXs if a NID needs disassembling. - Headless is preferred over the app build - the app's Debug config blocks on a Windows crash
dialog with no console output.
Emulation is not the slow part. A VSH boot runs at roughly real time even in a Debug build
(1 emulated second in a 2.5s run that includes process startup). If a scripted run takes minutes,
it is waiting, not emulating - and the usual cause is an explicit :wait cpu.stepping after acpu.runUntil*/cpu.resume/cpu.step*, which --sync already waits for. The redundant one waits
for a second cpu.stepping that never comes and burns the whole --sync-timeout. SeeTools/wsdbg/README.md.
Not included on this branch
The separate --vsh-reboot experiment (loading flash0:/reboot.bin for a real LLE kernel boot,
with NAND/KIRK/system-control MMIO emulation) lives on vsh-experiments. The direct HLE path here
reaches much further without any of it.
WebSocketDebugger (`docs/WebSocketDebugger.md`)
PPSSPP WebSocket Debugger
PPSSPP has a JSON/WebSocket-based debugger and automation API, served from the
same HTTP server used for "Remote ISO" disc sharing and file upload. It lets
an external tool (a script, a web page, another editor/IDE) inspect and
control a running emulation session: read/write memory, set breakpoints,
step the CPU, read GE/GPU state, send fake input, tail the log, etc.
This doc is a local reference, the user-facing documentation is on the website.
Where the code lives
Core/WebServer.cpp/Core/WebServer.h- the shared HTTP server (also
used for Remote ISO and file upload). It owns the listening socket and
dispatches/debuggerrequests.Core/Debugger/WebSocket.cpp- upgrades the HTTP request to a WebSocket and
runs the per-connection event loop (HandleDebuggerRequest).Core/Debugger/WebSocket/*.cpp/.h- one "subscriber" or "broadcaster" per
feature area (CPU, memory, GPU, HLE, input, breakpoints, ...). Each file's
top comment documents its events in detail - this doc gives the overview
and an index into those files.Core/Debugger/WebSocket/WebSocketUtils.h- sharedDebuggerRequest
helper (parameter parsing, response/error helpers) andDebuggerSubscriber
base class.Common/Net/WebsocketServer.h/.cpp- the low-level WebSocket framing.
Transport
- Runs on the same port as Remote ISO sharing (
g_Config.iRemoteISOPort;0
means "pick a free port automatically" - the actual bound port is written
back to that config value and logged:Listening on port N). - URL path:
/debugger. - WebSocket subprotocol:
debugger.ppsspp.org(required - a plain HTTP GET
to/debuggerwithout a websocket Upgrade just redirects to the bundled
web UI at/debugger/index.html). - Messages are JSON, both directions, always shaped as
{"event": "NAME", ...}. - One WebSocket connection = one client; PPSSPP does not limit the number of
simultaneous debugger connections. - The debugger only actually does anything while
WebServerFlags::DEBUGGER
is enabled (see "Enabling it" below) - the HTTP server itself may also be
running for other reasons (Remote ISO, upload).
Message protocol
Requests you send:
{ "event": "cpu.status" }Optionally include a "ticket" field (any JSON value) - PPSSPP echoes it
back verbatim in the response/error, so you can correlate requests and
responses when firing several at once. Tools/wsdbg (see below) assigns an
incrementing integer ticket automatically.
Responses use the same event name as the request:
{ "event": "cpu.status", "ticket": 1, ... }The ticket convention: send one whenever you care about the answer. Since
a response reuses the request's event name, a ticket is the only thing that
distinguishes your answer from an unsolicited broadcast of the same name, or
from the answer to an identical request you sent a moment earlier. Conversely,
for a request that doesn't answer immediately (below), leaving the ticket off
says you aren't waiting for anything.
Responses are not always immediate. cpu.resume, cpu.stepInto,cpu.stepOver, cpu.stepOut, cpu.runUntil, cpu.runUntilTime,cpu.nextHLE, cpu.stepping and gpu.stats.feed send nothing back at the
time of the request; what follows later is the broadcast that reports the
actual outcome (cpu.stepping / cpu.resume), which carries no ticket.input.buttons.press is the odd one out - it answers with the request's own
event name and ticket, but only once the button has been held for the
requested number of frames.
If you would rather not track which those are, ask to be told explicitly:
-> { "event": "client.config.set", "acknowledgeDeferred": true }
-> { "event": "cpu.resume", "ticket": 7 }
<- { "event": "deferred", "for": "cpu.resume", "ticket": 7 }
<- { "event": "cpu.resume" }With that on, every request draws exactly one immediate reply - a response, anerror, or a deferred - so a client can correlate without a hardcoded list,
including for events added in future versions. It is off by default and must
stay that way: an extra message would break a client that correlates purely by
ticket, and it can't reuse the request's event name because forinput.buttons.press that is exactly what the real, later answer looks like.
Errors look like this:
{ "event": "error", "message": "...", "level": 2, "ticket": 1 }level is a LogLevel (1=NOTICE, 2=ERROR, 3=WARN, 4=INFO, 5=DEBUG, 6=VERBOSE).
PPSSPP also sends unsolicited ("broadcast") events with no request - see
below.
By convention, send a version event right after connecting (seeWebSocket/GameSubscriber.cpp):
{ "event": "version", "name": "my-tool", "version": "1.0" }PPSSPP responds with its own name/version, and remembers yours (currently
just for internal bookkeeping/future logging). The response also carriespid (OS process id) and path (the executable/disc currently loaded, ornull), so an automation client can confirm it attached to the instance it
meant to - a port alone doesn't prove that, since a leftover process may still
be holding the port you asked for.
Broadcast (unsolicited) events
Sent without you asking, whenever the underlying state changes:
| Event | Sent when | Source |
|---|---|---|
log |
A new log line is emitted | LogBroadcaster.cpp |
game.start |
A game finishes booting | GameBroadcaster.cpp |
game.quit |
The game is closed/reset | GameBroadcaster.cpp |
game.pause / game.resume |
User opens/leaves the pause menu | GameBroadcaster.cpp |
cpu.stepping |
CPU enters a stepping/break state | SteppingBroadcaster.cpp |
cpu.resume |
CPU resumes from stepping | SteppingBroadcaster.cpp |
cpu.breakpoint.hit |
Any breakpoint trips, whether or not it stops the CPU | WebSocket.cpp |
input.buttons |
Any emulated button changes state | InputBroadcaster.cpp |
input.analog |
An analog stick position changes | InputBroadcaster.cpp |
A client can opt out of specific broadcast categories withbroadcast.config.set ({"disallowed": {"logger": true, "game": true, "stepping": true, "input": true, "breakpoint": true}}),
see ClientConfigSubscriber.cpp. gpu.stats.feed (see below) works the same
way for periodic GPU stats. client.config.set in the same file carries
per-connection settings that aren't about broadcasts - currently justacknowledgeDeferred, described under "Message protocol" above.
Source line info
Where a game shipped an unstripped ELF, PPSSPP decodes its DWARF .debug_line
and can map an address to a source file and line. cpu.breakpoint.hit andcpu.stepping carry file/line in the hit object, and hle.backtrace
carries them per frame - which is where it pays off most:
08841f98 move sp,fp mesh.zig:163
0883afa4 li v0,0x0 MenuState.zig:821
088260d8 andi at,v0,0xFFFF State.zig:40
0882a27c andi at,v0,0xFFFF engine.zig:468Both fields are null when there's no line info for that address, which is the
common case: PRX conversion strips every .debug section, so this never
applies to a commercial game. In practice it means homebrew that ships itsapp.elf next to the EBOOT - the same file the companion symbol loader uses -
or a plain .elf you built yourself. It follows bAutoSaveLoadSymbols along
with the symbols.
DWARF 2, 3 and 4 are decoded; version 5 units are skipped with a log line rather
than mis-parsed, since it re-encoded the file table. Nothing targeting the PSP
emits it today (psp-gcc produces 2, Zig 4).
Emulation speed
game.speed.set drives two independent things:
-> { "event": "game.speed.set", "fastForward": true } // unlimited
-> { "event": "game.speed.set", "percent": 200 } // double speed
-> { "event": "game.speed.set", "percent": 25 } // quarter speed
-> { "event": "game.speed.set", "percent": null } // drop the override
<- { "event": "game.speed.set", "fastForward": false, "percent": 200, "limitFps": 120 }Percentages are relative to 60 FPS, matching how the in-app settings present the
alternative speeds - so 200 means the same thing in both places. percent
must be at least 1; use fastForward for unlimited rather than 0, so there's
only one way to say it. fastForward wins while it is on, and percent is
remembered underneath it.
limitFps in the response is the frame rate throttling is actually aiming for
once everything - fast-forward, this override, the user's own alternative-speed
hotkeys - has been taken into account, with 0 meaning unlimited. It comes
straight from the function the frame timing itself consumes, so prefer reading
it over inferring the result from the other two fields.
This is a separate channel from the user's own alternative speeds. It
deliberately never touches g_Config, whose speed settings are persisted per
game, so a debugger session can't permanently change what the user configured.
Clearing the override also only stands down from a limit set through this event,
never from one the user set themselves. It resets on every game boot.
The request fails rather than being silently ignored when something else
owns the speed: RetroAchievements hardcore mode, or being connected to a network
game without "allow speed control while connected".
Headless sets fast-forward at startup and never turns it off on its own, sogame.speed.set works there too - turning fast-forward off makes headless
throttle to real time, which is occasionally useful for a wall-clock repro.
Breakpoint hits
cpu.breakpoint.hit fires every time a breakpoint's condition passes and it has
some action set - including log-only breakpoints, which never stop the CPU.
That's what makes them usable for automation: before this event existed, a
log-only breakpoint's only trace was a line in the log stream.
{
"event": "cpu.breakpoint.hit",
"sequence": 1,
"hit": {
"kind": "exec",
"pc": 142876568,
"address": 142876568,
"hits": 1,
"logged": true,
"paused": false,
"condition": null,
"symbol": "rendering.mesh.Mesh(rendering.Vertex.PspVertex).draw",
"breakpoint": { "start": 142876568, "end": 142876568 }
}
}The same hit object is attached to cpu.stepping when a breakpoint is what
stopped the CPU, so both can be parsed the same way. It is absent when the
break came from something else (the user pausing, a savestate load, an
exception), so test for its presence rather than for a kind.
Fields common to every kind:
| Field | Meaning |
|---|---|
kind |
"exec", "memory" or "register" |
pc |
The instruction responsible |
address |
Exec: the instruction. Memory: the address actually accessed |
hits |
Total times this breakpoint has tripped, matching *.list |
logged / paused |
Which actions it had - paused false means the CPU kept running |
condition |
The condition expression, or null |
symbol |
Symbol at address, or null - resolved here to save a round trip |
file / line |
Source location of pc, or null. See "Source line info" below |
breakpoint |
{start, end} identifying which breakpoint fired. Absent for "register", whose identity is the register, not an address |
Extra fields for "memory":
| Field | Meaning |
|---|---|
size |
Bytes accessed |
access |
"read" or "write" |
source |
Who performed it - "interpret", "CPU", "HLE", or an allocation tag such as "ThreadFillStack" |
Extra fields for "register": register (GPR index) and registerName
(e.g. "a0").
Note address and breakpoint.start are not the same thing for a memory
breakpoint watching a range - the first is the byte touched, the second is the
range being watched. On cpu.stepping the legacy relatedAddress field keeps
reporting the range start; hit.address is the accurate one.
sequence counts hits produced, not delivered. A connection whose queue backs
up (easy to do with a log-only breakpoint in a hot loop - one can produce tens
of thousands of hits per second) drops hits rather than growing without bound,
so a gap in sequence tells a client exactly how many it missed. Game and
stepping events are never dropped. Turning thebreakpoint category off via broadcast.config.set avoids the traffic
entirely.
Request/response event catalog
Full details (parameters, response shape) are documented as comments above
each handler in the corresponding Core/Debugger/WebSocket/*Subscriber.cpp
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 |
| 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 |
| 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 |
| Memory info/annotations | memory.mapping, memory.info.config/set/list/search |
MemoryInfoSubscriber.cpp |
| Disassembly | memory.base, memory.disasm (add compact=true for plain-text lines instead of full per-field objects), memory.searchDisasm (add findAll=true for every match instead of just the first - e.g. "every caller of this address"), memory.assemble |
DisasmSubscriber.cpp |
| GE display list disassembly | gpu.displaylist.disasm - like memory.disasm but for GE command words (CLEARMODE, PRIM, etc.) instead of CPU instructions; also supports compact=true |
GPUDisasmSubscriber.cpp |
| HLE | hle.thread.list/wake/stop, hle.func.list/add/remove/removeRange/rename/scan, hle.func.hash (the code hash and size of the analyzed function at an address, as hardcodedHashes in MIPSAnalyst.cpp needs them, and the name of the entry already matching it if any), hle.module.list, hle.module.saveSymbols/loadSymbols (save/load one module's symbols to/from its standard PSP/SYSTEM/SYMBOLS/<moduleName>_<crc>.ppsym file, shared across any game that loads the same module - see SymbolMap::GetModuleSymbolsPath), hle.game.saveSymbols/loadSymbols (the same for symbols that aren't inside any module - heap, stack, scratchpad, hardware registers - which describe one game's memory layout and so go to a per-game PSP/SYSTEM/SYMBOLS/<gameID>_syms.ppsym instead; see SymbolMap::GetGameSymbolsPath), hle.backtrace |
HLESubscriber.cpp |
| Data symbols | hle.data.list/add/remove/rename - label discovered data (structs, tables, buffers) with a name/type, same idea as hle.func.* but for ST_DATA symbols |
HLESubscriber.cpp |
| Kernel objects | hle.object.list (every live kernel object of every type at once, with an optional type filter - uid/type/name/one-line summary only); hle.eventflag.list/info, hle.mutex.list/info, hle.semaphore.list/info, hle.msgpipe.list/info, hle.callback.list/info (per-type full detail, including waiting-thread lists) - all read-only, never mutate kernel state |
HLEKernelObjectSubscriber.cpp |
| GPU stats | gpu.stats.get, gpu.stats.feed |
GPUStatsSubscriber.cpp |
| GPU recording | gpu.record.dump |
GPURecordSubscriber.cpp |
| GPU buffers | gpu.buffer.screenshot, gpu.buffer.renderColor/renderDepth/renderStencil, gpu.buffer.texture, gpu.buffer.clut |
GPUBufferSubscriber.cpp |
| Input injection | input.buttons.send, input.buttons.press, input.analog.send |
InputSubscriber.cpp |
| Replay | replay.begin/abort/flush/execute/status, replay.time.get/set |
ReplaySubscriber.cpp |
| Client config | broadcast.config.get/set, client.config.get/set |
ClientConfigSubscriber.cpp |
| Log channels | log.channels.list, log.channel.set - query/change a log channel's level (string: notice/error/warning/info/debug/verbose) and/or enabled state; the log event itself (the passive message stream, unaffected by this) keeps its existing numeric level, see LogBroadcaster.cpp |
LogConfigSubscriber.cpp |
Enabling it
- UI: Settings > Tools > Developer Tools > "Allow remote debugger"
checkbox (UI/DeveloperToolsScreen.cpp). The "Local Server Port" slider on
the Networking screen sets the port (shared with Remote ISO sharing;0=
auto-pick). - Config:
RemoteDebuggerOnStartup=trueinppsspp.ini
(g_Config.bRemoteDebuggerOnStartup) starts it automatically on launch
(UI/NativeApp.cpp). - Command line (both application and headless builds):
--debugger=PORT
(0= pick a port automatically) - a shared auto-param inCore/CmdLine.cpp/.h(CmdLineMode::Both).ApplyToConfig()setsiRemoteISOPort/bRemoteDebuggerOnStartupfor that run without persisting
them to the config file.- A non-zero
PORTis treated as mandatory (WebServerSetRequireExactPort()):
if it can't be bound, the server does not silently fall back to some other
free port the way the "Local Server Port" preference does, because a client
was told to connect there. Headless exits non-zero; the application build
logs an error and shows an OSD message but keeps running. Use--debugger=0
and read the actual port from theListening on port Nlog line if you'd
rather not care which port you get. - On the application build this is exactly like ticking "Allow remote
debugger" - the game boots and runs normally, debugger listening
alongside it. - On the headless build (
headless/Headless.cpp) it additionally
forcescoreParameter.startBreak = true, so the CPU halts before
running anything - useful for setting breakpoints before launch.
- A non-zero
Discovery
For LAN auto-discovery (mainly useful for mobile), the server periodically
reports its (local ip, port) to report.ppsspp.org/match/update (see RegisterServer() inCore/WebServer.cpp). Clients can query report.ppsspp.org/match/list to
get a list of candidate endpoints on the same network and try connecting to
each in turn.
The bundled web-based JS debugger
assets/debugger/ is a git submodule
(https://github.com/unknownbrackets/ppsspp-debugger.git, bundled branch -
see .gitmodules) containing a prebuilt React app. PPSSPP serves it directly
at /debugger/ (Core/WebServer.cpp's HandleFallback/ServeAssetFile),
so opening http://:/debugger/ in a browser gets you a full GUI
debugger for free. The actual editable source lives in a different branch of
that same repo (the bundled branch only holds the built output that gets
checked in here).
From reading the minified bundle (assets/debugger/static/js/main.*.js),
it connects like this:
- Manual connect:
new WebSocket("ws://ip:port/debugger", "debugger.ppsspp.org"). - Auto connect:
fetch("//report.ppsspp.org/match/list")for a list of{ip, port}candidates (as registered byRegisterServer()above), then
tries each with the same WebSocket call until one succeeds.
Talking to it yourself
scripts/websocket-test.py- old minimal Python one-shot script (needs thewebsocket-clientpip package).Tools/wsdbg/- a small Rust CLI/REPL client for this session's work (seeTools/wsdbg/README.md): connects, does theversionhandshake, and lets
you fire off events by hand or from a one-shot command line, printing
responses and broadcasts as they arrive. Built and smoke-tested against a
live PPSSPP instance while writing this doc.
Android Device Testing (`docs/android-device-testing.md`)
Testing an Android build on a device
Notes from driving real devices over adb. Most of what follows is not about Android being hard, but
about ways the tooling quietly answers a question you did not ask - you measure the wrong build, or
measure garbage that has not been collected yet, and get a confident wrong answer.
Install the build you think you are testing
versionCode is derived from the git commit count (see gitVersionCode inandroid/build.gradle.kts), so a feature branch has a lower code than a longer branch. Installing
one over the other fails:
adb: failed to install ...: Failure [INSTALL_FAILED_VERSION_DOWNGRADE]-r does not permit downgrades; -d does. Use adb install -r -d <apk>.
Do not pipe the install through tail -1 or grep Success and move on. The failure is a single
line and easy to swallow, and the device then keeps running whatever was there before - every
measurement afterwards describes the wrong binary, including A/B comparisons where both arms end up
being the same build and therefore agree with each other.
Verify after every install, not just when something looks odd:
adb shell dumpsys package org.ppsspp.ppsspp | grep versionNameA second, independent check is worth it when a result is surprising: grep the log for a symbol that
exists only on the branch under test (a renamed class or log tag, say).
The version string itself can also lie. The Android build regenerates git-version.cpp but does not
always recompile it, so the APK can carry the previous build's string while containing current code.
When the version matters:
find android/.cxx -name git-version.cpp.o -deleteForcing an activity recreate without touching the device
adb shell settings put system font_scale 1.15, then back.fontScaleis not in the activity'sconfigChanges, so each change destroys and recreates the activity. Independent of orientation.adb shell wm density <n>also recreates - but only whiledensityis absent fromconfigChanges.adb shell settings put system user_rotation 1does not rotate PPSSPP. The activity follows
the sensor, so this needs a physical rotation (or a cooperative device).
Put back whatever you changed: font_scale, accelerometer_rotation, user_rotation.
wm density reset is actively wrong on a device with a resolution override - a Galaxy S8 running
1080x2220 on a 1440x2960 panel resets to the physical panel's density and leaves the UI oversized.
Read the original with wm density first and set it back explicitly.
Measuring retained activities
adb shell dumpsys meminfo prints an Objects block with Activities and AppContexts.
The counts include objects that are garbage but not yet collected. Read it straight after N
recreates and you will see roughly N whether or not anything leaks, which looks exactly like a leak.
Force collection instead:
adb shell am send-trim-memory org.ppsspp.ppsspp COMPLETEthen wait around eight seconds and read. Repeat until two readings agree - three or four rounds is
normal, and a single trim is not enough.
The protocol that gives an answer you can trust: cold start and read the baseline, do N recreates,
trim until stable, compare. A sound build returns to the baseline; a leaking one sits at baseline+N
and stays there.
Worked example, Galaxy S8 on Android 9, same device and protocol, both builds confirmed byversionName. Before the activity handover fix: 13 recreates left 13 Activities and 18 AppContexts,
stable across repeated trims. After: the same 13 recreates settled back to 1 and 6.
A JNI global reference is a GC root, so an activity pinned by one shows up here. This is a usable
check for JNI ownership bugs, not only Java ones.
logcat on a noisy device
Filter to the process, since the tag alone is not enough:
adb logcat -d --pid=$(adb shell pidof org.ppsspp.ppsspp)On a device with a chatty system - a Quest especially - the ring buffer can rotate the app's lines
away within seconds, which reads as "logging stopped" rather than "logging was lost". Start a stream
before launching the app instead:
adb logcat -c && adb logcat -s PPSSPP:V PpssppActivity:V # in the background, then start the appVR (Quest)
- The VR flavor is a separate package:
./gradlew :android:assembleVrDebug, installing asorg.ppsspp.ppssppvr. It can sit alongside the normal build. adb exec-out screencapreturns solid black for an immersive app - it captures the 2D panel
layer, which such an app does not have. Use the runtime's own line instead: a logcat entry likeVrApi: FPS=73/72 ... App=2.67msproves frames are being submitted and how much GPU time the app
spent on them. Zero app time means the render loop is not producing.am startmakes the activitytopResumedActivity, but the shell keeps focus, so the app never
gets a surface and the render loop does not restart. Re-entering an immersive app has to be done
from inside the headset. A fresham start -Safter aforce-stopdoes work.debuggerd -b <tid>needs root, so no native stacks from a retail headset.
Check which paths the device actually exercises
Before concluding that a test covered something:
adb shell grep -E '^(MemStickDirectory|GraphicsBackend)' /storage/emulated/0/PSP/SYSTEM/ppsspp.iniA plain filesystem path for the memstick means the SAF/content-URI code
(Common/File/AndroidStorage.cpp, ContentUri.java) is never entered, so a run on that device says
nothing about it - a recents list full of plain paths says the same. The VR flavor readsppssppvr.ini, not ppsspp.ini.
Async Savestates (`docs/async-savestates.md`)
Async savestates
How savestate compression and file I/O were taken off the emulation thread, so saving and loading
don't stall the game long enough to glitch the audio. The worker and the operation queue are inCore/SaveState.cpp, rewind in Core/SaveStateRewind.cpp, and the file format code inCommon/Serialize/Serializer.cpp. Written 2026-10-19; file references will drift.
Keep memory use low. A state is about 38 MB uncompressed, and much of the hardware we run on is
short of memory. So don't waste it: no full-size buffers beyond what an operation needs, freed when
it's done. Within reason, though; a brief extra buffer in a rare case is fine. See Memory.
Where the time goes
Measured with Release headless on desktop x64, local disk, three commercial games:
| Emu thread today | Of which can move to a worker | |
|---|---|---|
| Save | 49-75 ms | compress (zstd, default level) + write: 47-70 ms. The snapshot itself is 2-5 ms |
| Load | 28-40 ms | read + decompress: 21-30 ms. Applying the state is 6-10 ms |
| Load from a slot | the save and the load above | LoadSlot first saves load_undo.ppst in full |
The waits at the start of SaveStart::DoState (utility IO, sas mix, async IO, pending GPU drawing)
were under 0.2 ms. Phones are several times slower, and their storage can be much slower again, so
the worker column is the part that hurts. The emu thread keeps only the snapshot and the apply.
Structure
Each operation splits into the part that touches emulator state (emu thread) and the part that
doesn't (worker).
- One savestate worker thread, with a FIFO job queue. Not
g_threadManager: file work on a slot
has to happen in order. On Android it holds anAndroidJNIThreadContextfor its lifetime, since
storage access can need JNI. - Callbacks still run on the emu thread, from
SaveState::Process(). The worker posts results to
a completion queue thatProcess()drains, so callers see the same threading as before, only
later. - An operation goes through: queued, emu phase, worker phase, completion, callback. A load does its
worker phase (reading) before its emu phase (applying).
Save
- Emu thread, in
Process():SaveStart::DoStateinto a fresh buffer (the snapshot). The waits
andFlushPendingDrawing()stay here, and so does thesaveStateGenerationbump. - Worker: compress (see Memory) and write the file, to
<file>.tmpfor a slot save, then for a slot save the rotation thatSaveSlot'srenameCallbackdoes today (old file to.undo,.tmpto the real name), including theg_Config.Save()for the undo bookkeeping. Then setg_filesStale. - Completion: the callback, with "Saved State" or the failure. The message appears when the file
is on disk, not at snapshot time, so a failed write is reported honestly. A failed write leaves
the previous file in place (it only ever wrote the.tmp).
The thumbnail screenshot is already asynchronous and doesn't change.
A save to a path that already has a save queued, and not yet snapshotted, is a duplicate
keypress. It isn't queued again. Its callback is attached to the queued save and called with that
save's status and an empty message (so it shows nothing, and the queued save's own callback shows
the result once). Once the snapshot is taken, another save to the path is a new save: the game may
have moved on, or a load been applied, since.
A save to a different path is queued, and takes its snapshot right away like any other. At most two
save snapshots exist at once: a third save, while two are still being written, waits for the
older of them before taking its snapshot. Two quick saves to different slots are rare and briefly
cost one extra buffer; more than that isn't worth holding 38 MB each for.
Load
- Worker: read the header and check it, then stream the file through the zstd decompressor into
the state buffer, while the game keeps running. - Emu thread, in the first
Process()after the read completes:- For a slot load, snapshot the current state for load undo. This has to happen here, at the
moment the game actually leaves that state, not when the load was asked for. - Apply the loaded state (
LoadPtr), with everythingProcess()does around it today
(hasLoadedState,Core_ResetException(), AV dump restarts,g_lastSaveTime).
- For a slot load, snapshot the current state for load undo. This has to happen here, at the
- Worker: compress and write the load undo snapshot to
load_undo.ppst, and updatesStateLoadUndoGame. This happens even when the apply failed: that's when the undo is most
useful, since a failed load resets the game. - Completion: the callback, after the apply.
The game runs a frame or two longer before the loaded state takes over. That isn't observable,
since the load replaces all of it.
Ordering
Operations still take effect in the order they were asked for:
- Emu phases run in queue order. A save queued after a load waits until that load is applied,
and snapshots the loaded state, as it does today when both run in the sameProcess(). A load
waiting for its file holds up only the emu phases queued behind it. - Worker jobs run in queue order, so a save to a slot followed by a load of it writes before it
reads. - UI-thread file operations on slots wait for the worker (
WaitForWorker(), for all jobs, since
it's rare and short):UndoSaveSlotandDeleteSlot.UndoLoadis a load, so it's queued behind
the load undo write like anything else.LoadSlot's existence check counts a slot with a save
pending as existing. The thumbnail screenshot isn't the worker's, so its rotation doesn't wait.
What stays synchronous
There's one code path. Waiting for it is WaitForWorker(), which simply blocks: worker jobs never
need the emu thread, so it can't deadlock.
- libretro:
retro_serialize/retro_unserializeare in memory with no file I/O, and stay as
they are. So does the freeze-frame feature (SaveToRam/LoadFromRamevery frame). - Verify stays synchronous.
- Shutdown and game switch (
SaveState::Shutdown(), from the kernel's shutdown, which app exit
goes through too): wait for the worker, so a save whose snapshot was taken is written. Then run the
completions' bookkeeping (the undo slot in the config) without their callbacks, which can refer to
an EmuScreen that's going away, and drop whatever is still queued, as before. A save still queued
at that point (asked for in the pause menu, which doesn't runProcess(), then quitting) is lost
like it always was: by then most of the kernel has shut down, too late to take a snapshot. - Loads at boot (auto-load,
--state, opening a.ppst) and the debugger'sgame.state.*withwaitalready work through callbacks, and need no special handling. The game already ran at least
a frame before a boot-time load applied.
Rewind
Rewind snapshots used to compress on a std::thread per snapshot, which the next snapshot joined
(stalling if it was still running), and the whole restore ran on the emu thread: wait for the
compressor, decode (rebuild the full ~38 MB state from the base and the changed blocks), apply. The
snapshot itself (2-5 ms, mostly copying RAM and VRAM, already a ParallelMemcpy) stays on the emu
thread.
Now:
- The emu thread owns the ring itself (which slots hold what); compressing and decoding are jobs on
the savestate worker, and the emu thread leaves the buffers a job uses alone until it's done. - Compression runs on the worker. If the previous snapshot's job is still running, the snapshot
is skipped and retried next frame rather than waited for: snapshots are best effort. - The deltas get zstd on top (level 1: higher levels shrink them a little for a lot more time).
The changed blocks still compress about 3x, so the ring takes about a third of the memory. - Restore is an async load from memory:
StartRestore()picks the state and has the worker
decode it (queued behind the newest snapshot's compression, if that's still going), andFinishRestore()applies it on the emu thread. No snapshots are taken in between. No prefetching
(see Memory).
Memory
What each operation holds at its peak, against today:
| Today | Async | |
|---|---|---|
| Save | snapshot (38 MB) + ZSTD_compressBound output buffer (about 38 MB) |
snapshot (38 MB) + the compressed output as it grows (10-12 MB). The snapshot is freed before writing |
| Load | whole compressed file (about 12 MB) + state (38 MB) | state (38 MB) + a small streaming input buffer |
| Load from a slot | the save, then the load | at apply: the loaded state + the load undo snapshot (76 MB), briefly. The loaded state is freed right after the apply, before the undo is compressed |
| Rewind restore | a static 38 MB decode buffer, never freed |
the same buffer, freed after the apply |
| Rewind ring | block diffs | the same, zstd-compressed: about a third |
Rules:
- No pools and no buffers kept between operations. Allocate a buffer when the operation starts
and free it when it ends. A 38 MB allocation costs far less than the stall this design removes,
and an idle emulator then holds nothing for savestates (rewind aside, which is opt-in and labelled
a memory hog in the settings). - At most two save snapshots at a time (see Save), and one load buffer at a time: a
second load queued behind a first doesn't start reading until the first is applied. - Stream compression in both directions. Saving,
ZSTD_compressStream2into a buffer that grows
with its output, instead of allocating the worst case up front. It isn't streamed straight to the
file, since the header holds the compressed size and seeking back to patch it in isn't safe on
every kind of storage (Android's content URIs). Loading,ZSTD_decompressStreamreads the file in
chunks, so the compressed data never sits in memory in full. The file format doesn't change. - Rewind adds nothing. The worker reuses rewind's existing buffers. There's no prefetching
(decoding a likely target before the rewind is pressed), which would need a spare full-size buffer.
Later, if phone measurements call for it
MeasureAndSavePtrtraverses the state twice (measure, then write). Writing into a buffer sized
from the previous measurement plus slack would skip the measure pass, but needs bounds checks in
the write pass.- The apply is mostly
DoStatelogic rather than copying; leave it unless it shows up.
Results
Measured the same way as above (Release headless, desktop x64), on the emu thread:
| Before | After | |
|---|---|---|
| Save | 49-75 ms | 2.4-2.5 ms (the snapshot) |
| Load | 28-40 ms | 12-15 ms (the apply, plus the warnings and callback around it) |
| Rewind restore | decode + apply | 12-13 ms (the apply) |
| A frame waiting on the worker | under 0.05 ms |
Rewind deltas went from 4-13 MB to 1.3-3.8 MB each, at 10-24 ms of worker time.
Testing
Headless, wsdbg and the game.state.* debugger commands cover all of it. game.state.save andgame.state.load take a slot instead of a path, which goes through the slot code like the save
and load keys: undo rotation, load undo, repeats. Use a scratch --memstick for that, not the app's,
or the tests overwrite real slots. wsdbg's --sync moves on when a request is acknowledged, not when
a wait result arrives, so put a :sleep between steps whose order matters, and one at the end.
Building (`docs/building.md`)
Building and testing PPSSPP
Agent-oriented notes on the various build systems and test suites. This is the long version of the
"Build and validation" / "Testing" sections in AGENTS.md.
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 viavswhere.exe (same tool/gotchas as described in the libretro section below) and build a specific project with /t:, e.g.:
$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=<platform> /m(swap /t:UnitTest for /t:PPSSPPWindows or another project name as needed; drop it entirely to build the whole solution).
On an ARM64 host, use $installPath\MSBuild\Current\Bin\arm64\MSBuild.exe instead. The MSBuild inBin\ itself runs emulated and picks the 32-bit x86-hosted compiler (bin\HostX86\arm64\CL.exe),
which can't always map the precompiled headers into its address space. Builds then fail
intermittently, every file of a project at line 1, with C3859: Failed to create virtual memory for PCH and C1076: compiler limit: internal heap limit reached. That looks like running out of
memory, but lowering /m or CL_MPCount doesn't help, and /p:PreferredToolArchitecture=arm64
has no effect. The build log's CL.exe path shows which host compiler ran.
`` is ARM64 or x64 - whichever the machine actually is, so look it up rather than
picking a default. The output directory follows it (Windows\\<configuration>\), which
makes building one and running the other an easy mistake. It is easiest to make on Windows-on-ARM,
where an x64 build runs anyway under emulation: everything appears to work, but it is slower than
the native build and any performance measurement from it describes the emulator rather than the
code. platform.machine() in Python reports the host; $PROCESSOR_ARCHITECTURE reports the shell,
which is AMD64 in an emulated shell even on an ARM64 machine.
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
UnitTestproject (unittest/UnitTests.vcxproj), then runWindows//Debug/UnitTest.exe all(`` beingARM64orx64, whichever you built) - Linux/Mac: configure with
-DUNITTEST=ON, then runbuild/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.
More generally, when you are bisecting a behavioural change, confirm the binary actually changed
before you believe the result - check the executable's mtime, or have the code you just added log
something you can grep for. A stale binary is indistinguishable from a real regression, and it lies
consistently, so a bisect on top of one produces a confident, entirely fictional answer. This cost
several hours once: a link that silently failed left a stale PPSSPPHeadless.exe in place, every
subsequent "revert this and retest" step reported the same failure, and a change was blamed that a
later clean rebuild proved innocent. The LNK1168: cannot open ... for writing case (a still-running
instance holding the exe, see Debugging) is the most common way to get there, which
is why killing leftover processes before building is worth doing unconditionally rather than only
when a build complains.
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.
UWP build
UWP has its own solution, UWP\PPSSPP_UWP.sln. Build the app with /t:PPSSPP_UWP (or a single
library such as /t:CoreUWP), same MSBuild setup as above:
& $msbuild "UWP\PPSSPP_UWP.sln" /t:PPSSPP_UWP /p:Configuration=Debug /p:Platform=<platform> /mAndroid assets
The APK's assets/ directory is the repo-root assets/ directory, wired up byassets.directories.add("../assets") in android/build.gradle.kts. There's no copy step, so a file dropped
in assets/ ships as-is. ndk-build doesn't read assets at all, and nothing populates android/assets/ -
if you have one, it's a leftover from the old Ant/Eclipse build and is ignored.
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 theNDKenv var toandroid/ab.sh(POSIX).
It should match thendkVersioninandroid/build.gradle.kts. The scripts just 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_headlessexecutable ends up inandroid/libs/<abi>/.
tvOS
The tvOS app is the iOS app with the iPhone-only parts compiled out, checked withPPSSPP_PLATFORM(TVOS) (tvOS also gets PPSSPP_PLATFORM(IOS)). The CMake side is the iOS toolchain
with -DIOS_PLATFORM=TVOS, which sets TVOS.
./b-tvos.shbuilds an unsigned app intobuild-tvos-xcode/Release-appletvos/.DEVELOPMENT_TEAM=<team> DEVICE= ./b-tvos.shsigns it, registers the Apple TV with the team
if needed, installs it and launches it with the console attached.- To pair an Apple TV, put it on Settings > Remotes and Devices > Remote App and Devices. It doesn't
show inxcrun devicectl list devicesuntil paired; find it withdns-sd -B _remotepairing-manual-pairing._tcpandxcrun devicectl device info details --device <name>,
thenxcrun devicectl manage pair --deviceand enter the code it shows. ./b.sh --tvos --libretro(and./b.sh --ios --libretro) build the libretro cores. tvOS has nosigaltstack, among other things, so this is the build that catches it.- There's no tvOS simulator build: FFmpeg has no tvOS simulator libraries.
- tvOS apps have no persistent storage except about 500 KB of
NSUserDefaults. Anything else may be
purged, so the memory stick is in Caches for now.
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 (viaplatform=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).
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. itscores/ 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 findvswhere.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):
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 likeVK_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).
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. A new file has to be listed in three build files, not two:CMakeLists.txt, unittest/UnitTests.vcxproj (and its .filters), and android/jni/Android.mk, which
builds a unit test executable of its own. The Android one is the easiest to forget, since missing it builds
fine everywhere you are likely to try it and only fails on Android CI.
A unit test is often the first thing to call a given function from outside its own .cpp, which makes theppsspp_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.
The compilers also disagree about floating point contraction, which matters for any test asserting that a JIT
is bit-identical to its C++ reference. Clang folds a * b + c into a single fused multiply-add by default;
MSVC never does, under /fp:precise, in Debug or Release. So on arm64, where the JITs emit FMLA, a
reference written as a * b + c matches on Mac, Linux and Android and is off by one ULP on Windows on ARM.
Don't leave it to the compiler: write fmaf(a, b, c) when the fused result is wanted (MSVC compiles it to a
single fmadd), and a * b + c when it isn't. PrescaleUV in GPU/Common/VertexDecoderCommon.cpp picks per
architecture, matching what each JIT does. x86 doesn't have the problem, since the SSE2 baseline has no FMA
instruction to contract into.
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):
python test.py -g --graphics=softwareThe -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.
LoongArch64 and RISC-V JITs under qemu
CI tests these JITs by cross-building headless and running pspautotests under qemu-user, and you can do the
same on Linux (packages: gcc-14-loongarch64-linux-gnu g++-14-loongarch64-linux-gnu qemu-user, or the riscv64
equivalents):
./b.sh --loongarch64 PPSSPPHeadless # builds into build-loongarch64/
mkdir -p build-qemu
printf '#!/bin/bash\nexec qemu-loongarch64 -L /usr/loongarch64-linux-gnu "$(dirname "$0")/../build-loongarch64/PPSSPPHeadless" "$@"\n' > build-qemu/PPSSPPHeadless
chmod +x build-qemu/PPSSPPHeadless
python3 test.py -g --graphics=software --cpu=jit-ir --timeout=60 --known-failures=loongarch64test.py runs the most recently modified build*/PPSSPPHeadless, so touch the shim after rebuilding the real
binary, or it runs your native build instead. The full suite takes a couple of minutes.
qemu reports LSX and LASX as present, so CI only exercises the LoongArch vector paths.
The scalar fallbacks never run here, so passing tells you nothing about them.
The 32-bit ARM JIT works the same way with ./b.sh --armhf (into build-armhf/) and qemu-arm -L /usr/arm-linux-gnueabihf. The cross packages are gcc-14-arm-linux-gnueabihf g++-14-arm-linux-gnueabihf, andsudo cmake/scripts/setup-cross.sh armhf adds the GL and EGL stubs the link needs. Without root, apt-get download
the packages, dpkg-deb -x them into one directory, and point CROSS_ARMHF at it: the toolchain file then takes
the sysroot from $CROSS_ARMHF/usr/arm-linux-gnueabihf. The binutils need LD_LIBRARY_PATH to include$CROSS_ARMHF/usr/lib/x86_64-linux-gnu, glibc's libc.so linker script names absolute paths that have to be
rewritten to the new root, and the stubs are copied in by hand. For a debugger, run qemu-arm -g PORT and attachgdb-multiarch to it.
hash/hash dies with a bus error under qemu-arm on every CPU core, so it's not a JIT problem, but it takes the rest
of its batch down with it: pass --known-failures=armhf.
Without a cross toolchain (on macOS, say) two things still help:
- A syntax check of the other JITs. The RISC-V and LoongArch backends (
Core/MIPS/RiscV,Core/MIPS/LoongArch64and their emitters) have no architecture guards, so the host compiler can check
them:clang++ -fsyntax-onlyper file, with the-I/-D/-std/-isystemflags of any Core file fromcompile_commands.json, plus-I<repo> -I<repo>/ext -I<repo>/Common(the database's include paths are
relative to the build directory). Check that a copy with a deliberate syntax error does report errors: zsh
doesn't word-split a$FLAGSvariable, which once made a broken check look clean. This proves the code
compiles, nothing about its output. - The portable software-renderer paths. riscv64 and loongarch64 run the software renderer's non-SSE,
non-NEON C++ paths, so their test failures reproduce natively: wrap the two NEON defines in the__aarch64__block ofppsspp_config.hin#ifndef PPSSPP_TEST_NO_SIMD, configure a separatebuild-nosimdwith-DHEADLESS=ONand-DPPSSPP_TEST_NO_SIMDinCMAKE_C_FLAGSandCMAKE_CXX_FLAGS,
and buildPPSSPPHeadlessthere. Delete it and revertppsspp_config.hafterwards, sincetest.pykeeps
picking the newest binary. A failure that's the same on both architectures points at the portable path, not
the JITs.
Quick rebuild on Linux
You don't need to do ./b.sh --debug to verify every single little change, instead use this shortcut:
cd build ; make -j32; cd ..Command Line (`docs/command-line.md`)
Command-line parsing
All command-line parsing for both the main app and the headless build 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 in those files - add a field
to CommandLineOptions instead.
Declaring an option
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 and meanings, because a givenParse() call only matches params whose mode is Both or equal to the current mode. --log is the
example to know: a String "log to FILE" option in Application mode, a Bool "full log output" option
in Headless mode. They don't collide.
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 insideCommandLineOptions::Parse() - the same way --graphics= and the boot filenames are handled.
Getting a parsed option into the emulator
ApplyToConfig() is where parsed options are pushed into g_Config / g_logManager. Prefer wiring a
new option through there, so every platform gets it for free, rather than reading CommandLineOptions
fields ad-hoc at each call site.
NativeInit() in UI/NativeApp.cpp still takes argc/argv, because several platform entry points
pass them in, but it shouldn't read them directly: by the time NativeInit() runs,CommandLineOptions should already hold everything.
Cpu Equivalence (`docs/cpu-equivalence.md`)
CPU core equivalence
The goal: every CPU core (interpreter, IR interpreter, JIT, IR JIT) produces the same bits on every
host (x86-64 and ARM64), NaN payloads (and possibly NaN signs) aside. Where the cores disagree, the
hardware decides which one is right, so several items below need a test on a real PSP before they
can be fixed.
The fuzzer
UnitTest CPUEquivalence (in unittest/JitHarness.cpp) generates random code (ALU, immediate,
FPU, VFPU, loads and stores, optionally forward branches) and a random start state, runs it on every core, and
compares the full end state against the interpreter: GPRs, FPRs, VFPU registers and control
registers, hi/lo, fcr31 (bit 23 aside, which some cores sync from fpcond and some don't), fpcond,
llBit, 1KB of scratch memory, and the cycles charged up to the terminating syscall. NaNs compare
equal to each other. Loads and stores go through k0, which points at the scratch memory and is
never written.
Each differing case is minimized (instructions dropped while it still differs, or turned into nops
when the code has branches, so none moves its target) and printed with its inputs. It ends with a count of the reported cases by their last instruction, which is usually the
one at fault. By default only a crash fails the test, as known differences remain.
Environment variables:
| Variable | |
|---|---|
PPSSPP_CPUFUZZ_COUNT |
number of cases (default 2000) |
PPSSPP_CPUFUZZ_SEED |
seed of the first case; case i uses seed + i |
PPSSPP_CPUFUZZ_CLASSES |
weighted string of a (ALU), i (immediate), f (FPU), v (VFPU), m (memory), r (a run of vdots the IR batches into one Vec4VfpuMultiDot); b anywhere adds forward conditional branches |
PPSSPP_CPUFUZZ_CODE |
comma-separated hex instructions to run instead of the generated code |
PPSSPP_CPUFUZZ_CODE_SEED |
apply PPSSPP_CPUFUZZ_CODE to this seed only |
PPSSPP_CPUFUZZ_SKIP |
cores to skip, by digit: 1 IR interpreter, 2 JIT |
PPSSPP_CPUFUZZ_REPORTS |
how many differences to minimize and print (default 20) |
PPSSPP_CPUFUZZ_VERBOSE |
print every case before running it, to find one that crashes |
PPSSPP_CPUFUZZ_DUMP |
write every core's end state of every case to a file |
PPSSPP_CPUFUZZ_STRICT |
fail on any difference, not only on a crash |
PPSSPP_CPUFUZZ_OPS |
comma-separated instruction name prefixes to generate only; dot is the dot products and the prefix ops |
PPSSPP_CPUFUZZ_HOSTEXT |
+ or -: turn the JIT's optional host extensions on or off (RISC-V Zba/Zbb/Zbs/Zcb/Zfa/Zfh/Zicond, and V with -, LoongArch LSX/LASX); the VFPUDotJit test follows it too |
PPSSPP_CPUFUZZ_BLOCKS |
print the IR and host code of every block the JITs ran, after each case |
PPSSPP_CPUFUZZ_UNKNOWNPREFIX |
compile as after a block left a prefix uneaten |
For quick rounds while working on some instructions, fuzz just those, on every core of the machine:PPSSPP_CPUFUZZ_OPS=dot Tools/cpu_fuzz_parallel.py build/PPSSPPUnitTest --count 2000 splits the seeds
over several processes and sums up the results.
Once a block ends with a prefix uneaten, the IR frontend stops assuming that blocks start with the
default prefixes (startDefaultPrefix), for as long as the JIT exists. In a run, the first case that
leaves a prefix does this, so whether a later case compiles one way or the other depends on the
cases before it, and a difference may not reproduce with that seed alone.PPSSPP_CPUFUZZ_UNKNOWNPREFIX puts every core into that mode first.
Always set CI=1 when running it from a script on Windows. Otherwise a JIT assert opens a message
box and the run hangs until someone clicks it.
Tools/cpu_fuzz_reduce.py runs the fuzzer and, if a case crashes (usually a JIT emitter assert),
finds it and reduces it to the instructions still needed for the crash. A crash that only happens
after other cases have run usually means uninitialized or stale state, not the reduced code.
To compare hosts: run with the same seed and count and PPSSPP_CPUFUZZ_DUMP on each, and diff the
files. Each line starts with the core and the seed. A Windows-on-ARM machine can run the x64 build
under emulation, which is enough for the host-dependent float behavior of the cores. It isn't for
timing, of course.
Fixed
Crashes (JIT emitter asserts) and wrong code found by the fuzzer. Entries for the "ARM64 JIT", the
"x86 JIT" and "both JITs" are about the legacy JITs, which have since been removed. Every JIT is now
IR-based (the "IR JIT" and "native IR backend" entries).
- ARM64 JIT:
extwith pos + size past 32, andinswith msb below pos, asserted in UBFX/BFI. - ARM64 JIT:
ApplyPrefixSTread an uninitialized register for a swizzle naming a lane past the
op's size. The ARM64 JIT now gates its VFPU ops on prefixes the way the IR frontend does (vdot,
vcmp, vi2f, vi2x, vocp, vhdp, vcmov, the matrix inits), and the x86 JIT got the gates it was
missing too (vdot, vhdp, vcmp, vf2i, vh2f, vi2f, vi2x, vocp, vsgn, the color conversions, the
matrix inits, vmmov, vmscl). - ARM64 JIT: vdot, vhdp, vmmul and vqmul used fused multiply-add, which rounds differently from
every other core. They now multiply and add separately, in the interpreter's order. - vcmov in both JITs ignored a T prefix, which the interpreter applies to D (read as T).
- IR: vmmul, vmscl, vmidt/vmzero/vmone and vmmov on a 1x1 matrix flipped the transposed bit, which
for 1x1 selects a different register. vmmul's fallback never wrote a 1x1 result at all. - IR: vmmul, vtfm, vmscl, vmmov and the matrix inits took a matrix that isn't a plain Vec4 layout to
be a transposed one, so a 4x4 matrix with a row/column offset (likeE723orM212) produced
Vec4 ops on unaligned registers. Native IR backends asserted, the IR interpreter computed garbage. - IR: with every lane write-masked by the D prefix, the destination lanes were all D temps, which
looked like a Vec4, and the mask blend then went into an unaligned destination. - x86 JIT:
scinto$zerowrote its result to an immediate and asserted. vmmul's SIMD path
crashed onvmmul.sand on matrices that wrap around (MatrixWrapsAround()), which now go to
the interpreter and the scalar path. - Native IR backends (all four):
llinto$zeromapped$zerofor writing and asserted. - ARM64 and x86-64 IR JITs:
scdidn't mark rt dirty, so its 0/1 result was lost. - IR interpreter: never applied the guest's rounding mode or flush-to-zero (fcr31) on block entry,
only after actc1in the same block. It now does what the native dispatchers do, and leaves the
host's mode in place around syscalls and replacement functions. - JITs: the mode was only taken into account once a
ctc1had been compiled (js.hasSetRounding),
so a mode from a savestate made on another core, a core or thread switch, or an interpretedctc1was ignored, and syscalls then ran in the guest's mode.UpdateFCR31()now sets the flag
and throws away the blocks compiled before. Ops left to the interpreter run in the guest's mode,
except syscalls, and an interpretedctc1takes effect at once.CPURoundingModetests all of
this on every core, with and withoutJitDisableFlags. - ARM64 JIT:
mfhi/mflointo$zerowrote the host register holding zero. - x86-64 IR JIT: helper calls (vsin, vrsq and the like) didn't flush RCX and RDX outside Windows,
where the System V ABI doesn't preserve them. - MIPSAnalyst:
ctc1/cfc1of a control register other than 31 counted as touching the FPU
condition, so the x86 JIT's register cache dropped a dirty fpcond before one. - x86 JIT: fpcond held the compare's all-ones mask instead of 1.
- ARM JITs:
vmfvcof a control register past the defined ones mapped the dest withoutMAP_DIRTY, so its 0 was never written. - IR:
vmmovbetween transposed matrices whose rows wrap around (likeE020) produced unaligned
Vec4 moves, which crashed the x86 IR interpreter. - RISC-V and LoongArch64 JITs: see below.
- The legacy JITs'
Comp_Genericran the interpreted instruction in the host's default rounding
mode instead of the guest's. - IR: vmscl and the matrix inits of two transposed matrices flipped the transposed bit to get
Vec4s, which for a matrix with an offset (one that wraps around) names other registers. vmscl
also took its Vec4 path before checking that the matrices don't partly overlap. - IR: vc2i and vuc2i read their source as a single, not as the first lane at the size encoded.
- IR: vf2i and vi2f wrote each lane straight to a destination that could hold a later lane's source.
- IR: vcmov under a saturating D prefix left lanes that didn't move with whatever the D temp held.
- IR: vmtvc to a control register that masks its value read the VFPU register numbered like the
control register. - IR
OptimizeFPMoves: propagated a copy into a multi-lane result covering the copied register,
and into the src3 of ops where it's also the destination (FCmovVfpuCC), moving the result. - IR
OptimizeLoadsAfterStores: compared offsets as unsigned, so a store at -16 of 16 bytes didn't
cover a load at -12, which reused the value from before the store. - Native IR backends: flushing a host register that cached
$zeroleft it mapped, so after a call
that clobbered it (the dot product routine), a store of$zerostored garbage. - The IR frontend continues past conditional branches, so a block can now leave with a prefix
pending at a mid-block exit. It doesn't continue then, as only the end of a block checks that. - Hardware answers 1, 2 (host agreement), 5, 7 (the interpreter and
vcmov), 8, 9 and 10 below.
Timing, which decides when interrupts, thread switches and other events happen, so a difference
sends a game down a different path on each core:
- IR:
j,jal,bc1tandbc1fdidn't charge their delay slot. - IR: a syscall charged itself before running, or, in a delay slot, never. It now sees every
earlier instruction charged and is charged once it returns, as in the interpreter. - Interpreter: a skipped likely delay slot cost 1 cycle, the IR cores charge what it would cost.
- Interpreter: it checked downcount after every instruction, the IR cores only between blocks. It
now runs to the same block ends (branches and jumps after their delay slot, syscalls, breaks,
replacements), so events happen at the same instruction. Its fast loop also charged a branch
and its delay slot together after both, so a syscall in the slot didn't see the branch.
The interpreter also follows the IR frontend on past a conditional branch that isn't taken
(IR_CONTINUE_BRANCHES), with the same limits, except in one case it can't see: the IR takes a
prefix register written by mtvc or vmtvc, or by a VFPU op it interprets that doesn't consume
prefixes, as unknown, where the interpreter goes by the value. Games set prefixes with vpfx.
The fuzzer only compares cycles at the end, so it doesn't see where a block ends; a trace of
syscalls and thread switches from a game does (God of War, Tekken 6 and Daxter agree on all three
cores).
Open differences
The goal, in order: every core gives the same result on both hosts, and then that result is the
PSP's. Where matching the PSP is expensive, agreeing with each other comes first.
Answered on hardware
Each of these has a pspautotests test recorded on a PSP (6.61). Distinct outputs across the eight
configurations (four cores, ARM64 and x86-64 under Rosetta), and lines that differ from the PSP.
The jit columns were measured with the legacy JITs, since removed; jit is now jit-ir on both.
| Test | Distinct outputs | ARM64 interp / ir / jit / jit-ir | x86-64 interp / ir / jit / jit-ir |
|---|---|---|---|
cpu/cpu_alu/shift_encodings |
1 | 0 / 0 / 0 / 0 | 0 / 0 / 0 / 0 |
cpu/cpu_alu/allegrex_encodings |
1 | 0 / 0 / 0 / 0 | 0 / 0 / 0 / 0 |
cpu/fpu/denormal |
2 | 54 / 54 / 54 / 54 | 54 / 54 / 54 / 54 |
cpu/vfpu/fcr31 |
2 | 50 / 50 / 50 / 51 | 50 / 50 / 50 / 51 |
cpu/vfpu/signed_zero |
4 | 14 / 37 / 37 / 37 | 14 / 37 / 36 / 37 |
cpu/vfpu/nan_gen |
7 | 51 / 51 / 57 / 57 | 51 / 51 / 51 / 54 |
cpu/vfpu/undefined |
4 | 0 / 6 / 3 / 6 | 0 / 6 / 9 / 6 |
cpu/fpu/fpu_nan |
2 | 0 / 0 / 0 / 0 | 4 / 4 / 4 / 4 |
Before the fixes, shift_encodings had 2 outputs and fcr31 7. nan_gen, fpu_nan anddenormal still come out as each host's own NaNs (item 6). What's left of fcr31 is items 3 and
vdot's summation, and of undefined the horizontal ops and matrix registers with an offset that doesn't fit the size. Rosetta behaves as an x86 CPU here: the x86-64 interpreter made0xFFC00000 NaNs and flushed denormal results but not inputs under FS (FTZ without DAZ).
The fuzzer agrees: integer, FPU and memory code (PPSSPP_CPUFUZZ_CLASSES=aaiiffm) gives the same
end state on every core and on both hosts in 20000 cases, apart from item 11 below. With VFPU code
mixed in, the interpreter differs between the hosts in 7 of 20000 cases, each a host NaN out of a
horizontal op (vmmul, vtfm, vdot, vdet) that vmin, vmax or vf2i then orders or converts by its
sign.
srl/srlv with stray bits (
shift_encodings, fixed). Only the low bit of the unused field
counts:srlrotates when bit 21 is set and shifts when it is clear, whatever bits 22-25 hold,
andsrlvthe same with bit 6. sll/sra/sllv/srav ignore the field. The Allegrex bit ops
(allegrex_encodings, fixed) decode only part of their op field too, so every value is one of
wsbh, wsbw, seb, seh and bitrev (seeAllegrexBitOp()), and special3 function 0x18 is a no-op.Flush-to-zero (FS) (
denormal). With FS clear the PSP handles denormals in full, which
both hosts match. With FS set it flushes denormal results to zero, with tininess decided before
rounding (a product that rounds up to FLT_MIN is still flushed), but never flushes inputs: sqrt,
div and the compares see a denormal as it is. One exception: add/sub with a zero operand
returns the other operand, denormal or not. x86 FTZ gets all of it but that exception (16
lines). ARM64's FZ also flushes inputs (54 lines). The x86 cores now set DAZ along with FTZ, so
both hosts flush inputs and agree, 54 lines away from the PSP. Matching the PSP needs either
the x86-like mode where ARM64 has it (FEAT_AFP: FPCR.AH with FIZ clear, not on every device),
or flushing results in software when FS is set.fcr31 doesn't affect the VFPU (
fcr31). All six fcr31 settings give identical results.
The VFPU always rounds to nearest, and always flushes denormal inputs and outputs to zero
(vf2iuof a denormal is 0, a denormal times 1 is 0). The cores should run VFPU ops in
round-to-nearest without guest FS, and flush denormals themselves, rather than inheriting the
FPU's mode. Some of the remaining lines are vdot's exact summation (1.5u + 1.5u + 3 - 3is 0
on the PSP). The cores agree with each other here (they all follow fcr31), so this is about
matching the PSP. A check on every VFPU op would cost too much. Since games run VFPU ops in
clumps, switching the host into round-to-nearest with flush-to-zero (inputs too) around a clump
and back afterwards may be cheap enough; not tried yet. The ops that move to the accurate dot
product get this right by themselves.Signed zero from sums (
signed_zero). Every summing op returns +0 for a zero sum,
whatever the signs of the products: vdot, vhdp, vfad, vavg, vdet, vcrsp, vqmul, vmmul, vtfm,
vhtfm, at every size, including vfad of all -0. vcrs (a product per lane) and plain vadd keep
-0. The interpreter is right apart from vcrsp, vqmul and vtfm/vhtfm.vmin/vmax and denormals (
fcr31). Denormals compare equal to zero, a tie returns the
second operand, and the result keeps the operand's bits (a denormal comes out unflushed). Fixed
on every core withvfpu_minmax_key(), an integer key compared instead of the floats
(minmax_zeroandminmax_tiepass everywhere).Generated NaNs (
nan_gen). VFPU arithmetic never keeps a payload: every NaN it produces is0x7F800001or0xFF800001. The sign is + for add, sub and every summing op, the XOR of the
operand signs for mul, div and vscl, the input's sign for vrcp, vrsq, vsin and vasin, and + for
vsqrt, vlog2 and vexp2. vsat0/vsat1, vabs and vneg pass NaN bits through. The cores make the
host's NaN instead, so x86-64 and ARM disagree. The
branchcpu-equiv-4fixes the single-lane ops (vadd, vsub, vmul, vdiv, vscl, vcrs, vsat0/1, vbfy,
vocp, vsocp) on every core, through four new IR ops and checks in the legacy JITs, and is held
back until its cost on every multiplication is measured. The horizontal ops wait for the
accurate dot product.The FPU makes the positive quiet NaN
0x7FC00000from operands that aren't NaN
(cpu/fpu/fpu_nan), which ARM64 does and x86 doesn't for add, sub and div. Also fixed oncpu-equiv-4.Undefined VFPU encodings (
undefined). The interpreter matches the PSP on all of them
(single-lane vdot/vhdp, vdet at every size, vcrsp/vqmul at .s/.p, matrix registers that wrap
around, vtfm/vhtfm size mismatches, vmfvc past register 15 reading 0, vcmov with bit 20 set),
now includingvcmovwith imm3 = 7, which tests a CC bit that is always clear: vcmovt never
moves and vcmovf always does (fixed on every core). The JITs still differ on the single-lane
dot products and vcrsp/vqmul at .s/.p (for the accurate dot product). The IR cores now match on
matrix registers whose offset doesn't fit the size (see Fixed).Loads and a pending D prefix (
prefix_load, fixed). lv.q, lv.s, lvl.q, lvr.q and mtv write
every lane, ignoring the D prefix's mask and saturation, and leave it pending. The interpreter
applied the mask to lv.q, lvl.q and lvr.q (and the IR cores, which hand lvl.q and lvr.q to it).vfim (
vfim, fixed). The half float converts like vh2f: denormal halves give a signed zero,
inf and NaN keep their mantissa bits in place. The interpreter halved denormals, the IR made
them IEEE.bltzal and friends with rs = ra (
branch_link_ra, fixed). The branch tests ra's value from
before the link. Every core linked first.
Known, no hardware test needed
- Divide-by-zero flags. Only the interpreter sets the cause and flag bits for
div.sby zero
(seeInt_FPU3op). The other cores leave fcr31 alone. Making the others match needs a new IR op
or an interpreter fallback for div.s, and the latter costs every division.
ARM32
Measured on a Galaxy S8+ and A21 with the armeabi-v7a build (android/ab.sh APP_ABI=armeabi-v7a HEADLESS=1 UNITTEST=1 SKIPAPP=1, run over adb). These measurements predate the IR-based ARM32 JIT, whenCreateNativeJit returned the legacy ArmJit (since removed). The IR JIT gives the IR interpreter's
end state in every fuzz case under qemu-arm (./b.sh --armhf, see building.md).
- The interpreter gives the same end state as on ARM64 in all 20000 integer/FPU/memory fuzz
cases, and in all but 3 of 20000 mixed ones (horizontal-op NaNs into vmin/vmax, as on x86-64). - All three cores match the Mac's same core on every
cpu/pspautotest, apart from horizontal ops
and NaNs:signed_zero(vdet, vqmul in the JIT),nan_gen, and in the JIT the NaNs out of vsat
(prefix_sat) and vbfy (specials). Those wait for the accurate dot product and item 6. - What it took: the interpreters apply the guest's FPSCR (rounding mode and FZ); the IR
interpreter's NEON Vec4 paths are ARM64-only, since ARMv7 NEON always flushes and rounds to
nearest; ARM32's libmceilf/floorfdon't flush a denormal input, soFlushForConversion()
does that under FS; and the ARM JIT got the other JITs' prefix gates, mtvc masking, and fixes to ins/ext/max/min, round.w.s of NaN, and vsgn.vh2fandvasingo to the
interpreter (the hardware half conversion quiets NaNs). - Before these JIT fixes, the fuzzer on the S8+ had the JIT differ from the interpreter in 1298
of 20000 mixed cases (the IR interpreter in 1446), not broken down. It crashes on the A21 in its
memory setup, so it hasn't been rerun since.
RISC-V and LoongArch64
Run under qemu-user on x86-64 Linux (cross toolchains from Ubuntu's gcc-15-riscv64-linux-gnu andgcc-15-loongarch64-linux-gnu, configured with -DHEADLESS=ON -DUNITTEST=ON). Each has three
cores, and the JIT is the IR JIT. RISC-V detection only reports Zba/Zbb/Zbs/Zicond for two SoCs and
never Zcb or Zfa, so the fuzzer forces them with PPSSPP_CPUFUZZ_HOSTEXT=+ (underQEMU_CPU=max); LoongArch runs both with LSX (qemu's la464) and without (-).
Fixed:
- RISC-V emitter:
c.lbu/c.sb(Zcb) had their two offset bits swapped, so byte loads and
stores at offset 1 or 2 went to the wrong byte. - RISC-V emitter:
CanFLItook any NaN for FLI's canonical one, sovfimof a NaN lost its
sign (Zfa). - LoongArch JIT:
vsat0/vsat1turned a NaN into the bound (FMIN/FMAX return the non-NaN
operand), andvcmp NEwas false for a NaN. - Both JITs:
Vec4Dot(vdot, vhdp, vtfm, vmmul) used fused multiply-add. Without SIMD it now
multiplies and adds in the IR interpreter's order; LoongArch's LSX path adds pairwise, like
the x86 and ARM64 JITs. - Interpreter:
vsgnof a negative NaN gave 1 on RISC-V, whose subtraction always makes the
positive canonical NaN. - RISC-V emitter:
vmv.s.xandvfmv.s.fwere encoded asvmv.x.sandvfmv.f.s, writing the
scalar register instead of the vector (nothing used them before the dot product routine).
The accurate dot product (Vec4VfpuDot) runs through a routine both JITs generate at startup and
call, like the ARM64 and x86-64 ones (RiscVVfpuDot.h, LoongArch64VfpuDot.h). RISC-V has a
scalar version, which uses Zbb and Zicond where it has them, and an RVV one used when the V
extension is detected. LoongArch has an LSX version and a scalar one. Inf and NaN go tovfpu_dot_reference(). In the interpreters, vfpu_dot() is vfpu_dot_scalar() on RISC-V andvfpu_dot_lsx() on LoongArch. The VFPUDotJit unit test runs every variant the host can run
against the reference on VFPUDot's inputs. Whether the RVV version beats the scalar one on real
hardware hasn't been measured: it moves values between the vector and scalar units three times,
which costs more on some cores than on others.
Neither JIT has Vec4VfpuMultiDot, so the frontend leaves runs of dots as singleVec4VfpuDots for them (IROptions::vfpuMultiDot). Both have the Vec4 vmin, vmax, vslt, vsge and
vi2f ops (IROptions::moreVec4Ops), lane by lane on RISC-V and on LoongArch without LSX.
Before the accurate dot product, the JIT gave the same end state as the IR interpreter in 20000
VFPU-heavy cases (vvvf), 20000 mixed and 10000 integer/memory ones, on RISC-V with and without
the extensions and on LoongArch without LSX. What was left were host NaNs out of a horizontal op
that something then orders by sign (item 6), and with LSX the summation order of the dot product.
With it, the JIT matches the IR interpreter bit for bit in 20000 default cases on LoongArch with
and without LSX, and in 6000 on RISC-V with RVV, without V and the extensions, and with the
extensions. The same holds in at least 4000 dot product cases (PPSSPP_CPUFUZZ_OPS=dot) in each of
those, and with unknown prefixes.
Against the other hosts:
- Neither ISA can flush denormals, so FS does nothing (
cpu/fpu/fpuandcpu/fpu/denormalfail,
63 lines from the PSP where x86 and ARM64 have 54). Both are inknown_failuresintest.py. - RISC-V never propagates a NaN operand: every NaN it makes is
0x7FC00000, which matches the
PSP's FPU (cpu/fpu/fpu_nanpasses) but loses a NaN's sign and payload (cpu/fpu/roundmode).
LoongArch propagates operands like x86 and makes the positive default NaN like ARM64, and also
passesfpu_nan. - Every core gives the same results as on x86-64 in 20000 integer/FPU/memory cases but one, a
div.sof a denormal under FS. With VFPU code, the interpreters differ by host NaNs from
horizontal ops into vsbn, vmin, vmax or vscmp.
Not looked into yet
- In 32000 default cases, the IR cores now differ from the interpreter only in item 11, on ARM64.
- Backward branches and loops: the generated branches only go forward. Prefixes across blocks only
as far as a forward branch carries them.
Unrelated
Headless used to take -r for the IR interpreter, but --root (whose short form is -r) won and took
the next argument as a directory. The short cpu core flags are gone now; use --cpu=ir.
Debugging (`docs/debugging.md`)
Debugging PPSSPP (agent notes)
How to drive PPSSPP's debugger facilities from a script or agent session, and the traps around them.
The WebSocket protocol reference itself is in WebSocketDebugger.md; the
threading rules for debugger code are in DebuggerThreading.md.
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 inCore/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 Building and testing), 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 undermemstick/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 pointTools/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:
./Windows/x64/Debug/PPSSPPHeadless.exe --cpu=interpreter --debugger=34567 --timeout-wall=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.txtThe timeouts apply to the whole session, not per test.
--timeout-wall=Nis real seconds and--timeout-emulated=Nis emulated ones; both default to infinity, both can be set at once, and whichever
is reached first ends the run (TIMEOUTorTIMEOUT (emulated)). With--state=, emulated time counts
from when the state has loaded: until then the game boots for however long the load takes on the host, so
counting from boot made runs end on different frames.--timeoutis the old name for--timeout-wall. As soon as you pass one it applies to your whole interactive debugging session too, so
pass something huge (--timeout-wall=100000) or the process exits out from under you mid-session. The
wall-clock check is skipped whileIsDebuggerPresent(); the emulated one doesn't need that escape hatch,
since sitting at a native breakpoint burns no emulated time.Which one you want depends on the question. Wall-clock is what stops a hang from hanging the machine.
Emulated is what you want for "has the game had long enough to get somewhere" - a heavy scene runs many
times slower than real time and a near-idle one much faster, so the same wall-clock budget means very
different amounts of game time. Booting a firmware VSH to its XMB is a good example: 10 emulated seconds
is about 25 real ones on 6.61 and about 7 on 2.00.Prefer
--debugger=0and scrapeListening on port Nfrom that run's own log over hardcoding a port. Alsotaskkill //F //IM PPSSPPHeadless.exebetween runs for hygiene (Git Bash here has nopkill) - leftover
instances are easy to accumulate when a script leaves the CPU stopped at a breakpoint.On Linux, kill leftovers with
pkill -x PPSSPPHeadless, neverpkill -f PPSSPPHeadless.-fmatches full
command lines, and the shell running yourpkillhas "PPSSPPHeadless" in its own command line, so it kills
itself (exit 144) and the emulator instances you meant to clear survive into the next run.Attaching gdb to a running instance fails under WSL/Ubuntu (
ptrace_scope: "Could not attach to process"),
so a hang has to be caught by starting the run under gdb. Interrupting a batch-mode gdb from another shell withpkill -INTnever got as far as the backtrace in the 2026-09-22 attempts. Before reaching for gdb, first rule out
the cheap explanations below (backend, flags) by diffing a good and a bad command line.Some history, because it silently produced a round of bogus results before it was fixed:
Common/Net/HTTPServer.cpp
used to setSO_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
logEntering 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'sSO_EXCLUSIVEADDRUSEon 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, theversionresponse carriespidandpath- check them.The headless build defaults to JIT (
Headless.cpp,CPUCore cpuCore = CPUCore::JIT), despiteg_Config.iCpuCorebeing force-set to INTERPRETER just above -ApplyToConfig()has the final say. Pass--cpu=interpreterfor the interpreter. (-ris--root, which eats the next argument as the root path.)When a test finishes, headless exits and the WebSocket connection closes (
CloseFrame { code: Away }). So "the
connection just closed" after acpu.resumenormally 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
--syncwill burn its full timeout on them:gpu.stats.getandgpu.stats.feed(documented - they answer after the next flip),gpu.record.dump, andinput.buttons.press(waits for N frames). Send them with wsdbg's:nowait, and let the next line
(acpu.runUntilTime) run the CPU::nowait input.buttons.press button=circle duration=4.durationcounts vblanks, not seconds.Don't send a bare
cpu.resumein a--syncscript.--syncwaits for the CPU to stop again, which
a free run never does, so it burns the whole--sync-timeoutwhile headless runs unthrottled, which can
be hundreds of emulated seconds. Usecpu.runUntilTime.Log broadcasts drown scripted output. Pass
--quietto wsdbg, which turns them off.Nested parameters work in wsdbg's
key=valueshorthand: values are parsed as JSON, and single quotes keep
the inner double quotes, e.g.input.buttons.send buttons='{"cross":true}'. Prefer that over a raw JSON line,
which gets no ticket (see below).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.--synccan only match a response to a request that carries a ticket, and wsdbg only assigns tickets to itskey=valueshorthand. A raw JSON line without aticketisn't waited for at all, so use the shorthand (nested
values included, see above). Hex works there:memory.disasm address=0x08804000.To see the screen from a script, use wsdbg's
:screenshot <file.png>while the CPU is stopped, e.g. after acpu.runUntilTime. With headless, run--graphics=softwarefor it: headless Vulkan has no output image to read
back and asserts. Give a native path on Windows (C:/..., not/c/...).Headless reports
SYSPROP_HAS_DEBUGGERas false (onlyWindows/main.cppimplements it), so anything gated on
it does nothing there -LoadSymbolsIfSupported()inCore/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
memsticknext to the executable (Headless.cpp). Pass--memstick=DIRto
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 withLNK1168: 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_u32answers withvalue, whilecpu.getReganswers withuintValue. A parser defaulting a missing key to 0 will quietly report zeroes - read the handler's comment inCore/Debugger/WebSocket/*Subscriber.cpprather than guessing.broadcast.config.setaccepts all five categories now (logger,input,game,stepping,breakpoint);
it used to rejectgameandsteppinguntil 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:quitdisconnects 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
(SendDebugOutputinheadless/Headless.cpp) thatfwrites 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, andtaskkill //Fthrows
it away rather than flushing. To actually read a crash trace, give that run a short--timeout-wallandwaitfor
the process to exit on its own.0xFFFFFFFFis not an invalid instruction - it decodes tovflush, a real Allegrex VFPU op, so writing it
over code to test illegal-instruction handling just runs it. Check what an encoding actually is withmemory.disasmbefore assuming it's garbage; the interpreter raisesExecExceptionType::ILLEGALonly whenMIPSGetInstructionhas no interpreter for it (tge/tlt/teqand friends).To line input injection up with a wall-clock repro, use
cpu.status'susfield (emulated microseconds), notticks. The PSP's clock frequency is changeable and games do change it - CrossCraft Classic runs at 333MHz, soticks / 222000000is off by a factor of 1.5.clockHzis reported alongside.--graphics=softwarecannot see "the screen stops updating" bugs. When HLE writes pixels straight into a
display buffer - video decoding,sceJpeg,sceMpegAvcCsc- the hardware backends only find out because the HLE
callsgpu->PerformWriteFormattedFromMemory(). The software renderer reads that memory directly and so renders
the frame whether or not anything was notified. A missing notification therefore looks perfect under--graphics=softwareand shows up as a frozen screen on every real backend, while the decode logs keep scrolling
past as if all were well. Reproduce display bugs on a hardware backend (see "Choosing a GPU backend" below;directx9is not a valid value) and compare--screenshot-save=output, not the log.wsdbg --launchonly works with the headless build. The app build is a GUI-subsystem exe with no stdout, so
theListening on port Nline never reaches the launcher and it gives up. Start it yourself with an explicit--debugger=PORTand point wsdbg at that port. Note also that--debugger-runisCmdLineMode::Headless; the
app takes plain--debugger=PORT.--launchneeds--logto find the port. Without it, headless prints only emulated printfs, so theListening on port NNOTICE never appears and--launchfails with "PPSSPP never reported a debugger port".--log --loglevel=3keeps the port line while cutting the debug flood - full--logat the default level costs
a lot of emulation speed (a 25-second run of a game playing video wrote 450k lines and ran several times slower
than real time, which on its own looks like the stall you're hunting).
Measuring a commercial game with headless
Headless will happily run a game and print nothing, or run a different configuration than the one you asked
for, and both look like a clean pass if you are counting log lines. Four traps, each of which silently
produced a round of bogus results here:
--logis required for any log output at all, and the log goes to stderr. Without it you get only
headless's own lines (Loaded State,TIMEOUT);-d/-vset the level but don't turn the printf logger
on. So folding stderr in isn't optional if you're grepping, which is what makes the third trap bite.- Headless's memory stick is not the app's. It defaults to
<exe dir>/memstick(headless/Headless.cpp),
soWindows//<config>/memstick, while the app derives its own frominstalled.txtor Documents
(InitMemstickDirectoryinWindows/main.cpp, which carries a TODO about sharing the derivation). Headless
creates that directory, emptyPSP/NAND/flash0and all, so firmware installed through the app is invisible
and every LLE module quietly falls back to HLE - at which point you are measuring the HLE you were trying to
compare against. Pass--memstick=explicitly; it resolves relative to the CWD, not the exe. - Check the exit code. An unrecognised parameter prints
Error: ...to stderr and exits 1, which is
correct and easy to throw away: fold stderr into stdout (which the first trap forces), count grep hits, and a
run that never started reports the same all-zero line as a clean one. - Zero is not a pass. Measuring by log-grepping needs a positive precondition asserted separately ("did
this run reach the code at all"), or "0 errors" also means "0 anything" - which is equally what a failed
boot, a savestate that never reaches the cutscene, and a silent HLE fallback produce.
The last three compound: the fix is to treat the run's exit code and a positive "we got here" counter as
preconditions, and only then believe the error counts.
Rather than silently falling back to HLE, headless refuses the run: an explicit--disable-hle= whose firmware module isn't there names the module, prints the flash0:/kd and memory stick
it looked in (usually enough to spot that it's the one beside the exe), and exits 1. Only an explicit--disable-hle binds - sceMpeg and sceMp4 are LLE by default and still fall back quietly, or every run on a
machine with no firmware would fail. So when a measurement depends on the real module, pass the flag
explicitly even though it's on by default, and the run will tell you if it didn't get it.
--force-hle= is the other direction, and the one to reach for when deciding whether something is
our fault: it puts our HLE back for libraries that now run the real module by default, so the same
repro can be run both ways and the logs diffed. That is how the leftover warnings in Tekken 6 were
sorted - three appeared identically with --force-hle=16, which made them the game's own, and the
fourth only under the real module, which made it ours. Neither --nand= pointing somewhere empty
nor --appendconfig does this job: the firmware gets found anyway and the setting is per-game.
Choosing a GPU backend
--graphics=software(the default) needs no GPU at all, and matches the GPU pspautotests' reference
screenshots (taken on a PSP) best. It's slow for games, though: it runs display lists synchronously insidesceGeListEnQueue, which then dominates any profile of the emulator thread.--graphics=vulkanrenders offscreen, into images of its own with no window or swapchain, so it needs no
display. On macOS it goes through MoltenVK.--graphics=openglrenders offscreen on macOS (a CGL context with a framebuffer object of its own), and
through a hidden SDL window elsewhere.--graphics=d3d11(Windows) renders through a hidden window.
For game runs, prefer a hardware backend: 30 emulated seconds of God of War take 3-4 seconds instead of a minute.
The pspautotests pass on Vulkan and OpenGL except for 17 GPU tests, whose references are hardware screenshots
that the hardware backends don't match exactly.
To smoke-test a CPU or GPU change on real games, run the old and the new binary on the same games with--timeout-emulated and --screenshot-save=, and compare the screenshots. Most games are deterministic from
run to run, so a different screenshot means different behaviour. God of War: Chains of Olympus isn't: its menu
animation follows wall-clock time, so even the same binary lands on different frames.
To check that a game keeps running after --state= loads, pass --frame-stats: at the end of the run it prints
the flips in each emulated second since the load. A stuck or crashed game shows as zeros or a short list, and a
steady game as a flat 30 or 60. The state's load result is printed too, and a failed load exits 1.
To get past menus, --press=SECONDS:MASK[:DURATION],... holds buttons at emulated times (MASK is a sceCtrl
mask, 0x8 START and 0x4000 CROSS, held 0.15 seconds by default), and --screenshot-at=SECONDS:FILE,... saves
screenshots along the way.
For audio, --dump-audio=FILE.wav records the mixed output (before the game volume setting) for the whole run.
An audio glitch you can hear usually shows in the waveform: a click is a jump between neighbouring samples, and
a repeated or dropped mixer block is two identical 64-sample runs in a row.
Pass --graphics explicitly even when you want the default, so a copied command line doesn't depend on it. If a
run goes silent with no CPU use, a host thread is blocked, and neither --timeout-wall nor --timeout-emulated
will end it, as both are only checked when the emulation loop comes around.
Measuring speed
Tools/headless_bench.py compares the speed of headless binaries on savestates or frame dumps. It alternates
the binaries from round to round and reports medians of four numbers per binary:
- Wall time is what a player sees, but it swings by several percent between identical runs (heat, the
scheduler moving threads between performance and efficiency cores). - CPU time (all threads) says how much work was done. A change that keeps it but cuts wall time spreads
the work better, and one that cuts it while wall time stays has left the threads waiting. - Instructions retired vary by well under 0.1% between identical runs, so they show changes too small for
wall time. - Cycles also count stalls (a division, a missed cache line) that instructions don't.
The last two come from /usr/bin/time -l on macOS and perf stat on Linux. The tool checks for mains power
first: on battery (or in macOS low power mode) the clock speed varies and the numbers aren't comparable. Stop
anything else that uses the CPU, including a forgotten usbhostfs_pc, which can spin a core indefinitely. A run
that exits with an error, misses TIMEOUT (emulated) or doesn't load its savestate stops the tool rather than
passing as a fast run.
To measure the CPU emulation alone, use --graphics=null (headless only, GPU/Null/GPU_Null.cpp). It runs
display lists, bounding boxes, matrix loads and block transfers, moves the vertex and index pointers past
each draw, and charges the same GE time per draw, but draws nothing and creates no draw context. Games flip
the same frames as with Vulkan, in roughly half the host instructions. Nothing is rendered, so screenshots
and anything that reads a framebuffer back through the GPU (rather than from PSP memory) get nothing.
Headless decodes vertices with the JIT like the app, where the host can (--vertex-jit=false for the C++
decoder, which the tests don't need either).
Profiling the CPU cores
--jit-profile=FILE (app and headless) samples the emulator thread's host PC every 50 µs and, when the JIT
goes away or clears its cache, appends a report to FILE:
- the share of samples in JIT blocks, in the fixed routines they call (like the dot product routines), in
the dispatcher, and outside JIT code by host function (with symbols wheredladdrfinds them); - the 30 hottest blocks, each with its MIPS code, its IR, and its host code with the share of samples per
host instruction, the IR instruction each comes from, and the samples in routines charged to the call; - the hot fixed-code routines, per instruction.
It costs nothing when off. The native JITs (macOS, Linux and Android by signal, Windows bySuspendThread; RISC-V and LoongArch on Linux only) sample the host PC; the IR interpreter instead times every block, which used to be theIR_PROFILING define. Short runs from a savestate include compiling everything after the load, which
shows as emitter and IR pass functions; profile 20 emulated seconds or more to see the game itself.
To compare two builds by where the time goes, profile one game at a time: several at once wait on each
other's GPU work, which moves the shares.
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 | JIT / IR JIT | |
|---|---|---|
cpu.breakpoint.* (exec) |
works | works |
cpu.stepInto/Over/Out, runUntil, nextHLE |
works | works |
memory.breakpoint.* (memchecks) |
works | only for constant addresses |
cpu.regBreakpoint.* |
works | never trips (as documented) |
Native debuggers and the memory fault handler
With fast memory on, a bad guest access from JIT code is a real host SIGSEGV/SIGBUS (an access violation on
Windows), which Memory::HandleFault (Core/MemFault.cpp) catches and turns into a clean emulator-side
exception. A native debugger sees the fault first and stops there, so a run that would have reportedRead Word: SIGSEGV at ... looks like a crash instead. Continuing hands the fault to the handler, like a
first-chance exception in Visual Studio. To keep the debugger out of the way when the faults are expected:
lldb (macOS/iOS) needs both of these, verified 2026-09-28 against
pspautotests/tests/cpu/crash. The
first skips the stop on the Mach-level EXC_BAD_ACCESS, the second the stop on the signal it turns into:textsettings set platform.plugin.darwin.ignored-exceptions EXC_BAD_ACCESS process launch --stop-at-entry process handle SIGSEGV SIGBUS -s false -n false -p true continue(
process handleneeds a live process, hence the--stop-at-entry.) The catch is that a genuine host crash
then kills the process without stopping in the debugger.gdb (Linux/Android):
handle SIGSEGV nostop noprint pass.
The pspautotests/tests/cpu/crash/crash_*.prx binaries each make one bad access (read, write, float, bad jump),
which makes them the quickest check that the handler works on a platform: run one with --cpu=jit in headless. They
should print the guest exception and exit 0, not die with signal 11.
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 intomesh.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.
Finding a game function to replace by hash
The tell is a game that's right on the software renderer and stale on the hardware backends, usually a video
that sticks on its first frame while the audio plays on. The game is writing pixels into VRAM with the CPU, and
the hardware backends only see that when it goes through something we hook. Games ship their own memcpy,memset and so on, and we recognise known ones by a hash of their code: the hardcodedHashes table inCore/MIPS/MIPSAnalyst.cpp names them, and Core/HLE/ReplaceTables.cpp swaps in a replacement
(Replace_memcpy hands VRAM copies to gpu->PerformMemoryCopy). SSX On Tour's videos were fixed this way.
Finding the function:
- Get to the right moment. Confirm the thing is actually happening before breaking on it - SSX's
videos only start 11.5s in, and a screenshot from earlier just shows the paper background.memory.readof
the decode buffer at a fewcpu.runUntilTimepoints tells you when it starts changing. - Break on the write, under the interpreter (
--cpu=interpreter), since JIT memory breakpoints only work for constant
addresses:memory.breakpoint.add address=<a pixel mid-framebuffer> size=4 write=true enabled=true, thencpu.resume. Pick a pixel in the middle of the frame, not the first one, which clears also hit. hle.backtracegives the function's entry address. Addresses come back
as decimal in JSON; convert carefully (145311076is08a94564, not08ab1564).memory.disasmthe whole function and check that it really is what you're going to call it: argument
order (a0dest,a1src,a2size for memcpy), and no side effects the replacement wouldn't have. A
replacement that doesn't match the original is a much worse bug than the one it fixes.
Steps 2 and 3 as one script (the SSX run, so 12s in and a pixel at 240,136 of each 8888 stride-512
framebuffer). Parameters take hex as well as decimal; replies are always decimal:
cpu.runUntilTime us=12000000
memory.breakpoint.add address=0x040443c0 size=4 write=true enabled=true
memory.breakpoint.add address=0x040cc3c0 size=4 write=true enabled=true
cpu.resume
hle.backtrace
:quitTools/wsdbg/target/release/wsdbg.exe --sync --compact --quiet --launch \
Windows/x64/Release/PPSSPPHeadless.exe GAME.iso --memstick=memstick --cpu=interpreter --graphics=vulkan \
--timeout-wall=240 < script.txt 2>/dev/null | grep -v '^<- log'The first hle.backtrace frame is the writer: entry is the function's start, code the store that hit.
Follow up with memory.disasm address=<entry> count=64 and hle.func.hash address=<entry> in the same
session. Once a replacement is in place, the same script shows it working: the hit's source readsGPUMemcpy/..., its pc is the function's entry, and the backtrace starts at the caller.
Getting the hash: hle.func.hash address=<anywhere in the function> returns what the table entry needs:
<- hle.func.hash {"address":145310976,"hash":"3966eef670a93781","known":null,"size":188}known names the entry that already matches, if any (it read memcpy once SSX's was added). The hash is the
one HashFunctions() in MIPSAnalyst.cpp takes when the code is scanned (CityHash64 over the
instructions with immediates masked out), so it doesn't depend on where the function was loaded.
Add the entry to hardcodedHashes in hash order, with a comment naming the game. Then check that it took
effect: with --log --loglevel=4 the loader prints Replaced memcpy at 08a94500 with hash 3966eef670a93781.
Check other regions of the game too. Since the hash doesn't depend on address, they usually match the same
entry (SSX's Korean release has the function at 08a94200).
Dnas Patches (`docs/dnas-patches.md`)
Game patches installed on a PSP (DNAS)
Some early games shipped their updates and DLC as an "installer" package that the PSP's install
utility turns into a module only that PSP can load. Killzone: Liberation's Chapter 5 DLC is the known
case (issues #12343, #12344). PPSSPP installs the EU Killzone download itself when the game boots,
and can also load a patch that was installed on a real PSP, given that PSP's Fuse ID.
What the download is
PSP/GAME/UCES00279/ (UCUS98646 for the US game) gets two files:
EBOOT.PBP, 8.5 MB,CATEGORY=MG,BOOTABLE=0, title "Killzone: Liberation Installer". ItsDATA.PSPis the encrypted package the install utility unpacks. The XMB refuses to start it.CHAPTER5.BIN, 68 MB, the chapter's data. The patched game module reads it as it is.
Installing it on a PSP
The install is done by the game's own boot program (disc0:/PSP_GAME/SYSDIR/EBOOT.BIN), which
imports sceUtilityInstall*. It only installs when started with the argument -i, and then only ifms0:/PSP/GAME/UCES00279/EBOOT.PBP is larger than 1 MB. The game passes -i itself, after the
online download in its menus (from kzl.killzone.com, long gone), so there's no way to trigger it
from inside the game today.
Starting the boot program with -i from PSPLink works. modexec doesn't (the LoadExec is refused),
but a tiny kernel-mode PRX calling the CFW's sctrlKernelLoadExecVSHDisc (SystemCtrlForKernel,
NID 0x577AF198) with key = "game" and the argument block "disc0:/PSP_GAME/SYSDIR/EBOOT.BIN\0-i\0"
brings up the system's install screen. Afterwards the folder holds KZL.PRX (8.6 MB, the patch),
and the 8.5 MB EBOOT.PBP has been replaced by a 10 KB one for the XMB.
The Fuse ID is the two words at 0xBC100090 and 0xBC100094, which only kernel code can read.
PSP tools show it as 12 hex digits, the 0x94 word first.
Installing it in PPSSPP
InstallDrmBBPackage() in Core/PSPLoaders.cpp runs when one of the games it lists boots (thoseg_kirk2Results covers) and PSP/GAME/<DISC_ID>/EBOOT.PBP is over 1 MB. It does what psheet does, with no console in the
picture: it writes the package's files next to it and replaces the installer with the package's ownEBOOT.PBP. It only does this if it can decrypt every .PRX in the package, so otherwise the download is
left alone.
The package is the PBP's DATA.PSP, read by pspReadDrmBBPackage() (layout from psheet'sbbox_access.c functions). Everything in it uses sceDrmBB MAC and cipher type 1, so known keys only:
- The whole PBP is MACed with a fixed key from psheet's data segment, in its last 16 bytes.
DATA.PSP+0x00: a 0x50-byte header MACed (at +0x50) with the secure install ID, which means the
ID can be recovered from the MAC (bbmac_getkey). The EU and US downloads use the same one. Its
bytes 0x10-0x50 decrypt with the fixed key (header key: bytes 0-0x10, seed 0) to two 16-byte keys,
then version 1, flags, 1, 0, the parameter table's offset and count, and the file table's offset.- Parameters are (id, value) pairs, decrypted with the first key and the secure install ID.
0x01010100
is the number of files. - File entries are 16 bytes (u16 name offset, u16 name length, u32 data offset, u32 size, relative
to the file table), and their names and data are decrypted with the second key and the secure install ID.
The seed is always the 16-byte block number.
A .PRX comes out as a type 3 module whose first step (the 0x50 bytes at 0x80-0xB0, 0xC0-0xD0 and
0x12C-0x13C, with the KIRK header at 0xEC) is encrypted for KIRK command 2. The install sends the
first 0x400 bytes through sceMesgIns_driver_D062B635 (uofw's sceUtilsPrepareGetLoadModuleRLength),
which runs command 2 in place and re-encrypts that block for the console. The rest of the module is
the same before and after. Command 2's key is unknown, but its result is the same on every PSP, sog_kirk2Results in PrxDecrypter.cpp stores it for each module we know, keyed by the 16 bytes at
0xEC. pspDecryptType3() uses it in place of the first step.
To get the result for another package, run --re-decrypt on it, which then writes the module as
the package has it to <out>.module. Then either decrypt a PSP
install of it with KIRK command 3 and that PSP's Fuse ID, or run sceMesgIns_driver_D062B635 on its
first 0x400 bytes on a PSP and decrypt that output with command 3 there.
Loading it
The boot program calls sceKernelLoadModuleDNAS(path, secureId, 0, 0) with a 16-byte secure
install ID from its data (81A19A6F0FA9CFBAE378F73FFD504C7F for UCES00279), and treats any negative
result as "no patch", falling back to disc0:/PSP_GAME/USRDIR/KZL.PRX. Two layers:
- A PGD (key index 1, DRM type 0), opened by
amctrlwith the secure install ID and the "flag 1"
DNAS key. Its MAC and cipher use KIRK commands 5 and 8, which are AES under the per-console key
with seed 1. - A type 3 PRX, decrypt mode 18, tag
0x89742B04. This is type 5 with the first step done by
KIRK command 3 instead of 7: a small command 3 block whose header is at0xECand whose 0x50-byte
body is the usual kirk header + SHA1 pieces, XORed with the tag's key and the secure install ID.
The install produced that block with KIRK command 2, re-encrypting under the per-console key with
seed 0. Command 2's own key (AES keyslot 3) isn't known, which is why the download can't be
installed here.
The per-console keys come from the Fuse ID: the key mesh on psdevwiki's Kirk page, and the final
step from the KIRK ROM's aes_set_perconsole_key (uofw src/kirk/kirk-rom.c). The type 3 layout is
JPCSP's crypto/PRX.java. The tag's key and XOR are in its KeyVault.java, and uofw's mesgled.h
documents the decrypt mode.
In PPSSPP:
kirk_individual_key(),kirk_CMD3/5/8inext/libkirk/kirk_engine.c.pspDecryptType3()andpspUnwrapDNASPGD()inCore/ELF/PrxDecrypter.cpp.sceKernelLoadModuleDNAS()inCore/HLE/sceKernelModule.cpptakes the Fuse ID from theFuseID
setting ([SystemParam]inppsspp.ini) or--fuse-id.
A wrong Fuse ID fails the PGD's MAC, so the game just runs unpatched. A module PPSSPP installed has
no PGD and needs no Fuse ID.
To decrypt one offline:
PPSSPPHeadless --re-decrypt KZL.PRX --re-decrypt-out KZL.elf \
--re-decrypt-key 81a19a6f0fa9cfbae378f73ffd504c7f --fuse-id <12 hex digits>--re-decrypt also takes the download's EBOOT.PBP, and decrypts the .PRX in it.
Frametest (`docs/frametest.md`)
Framedump rendering tests (frametests)
PPSSPP has a test system that replays GE frame dumps (.ppdmp) throughPPSSPPHeadless with various rendering settings, comparing the rendered
output against stored reference images. It is designed to catch rendering
regressions across GPU backends and configurations.
The system has three parts:
- The test runner:
frametests.py(repo root). Test-set agnostic - it
works against any test data tree pointed to by its configuration. - The configuration: a JSON file describing where the test data lives and
which rendering variants to run (e.g.frametests/frametests.json). - The test set: the frame dumps and reference images, maintained as the
frametestsgit submodule (repohrydgard/ppsspp-frametest-ci, pinned by
commit - its configframetests.jsonlives inside it). Different
environments can use different test sets - GitHub CI uses the submodule,
custom machines can use their own (possibly much larger) ones.
Quick start
# Build PPSSPPHeadless (see headless/README.md), then:
python3 frametests.py frametests/frametests.jsonThe first run generates reference images for any dumps that don't have them
yet (status NEW) and compares the rest (status PASS/FAIL). A summary is
printed and a self-contained HTML report is written toframetests/out/report.html (default output dir; gitignored).
Exit code is 0 if all tests passed, 1 if anything failed, 2 on
configuration/usage errors.
Configuration
The configuration is a single JSON file. All relative paths are resolved
against the config file's directory, so the same script works with any test
set by just pointing --config at a different file. The config lives with
the test set it describes (e.g. frametests/frametests.json) and is not
committed to the repository.
{
"testRoot": "dumps",
"refRoot": "ref",
"outputRoot": "out",
"headlessPath": "",
"timeout": 60,
"maxMse": 0.0,
"variants": {
"soft": {
"args": "--graphics=software"
}
}
}| Key | Description |
|---|---|
testRoot |
Directory tree containing frame dumps (.ppdmp files, possibly wrapped in .zip). |
refRoot |
Where reference images live; mirrors the testRoot tree. |
outputRoot |
Where logs, diff images, the report, and generated references go (default out). |
headlessPath |
Path to the PPSSPPHeadless binary. Empty = auto-detect (see below). |
timeout |
Per-test timeout in seconds. |
maxMse |
Maximum allowed MSE for screenshot comparison (0 = exact match). |
variants |
Map of variant name to variant configuration (see below). |
Variants
Each test runs once per variant. A variant has a suffix and a
compare-suffix:
- The suffix (default: the variant name) is appended to the variant's own
output image filenames (actuals,diffs,logs). - The compare-suffix (default: the suffix) names the reference image the
variant compares against:<name>-<compare-suffix>.png. - Reference images are only generated for variants whose suffix matches
their compare-suffix. This lets several variants share a single reference
set - e.g. OpenGL and Vulkan outputs should be nearly bitwise identical, so
aglvariant can compare against thesoftreferences:
"variants": {
"soft": { "args": "--graphics=software" },
"gl": { "args": "--graphics=opengl", "compare-suffix": "soft" },
"vul": { "args": "--graphics=vulkan", "compare-suffix": "soft" }
}A plain string value ("soft": "--graphics=software") is shorthand for{ "args": "...", "suffix": "soft", "compare-suffix": "soft" }.
How a test runs
For each dump (recursively under testRoot) and each variant:
- If the variant's reference image
<name>-<compare-suffix>.pngis
missing and the variant generates references (suffix ==
compare-suffix): the dump is rendered and the output saved as the new
reference. Status:NEW. This is how references are created - run locally,
then commit the generated images (also copied to<outputRoot>/generated/
for convenience). - If the reference is missing for a variant that doesn't generate
references (suffix != compare-suffix): Status:ERROR- the shared
reference needs to be generated by the matching variant first. - If the reference exists: the dump is rendered, the output saved to
<outputRoot>/actuals/, and compared against the reference using MSE
(mean squared error over R, G, B per pixel, alpha ignored). A visual
comparison image (actual / reference+diff) is saved to<outputRoot>/diffs/whenever a comparison runs. Status:PASSorFAIL
(mismatch, crash, or timeout).
A FAIL with no MSE reported usually means the headless binary crashed
before producing a screenshot, and a reference image that can't be loaded
(corrupt) is reported as ERROR. The full emulator log of every non-passing
test is kept in <outputRoot>/logs/.
Reference images
- PNG, 512×272 (480×272 display in a 512-wide framebuffer), stored top-down
(row 0 = top of screen). The BMP output format is bottom-up per the BMP
spec; the flip is applied only when writing BMPs. - The alpha channel is forced to 255 when writing PNGs (games often use alpha
for non-visual purposes, which would otherwise produce transparent-looking
images); pass--screenshot-keep-alphato the headless binary to preserve
it (e.g. via a variant). The MSE comparison ignores alpha either way. - Generated with
--graphics=softwarethey are fully deterministic: a
subsequent run produces byte-identical output, somaxMsecan be 0. - If rendering code changes the output, existing references may need
regenerating: delete the affected reference images and re-run to regenerate
them.
Headless flags used
The runner uses --screenshot=<ref> (compare), --screenshot-save=<file>
(save output; PNG if the path ends in .png, else BMP) and--screenshot-diff=<file> (always write a visual comparison when comparing).
See headless/README.md for details.
--depth-save=<file> writes the current depth buffer next to the screenshot: a
u32 width and height, then a u16 per pixel, or a float if the path ends in.f32. The software renderer gives the GE's own values. The hardware renderers
keep depth at higher precision without the GE's rounding, so their u16 isfloor(d * 65536) and usually within one of the software renderer's; the.f32 version keeps the fraction, which shows whether two draws of the same
surface meet a depth test like >=.
Command line options
usage: frametests.py [OPTIONS] [CONFIG.json]| Option | Description |
|---|---|
CONFIG.json |
Path to the configuration file (default: frametests/frametests.json). |
--filter=SUBSTR |
Only run dumps whose relative path contains SUBSTR (case-insensitive). |
--strict |
Treat missing reference images as failures (a configuration error). |
--out-mode=all|failures |
all keeps everything in the output dir; failures keeps only artifacts of non-passing tests (smaller CI artifacts). Default: all. |
--variants=NAME=ARGS,... |
Run these variants instead of the config's. |
--adb[=SERIAL] |
Run on an Android device instead; see "Running on Android" below. |
--ios[=DEVICE] |
Run on an iPhone or iPad instead; see "Running on iOS" below. |
The headless binary is located, in order of preference:
headlessPathfrom the config file.- The
PPSSPP_HEADLESSenvironment variable. - Well-known paths relative to the current directory (e.g.
Windows/x64/Debug/PPSSPPHeadless.exe,build/PPSSPPHeadless), newest
by modification time.
Running on Android
--adb runs every dump on an Android device over adb, with the ppsspp_headless executable from the
legacy NDK build, and compares each screenshot with the host's Vulkan render of the same dump (throughTools/image_compare.py, so rasterizer noise doesn't count). The references in the config aren't used:
the question is whether the device's drivers draw what a desktop GPU does.
cd android && NDK=/path/to/ndk ./ab.sh APP_ABI=arm64-v8a HEADLESS=1 SKIPAPP=1 -j8 && cd ..
python3 frametests.py frametests/frametests.json --adbThe first run pushes the binary, assets/ and the dumps to /data/local/tmp/ppsspp-frametests; later runs
only push what changed. The variants default to vulkan=--graphics=vulkan --dump-compat,gl=--graphics=gles --dump-compat, and --host-args (default --graphics=vulkan --dump-compat) sets how the host renders its
side. --dump-compat applies the game's compat.ini settings, which headless otherwise skips for dumps;
without them some games look wrong on every backend (Coded Arms needs AccurateSplines). A failing test's diff image shows host, device and the
differences side by side. Runs go to the device one at a time.
Running on iOS
--ios does the same on an iPhone or iPad, comparing against the host's Vulkan render the same way.
An iOS device only runs apps, so PPSSPPHeadless is built as one there (org.ppsspp.headless,ios/HeadlessMain.mm): it starts UIKit, runs the usual headless main on a thread with the argumentsdevicectl launched it with, and exits. The screen stays black meanwhile, since it renders offscreen.
It needs a development-signed build, which is what lets devicectl copy files in and out of it.
cmake -S . -B build-ios-headless -GXcode -DCMAKE_TOOLCHAIN_FILE=cmake/Toolchains/ios.cmake \
-DIOS_APP_STORE=ON -DDEVELOPMENT_TEAM_ID=<team> -DHEADLESS=ON
xcodebuild -project build-ios-headless/PPSSPP.xcodeproj -scheme PPSSPPHeadless -configuration Release \
-destination 'generic/platform=iOS' -allowProvisioningUpdates build
python3 frametests.py frametests/frametests.json --iosKeep the device unlocked while it runs. frametests.py installs the app, copies the dumps the device doesn't
have yet into its Documents/dumps, and renders each variant's dumps in launches of up to 100 (eachdevicectl call costs a second or two, so one launch per dump would be slow). A dump that crashes the app
ends its launch; the rest go into the next one. The variants default to vulkan=--graphics=vulkan --dump-compat, as iOS has no OpenGL. There's no JIT without a debugger attached, so the device runs the
IR interpreter and decodes vertices without the vertex JIT.
To run the app by hand:
xcrun devicectl device copy to --device <udid> --domain-type appDataContainer \
--domain-identifier org.ppsspp.headless --source dump.ppdmp --destination Documents/dump.ppdmp
xcrun devicectl device process launch --device <udid> --terminate-existing --console org.ppsspp.headless \
--graphics=vulkan --screenshot-dir=shots dump.ppdmpRelative paths are relative to Documents, and --console shows its output. --ios-clear=DIR
(handled by the app, not headless) deletes a folder under Documents first. devicectl has no way to
delete files, and never pass it --remove-existing-content: that empties all of Documents, not
just the destination folder.
The build-ios folder works too if it's configured with -DHEADLESS=ON, as long as Xcode doesn't
have that project open: xcodebuild then hangs before building anything, even xcodebuild -list.
CI integration
In CI (GITHUB_ACTIONS is set) the runner behaves as if --strict was
passed and prints ::error annotations for each failing test, so failures
show up inline in the GitHub Actions log.
The CI workflow extends the existing test job (Linux leg) in.github/workflows/build.yml: the Fetch tests step inits the frametests
submodule (alongside pspautotests), then after the pspautotests run it
executes the frametests and uploads the report as an artifact:
- name: Fetch tests
run: git submodule update --init pspautotests frametests
- name: Execute frametests
if: runner.os == 'Linux'
run: python3 frametests.py frametests/frametests.json --out-mode=failures
- name: Upload frametest report
uses: actions/upload-artifact@v7
if: runner.os == 'Linux' && always()
with:
name: frametest-report
path: frametests/out/A missing reference image in CI means the test set is incomplete - the run
fails and the generated reference is available inframetests/out/generated/ (part of the uploaded artifact) so it
can be committed.
Custom machines
The script is designed to run on machines with different GPUs and bigger test
sets than CI. Copy the repo, point at a custom config:
python3 frametests.py /path/to/my-config.jsonThe config can live anywhere and point at any test data tree; only the
script itself is shared. Add hardware-specific variants (--graphics=vulkan,--msaa=..., etc.) to the machine's own config - no code changes needed for
new flag combinations, as long as the headless binary supports them.
Adding a new framedump
- Drop the dump into
frametests/dumps/(either as a.ppdmpor zipped). - Run
python3 frametests.py frametests/frametests.json --filter=<name>-
the reference image(s) are generated and copied to<outputRoot>/generated/. - Commit the dump and the reference images under
frametests/ref/, and push
them to theframetestssubmodule repo (hrydgard/ppsspp-frametest-ci).
Then bump the submodule pointer in PPSSPP so CI picks up the new test.
The CI test set (dumps and references) is maintained separately from the
runner; the frametests/ submodule is just one of several possible test sets
- custom machines can use their own (possibly much larger) ones.
Comparing renderers
Two backends never match pixel for pixel: edges land a pixel apart, filtering and interpolation round
differently, 16-bit framebuffers expand their colors differently. To find what really differs between,
say, Vulkan and the software renderer (or a PSP capture), render the same dumps with each and compare the
directories with Tools/image_compare.py:
python3 Tools/image_compare.py out_vulkan/ out_soft/ --sort --heatmaps heat/It counts a difference only where a pixel is outside the range of the other image's 3x3 neighborhood
(widened by a tolerance) and enough of its neighbors are too, so shifted edges and speckle drop out, and
separately counts 8x8 blocks whose averages differ, for broad shifts. The heatmaps show both images and the
counted pixels. On the GitHub dump set, Vulkan and OpenGL come out equivalent for 795 of 957 dumps, and
what's left is mostly real (frames one of them renders black, missing effects).
Replaying a dump on a real PSP
The references above are PPSSPP's own renders, so they catch regressions, not
wrong output. To see what the hardware draws, replay the dump on a PSP:pspautotests/utils/ppdmp-playback is a PSP program that runs a .ppdmp natively
(all dump versions PPSSPP writes), and its run.py drives it over PSPLink from
the host:
cd pspautotests
python3 utils/ppdmp-playback/run.py --out /tmp/shots \
--headless ../build/PPSSPPHeadless "../frametests/dumps/Depth/21391 Coded arms ULUS10019_0001.zip"It saves what the PSP displays as NAME-psp.png, and with --headless renders
the same dump in PPSSPPHeadless (--graphics=software by default), savesNAME-ppsspp.png and prints the MSE between the two. Each frame stays on the
PSP's screen for 1.5 seconds (--hold). It needs the PSPSDK (it rebuilds the PRX) and a PSP in
PSPLink with usbhostfs_pc serving the pspautotests root, the same setup as
pspautotests-hardware.md; like every hardware run,
one at a time. See its README for replaying a subset of the primitives.
A zip holding several dumps plays its last .ppdmp, in PPSSPP and in the replayer, so extract that one
when you need the file itself.
If a replay hangs, run.py resets the PSP and prints the last lines the replayer wrote. --progress=N
prints a line every N dump commands and --trace-from=N every command from N on, and --cmds=N replays
only the first N dump commands, which bisects a hang in a few runs. The replayer's Makefile has no header
dependencies: after changing replay.h, delete the .o files, or a stale object corrupts the Replay
object.
To find the draw where the two diverge, stop both at the same primitive:run.py --end=N on the PSP and --replay-end=N on PPSSPPHeadless. A draw into
an offscreen buffer can be compared directly: run.py --display=ADDR,STRIDE,FMT
shows and screenshots that buffer instead of the display (e.g.--display=04000000,512,3 for an 8888 target at the start of VRAM), and
PPSSPPHeadless takes --screenshot-render-target to save the current render
target.
PPSSPP's software renderer now matches the PSP bit for bit on nearly every dump
here, so a difference is worth chasing. Hardware backends still differ slightly
(filtering, edge rules), so compare their MSEs between versions instead.
To record a dump from a running game without the UI, use the WebSocket debugger'sgpu.record.dump through Tools/wsdbg. Its reply comes later, so wait for it:
printf ':sleep 6\ngpu.record.dump\n:wait gpu.record.dump 60\n:quit\n' | \
Tools/wsdbg/target/release/wsdbg --sync --compact --quiet --launch \
build/PPSSPPHeadless game.iso --state=STATE.ppst --graphics=software --debugger-run=0 > out.txtThe reply's uri is a base64 data: URI holding the .ppdmp.
Kernel Hle Review (`docs/kernel-hle-review.md`)
sceKernel HLE review notes
Notes from a review pass over Core/HLE/sceKernel*.cpp (~16k lines across 14 files), done
2026-09-03. Kept mostly so the next person doesn't re-derive the same conclusions - the
"verified clean" section is as much the point as the findings.
Cross-referenced against JPCSP (../JPCSP) where the correct behaviour wasn't obvious from
the PSP docs.
Bug classes that turned up
Each of these appeared in more than one place, so they're worth grepping for when touching this code:
Timeout writeback without a clamp. CoreTiming::UnscheduleEvent returns the scheduled time
minus the current time, which is negative when the event is overdue but not yet processed - the
exact situation when a wait is satisfied around its own timeout. Nine of ten sites wrote(u32)cyclesToUs(cyclesLeft) straight into the game's timeout variable. Now funnelled throughHLEKernel::WriteRemainingTimeout in KernelWaitHelpers.h; the two thread-end sites keep their own
copy because they unschedule even when the game passed no timeout pointer.
Addresses validated, extents not. Memory::IsValidAddress checks a single byte.KernelImportModuleFuncs used it for nidData and varData and then indexed those arraysnumFuncs (u16) and numVars (u8) times; sysclib_strcpy used it for a destination it then wrotestrlen(src) + 1 bytes into. Use IsValidRange with the size you're actually going to touch.
Unbounded walks over game-supplied data. The module variable relocation list was scanned until
it happened to hit a zero word; the mbx packet list was walked until it came back around to its
head. Both follow only valid pointers, so nothing faults - they just run off the end of memory or
spin forever. Bound them by what's mapped (Memory::ClampValidSizeAt) or by the count the object
already tracks.
Overflow checks that don't match the arithmetic they guard. sceKernelCreateFpl andsceKernelCreateTlspl validate blockSize * count at 4-byte alignment, but then allocate using an
alignment the caller chooses, so the aligned total can still wrap - allocating a small block while
the block count stays huge, and later handing out address + block * alignedSize outside it.
Functions that return a pointer, returning an error code. sceKernelAllocHeapMemory passedBlockAllocator::Alloc's (u32)-1 straight out, and returned UID error codes for a bad heap id.
JPCSP documents the contract as "the address of the allocated memory block, or NULL on error", so
every failure has to be 0 or callers checking for null see success.
Verified clean - don't re-flag these
readyCallbacksCountbookkeeping. There's a self-suspecting"became negative"report in__KernelCheckCallbacks, but the invariant holds. The increment (only on the 0 -> nonzero
transition in__KernelNotifyCallback) is matched by three decrements: running a callback
(__KernelRunCallbackOnThreadzeroesnotifyCountfirst),sceKernelDeleteCallback, and thread
deletion. The thread-deletion path looks like it should double-decrement with a latersceKernelDeleteCallback, butPSPThread::Cleanup()destroys the callbacks immediately after the
decrement loop, so the second lookup fails.u32 error;uninitialized beforekernelObjects.Get, thenif (error) return;. Safe -GetwritesoutErroron both paths, 0 on success.- Kernel object name copies. Every
strncpy(x.name, name, KERNELOBJECT_MAX_NAME_LENGTH)writes
into achar[KERNELOBJECT_MAX_NAME_LENGTH + 1]and follows with an explicit NUL. Correct. strncpy(moduleName, modinfo->name, ARRAY_SIZE(module->nm.name))insceKernelModule.cpp
mixes two buffers' sizes but is safe: 28 into a zero-initialised 29.- VPL and FPL allocation failure handling. Both check for
(u32)-1properly; the heap was the
outlier. - The unchecked
ReadUnchecked_U32sites insceKernelThread.cpp(register restore, extended
stack restore, the exit-callback parameter area) all haveIsValid4AlignedRangechecks a few
lines above. sceKernelCreateHeap'salloc.Init(address + 128, size - 128)looks like it underflows for a
heap smaller than 128 bytes, but can't:BlockAllocator::AllocAlignedrounds the caller's size
variable up by reference and every partition allocator is constructed with grain 256.- VTimer scheduling arithmetic. The
base + schedule - currentu64->s64 conversion wraps
negative on underflow, which thegoalUs < minGoalUsclamp catches.
Latent, worth knowing about
__KernelSendMsgPipe / __KernelReceiveMsgPipe have two shapes of transfer loop. The buffered one
explicitly breaks when a transfer would move zero bytes; the unbuffered ones (bufSize == 0) don't -
they only make progress inside if (bytesToSend > 0), so a queued waiting thread with a zerofreeSize would spin forever. That can't happen today because every AddSendWaitingThread /AddReceiveWaitingThread call site is guarded by a size != 0 check, but the invariant lives two
call sites away from the loop that depends on it.
Open, deliberately not changed
__KernelStartThreaddoesn't boundargSize.sp -= (argSize + 0xf) & ~0xfwithargSize
up toINT_MAXpushes the stack pointer below the thread's stack. The copy itself goes through
the validatingMemory::Memcpy, so this is guest-side corruption only, and real hardware probably
doesn't validate it either - changing it risks breaking games that depend on that. TheargSize + 0xfsigned overflow is UB regardless and deserves a cast.- Three unchecked
__GetCurrentThread()dereferences:__KernelCurHasReadyCallbacks,__KernelWaitCallbacksCurThread,KernelRotateThreadReadyQueue. Of 40 uses in that file the rest
check, assert, or use a ternary - and__KernelWaitCurThread, the direct sibling of the second
one, has_assert_(thread != nullptr). No path was found where a syscall runs without a current
thread, so these were left alone rather than papering over a real invariant.
Coverage
Read closely: Heap, Semaphore, Alarm in full; Module's import path; Interrupt's sysclib
block; Memory's pool creation and PartitionMemoryBlock; sceKernel.cpp's object pool and
savestate loop; Thread's start/refer paths and the callback machinery; Mbx's message list;VTimer's scheduling.
Audited across all 14 files by pattern: unchecked memory access, timeout writeback, null checks
after kernelObjects.Get, name copies, allocator return values, uninitialised members.
Also checked: MsgPipe's transfer loops, and the interrupt dispatch in sceKernelInterrupt.cpp
(__TriggerInterrupt indexes intrHandlers without a bounds or null check, unlike__RunOnePendingInterrupt, but every caller passes an internal constant).
Not read line by line: the EventFlag / Mutex wait-queue bodies, and thread scheduling proper.
Metal Backend (`docs/metal-backend.md`)
The Metal backend
A native Metal backend for Mac and iOS (tvOS too), alongside OpenGL, D3D11 and Vulkan. It's selected asGPUBackend::METAL (--graphics=metal). It's the default on iOS since 2026-10-22; Vulkan through MoltenVK
stays the default on Mac and tvOS. Written 2026-10-19; file references will drift.
Why: programmable blending. Apple GPUs let a fragment shader read the color it's about to blend with
([[color(0)]]), so PSP blend modes that need shader blending no longer copy the framebuffer for every
draw. MoltenVK can't expose that. Removing the MoltenVK dependency is a smaller benefit, and comes later.
Where things are
| Path | What |
|---|---|
Common/GPU/Metal/MetalRenderManager.h/.mm |
Command recording and replay, push buffers, shader translation, background compiles |
Common/GPU/Metal/thin3d_metal.mm |
thin3d's DrawContext, for the UI, presentation and the shared GPU code |
Common/GPU/Metal/MetalGraphicsContext.h/.mm |
The GraphicsContext: device, layer (or an offscreen backbuffer for headless), render thread |
GPU/Metal/ |
The emulation side: draw engine, shader manager, texture cache, framebuffer manager, GPU_Metal |
The backend only exists where PPSSPP_API(METAL) is set: Mac and iOS, not the libretro cores. The Objective-C
files are compiled with ARC.
Shaders
The backend uses the same shaders as Vulkan: the generators emit Vulkan GLSL, glslang compiles it to
SPIR-V, and SPIRV-Cross's CompilerMSL turns that into MSL 2.3 (TranslateVulkanGLSLToMSL). Generating
MSL directly was considered and dropped. Translation takes about 0.5 to 0.7 ms (glslang) plus 0.2 to 0.4 ms
(SPIRV-Cross) per shader, against around 100 ms for Metal's own compile of the MSL when it isn't in the
system's Metal cache.
- Bindings: a Vulkan binding number is used as the Metal buffer, texture and sampler index. The vertex
buffer is at index 16, and compute shaders' push constants at 15. - Coordinates:
flip_vert_ymakes clip space match Vulkan's, so everything else (viewports,
framebuffer origin, texture coordinates) matches Vulkan exactly. thin3d reports the Vulkan coordinate
convention. - Framebuffer reads: the fragment shader generator declares a
subpassInputfor shader blending whenFS_BIT_USE_FRAMEBUFFER_FETCHis set, and SPIRV-Cross (use_framebuffer_fetch_subpasses) turns it into[[color(0)]]. Only Apple GPUs reportframebufferFetchSupported. - Invariance: functions are compiled with
preserveInvariance, since multipass effects depend on
different vertex shaders producing identical positions. It costs no measurable speed.
Shaders are translated when the shader manager creates them, since recording needs the texture masks the
translation produces. The MSL compile and the pipeline state are created on compile threads
(RunOnCompileThread). Draws record a MetalPipelineState that may still be compiling, and the replay
waits for it, as Vulkan does. Nothing is skipped.
Each game's shader IDs and pipeline keys go to <discID>.mtlshadercache (with a checksum, like the Vulkan
and GL caches), and are recreated while the game boots. Cold Wipeout Pure, 5 emulated seconds from a
savestate: 3.0 s with synchronous compiles, 1.6 s with background compiles.
The render manager
The emulator thread records commands into a chunk, and they're encoded into Metal command buffers on a
render thread (when bRenderMultiThreading is on and more than one frame is in flight), or inline at
submit.
- Lazy passes. A render pass starts on its first draw, since Metal needs the load actions up front and
resets all encoder state with each pass. A clear restarts the pass with clear load actions; binding the
framebuffer that's already bound continues the pass, like Vulkan. - Redundant state is dropped while recording.
- Pass merging. The replay merges later passes to the same framebuffer into earlier ones when nothing
in between reads or overwrites it, by the rules ofVulkanQueueRunner::ApplyRenderPassMerge. God of War
went from 41 to 18 render passes a frame. - Depth. A pass where no draw tests or writes depth or stencil neither loads nor stores it. The
backbuffer's depth is memoryless on Apple GPUs. - Uploads for new textures go in a command buffer of their own, enqueued before the frame's commands.
Texture scaling's compute dispatches run there too. - Lifetimes. Command buffers don't retain what they use (
commandBufferWithUnretainedReferences), which
saves a retain and release per binding. Anything that may still be in use goes throughDeferRelease,
which keeps it until the GPU is done with the frame. Frames in flight are limited by a semaphore. - Push buffers keep 256 bytes free after each allocation, since the vertex decoder can write past the
end of its output.
Textures and framebuffers
- 16-bit PSP textures (5650, 5551, 4444) stay 16-bit on Apple GPUs, in
B5G6R5Unorm,BGR5A1UnormandABGR4Unormwith a swizzle. Metal names packed channels from the lowest bits up, and the PSP stores red
lowest. - At max quality filtering, textures up to 256x256 get a full mip chain, generated with a blit.
- Hardware texture scaling runs the same compute shaders as Vulkan. Shaders with a constant buffer (only the
smiley test shader) aren't supported. - Framebuffers are
RGBA8UnormwithDepth32Float_Stencil8. A depth-only or stencil-only copy goes
through a buffer, since texture copies take both planes. - MSAA works like the Vulkan backend's: a multisampled framebuffer has multisampled color and depth textures
that are drawn to and kept between passes, and each pass resolves them into the ordinary ones (depth and
stencil to sample 0), which everything else reads. Copies between two multisampled framebuffers copy the
samples too. Metal can't copy a single plane of a multisampled depth/stencil texture, so those copy both.
With programmable blending, the shader blends each sample with its own color, so edges come out a little
different from Vulkan's, which blends with the resolved color. - Readbacks for the GE debugger and screenshots work. Headless has no backbuffer to read, like headless
Vulkan, and the debugger falls back to the PSP's framebuffer.
Testing
PPSSPPHeadless --graphics=metalrenders offscreen, for frame dumps, savestates and the WebSocket debugger.- With
MTL_DEBUG_LAYER=1, Metal validates the API use. Lifetime bugs show up more reliably in the app,
booting and quitting a game withNSZombieEnabled=YEStoo. - Comparing against Vulkan on the same Mac is the main check. All 1061 frame dumps in
ppsspp-framedumps,
rendered with both: 1049 are identical. Of the rest, 10 differ because of programmable blending, and are
closer to the PSP captures (Tenchu went from about 10,000 differing pixels to 900). The Kurohyou dumps are
unreliable. The benchmark savestates match Vulkan to within a few levels per channel. - On iOS, pass launch options through
devicectl's process arguments (--graphics=metal); the app adds--options from its own launch arguments.
Speed
Measured 2026-10-17 on an Apple Silicon Mac, 10 emulated seconds from 8 savestates, against Vulkan through
MoltenVK: wall time from 11.7% lower (Ridge Racer) to 2.6% higher, CPU time 2 to 11% lower. God of War was
6% faster after pass merging; before it, Metal was slower because of the extra render passes.
Not done
- Intel and AMD Macs are untested. They get no programmable blending, no 16-bit textures and no
memoryless depth (all Apple GPU only), so they take the same paths as other GPUs elsewhere. - tvOS shares the iOS code, but hasn't been built or run with Metal.
- The dev screen's pipeline list is Vulkan only.
- Stereo rendering (multiview, framebuffer arrays) is deliberately left out.
- Making Metal the default on Mac, and removing MoltenVK, come later. Existing iOS installs keep the
backend saved in their ini; only new installs and reset settings get Metal. The render manager includes
Vulkan headers forGLSLtoSPV, which has to change before MoltenVK can go.
Multi Swapchain (`docs/multi-swapchain.md`)
Multiple swapchains (design)
Status: design, nothing implemented yet.
The first user is an iOS "TV mode": with AirPlay connected, the game runs full screen on the TV and the
phone shows only the touch controls (and the menus). The same mechanism is meant to carry later uses:
ImGui debugger windows outside the main window (ImGui viewports), a desktop window mirroring a VR
headset, and an external display on Android or desktop. So the graphics side is general, and the iOS
part is just its first client.
Where we are
Everything assumes one output, at every layer:
- thin3d: the backbuffer is the
nullptrframebuffer.BindFramebufferAsRenderTarget(nullptr),GetFramebufferDimensions(nullptr),CopyFramebufferToMemory(nullptr),Present(), theLOST_BACKBUFFER/GOT_BACKBUFFERevents and the present-mode caps all mean "the" swapchain
(Common/GPU/thin3d.h). - Vulkan:
VulkanContextholds onesurface_/swapchain_/presentation_and the surface-derived
state (format, extent, caps, transform, present modes). The render manager has oneFrameDataShared::swapchainImages_, one set of backbuffer framebuffers and one depth buffer
(VulkanQueueRunner.h). A frame can contain only one backbuffer pass (SanityCheckPassesOnAdd
asserts). The BACKBUFFER render pass is looked up without a format in its key
(VKRRenderPass::Get). Queue selection checks present support against the one surface
(ChooseQueue). VulkanPresentation(Common/GPU/Vulkan/VulkanPresentation.h) already abstracts an image chain
(acquire, present, images, extent, format, final layout). libretro and headless use it to replace
the swapchain. It is the seed of this design.- Metal: one
layer_,rtDrawable_andbbDepth_inMetalRenderManager. Backbuffer passes are
merged against a singleBACKBUFFER_TARGETsentinel. - D3D11: the platform context (
D3D11Context) owns the oneIDXGISwapChainand hands its RTV in
throughGOT_BACKBUFFER. - OpenGL: one context, one default FBO (
g_defaultFBO), oneswapFunction_. - Frontend: one global
g_display, read in about 250 places, from layout to input scaling.ScreenManager::Renderis the only place that binds the backbuffer.EmuScreen::renderdraws the
game (gpu->CopyDisplayToOutput) and then the touch controls (renderUI) into that same pass.
Graphics: `Draw::Swapchain`
A swapchain is a presentable chain of images: a window, a CAMetalLayer, an XR swapchain, a libretro
frame callback. It's a real object, a kind of Framebuffer, so the existing framebuffer calls take it
and the backbuffer stops being a special nullptr. The current implicit backbuffer becomes the
primary swapchain, which the draw context creates along with itself.
/* Detailed source-code truncated for AI context efficiency. */nullptris an alias for the primary, so nothing breaks. The direct callers useGetPrimarySwapchain(). Some code still passes aFramebuffer *that may be null (non-buffered
rendering, for one), sonullptrcan only become an assert after that's audited. Backends that
support one swapchain map the primary to their oldnullptrpath (PrimaryToNull). Metal treats
all its swapchains the same way.Size changes on resize. Unlike other framebuffers, a swapchain's
Width()/Height()change when
its surface does, and only at a frame boundary.Platform code keeps the primary out of its hands.
GraphicsContext::InitSurfacestill creates
the primary, so platform code doesn't change.One frame, many presents.
BeginFrame/EndFrameand the frame semaphores and fences stay
global.Present()presents every swapchain that got a pass this frame. A swapchain without a pass
is not acquired or presented, so a debugger window can update at 10 Hz for free.Lifetime:
CreateSwapchainandDestroySwapchainrun on the render thread at a frame boundary.
Destroy waits for the frames in flight that use it. The platform keeps the native surface (layer,
window) alive until destroy returns (see "iOS").Out-of-date and resize are per swapchain. Recreating the TV's swapchain must not touch the
primary's. Losing a secondary surface makes it skip frames until resized or destroyed, never fail
the frame.Formats: phase 1 requires every swapchain to use the primary's color format, which is BGRA8 on
every platform we care about. Then the BACKBUFFER render pass and every pipeline compiled against it
stay compatible. Mixed formats would need the format in the render-pass key and per-format pipeline
variants; that's left for when something needs it (HDR output).Caps:
DeviceCaps::multipleSwapchains. The frontend checks it and falls back to mirroring.
Frame pacing
FIFO swapchains block on acquire. With two of them, the frame loop paces to whichever is slower, and
an unlucky vsync phase between two displays makes that stutter. Policy:
- Exactly one swapchain paces. Its acquire blocks as today, and the frame-timing machinery
(present IDs,FrameTimeHistory,GetCurrentPresentMode) follows it. In TV mode that's the TV,
since the game is there. - The others never block. They acquire with a timeout of 0. If no image is ready, that swapchain
skips the frame (its pass becomes a no-op), and it's presented on the next one. They use MAILBOX or
IMMEDIATE where the surface offers it. - On iOS a
CAMetalLayeris always vsynced (displaySyncEnabledis macOS-only), so a
non-pacing layer just drops frames when it has no drawable. That's acceptable for touch controls.
This needs measuring with a 60 Hz TV and a 120 Hz ProMotion phone.
Vulkan
- Split
VulkanContext's surface state intoVulkanSurfaceSwapchain: surface, swapchain,
format, extent, caps, pre-transform, present modes,cbGetDrawSize. MoveInitSurface,ReinitSurface,InitSwapchain,DestroySwapchainandDestroySurfaceonto it.VulkanContext
keeps a primary one, and its current accessors forward to it, so callers don't change. - Make the real swapchain a
VulkanPresentationtoo. Today it's thepresentation_ == nullptr
branch in each consumer. With a real-swapchain implementation, every output is a presentation:
KHR swapchain, libretro, offscreen, later OpenXR. The render manager then deals with one interface,
and the branches go away. - Per-output state in the render manager.
FrameDataShared::swapchainImages_and the queue
runner'sbackbuffer_,framebuffers_anddepth_move into aVKROutput(presentation, images,
views, semaphores, framebuffers, depth, out-of-date counter). The per-frameacquireSemaphore,curSwapchainImage,hasAcquiredandskipSwapbecome a small array indexed by output (cap of
4). Output 0 is the primary. - Steps. A RENDER step with no framebuffer gets an output index.
SanityCheckPassesOnAddchanges
from "one backbuffer pass" to "one pass per output, after all offscreen passes".RunSteps
already acquires lazily on the first backbuffer step, and that becomes per output. - Submit and present. The present command buffer waits on the acquire semaphore of every output
acquired this frame (stage COLOR_ATTACHMENT_OUTPUT) and signals each one's rendering-complete
semaphore. Real swapchains present in a singlevkQueuePresentKHRwithswapchainCount = N, and
read the per-swapchain results frompResults. Other presentations get their ownQueuePresent. - Queue support.
CreateSwapchainchecksvkGetPhysicalDeviceSurfaceSupportKHRfor the
existing graphics queue family and fails if it's missing. That never happens on Apple, Windows or
desktop Linux. On Android it's always the same queue. - Rotation. The pre-transform is per surface, and so is the rect rotation that reads
g_display.rotation(VulkanRenderManager.cpp,RotateRectToDisplayin the queue runner). The
rotation goes intoVKROutputand gets applied per pass.
The single-output path should come out doing exactly what it does now: same waits, same single
present. Most of the regression risk is in step 5, which every platform runs.
Metal
layer_, rtDrawable_, bbDepth_ and the pending size move into a MetalSwapchain. TheBACKBUFFER_TARGET sentinel becomes the swapchain pointer, so passes merge per target. Replay acquires
each drawable lazily, as today, and the frame's last command buffer does presentDrawable: for each.
For a non-pacing layer, skip the pass when nextDrawable would block.
D3D11 and OpenGL
- D3D11: easy, and later. Creating the
IDXGISwapChainmoves fromD3D11Contextinto the draw
context, one per HWND, andPresent()presents each one. - OpenGL:
multipleSwapchains = falsefor now. Desktop GL could use one context withMakeCurrent(window_i)before each window's pass and swap, which needs a per-surface default FBO
and swap function, and a surface ID in the PRESENT task. Nothing on the iOS path uses GL.
Frontend: outputs and roles
g_display stays as it is and describes the primary output: the screen the UI and touch input
live on (the phone). Its 250 readers don't change. Making it per-output would touch layout, input,
DPI and fonts everywhere, for no gain here.
Secondary outputs are described separately:
enum class OutputRole {
Game, // Just the emulated display, laid out full screen. No UI.
Mirror, // A copy of the primary (unused at first, the OS mirrors for us).
ImGui, // Later: an ImGui viewport.
};
struct OutputDisplay {
Draw::Swapchain *swapchain;
OutputRole role;
int pixelWidth, pixelHeight;
float refreshHz;
float overscanInset; // TVs.
};These live in a small OutputManager owned by the screen manager. The platform layer adds and
removes them (see "iOS").
Rendering
There is still one screen stack. A screen can draw to more than one output:
// Draws this screen's content for a secondary output. The swapchain is already bound.
// Returns false if the screen has nothing for it.
virtual bool renderOutput(const OutputDisplay &output) { return false; }ScreenManager::Render stays the only place that binds a swapchain:
PreRenderfor every screen, as now. Emulation and post-shader passes run here.- For each secondary output, bind its swapchain and ask the stack from the top down. The first
screen whoserenderOutputreturns true has drawn it. If no screen claims it, the output falls
back to clearing it (or, on iOS, it's taken away so the system mirrors again).EmuScreenclaimsGameoutputs. It drawsCopyDisplayToOutputwith that output'sOutputParamsand nothing else.- The pause menu and the other dialogs don't claim anything. So while the pause menu is up, the
phone shows the menu and the TV keeps the game (darkened, as behind the menu today). - Later, other screens can claim a TV too. The game browser could show a big-screen backdrop
instead of a mirror of the menu.
- The primary pass, as now. While
EmuScreensends the game to another output, itsrenderleaves
out its ownCopyDisplayToOutputand draws the touch controls over black. Optionally it draws a
small game preview, which is a secondCopyToOutput.
The other option was a stack entry holding one screen per swapchain. It doesn't fit, because input,
focus and dialogs belong to one place (the phone). Per-output screens would have to be pushed and
popped in step, and most entries would have only one.
ImGui windows aren't screens. ImGui owns its viewports. After the screens, the ImGui step draws each
viewport to its own swapchain, so it shares the swapchain layer with the screen stack and nothing else.
Presentation parameters
PresentationCommon should get its output size and layout explicitly instead of from g_display.
Mostly it already does, through PSP_CoreParameter().pixelWidth/Height andUpdateDisplaySize, and "auto" render resolution follows the same values, so it scales for the TV
on its own. What still reads the global:
GetScreenFrame(insets, DPI)- the stretch logic in
CalculateDisplayOutputRect(device orientation) rot_matrixinCopyToOutputand the post-shader passes- the DisplayLayoutConfig choice (
GetDeviceOrientation())
These become an OutputParams { width, height, rotation, insets, orientation } handed toCopyDisplayToOutput. The game output on a TV is always landscape, with no rotation and with overscan
as its insets. That refactor is contained (about five call sites) and is worth doing first, since it
can be tested on its own.
Restrictions
- Skip buffer effects (non-buffered rendering) draws the game straight into the bound
backbuffer during emulation, so it can't target another output. In that mode the game stays on the
phone, with a one-time notice. - Screenshots taken from the backbuffer (the primary swapchain) would capture the
phone. Game screenshots should read the game output, or render the game to an offscreen buffer. - Cardboard VR turns off on a
Gameoutput.
iOS
- Getting the scene: up to iOS 26, a
UIWindowSceneSessionRoleExternalDisplayNonInteractive
configuration in Info.plist, withExternalDisplaySceneDelegate. From iOS 27, that's ignored, and the
app has to register from a view controller instead (registerSceneAccessory:withUISceneAccessory externalNonInteractiveSceneAccessoryWithConfiguration:). We do both. Without a
scene, iOS just mirrors. An AirPlay Apple TV 4K comes in at 3840x2160. - Connect: the delegate keeps the scene, and posts "external display available" (size from
screen.nativeBounds, refresh rate, overscan) to the native side. It doesn't create a window yet. - TV mode on: when it should be active (setting on,
EmuScreenrunning), the main thread creates
aUIWindowon the scene with aCAMetalLayer-backed view (contentsScale = screen.nativeScale).
It then queues a request to the render thread, which callsCreateSwapchain(METAL_EXT, layer)at a
frame boundary and registers aGameoutput. - TV mode off, disconnect, or resign active: the render thread destroys the swapchain first, and
the main thread waits for that (the same handshake asrequestExitVulkanRenderLoop). Only then does
it remove the window. Removing the window should give the TV back to system mirroring. Verify on
the device: if iOS doesn't resume mirroring, we need aMirroroutput for the menus instead. - Settings: "When a TV is connected: Show the game on the TV (default) / Mirror the screen". The
pause menu gets a toggle for the current session. - Out of scope: iPad Stage Manager's interactive extended displays (role
Application), and
tvOS. - AirPlay latency: this is the same video stream as mirroring, so the same lag. Input stays local,
but the picture is late.
Later clients
ImGui viewports:
ImGuiConfigFlags_ViewportsEnable, a platform backend that creates SDL or
Win32 windows, and renderer hooks that map to this design:Renderer_CreateWindow→CreateSwapchainRenderer_RenderWindow→BindFramebufferAsRenderTarget(swapchain)and drawRenderer_SwapBuffers→ nothing,Present()covers it
It also needs input routed per window, which is its own piece of work.
Desktop VR mirror: with OpenXR on Vulkan, each XR swapchain is a
VulkanPresentation(acquire
maps toxrAcquireSwapchainImage/xrWaitSwapchainImage, present toxrReleaseSwapchainImage),
and the desktop window stays primary. VR is GL-only today, so this also needs a Vulkan OpenXR path.Android external display: a
Presentationdialog with its ownSurface, as aGameoutput on
Vulkan.
Phases
OutputParamsrefactor ofPresentationCommonandCopyDisplayToOutput. No change in
behaviour, checked with frametests.- thin3d
SwapchainAPI plus Metal, the iOS default. Single-output path unchanged: unit tests,
pspautotests, frametests. - Dev client on macOS SDL: a
--game-windowoption opens a second SDL window as aGameoutput
(with--graphics=metal). This tests most of the design in a fast loop, without AirPlay. Headless
can add an offscreen second output, so frametests can check what it shows. - iOS TV mode, on Metal, tested with the Apple TV.
- Vulkan (
VulkanSurfaceSwapchain, presentation-for-everything, per-output render manager
state), for Android, the Mac default and Vulkan users on iOS. Single-output path unchanged: unit
tests, pspautotests, frametests, an Android device. Then D3D11 when something wants it.
Open questions:
- Does removing the external window resume mirroring?
- How do two vsynced layers pace on a ProMotion phone?
Patching Files (`docs/patching-files.md`)
Patching files from a script
Most edits should go through an editor tool that does exact string replacement. This document is about
the cases where you reach for a script instead - a repetitive change across many files, say - and the
two ways that silently corrupts a diff on Windows.
The short versions of both live in AGENTS.md (General instructions, rules 5 and 6). This is the detail.
Line endings
Which ending a file has depends on where it was checked out. Markdown, .cpp and most sources are
stored in git with LF, and a Windows checkout converts them to CRLF on the way out; a Linux checkout
leaves them alone. So the same file - .vcxproj, .vcxproj.filters, android/jni/Android.mk,libretro/Makefile.common, AGENTS.md, much of the source - is CRLF in one working copy and LF in
another. Don't hardcode either, and don't "fix" a file's endings to match what a doc claims.
If you patch one with a script, read and write with newline='', which keeps whatever was there.
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.
Backslashes in a bash heredoc
Don't feed Python to bash -c via a heredoc when the code contains backslashes. The Git Bash /
MinGW layer strips one level of backslash escaping on the way in, even with a quoted delimiter
(<<'PY'), which normally suppresses all substitution. So the script Python receives is not the one
you wrote:
| You write in the heredoc | Python actually sees | Result |
|---|---|---|
"\r\n" |
"\r\n" |
fine - survives, because you want Python to interpret it |
"\\n" (intending a literal \n in the output) |
"\n" |
a real newline is written into the file |
'foo \\\r\n' as a match anchor |
'foo \<CR><LF>' |
anchor silently doesn't match, reported as "anchor missing" |
The tell for the first case is a compiler error like C2001: newline in string literal; the second case
produces no error at all, just a patch that quietly did nothing. Both are invisible in the heredoc you
wrote.
The rule: a heredoc is fine as long as every backslash in the script is one you want Python to
interpret. The moment you need a literal backslash in the output, stop. Then either:
- use an exact-string-replacement editor tool instead (no shell in the path - the best option for the
common case of "insert a few lines of C++ that contain\n"), or - write the script to a file and run
python thescript.py, or - build the backslash as
chr(92)so no literal backslash appears in the heredoc at all.
Note this is about the Python source, not the data: reading and rewriting a CRLF file withnewline='' and \r\n anchors works fine in a heredoc, and is the normal way to patch files here.
Pkg Notes (`docs/pkg_notes.md`)
The PKG package format (PSP game updates)
A .pkg is the NPDRM container Sony distributed downloadable content in - full PSN games, DLC,
themes, and the thing these notes are about: game updates. An update package holds a patched
EBOOT (PBOOT.PBP) plus whatever data files the patch replaces, and installing it drops them inms0:/PSP/GAME/<DISC_ID>/. The patched EBOOT then runs with the original UMD (or the original
PSN game) still supplying everything it doesn't override.
This document is what was learned decoding some update packages on 2026-08-21. Tools/pkg.py is a
working parser/extractor built from it; PPSSPP reads and installs them itself now - see "How PPSSPP
handles them" and the verification sections at the end.
Format reference: https://www.psdevwiki.com/ps3/PKG_files, cross-checked against
pkg2zip.
File layout
+0x000 header (0xC0 bytes, plaintext)
+0x0C0 extended header (0x40 bytes, plaintext, PSP/Vita only)
+0x100 hashes/signatures
+0x280 metadata (plaintext, offset and count are in the header)
...
+data_offset encrypted area: item table, then filenames, then file contentsHeader
+0x00 u32 "\x7FPKG" magic (0x7F504B47)
+0x04 u16 revision. 0x8000 retail, 0x0000 debug
+0x06 u16 type. 1 = PS3, 2 = PSP/Vita
+0x08 u32 metadata offset (0x280 in everything seen)
+0x0C u32 metadata entry count
+0x10 u32 metadata size
+0x14 u32 item count
+0x18 u64 total package size
+0x20 u64 data offset - start of the encrypted area
+0x28 u64 data size
+0x30 char[0x30] content id, e.g. "JP0177-ULJM05681_00-PJD2UPDATEVR0101"
+0x60 u8[0x10] QA digest
+0x70 u8[0x10] riv - the AES counter block (see below)
+0x80 u8[0x40] header CMAC and signaturesExtended header
Present on PSP and Vita packages, magic "\x7Fext" (0x7F657874) at +0xC0. The only field that
matters for reading the package is the key index, a u32 at +0xE4 - or equivalentlyheader[0xE7] & 7, which is how pkg2zip reads it. Every PSP package seen uses key index 1.
Metadata
A flat sequence of u32 id, u32 size, u8 value[size] records. The ones worth reading:
| id | Meaning |
|---|---|
| 2 | content type. 7 = PSP (also 0xE/0xF/0x10 for PC Engine / Minis / NeoGeo) |
| 4 | package size |
| 6 | title id |
| 13 | offset and size of the item table, inside the encrypted area |
| 14 | offset and size of PARAM.SFO, inside the encrypted area |
Everything else (DRM type, SDK revision, QA digest, install dir) is informational.
Encryption
The whole area from data_offset on is AES-128-CTR, with the counter block starting at riv
(header +0x70) and incrementing once per 16 bytes: the block at byte offset n of the encrypted
area uses counter riv + n/16. Offsets in the item table are relative to data_offset, so
that division is straightforward - no separate bookkeeping.
Two different keys are used within the same package, and each item says which one applies via
its own psp_type byte (see the item table below):
| Package / item | Key |
|---|---|
PS3 package, or PSP item with psp_type != 0x90 |
2e7b71d7c9c9a14ea3221f188828b8f8 |
PSP item with psp_type == 0x90 |
07f2c68290b50d2c33818d709b60e62b |
| Vita, key index 2/3/4 | AES-ECB(vita_key_N, riv), key by index |
That per-item split is the one thing that isn't obvious from the wiki page and will make a reader
produce garbage filenames for most of a package while a couple of entries decode perfectly. In a
game update it's the PBOOT.PBP and the patch data files that carry 0x90; the icons,PARAM.SFO, PS3LOGO.DAT, the directory entries and ISO.BIN.EDAT use the PS3 key.
Item table
item_count records of 0x20 bytes, at the item table offset from metadata id 13 (0 in every
update package seen, i.e. right at the start of the encrypted area):
+0x00 u32 filename offset (relative to data_offset, always 16-byte aligned)
+0x04 u32 filename length
+0x08 u64 data offset (relative to data_offset, always 16-byte aligned)
+0x10 u64 data size
+0x18 u8 psp_type - 0x90 selects the PSP key, see above
+0x19 u8[2] padding
+0x1B u8 flags
+0x1C u32 paddingFilenames are stored in the encrypted area too, and are decrypted with the item's own key, not
the package's main key.
flags is a content type, and in these packages it maps 1:1 onto how the file contents are
encrypted - a decoder can tell what it is holding before looking at it:
| flags | Meaning | Contents start with |
|---|---|---|
| 2 | NPDRM EDAT | NPD\0 - only ever ISO.BIN.EDAT |
| 3 | plain file | whatever it is (PNG, \0PSF, ...) |
| 4 | directory | - |
| 5 | PSP EDAT | \0PSPEDAT |
| 8 | PSP EDAT (.sprx modules) |
\0PSPEDAT |
| 11 | PBP | \0PBP |
Both flags 5 and 8 give you a \0PSPEDAT, but they are not the same thing inside. A flags-5 data
file wraps a PGD, which PPSSPP decrypts at runtime through sceNpDrmEdataSetupKey() inCore/HLE/scePspNpDrm_user.cpp. A flags-8 .sprx wraps an encrypted PRX instead, and goes through
the module loader - see "NPDRM .sprx modules" below. The byte at 0x0E of the header tells them apart
(3 for the PGD kind, 1 for the PRX kind).
What an update package contains
Always this shape:
PARAM.SFO CATEGORY=PP, TITLE_ID, VERSION
PS3LOGO.DAT a PNG, despite the name
ICON0.PNG / PIC0.PNG / PIC2.PNG sometimes
USRDIR/ directory entry
USRDIR/CONTENT/ directory entry
USRDIR/CONTENT/PBOOT.PBP the patched EBOOT
USRDIR/CONTENT/... the patch data files
USRDIR/ISO.BIN.EDAT 272 bytes, NPD headerThe outer PARAM.SFO is the package's - CATEGORY=PP (game patch), and its VERSION is the
package version, not the patch version. The interesting SFO is the one inside PBOOT.PBP:
that one has CATEGORY=PG, DISC_ID, DISC_VERSION, APP_VER (the patch version) andPSP_SYSTEM_VER (the firmware the patch needs), which is what a real PSP matches against the
disc before deciding to boot the patch.
What the packages measured
All are content type 7,
CATEGORY=PP. Some are for digital NP* titles, others for UMD UL*/UC*
titles.PSP_SYSTEM_VERranges 6.10 to 6.60.Every single
PBOOT.PBPuses PRX tag0x2E5E10F0in itsDATA.PSP. PPSSPP already has
that key -Core/ELF/PrxDecrypter.cpp, in theTAG_INFO2table, commented
"5.00 PSP-2000 (Game PSN Update 2 LBP)". So no new crypto is needed to run these.Verified end to end rather than assumed: extract Hatsune Miku Project DIVA 2nd's update, rename
PBOOT.PBPtoEBOOT.PBP, and boot it headless with the UMD mounted -text./build/PPSSPPHeadless --cpu=interpreter --graphics=software --memstick=<ms> \ --mount="Hatsune Miku - Project Diva 2nd (Japan).iso" \ <ms>/PSP/GAME/ULJM05681/EBOOT.PBPIt logs
Decrypting tag 2E5E10F0, loads the ELF (tag=ELF/PdvApp), resolves its imports, and
readsDiva2Data.cpk/Diva2Script.cpk/Diva2Sound.cpkoffdisc0:. Runs without error.
(--mountis what makes that work:Load_PSP_ELF_PBPinCore/PSPLoaders.cppmounts the ISO
ondisc0:,umd:andumd1:when booting an ELF or PBP.)
pkg2zip cannot extract update packages, so it is not an alternative here. Its PSP path only
recognises USRDIR/CONTENT/EBOOT.PBP (a full PSN game, which it converts to an ISO),PSP-KEY.EDAT and CONTENT.DAT, and continues past everything else - PBOOT.PBP and every
patch file are silently dropped, with no warning that anything was skipped.
How PPSSPP handles them
Four pieces:
Core/Util/PkgUnpack.cppreads a package: header, item table, both PARAM.SFOs, and the
decryption.PkgReader::Open()gives you aPkgInfowith the disc ID, disc version and patch
version;InstallPkg()writes the payload out. Sits next toPSARUnpack.cpp, and like it needs
nothing but the AES already inext/libkirk.UI/InstallPkgScreen.cppis what opening a.pkggets you, the same way a.zipgetsInstallZipScreen- it shows what the update patches, what it'll take up on disk, and where it's
going. The size is exact rather than an estimate: package contents aren't compressed, so summing
the item table is the answer.GameManager::InstallPkgOnThread()does the work.GameScreenshows an installed update in the info pane, and offers "Delete Game Update" in
its context menu. Deleting takes the wholePSP/GAME/<DISC_ID>folder when the update is all
that's in it, and onlyPBOOT.PBPwhen a digital game shares the folder - nothing records what
an install wrote, so the executable is the only part still identifiable afterwards.FindGameUpdatePBOOT()inCore/PSPLoaders.cppis the boot-time half. Starting a disc looks
forms0:/PSP/GAME/<DISC_ID>/PBOOT.PBP, and boots that instead ofdisc0:/PSP_GAME/SYSDIR/EBOOT.BIN
if it's there, leaving the disc mounted.
PPSSPPHeadless --install-pkg=DIR <file.pkg> does an install without the UI, which is how the
above got tested. It prints what the package is and installs into DIR exactly (the app picksPSP/GAME/<DISC_ID> itself).
Install layout
The package's own PS3-style wrapping is stripped: USRDIR/CONTENT/<x> and USRDIR/<x> both become<x> in the game folder, and the USRDIR and USRDIR/CONTENT directory entries are dropped
rather than created. The root-level files - PARAM.SFO, PS3LOGO.DAT, ICON0.PNG, PIC0.PNG,PIC2.PNG - are store metadata and are not installed. Writing that PARAM.SFO in particular
would be actively wrong: a folder holding a PARAM.SFO and no EBOOT.PBP is what PPSSPP
identifies as save data, so the update would show up in the savedata list.
Which updates get used
The update's DISC_ID has to match the disc's, or it's ignored with a warning - that's what keeps
an update from being applied to the wrong game.
DISC_VERSION is advisory. An update is built against one specific disc revision, and PPSSPP logs
a warning when they differ, but still boots it: refusing outright is a worse failure mode than
letting the user find out, since they installed it deliberately. This is not what a real PSP does.
It comes up in practice - the LittleBigPlanet v2.05 update targets disc version 1.00, and the
common European dump is 1.01, and it works.
There's no setting to turn this off. Anyone who has a PBOOT.PBP sitting in a game folder either
installed it here or copied it off a real memory stick, and in both cases booting it is what they
were after.
Verified
- All packages parse and install through the C++ path, byte-identical to
Tools/pkg.py- each
one reports its disc ID and versions and writes its payload without an error. - Hatsune Miku Project DIVA 2nd (ULJM05681): install the v1.01 update, boot the UMD, PPSSPP boots
PBOOT.PBPand the game reads its CPKs offdisc0:. - LittleBigPlanet (UCES01264): install the v2.05 update, boot the v1.01 UMD, and the patched game
opensms0:/PSP/GAME/UCES01264/PATCH.ARCalongside the disc's ownlbp_archive.arc- the update
is actually in use, not just booted. python3 test.py -g --graphics=software: no failures, so restructuring the disc boot
path didn't disturb anything.- Truncated and item-table-corrupted packages are refused with a message rather than crashing.
Corruption inside file data still installs - nothing here verifies the package CMAC, same as
every other PKG tool. - The browser listing, install screen and install itself were checked by hand in the app.
Digital (NP\*) titles
About half of the packages patch a digital title rather than a UMD, and that half is tested too.
The one that settles the question is Super Robot Taisen Operation Extend (NPJH50521), because
it's a real NPUMDIMG EBOOT.PBP rather than a decrypted ISO dump - NPDRM: PSAR ID: 4d55504e,
mounted on disc0: by the NPDRM block device, with the game's own 560 MB EBOOT sitting in the same
folder as the update. All eight of its update revisions were installed and booted in turn, and each
one loads a distinguishably different executable:
disc executable .text 0x419b1c
v1.01 0x41fb8c v1.02 0x4233dc v1.03 0x42952c v1.04 0x42a53c
v1.05 0x42a61c v1.06 0x42a59c v1.07 0x42b48c v1.08 0x42b7dcSo ISO.BIN.EDAT does not re-key the PBOOT, which was the open worry: a digital title's patched
executable is encrypted exactly like a UMD one, and needs nothing PPSSPP doesn't already have.
DISC_VERSION being advisory matters far more than expected. Most of the pairs hit a mismatch,
because the dumps in circulation are later disc revisions than the updates were built against.
Refusing outright would make most of them unusable. Elminage Original was the one clean
exact-match case, disc 1.01 against an update for 1.01, and it boots without a warning.
NPDRM `.sprx` modules
Package payloads are full of \0PSPEDAT files. That's fine for
data: sceNpDrmEdataSetupKey() in Core/HLE/scePspNpDrm_user.cpp wraps an open file descriptor
with the 0x04100002/0x04100001 ioctl pair, and the game reads plaintext.
A few packages wrap executables that way - .sprx modules the game loads withsceKernelLoadModuleNpDrm. Those need more than the data path does. Until they were handled, God
Eater 2 (NPJH50832) installed cleanly, booted its PBOOT.PBP, and then looped forever failing to
load system.sprx; Shiren 4 Plus (NPJH50698), which keeps the whole game in one .sprx behind a
small loader, failed the same way.
An NPDRM module is two layers, and the loader originally saw only the outer one:
+0x00 "\0PSPEDAT" header, 0x90 bytes
+0x08 u32 key mode; low byte is what sceNpDrmGetFixedKey takes (3 in all of these)
+0x0C u16 payload offset (0x90 in everything seen)
+0x0F u8 flag bits: 1 = XOR in the licensee key, 2 = XOR in the 16 bytes at 0x40
+0x10 char[0x30] content ID, "JP0365-NPJH50698_00-SIREN4PLUS2012MA"
+0x90 a normal "~PSP" PRX: tag 0x407810F0 at 0xD0, decrypt_mode 23 at 0x7CsceKernelLoadModule() steps over the EDAT header, and the PRX inside then decrypts. Two keys go
into that, both worked out from JPCSP (ModuleMgrForUser.sceKernelLoadModuleNpDrm, crypto/DRM.java,crypto/PRX.java, crypto/KeyVault.java), and both feed pspDecryptType5(), which already had a
slot for each.
xor2, the per-content key - NpDrmDeriveModuleKey() in Core/HLE/scePspNpDrm_user.cpp, four
steps in order:
sceNpDrmGetFixedKey(kirk, key, edat+0x10, 0x01000000 | edat[0x08])- already inext/libkirk/amctrl.c, and identical to JPCSP'shleNpDrmGetFixedKey: ourkey_363Cis itsdrmFixedKey, ourkey_357C[0/1/2]are itsdrmEncKey1/2/3, and its AES-CBC under an all-zero
IV over one block is ourAES_encrypt. Nothing had called this function before.- If
edat[0x0F] & 1: XOR the licensee key the game passed tosceNpDrmSetLicenseeKey(), which
this file already kept but never used. The game sets it before it loads the module. - If
edat[0x0F] & 2: XOR the 16 bytes atedat+0x40. None seen here use it. - AES-128 decrypt the result under
drmModuleKey, the one constant that had to be added here.
JPCSP does CBC with a zero IV; over a single block that is a plainAES_decrypt.
xor1, a static key picked by decrypt_mode - the PRX header byte at 0x7C being 23
(DECRYPT_MODE_SPRX), which is what these payloads are. JPCSP keys this on the mode rather than on
the tag, and so do we: tag 0x407810F0's table entry has no seed of its own in JPCSP's tables either,
so our table was never wrong, it just had nothing to say about a case selected somewhere else.pspDecryptType5() takes the mode-derived XOR when the mode calls for it and falls back to the tag
table's otherwise - the same precedence JPCSP uses, which leaves every tag that does carry a seed
(the pauth ones) exactly as it was.
No new decryption logic was needed. pspDecryptType5() is structurally identical to JPCSP's "new
method" for type 5: expandSeed(pti->key, pti->code, seed) XORs xor2 over the 0x90-byte scrambled
key buffer as RoundXOR(buf2, 0, 0x90, xor2, null) does; PRXType5::decrypt() XORs both over the
0x50-byte kirk header and SHA1 as RoundXOR(buf2, 0x14, 0x50, xor1, xor2) does, and then xor1 alone
over the 0x60 bytes at id as RoundXOR(buf4, 0x14, 0x60, xor1, null) does. JPCSP's RoundXOR isbuf[i] ^= key[i & 0xF], the same repeating XOR as our xor[i % 0x10].
The other half of it: KL4E
Decrypting is only half the job. Shiren 4 Plus's f5psp.sprx decrypts to bytes that start withKL4E: every one of these modules has comp_attribute = 0x0201, i.e. compressed, and(comp_attribute & 0xF00) != 0 means KL4E/KL3E rather than gzip. So decryption alone would just
move the failure from "unsupported PRX type" to "decompression failed".
Core/Util/KL4E.cpp already handles that - it went in for firmware modules that use the same
compression, and these get it for free. It's worth knowing the two halves are independent, because
each one on its own leaves the module unloadable and the error doesn't say which is missing.
Note that none of this can be checked offline against the hand-decrypted .sprx files that
circulate for God Eater 2: their flag byte says the licensee key is part of the derivation, and that
key only exists while the game is running. Those files are still useful as a cross-check of the
installer itself - the PBOOT.PBP in such a folder is byte-for-byte what InstallPkg() writes, and
the only difference is that the .sprx files beside it were decrypted by hand.
Where that leaves the three module titles
- God Eater 2 (NPJH50832) installs, boots, loads its modules and plays. Decryption alone wasn't
enough for it: it also needed the type-B relocation fix inElfReader::LoadRelocations2(issue
#8075), where twoluis sharing oneaddiugot different high halves, so a callback pointer
landed 0x48 bytes inside a function. - Shiren 4 Plus (NPJH50698) loads
f5psp.sprx- the log says'FDS3PSP' is KL4E-compressed, decompressing- and runs. - Tales of the World Radiant Mythology 3 (NPJH50353) still doesn't reach its modules inside a
headless boot, so it remains untested rather than known-good.
Still not tested
- Only God Eater 2 has been played past a title screen. For the rest the runs are short headless
boots, so "the update is in use" means the patched executable is what loaded and ran - not that a
patched asset was read. LittleBigPlanet'sPATCH.ARCcovers that for a UMD title; there's no
equivalent observation for a digital one yet.
Pspautotests Hardware (`docs/pspautotests-hardware.md`)
Writing pspautotests and running them on a real PSP
docs/pspautotests.md covers running the existing tests against PPSSPPHeadless.
This document covers the other half: connecting a real PSP over USB, running a test on it, and
recording its output as the .expected file that becomes the ground truth.
You need this whenever the answer to "what does the hardware actually do?" isn't already in an.expected file - which is most of the time when you're implementing something new.
What you need
- A PSP with custom firmware and a USB cable. (Verified against firmware 6.61 / PSPLink v3.0.)
- PSPLink installed on the PSP, in
PSP/GAME/psplink, and running - launched from the game
menu. This is not the same as connecting the PSP in USB mass-storage mode; if the PC sees
"PSP Type A" you're in USB mode, and you want "PSP Type B". - The pspdev toolchain on the PC, which supplies
psp-gcc,pspshandusbhostfs_pc.
On macOS it lives in~/pspdevby default and is not onPATH, so prefix commands withPATH="$HOME/pspdev/bin:$PATH"or export it once per shell. On macOS you also needbrew install libusb-compat; on Windows, the libusbK driver via Zadig.
Ask the user to connect the PSP and start PSPLink - you can't do it for them. To confirm it's
there before touching anything else:
ioreg -p IOUSB -l -w 0 | grep -i "USB Product Name" # macOS; want "PSP Type B"The three moving parts
PSP (PSPLink) <--USB--> usbhostfs_pc <--TCP 3000--> pspsh / gentest.pyusbhostfs_pc -b 3000bridges USB to a TCP port and serves the PC filesystem to the PSP ashost0:/. Start it from thepspautotestsroot, becausehost0:/is literally its working
directory, and that's where a test's output files land.pspsh -p 3000is a shell on the PSP.pspsh -p 3000 -e "<cmd>"runs one command and exits.
Handy ones:ls,pwd,reset(reboot PSPLink after a hung test),pspver,power,scrshot.gentest.pybuilds a test, runs it throughpspsh, waits for it to finish and copies the
output over the.expectedfile. It startsusbhostfs_pcitself if the port isn't already open.
One PSP, one test at a time. Never start a second gentest.py or pspsh while one is still
running, and that includes an agent issuing several as parallel tool calls. They share the one
PSPLink session and the output files in host0:/, so they collide: tests that pass alone "time out
after 10 seconds", or the PSP hangs. Re-recording a directory means running its tests one after
another.
Bringing it up, once, from the repo root:
export PATH="$HOME/pspdev/bin:$PATH"
cd pspautotests
(cd common && make) # builds libcommon.a; gentest.py refuses to run without it
usbhostfs_pc -b 3000 & # leave running; prints "Connected to device"
pspsh -p 3000 -e ls # sanity check - should list the pspautotests directoryIf ls shows host0:/ contents you have a working chain. If it hangs or shows nothing, PSPLink
isn't running or the USB driver didn't bind.
Under WSL the PSP first has to be passed through from Windows with usbipd-win (steps 10-12 of
https://pspdev.github.io/psplink/windows.html). Tools/psplink_wsl.sh does that and then startsusbhostfs_pc in the current directory, so run it from pspautotests in place of theusbhostfs_pc line above. It asks for a UAC prompt the first time the PSP is shared, and re-attaches
after a PSPLink reset or a move to another USB port.
How a test reports its results
common/common.c wraps every test. Before main() it redirects the process's stdout andstderr to host0:/__testoutput.txt and host0:/__testerror.txt, and after main() returns it
writes host0:/__testfinish.txt as a completion marker. So in the pspautotests root you'll see:
| File | Meaning |
|---|---|
__testoutput.txt |
the test's stdout - this is what becomes the .expected file |
__testerror.txt |
stderr; non-empty means gentest.py refuses to write .expected |
__testfinish.txt |
written on clean exit; its absence is how a timeout is detected |
__screenshot.bmp |
written by emulatorEmitScreenshot(); becomes .expected.bmp |
gentest.py deletes all four before each run, so a stale file can't be mistaken for a result.
That redirection is also why usbhostfs_pc's working directory matters: get it wrong and the
output files appear somewhere unexpected, or the test can't open its data files.
Writing a new test
A test is one directory under pspautotests/tests/, holding a Makefile, the source, the built.prx, and the .expected. Minimal Makefile - use common.mk, not the older verbose style
still found in some directories:
TARGETS = shortname
COMMON_DIR = ../../../common
include $(COMMON_DIR)/common.mkTARGETS lists every test in that directory (one .c/.cpp per entry); COMMON_DIR is a
relative path, so count the levels. common.mk sets BUILD_PRX = 1 and links libcommon.
The source includes <common.h>, which #defines main to test_main so the wrapper above runs.
Add <sysmem-imports.h> if you need sceKernelSetCompiledSdkVersion*. Then just printf.
#include <common.h>
#include <pspiofilemgr.h>
int main(int argc, char **argv) {
printf("result: %08x\n", sceIoSomething());
return 0;
}Two things common.h gives you that are easy to miss: ARRAY_SIZE, and checkpoint(), which
prefixes each line with [r]/[x] to record whether a reschedule happened - see
docs/pspautotests.md for what those markers mean. Use plain printf when
scheduling isn't what you're testing.
Then generate the expected output, from the pspautotests root:
python3 gentest.py io/shortname/shortname # path under tests/, without .prxIt runs make in the test's directory first, then runs it on the PSP and writestests/io/shortname/shortname.expected.
Finally register the test in the repo root's test.py: new tests go in tests_next, and move
to tests_good only once PPSSPP passes them. Then check PPSSPP against the hardware:
python3 test.py --graphics=software io/shortname/shortnameDesign rules for a test that can actually pass
- Only print things that are the same on hardware and in the emulator. Kernel pointers, heap
addresses and absolute times are not - PSPLink shifts the memory layout, so a test that prints
them can never pass. Print offsets from a base, or ranges, instead. - Sort anything whose order isn't the point.
sceIoDreadreturns a real FAT directory in
creation order and a host directory in whatever order the host filesystem gives; if you're
testing names,qsortby name and the difference disappears. - Make it repeatable. If the test creates files, delete them at the start and the end - an
aborted run otherwise leaves state that changes the next run's output. - Choose an encoding with no ambiguity. When dumping a buffer, don't render NUL as
.if the
data can contain a literal.. Pick a character the data can't contain (|is forbidden in FAT
names, so it works there) - otherwise the.expectedquietly lies.
Gotchas
gentest.pyrunsmakefor the whole test directory, not just your test, so a neighbour
that doesn't compile stops your test before it ever reaches the PSP. Everything undertests/
builds with pspdev GCC 15 as of the "Make the tests build with a current pspdev toolchain"
commit; if you hit a broken one anyway,gentest.py -k(--keep) skipsmakeentirely and
you can build your own target by hand withmake yourtest.prx.- When you touch one test, rebuild every
.prxin its directory and re-record them all. Many
committed binaries were built with a much older SDK, and a rebuild with the current toolchain can
change what a test does:time_tis 64-bit now, sortc/convert'ssceRtcSetTime_t(&pt, 62135596800ULL)marshals differently, and intests/intrthe SDK'sModuleMgrForUserstubs, linked next to the directory's own import of that library, silently did
nothing -waitsprintedsceKernelGetModuleId's result forsceKernelStartModule. Rebuilding
a whole directory at once turns such surprises up while you're there to explain them, rather
than one at a time whenever someone happens to touch a neighbour. So run a plainmakein the
directory, record each test on hardware, and commit the binaries and.expectedfiles together.
(The binaries also grow by roughly a third:testgp.prxwent from 117 KB to 191 KB.) - Diff every re-recorded
.expectedagainst the old one before committing. A difference means
the test changed on purpose, the toolchain changed what it does (fix the test), or the firmware
did. Many old recordings are likely from a PSP on an earlier firmware than today's 6.61, and a
driver change or a bug fix between versions shows up regardless ofsceKernelSetCompiledSdkVersion
(intr/registersubdepends on which drivers have hooked which interrupts: interrupt 8 had a
handler in the old recording and has none on 6.61). Say which in the commit message, and keep
the 6.61 result, since that's the firmware PPSSPP models. Note the committed.expectedfiles have CRLF
line endings (they were recorded on Windows) while a fresh run writes LF, so compare withdiff <(tr -d '\r' < __testoutput.txt) <(tr -d '\r' < the.expected). - Each test PRX stays resident after it runs. Run a handful back to back and the next
Load/Startfails with0x80020190(out of memory) - which looks exactly like a hung test.pspsh -p 3000 -e resetbetween runs, and wait for the PSP to come back before the next one. host0:is not a FAT volume, and it isn't even the same across hosts. It's PSPLink's bridge
to the PC, so it inherits the PC's filesystem:tests/io/directorywas recorded on Windows and
does not match on macOS, where..reports a different size and short names come back as1.txtrather than1.TXT. Anything testing FAT semantics has to run againstms0:- create a
scratch directory on the real memory stick and clean it up - and anything readinghost0:will
only reproduce on the OS it was recorded on.- Start
usbhostfs_pcyourself before the firstgentest.pyrun in a scripted session. Whengentest.pyhas to start it, the bridge inherits the script's stdout and keeps running, so
anything waiting for the pipe to close (gentest.py ... | tail, or an agent's shell tool) hangs
long after the.expectedfile has been written. With the port already open it returns at once. - A test that hangs leaves the PSP wedged.
gentest.pyissuespspsh -e resetafter a
timeout, but if you ran the PRX by hand, do that yourself. Default timeout is 10s; raise it with-t SECONDS. - Bound every wait on the PSP, including the whole command. A test that hasn't finished in 10
seconds is hung; a longer timeout only delays finding out which call blocks. To check whether the
PSP is alive, runpspsh -p 3000 -e lsin the background and give up after 10 seconds (macOS has
notimeoutcommand).gentest.py -tdoesn't cap the run either: its recoverypspsh -e reset
blocks forever if the PSP is really gone, so put a limit on the shell command as well. - A PSP that doesn't answer may only need the host side restarted. If it still enumerates over
USB (ioreg -p IOUSB -l -w 0 | grep -i "USB Product Name"shows "PSP Type B" on macOS), killusbhostfs_pc, start it again from the pspautotests root, and probe once more. Otherwise someone has
to restart PSPLink or reconnect the cable. - Never let a test exit with a utility dialog still active. A dialog left in INIT, RUNNING or
FINISHED keeps the utility module busy, and the nextresethangs PSPLink until it's restarted by
hand. In utility tests, bound every wait, and run a case that might not finish last and alone. tests_to_generateingentest.py- the list used when you pass no arguments - is stale;
several paths in it no longer exist. Always name the test you want.--sdkvermatters for some APIs.gentest.py --sdkver=6060010 --sdkver-func=606makes the
test callsceKernelSetCompiledSdkVersion606()at startup, and-a/--all-versionssweeps every
known version reporting which ones behave differently - a fast way to find version-gated
behavior. A test can also callsceKernelSetCompiledSdkVersion*()mid-run to cover several
versions in one.expected.- On a PSP, printed output is also drawn on the screen, into the display framebuffer. A GPU test that
renders into that framebuffer and prints between draws reads its own text back: each line changed the
alpha of a few pixels under it in the next draw, deterministically on the PSP and never in PPSSPP. SetHAS_DISPLAY = 0(extern "C" int HAS_DISPLAY;, fromcommon.c) after setting up the display. - The module must be named
TESTMODULE(common.cdoes this) orgentest.pyprints the load
line as an unexpected result.
Kernel-mode tests
Some behaviour differs by privilege - sceKernelCreateTlspl accepts partitions 1, 3 and 4 from a
kernel module and rejects them with ILLEGAL_PERM from user mode - so occasionally a test has to
run as a kernel module. tests/threads/tls/kernel is the working example. Set COMMON_KERNEL = 1
in the Makefile before including common.mk and you get a kernel build:
TARGETS = partition
EXTRA_OBJS = tlspl-imports.o
COMMON_KERNEL = 1
COMMON_DIR = ../../../../common
include $(COMMON_DIR)/common.mkThree things make this work, and all three took a while to find, so don't undo them casually:
USE_KERNEL_LIBS, set automatically bycommon.mk. The stockcrt0_prx.oreferences__libcglue_init, which pulls in the whole of libcglue, which importssceNetInet,sceUtilityand theForUserIO libraries. A kernel module that imports any of those fails to
load with8002013C(library not found), and no amount of trimmingLIBShelps because the
reference comes from the startup object itself.USE_KERNEL_LIBSselects a different startup -
and brings-nostdlib, socommon/kernelglue.csupplies the newlib support hooks that go
missing:_sbrkover a fixed heap, the_read/_write/_closestubs, no-op retargetable
locks, andmodule_start.- Nothing may import a
ForUserlibrary.ThreadManForUseris fine, butSysMemUserForUser
is not, which is whycommon.ccompiles thesceKernelSetCompiledSdkVersionhelpers out of
kernel builds - a function pointer table referencing them is enough to pull the stubs in.
Watch for this:psp-strings yourtest.prx | grep Forlists what you're importing. Beware also
thatThreadManForKerneldoes not export the Tlspl calls - importing them from there links
cleanly and then returns8002013A, library not yet linked, from every call. - Size. The kernel partition has about 285 KB free with PSPLink resident, 242 KB of it
contiguous, and loading over the cable needs room for the file and the loaded image at once.
A kernel build therefore gets a 4 KBschedfBufferand a 4 KB heap instead of the user build's
64 KB and 21 MB.psp-size yourtest.elfplus the.prxfile size againstmeminfo's MAXFREE
tells you whether it will fit;800200D9(memblock alloc failed) means it didn't.
Output works normally - printf, checkpoint and schedf all behave - but a kernel build writes
to host0: with sceIo directly rather than through newlib's FILE, since that's what dragged
libcglue in. Formatting still goes through newlib's vsnprintf, so the usual format specifiers
are all available.
Output reaches an emulator the same way it reaches the cable: a kernel build feedssceIoDevctl("emulator:", SEND_OUTPUT) as well as writing the file, and callssceKernelExitGame at the end so headless stops rather than spinning to its timeout. Both are
easy to forget when writing a new harness - without the first the test appears to produce nothing,
and without the second it always reports TIMEOUT even though it ran.
One emulator-side note, since it took a while to pin down: privilege on the PSP belongs to the
caller, not to the syscall. PPSSPP's hleIsKernelMode() only reports whether the entry point
itself is a kernel-only export, so a kernel module calling an ordinary ForUser NID used to look
like user mode. __KernelCurThreadIsKernelMode() answers the question this test needs.
Worked example: FAT short names
tests/io/shortname was written this way and is a decent template. It creates a scratch directory
on ms0:, fills it with names that exercise the 8.3 rules, and dumps the raw d_private block
that sceIoDread fills in.
It was written to dump rather than decode on purpose, and that immediately paid off: the
struct in the SDK's own pspiofilemgr_dirent.h does not match what firmware 6.61 writes when the
compiled SDK version is unset or below 3.08. The real layouts are
| Compiled SDK version | Layout |
|---|---|
| unset, or <= 3.07 | short name at byte 0 (13 bytes), long name at byte 13 - no size field |
| >= 3.08 | caller-supplied size at byte 0, short name at byte 4 (16 bytes), long name at byte 20 |
which is what Core/HLE/sceIo.cpp implements, now confirmed on hardware rather than inherited from
JPCSP. Decoding with the SDK struct instead would have produced strings truncated at the front and
an .expected that silently enshrined the mistake.
The test also caught three real emulator/hardware differences, still open in tests_next:
hardware preserves the original case in d_name (readme.txt) where PPSSPP uppercases it
(README.TXT), and hardware appends ~1 to the short name of any name that isn't already valid
uppercase 8.3 (readme.txt -> README~1.TXT, MiXeD.txt -> MIXED~1.TXT) where PPSSPP only does
so on a collision.
Committing
pspautotests is a git submodule, so a new test is two commits:
- Commit inside
pspautotests/(the new test directory, including the built.prxand the
generated.expected), rebase it ontoorigin/master, and push it straight tomaster- no
branch, no PR, and no need to ask first. - Commit in the PPSSPP repo, including both the
test.pychange and the bumped submodule
pointer (git add pspautotests) - otherwise CI checks out the old submodule and the test
doesn't exist.
Pspautotests (`docs/pspautotests.md`)
pspautotests
This test suite is located in the pspautotests submodule/subdirectory at the root of the repo. Its tests/ subdirectory contains tests arranged by category (audio/, cpu/, gpu/, threads/, etc.).
The runner script test.py at the repo root contains two lists: tests_good (known-passing regression tests) and tests_next (tests that don't yet pass — move things here → tests_good by fixing PPSSPP).
Tests are compiled as PRX binaries (a variation on ELF) which PPSSPP can load. They also have an .expected file alongside them, containing the reference output from running on a real PSP via PSPLink. The goal is for PPSSPPHeadless's output to match.
Prerequisites
- Initialize the submodule (if not done):
git submodule update --init - The PRX binaries and
.expectedfiles are already committed inside the submodule — no need to compile tests yourself just to run them. - To build PPSSPPHeadless itself, see the next section.
Building PPSSPPHeadless
Windows (Visual Studio)
Open Windows/PPSSPP.sln in Visual Studio and build the PPSSPPHeadless project, or use MSBuild directly:
MSBuild.exe -noAutoResponse Windows/PPSSPP.sln -target:PPSSPPHeadless -property:Configuration=Debug -property:Platform=x64 -maxCpuCountThe built executable will be at Windows/x64/Debug/PPSSPPHeadless.exe.
CMake-based builds do exist but are not the recommended way on Windows — stick with the VS solution.
Linux / macOS
Use ./b.sh --debug, then find the executable under build/ (e.g. build/Debug/PPSSPPHeadless).
Running tests
Using test.py (recommended)
Make sure Python is available. On Windows the Microsoft Store alias may interfere; use py or the full path to python.exe.
- Run all good tests:
py test.py -g - Run all broken/next tests:
py test.py -b - Run all tests (good + next):
py test.py - Run a specific test (or space-separated list):
py test.py -g cpu/cpu_alu/cpu_alu - Run a group of tests by prefix match (add
-m):py test.py -g -m audio/atrac
Direct headless invocation
Windows/x64/Debug/PPSSPPHeadless.exe --root pspautotests/tests/../ --compare --timeout-wall=5 --graphics=software pspautotests/tests/audio/atrac/addstreamdata.prxInstead of a single PRX, you can pass a directory (e.g. pspautotests/tests/threads/mbx/...) to run all tests under it, recursively.
Key flags:
--root— points to the directory abovetests/so the headless can find the expected directory layout.--compare— enables output comparison against.expectedfiles.--timeout-wall=N— real seconds per test before killing it (default 5).--timeout-emulated=Nis the
same idea in emulated time, and both can be set at once.--timeoutis the old name for--timeout-wall.--graphics=software— uses software GPU backend (required for headless; no real GPU available).
What you'll see
Passing test:
pspautotests/tests/audio/atrac/addstreamdata.prx:
audio/atrac/addstreamdata - passed!
1 tests passed, 0 tests failed, 0 tests missing.Failing test:
pspautotests/tests/threads/mbx/refer/refer.prx:
O hi 0 prio=00 next=OTHER hi 1 prio=00 next=ITSELF ...
E hi 1 prio=00 next=ITSELF ...
+ ...
0 tests passed, 1 tests failed, 0 tests missing.
Failed tests:
threads/mbx/refer/referLines prefixed with O are what PPSSPP produced (the Output), E is the corresponding line from
the .expected file recorded on a real PSP, and + means a match.
The diff is line-by-line, so an O line followed by an E line at the same conceptual position
means PPSSPP produced different output at that spot. A + line means both outputs agreed on that
line.
The `[x]` and `[r]` markers - they're about scheduling
Most tests print their lines through checkpoint() in pspautotests/common/common.c, which tags
every line with [x] or [r]:
O [x] While open: OK (allocated 12)
E [x] While open: OK (allocated 0)The tag is not decoration. Each checkpoint() call starts a helper thread that does nothing but
set a flag, then terminates and restarts it for the next one. If that thread got a chance to run
before the next checkpoint, the line is tagged [r] - a reschedule happened while the code
under test ran. If it never got scheduled, the line is tagged [x]. (With CHECKPOINT_ENABLE_TIME
the tag becomes [x/1234], adding the microseconds since the previous checkpoint.)
So the marker records whether the syscall between the two checkpoints yielded to another
thread, which is a big part of what these tests are checking.
That means a diff where only the marker differs, like
O [x] GetCharInfo on open: 00000000
E [r] GetCharInfo on open: 00000000is not a value bug at all - the return value matched. It says our implementation of that call
doesn't reschedule where the real one does (or the other way round). Fixing it means changing
whether the HLE function yields - hleReSchedule, hleDelayResult and friends - not what it
returns. Don't go hunting for a wrong value; there isn't one.
Workflow for fixing a test
- Pick a test from
tests_nextintest.py. - Run it with headless to confirm failure and see what differs (
OvsElines). - Read the test source (
.c/.cpp) and the.expectedfile to understand the API being tested.
The.expectedfile was recorded from a real PSP — it's the ground truth. The test source
reveals what syscalls are made and in what order. Sometimes the test deliberately corrupts
state to probe kernel error handling. - Form a hypothesis: look for a systematic pattern in the diffs (wrong order, wrong error
code, missing output). Cross-reference with multiple expected files that exercise the same
API — they may reveal the PSP's real behavior from different angles. - Make changes to PPSSPP's HLE or other core code. Do not make super-targeted changes just
to fix the test — instead, fix the underlying issue in a way that would also make sense
on real PSP hardware. - Rebuild PPSSPPHeadless and re-run the test.
- Run the full
tests_goodsuite (py test.py -g) to check for regressions. A correct fix
should not break any previously passing tests. - Rinse and repeat until it passes, then move it from
tests_nexttotests_goodintest.py.
Tips from experience
- The diff output compares the full text output line-by-line. To see PPSSPP's raw output
without the diff overlay, omit--compare:There's another trick too, --print-equal-lines, which prints matching lines with a '=' prefix, so you can see the full output with context.textWindows/x64/Debug/PPSSPPHeadless.exe --root pspautotests/tests/../ --timeout-wall=5 --graphics=software path/to/test.prx - Tests can show contradictory expected outputs at first glance. For example, the mbx/send
test expected file shows FIFO message order (normal sends), while the mbx/refer test shows
LIFO (after corruption) — becausesceKernelReferMbxStatuson a real PSP updatesfirstMessageduring traversal, changing the apparent head. Understanding the expected
file's behavior often requires reading multiple related tests together. - When searching for the underlying issue, trace through the HLE implementation with the
test's syscall sequence. Check whether the kernel writes into PSP-visible memory — if so,
test code can corrupt those values, and the PSP kernel may have specific handling for that. - Pointer addresses differ between PSP and PPSSPP. A test that prints raw kernel pointers
(heap addresses, TLS block addresses, etc.) will always have mismatched expected output
because PSPLink shifts memory layout. Fix by changing the test to print offsets from a
base address instead of absolute addresses. After changing the test, run it on hardware
to generate a new.expected. - The diff notation:
Oline = present in PPSSPP's output but not in expected (PPSSPP-only).Eline = present in expected but not in PPSSPP output (expected-only).+line = context line (shown around diffs for context).=line (with--print-equal-lines) = matching line.[r]/[x]prefix = rescheduling occurred / did not occur since last line.
- When a function is a stub (
UNIMPLin log), the expected output is a good specification
for what to implement. Look at multiple test cases in the expected file to understand the
full range of valid and invalid inputs, return codes, and side effects. - Time-dependent tests (RTC, timezone conversions) depend on the host machine's clock
and timezone. The PSP'ssceRtcParseDateTimewas a stub — the expected file showed
exactly which RFC 3339 / RFC 2822 formats are accepted and which are rejected (return -1).
Troubleshooting
- "Python was not found" on Windows — the Microsoft Store alias is interfering. Use
py(the Python launcher) or the full path, e.g."C:/Users/.../AppData/Local/Programs/Python/Python314/python.exe" test.py -g. Or disable the alias in Settings > Apps > Advanced app settings > App execution aliases. - No PRX files found — run
git submodule update --initfrom the repo root to fetch thepspautotestssubmodule. - PPSSPPHeadless exits immediately / "CPU not started" — the test PRX may be missing or the
--rootpath is wrong. Ensure--rootpoints to the parent oftests/. - MSBuild error MSB1008 — the
MSBuild.rspresponse file may be interfering. Always pass-noAutoResponseon Windows builds.
Reverse Engineering (`docs/reverse-engineering.md`)
Reverse-engineering a PSP firmware module
PPSSPPHeadless --re-module loads a single PRX on its own - no game, no boot - and writes a report
about it: the module header, its exports and imports with NIDs resolved to names, one annotated
disassembly file per function, and the call graph.
This is a developer tool for understanding the PSP, not something a user ever runs.
Running it
./build/PPSSPPHeadless --memstick ~/.config/ppsspp \
--re-module flash0:/kd/libmp3.prx \
--re-out /tmp/reTo copy a file out of a disc image as it is (a movie, a data archive), use --dump-file disc0:/PSP_GAME/USRDIR/X.PMF --dump-file-out OUT game.iso, which also lists a directory. Use it, or--re-module for the game's own binaries, rather than writing an ISO or CSO reader.
--re-module takes either a host path or a PSP-style flash0:/kd/foo.prx, which is resolved
against the configured NAND directory - so --memstick (or --nand) has to point at a dump.
PPSSPP can produce one itself from a firmware updater with --unpack-updater; see
PsarFileFormat.md.
| Option | |
|---|---|
--re-module PATH |
The module to load. Host path, or flash0:/kd/foo.prx. |
--re-out DIR |
Where the report goes. Created if missing; defaults to re-out. |
--re-func NAME |
Only disassemble this one function, by name or 0x08801234. |
--re-syms FILE |
A .ppsym file of names to apply first, so the disassembly reads properly. |
What comes out
<module>.index.md- header, segments, export table (library, NID, address, name), import table,
and every function with its size, caller/callee counts and whether it writesv0.<module>/<addr>_<name>.asm- one file per function.<module>.xref.json- the call graph, for asking "who calls this" without grepping.
Two things in the disassembly are worth knowing about:
lui/addiu pairs are folded and reported as the address they form, with a symbol name where
one is known (; = 08004a10 <sampleRateTable>). Every global and constant table is reached through
such a pair, so this is usually how you find the data a function works on.
Each function gets a register evidence block rather than a guessed signature. It reports, fora0-a3, whether each was read before being written, written before being read, never touched, or
- the interesting case - never read but still live across a call:
; a0 READ before written -> used as a parameter here
; a1 never read, but live across a call -> FORWARDED from our callerThat last one matters because MIPS code routinely takes an argument it never touches and leaves it
in place for a callee to pick up. A tool that inferred "this function takes one argument" from the
reads alone would be wrong, and so would you. Treat FORWARDED as evidence that the real arity is
larger than what is read here, and settle it by looking at what the callees do with the register.
Naming things
The loader's scan names every function it finds z_un_<address>, which makes for unreadable
disassembly. The tool names what it can automatically - exported functions get their name from the
NID via PPSSPP's own HLE tables, and every import stub is named after the function it resolves to -
but the rest is up to you.
Accumulated names go in a .ppsym file, the same module-relative format the emulator saves fromhle.module.saveSymbols and the ImDebugger, and are applied with --re-syms. Names compound: once
a function is named, every call site that reaches it reads as that name, so later functions get
progressively cheaper to work out.
Those files are keyed by module name and crc32 (PSP/SYSTEM/SYMBOLS/<name>_<crc>.ppsym), so a set
of names only ever attaches to the exact build of the module it was written against. The report's
header line prints the crc to match.
Notes and caveats
- Modules load at a fixed base (
0x08000000for kernel modules), and the index prints a+offset
column, so addresses can be compared against another tool's view of the same module. - Some modules -
sysmem.prxandloadcore.prxamong them - havemodinfopointers that are file
offsets rather than addresses, so the loader's own function scan finds nothing in them. The tool
falls back to scanning the module's text range directly. Those modules are hand-written assembly
without standard prologues, so expect few, large "functions". - Nothing is executed. The tool brings up the memory map, timing, the HLE tables and the kernel
allocators, then runs the real module loader and stops.
SceAudio (`docs/sceAudio.md`)
sceAudio: how the PSP's audio output actually behaves
This describes the behavior Core/HLE/sceAudio.cpp and Core/HLE/__sceAudio.cpp model, and
where that behavior came from. Confirmed on a real PSP by pspautotests/tests/audio/blocking.
The headline, because it is the thing most likely to be assumed wrong: the blocking output
calls are not a queue that callers line up behind. Each channel holds one buffer, the
Output2/SRC channel holds two, and a caller who finds no room is toldSCE_ERROR_AUDIO_CHANNEL_BUSY and expected to go away and come back.
Two different pieces of hardware
sceAudio presents nine channels, but they are not the same thing underneath, which is why the
emulator keeps them in two different structures rather than one array of nine: AudioChannel
for the eight the mixer walks, and a single AudioSRCChannel for the ninth.
Channels 0-7 go through a software mixer running as a kernel thread. Every time the audio
DMA finishes a block, that thread wakes, takes up to 64 samples from each channel that has a
buffer, sums them into a 32-bit accumulator, clamps, and writes 64 stereo frames into the other
half of a double buffer.
Channel 8 - sceAudioOutput2*, sceAudioSRC* and sceVaudio* are all the same channel -
never touches the mixer. It builds DMA descriptors that point straight at the game's buffer and
lets the codec resample it. sceAudioOutput2Reserve(n) is literallysceAudioSRCChReserve(n, 44100, 2), and the other Output2 entry points are one-instruction tail
calls to the SRC ones.
They also have separate DMA channels, so one starting does not disturb the other.
The driver plays out of the game's memory
Nothing is copied when a buffer is handed over. sceAudioOutputBlocking stores the pointer, and
the mixer walks it forward 64 samples at a time until it is spent, then clears it and signals.sceAudioGetChannelRestLen counts down in steps of 64 for exactly that reason.
The emulator used to copy the whole buffer into a ring of samples at enqueue time, with the
volume already applied. That made a game rewriting a buffer it had already handed over
invisible, and it made the queue depth an emulator choice rather than a hardware fact.
One buffer per channel
The driver's single enqueue point, which all four sceAudioOutput* functions reach, is:
if (sampleCount == 0) return SCE_ERROR_AUDIO_CHANNEL_NOT_INIT;
if (sampleAddress != 0) return SCE_ERROR_AUDIO_CHANNEL_BUSY;
remaining = sampleCount;
sampleAddress = ptr; // may be 0
return sampleCount;sceAudioOutput and sceAudioOutputPanned return that result as it stands, so a second
non-blocking output is refused until the first buffer has finished.
The blocking pair adds a wait, but only for the first thread to ask:
r = enqueue(...);
if (r != BUSY) return r;
if (channel->waiting) return BUSY; // somebody else is already parked here
channel->waiting = 1;
r = wait for this channel's bit in the driver's event flag;
if (r < 0) return r; // waiting is left set! (not emulated)
retry the enqueue;
channel->waiting = 0;The error path is a real firmware bug. The event flag wait fails at once with interrupts or
dispatch disabled (800201a7) or inside an interrupt (80020064), and the driver returns that
without clearing waiting. Nothing ever clears it after that, so the channel stays busy for
good, and sceAudioChRelease refuses to release it.
The emulator returns the same errors but doesn't leave the flag set. Whether the channel was
busy at that moment is a question of timing, and ours is close but not exact: emulating the bug
means a small scheduling difference could drop a channel for the rest of the game where hardware
wouldn't. No game can depend on losing a channel, so the risk only runs one way.tests/intr/waits leaves the case out for this reason.
The SRC channel's wait fails the same way, and since the flag is never looked at, even a
completion that was already there isn't taken: the buffer stays armed and the error comes back.
That one is emulated, since nothing is lost - the buffer plays out as usual.
Only one thread can be parked on a channel. That single flag is why a game that runs a movie
thread and a sound-effect thread over one output gets sensible behavior on hardware and did not
in the emulator: the loser is told the channel is busy and skips its turn, where an emulator that
blocked it instead made the two threads alternate, halving the movie's audio rate. That is
https://github.com/hrydgard/ppsspp/issues/12888, and it is whattests/audio/blocking/contend pins down.
The same flag locks out sceAudioChRelease, sceAudioSetChannelDataLen andsceAudioChangeChannelConfig, which all return SCE_ERROR_AUDIO_CHANNEL_BUSY while a thread is
parked. sceAudioChangeChannelVolume does not check anything.
A null pointer is accepted and sets remaining without setting sampleAddress, so nothing
plays. That is the documented way to wait for a channel to drain, and it is the one case where
the two rest-length calls disagree - see below.
Two buffers on the SRC channel
Channel 8 has two DMA descriptors, so two buffers can be in flight. The third caller is refused
outright and does not even get the chance to wait:
if (!reserved) return SCE_ERROR_AUDIO_CHANNEL_NOT_RESERVED;
if (both descriptors armed) return SCE_ERROR_AUDIO_CHANNEL_BUSY;
arm one;
wait for one descriptor to retire;
return sampleCount;Starting the DMA sets the same event flag bit that a retiring descriptor does, which is why the
first output after an idle stretch returns without waiting while every one after it waits exactly
one buffer. The emulator models that unclaimed completion with a flag on the channel.
The two rest-length calls are not the same function twice
sceAudioGetChannelRestLen(ch) = remaining + (waiting ? sampleCount : 0)
sceAudioGetChannelRestLength(ch) = (sampleAddress ? remaining : 0) + (waiting ? sampleCount : 0)
sceAudioOutput2GetRestSample() = (armed descriptor count) * current sampleCountBoth channel versions count a parked thread's buffer as well as the one playing. They only
differ after an output with a null pointer, which leaves remaining set with no buffer, and the
hardware duly reports 0x400 from one and 0 from the other.
sceAudioOutput2GetRestSample reports in units of the current length, so aftersceAudioOutput2ChangeLength(64) a 4096-sample buffer armed earlier reads back as 0x40.
Where the emulator has to fake something
The mixer thread outranks whoever called, so on hardware the first 64 samples of a buffer are
consumed before the output call has returned - a channel reserved for exactly 64 samples is free
again immediately. The emulator's mixer is a timer event instead, so it re-phases that event to
the moment the mixer's DMA starts and runs one block right then. The SRC channel gets the phase
reset but no early read, since its DMA feeds the codec directly. Without this the answer tosceAudioGetChannelRestLen right after an output would depend on where the timer happened to
be, and would differ from run to run.
That only happens when the DMA is actually idle. After a block that had samples in it, the DMA
is still playing that block out, so a buffer handed over within it is not read early: it waits
for the next block like any other. (Also from tests/intr/waits, where a second 64-sample buffer
is still in the channel when the next call looks.)
Two things are still approximate, both below one mix block:
- A descriptor retires when the DMA transfer finishes, which is slightly ahead of the audio
being heard, so hardware frees an Output2 buffer about 100us earlier than the emulator does. - The emulator only retires SRC buffers on block boundaries, so a buffer whose length is not a
multiple of the block can be up to 1.5ms late.audio/output2/frequencyandaudio/output2/restare intests_nextfor this reason.
Argument checking
Recorded here because several of these were guesses before, and because the order of the
checks is observable.
| function | checks, in order |
|---|---|
sceAudioChReserve |
the free-channel search runs 7 down to 0 and needs the channel both released and finished playing; bad channel 80260003; already reserved 80260003; count not a positive multiple of 64 up to 0xFFC0 80260006; format not 0 or 0x10 80260007 |
sceAudioChRelease |
bad channel 80260003; not reserved 80260008; a thread parked 80260002. Clears only the reservation - a buffer already playing plays out. |
sceAudioSetChannelDataLen |
bad channel 80260003; bad length 80260006, before the reservation is looked at; parked thread 80260002; not reserved 80260001 |
sceAudioChangeChannelConfig |
bad channel 80260003; parked thread or buffer in flight 80260002; not reserved 80260008; bad format 80260007 |
sceAudioChangeChannelVolume |
either side above 0xFFFF 8026000b; bad channel 80260003; no reservation check; a negative volume leaves that side alone |
sceAudioOutput, sceAudioOutputPanned |
each volume compared signed, so negatives pass and mean "leave unchanged" |
sceAudioOutputPannedBlocking |
the two volumes are ORed before comparing, so a negative one fails with 8026000b |
sceAudioOutput2ChangeLength |
length outside 17..4111 80260006, before the reservation; not reserved 80260008 |
sceAudioSRCChReserve |
channels not 2 or 4 80000104; channels 4 80000003; count outside 17..4111 80000104; bad frequency 8026000a; already reserved 80268002 |
sceAudioSRCChRelease |
not reserved 80260008; a descriptor still armed 80268002 |
Accepted SRC frequencies are 8000, 11025, 12000, 16000, 22050, 24000, 32000 and 48000, plus
whatever the output is currently running at - which is how 44100 and 0 get through.
sceAudioOneshotOutput
The odd one out: it plays a single buffer on a channel it never reserves. The channel stays
free as far as everything else is concerned, so sceAudioChRelease on it answers 80260008,
and it returns to the pool by itself once the buffer runs out. It returns the channel number,
takes any positive sample count - alignment and the 0xFFC0 ceiling do not apply - and unlike
the other outputs a negative volume is an error rather than "leave it alone". There is no busy
check either: a second one-shot while the first is playing just replaces it.
No game is known to call it. It is implemented because the behavior turned out to be simple
once traced, not because anything needed it.
sceVaudio is a third shape
sceVaudio reserves the same channel as Output2 and SRC, but its own release is not the same
call. It hands the channel a null pointer first - the drain idiom above - and only then does the
ordinary SRC release. So it blocks for one buffer and returns 0 where the other two would refuse
with 80268002, and whatever was playing is played out rather than cut off. It also ignores its
own reservation flag and releases whatever holds the SRC channel, which is whytests/audio/sceaudio/reserve gets away with what it calls the "wrong release".
sceVaudioChReserve marks vaudio reserved before delegating and does not undo that when the
delegate fails, so a caller that got 80268002 because Output2 held the channel is told80000021 next time round until a release clears it.
What a call costs
Worth knowing because a game whose losing thread retries in a loop feels it directly.tests/audio/blocking/overhead measures it in buckets:
| call | cost |
|---|---|
| Output2/SRC output, both descriptors already armed | 30-100us |
| Output2/SRC output, any other outcome, including an unreserved channel | over 100us |
| mixer output that is refused, blocking or not | under 10us |
| mixer output that starts the DMA | over 100us |
Every SRC output ends up querying the codec, which is the slow part; only the refused case gets
out before it. Mixer channels are the other way round - cheap unless the call is the one that
brings the DMA up.
Savestates
AudioChannel is at section version 4 and the SRC channel has a section of its own. Anything
older stored a ring of already-mixed samples, which cannot be turned back into a buffer pointer
and a position, so loading one drops the pending audio and wakes any parked threads. That costs
a fraction of a second of silence on load and nothing else. Older states also carry the SRC
channel as a ninth entry in the channel array, which is read and discarded.
SceGe (`docs/sceGe.md`)
sceGe: how the PSP's display list queue behaves
This describes the behavior GPU/GPUCommon.cpp (the queue) and Core/HLE/sceGe.cpp (the
interrupt side) model. All of it is what a game can observe, and all of it is pinned down on a
real PSP by the tests in pspautotests/tests/gpu/ge (queue, queue2, break, breakwait,callbackstate, intrsuspend, enqueueparam) and pspautotests/tests/gpu/signals.
The headline, because everything else follows from it: the GE stops at every SIGNAL and every
FINISH, and it takes the interrupt to get it going again. After a signal it carries on with the
same list. After a FINISH the game's finish callback runs first, and only then does the list
leave the queue and the next one start. Nothing the game calls in between makes the GE run.
Lists and their states
Up to 64 lists can exist at once; with all of them in use, enqueueing fails with 0x80000022.
Ids are opaque, and an id is reused once its list is gone.
| State | Meaning |
|---|---|
| NONE | Not a list. sceGeListSync(id, 1) gives 0x80000100. |
| QUEUED | On the queue, not executing. |
| RUNNING | The GE is executing it, or is stalled on it. |
| COMPLETED | Hit its FINISH and is off the queue. The id can still be asked about, until sceGeDrawSync(0) turns every COMPLETED list into NONE. |
| PAUSED | Stopped by sceGeBreak(0), by a PAUSE signal, or put at the head with sceGeListEnQueueHead. |
Besides its state, a list remembers whether it has started executing at some point
(DisplayList::started), which several checks below depend on, and whether something is pending
on it: a pause that was requested but hasn't been delivered, a pause that has been, or a SYNC.DisplayList::signal holds that, in the same enum as the behaviors of the SIGNAL command, withPSP_GE_SIGNAL_HANDLER_SUSPEND doubling as "paused, and the game has been told".
Enqueueing
sceGeListEnQueue and sceGeListEnQueueHead differ only in where the list goes. In order:
- The list, stall, context and stack addresses all have to be 4-byte aligned (
0x80000103), and
a stack depth of 256 or more is0x80000104. - Games built with SDK version
0x01FFFFFFor below get a 32-entry stack they didn't ask for,
and the next step can't fail for them. Otherwise the stack is whateverPspGeListArgssays,
which may well be none. - The new list is compared against every list on the queue:
- Same list address:
0x80000021. What's compared is the address the other list was
enqueued with, not where it has got to since, and the uncached mirror of an address
counts as the same. AftersceGeBreak(0)stops a list, it's the address it stopped at that
collides instead, and the address it was enqueued with is free again. - Same stack, and the other list has started:
0x80000021. Two lists that are both still
waiting their turn can share a stack without complaint.
- Same list address:
- Then, depending:
- Tail, queue empty: RUNNING, and the GE starts on it right away. If it has a context, the
GE state is saved into it first. - Tail, queue not empty: QUEUED at the back.
- Head, queue not empty: the current head has to be PAUSED, or it's
0x800001FE. The new list
becomes PAUSED at the head, and the old head QUEUED behind it. - Head, queue empty: PAUSED, and not started. It takes a
sceGeContinueto run it.
- Tail, queue empty: RUNNING, and the GE starts on it right away. If it has a context, the
"Every list on the queue" includes a list whose finish callback is running right now. So a
finish callback that enqueues its own list again gets 0x80000021 (on a newer SDK), and anything
else it enqueues is QUEUED behind it rather than started.
SIGNAL
A SIGNAL followed by END stops the GE and raises an interrupt. It always applies to the list at
the head of the queue.
| Behavior | What happens |
|---|---|
HANDLER_SUSPEND (1) |
The signal callback runs with the GE stopped, then the GE carries on. For SDK <= 0x02000010, the list reads as PAUSED for the duration. |
HANDLER_CONTINUE (2) |
The GE carries on, then the signal callback runs. |
HANDLER_PAUSE (3) |
The list becomes PAUSED immediately, the argument is remembered, and the GE carries on. No callback yet. See below. |
SYNC (8) |
The GE carries on, and the list's next FINISH is swallowed: no callback, no completion, it just keeps going. |
JUMP, CALL, RET, and their relative and origin variants (0x10 - 0x16) |
Done in software by the interrupt. CALL uses the list's stack; overflowing or underflowing it leaves the GE stopped. |
| 0x20 - 0x2F, 0x30, 0x38 | Set a texture or CLUT address relative to the list or to the offset address. PPSSPP doesn't implement these. |
PPSSPP treats HANDLER_CONTINUE like HANDLER_SUSPEND: the GE waits for the callback. On
hardware the two run side by side, so a callback that looks at the GE finds it further along,
which is all gpu/signals/continue fails on. Waiting is the safe side to err on - a callback
that prepares something the list is about to use still gets there first.
Callbacks get the low 16 bits of the SIGNAL (or FINISH) command, the argument they were
registered with, and - for SDK > 0x02000010 only, otherwise 0 - the address after the END.
The pause window
Between a PAUSE signal and the FINISH that delivers it, a list is in an odd place. The GE is
still executing it, but:
sceGeListSync(id, 1)already says PAUSED.sceGeContinuereturns0x80000021. So doessceGeBreak(0).sceGeListUpdateStallAddrreturns 0 but has no effect on the GE, because only a RUNNING
list's stall address does. So a list that stalls inside the window is stuck for good, andsceGeBreak(1)is the only way out. Games therefore never put a stall there, and neither
should a test that wants to finish.
When the FINISH arrives, the signal callback (not the finish one) is called, with the
argument from the PAUSE signal. The list stays PAUSED at the head of the queue, and nothing
behind it runs until the game calls sceGeContinue, which now works.
FINISH
A FINISH followed by END stops the GE and raises an interrupt. If a SYNC or a pause is pending on
the list, see above. Otherwise, in this order:
- The list becomes COMPLETED. It is still at the head of the queue.
- The finish callback runs. The GE is idle, and its state is still what the list left it as:
a context isn't restored until the next step. From in here,sceGeListSyncon the next list
says QUEUED,sceGeDrawSync(1)says DRAWING if there is a next list and COMPLETED if not,sceGeListDeQueueon the finished list says0x80000021, andsceGeListUpdateStallAddron
it says0x80000020. - If the list had a context, the GE state is restored from it.
- The list leaves the queue.
- The new head, if there is one and it isn't PAUSED, is started. If it has a context and hasn't
started before, the GE state is saved into it. - If the queue is empty instead, threads waiting in
sceGeDrawSync(0)are woken. - Finally the threads waiting in
sceGeListSync(id, 0)for the finished list are woken - after
the ones from step 6, which shows when they have the same priority.
If a SIGNAL and a FINISH are both pending by the time the interrupt runs, only the FINISH counts.
Is the GE busy?
sceGeSaveContext and sceGeRestoreContext fail with -1 while the GE is executing, and that's
all they go by - not whether lists are queued. A GE waiting at a stall address counts as
executing. One that has stopped at a SIGNAL or FINISH doesn't, so both work from a finish
callback and from a HANDLER_SUSPEND callback, however much is queued behind. From aHANDLER_CONTINUE callback they fail, as the GE is off again by then. GPUCommon::BusyDrawing().
While the interrupt can't be taken
All of the above happens in the interrupt, so with interrupts off (sceKernelCpuSuspendIntr)
the queue simply stands still at the first FINISH: that list keeps reading as DRAWING, the next
one as QUEUED, and no callback runs. It all catches up, in order, when interrupts come back.
Suspending thread dispatch (sceKernelSuspendDispatchThread) doesn't hold any of it up.
How PPSSPP does this
PPSSPP doesn't run the GE in parallel with the CPU. ProcessDLQueue() executes a list all the way
to whatever stops it - a stall, a SIGNAL, a FINISH - adding up how long that would have taken, and
schedules the interrupt for that point in the future. That's the accepted inaccuracy, and it means
there's a stretch where a list has reached its FINISH but the game hasn't been told, which doesn't
exist on hardware. DisplayList::pendingInterrupt marks it.
What keeps the sequencing right in spite of that is one rule in ProcessDLQueue(): while the
head of the queue has an interrupt pending, nothing runs, whoever asks - an enqueue, a stall
update or a continue. The finished list stays on the queue until InterruptEnd(), which takes it
off and lets the next one go, as in steps 4 to 7 above. Before that rule, the next list ran right
away and the finished one left the queue at once, so a finish callback saw an empty queue: a list
it enqueued was started instead of queued, and then couldn't be dequeued.
Three places deliberately treat a list with its FINISH pending as already gone, since on hardware it
would be: the duplicate check when enqueueing (Exit enqueues the same list again right after it
ends), drawCompleteTicks, which is set when the last list reaches its FINISH rather than
when the interrupt is delivered, so that a sceGeDrawSync(0) in between doesn't wait for nothing,
and sceGeBreak(1). That one throws away interrupts that have been raised and couldn't be taken,
as hardware does, but not the ones we simply haven't raised yet: a game that breaks right after
its last list and then waits for what the finish callback signals had that callback long ago.
Waiting
The two waiting calls, sceGeListSync(id, 0) and sceGeDrawSync(0), refuse outright where a
thread can't wait, even if there is nothing to wait for: 0x80020064 from a GE callback or
any other interrupt, 0x800201A7 with dispatch or interrupts suspended.
sceGeListSync(id, 0) doesn't care what state the list is in. It waits for that id to complete or
be dequeued, and for an id that isn't pending - never used, or done - it returns 0 at once.
sceGeListSync(id, 1):
| List state | Result |
|---|---|
| RUNNING | STALLING (3) if the GE is at the stall address, else DRAWING (2) |
| QUEUED | PAUSED (4) if it has started before (an EnQueueHead bumped it), else QUEUED (1) |
| COMPLETED | 0 |
| PAUSED | 4 |
| NONE | 0x80000100 |
sceGeDrawSync(0) waits for the queue to drain, and then turns every COMPLETED list into
NONE. Nothing else does, short of sceGeBreak(1). sceGeDrawSync(1) looks at the head of the
queue, or the one after if the head is COMPLETED, and answers STALLING or DRAWING depending on
whether the GE is at that list's stall address - which it can't be if it isn't on that list.
Break, continue, dequeue, stall
sceGeBreak(0), by the state of the head of the queue:
| State | Result |
|---|---|
| RUNNING | The GE is stopped, and the list becomes PAUSED, remembering where it got to. If that was in the middle of a SIGNAL/END or FINISH/END pair, it resumes from the start of the pair. Returns the id. |
| QUEUED | Becomes PAUSED, id returned. |
| PAUSED | 0x80000021 for SDK <= 0x02000010 or inside the pause window, otherwise 0x80000020. |
| no list | 0x80000020 |
sceGeBreak(1) throws the whole queue away and resets the GE: every list becomes NONE. An
interrupt that was raised but not taken yet goes too, so a list that reached its FINISH just
before never gets its finish callback. It doesn't wake anybody. A thread in sceGeListSync(id, 0) or sceGeDrawSync(0) stays there - until
a new list happens to get the same id and completes, or the queue next drains, at which point
they return 0 as if nothing had happened.
sceGeContinue: with no list, 0. A PAUSED head resumes where it left off (0x80000021 inside the
pause window). On a RUNNING head, 0x80000020; anything else, 0x80000004. Both of those are -1
for SDK <= 0x01FFFFFF.
sceGeListDeQueue: 0x80000100 for NONE, 0x80000021 for a list that has started - which
includes COMPLETED ones, until sceGeDrawSync(0) - and otherwise the list leaves the queue,
becomes NONE, and threads waiting for it are woken. Other lists are left alone: if that was the
last one queued, COMPLETED lists stay COMPLETED, and nobody in sceGeDrawSync(0) is woken.
sceGeListUpdateStallAddr: takes effect only if the list is RUNNING, and a GE stalled on that
list resumes by itself. For QUEUED and PAUSED lists it's just remembered for when they run.
COMPLETED is 0x80000020.
One thing not to do, in a test or anywhere: sceGeBreak(0) from inside a HANDLER_SUSPEND
callback with an SDK version above 0x02000010. With an older one the list is PAUSED for the
duration and the break is refused (0x80000021). With a newer one it goes through, the GE is
restarted anyway when the callback returns, and the PSP hangs.
Starting another executable
sceKernelLoadExec restarts the kernel, and the GE driver with it: the new executable finds an
empty queue, whatever the old one left on it, and a GE with every register and matrix set to
zero, which is how the driver starts up - the same as a program that was booted directly.GPUCommon::Reinitialize() is that, and has to leave nothing behind - not the lists, not the
queue of their ids, and not the old program's GE state. (This part isn't confirmed by a test:
whatever runs after the restart can't report back through PSPLink.) It used to keep the
queue, so the old executable's ids came back as lists with no state and a pc of 0, waiting their
turn behind the new executable's first list, where they'd block everything. Crazy Taxi: Fare
Wars, which is a launcher for its two games, stopped at a black screen that way (#19894).
Known leftovers in PPSSPP
gpu/signals/jumpsandgpu/signals/simpleask for a list's state the momentsceGeListUpdateStallAddrreturns. On hardware the list is long done by then, callbacks and
all. For PPSSPP it has reached its FINISH, but the interrupt is still to come.IgnoreEnqueueincompat.ini(Metal Gear Acid 2, #10906) skips the stack check. It was most
likely hitting the not-started case, which no longer fails. If the game is fine without it,
remove it.The GE has one stall address, which isn't the same thing as the one each list remembers:
sceGeListUpdateStallAddralways updates the list's, but only a RUNNING list's update reaches
the GE. PPSSPP has justDisplayList::stallfor both. The pause window gets away with that,
since a PAUSED list doesn't run. What doesn't is an old-SDK game updating the stall from inside
aHANDLER_SUSPENDcallback, where the list reads as PAUSED: on hardware the GE then stops at
the old stall address until the next update from a thread, while reading as DRAWING rather
than STALLING. PPSSPP lets it run on.gpu/signals/handlercallsshows it, and fails on it.
Fixing it means givingGPUCommona stall address of its own for execution, which touches the
fast paths inGPUCommonHW.cppand both GE debuggers.sceGeBreak(0)doesn't back up over a half-done SIGNAL or FINISH pair. With lists executing in
one go, it's rarely in the middle of one.Signal CALL and RET save and restore only the pc, the offset address and the base address. On
hardware the GE's own return addresses are part of it as well, and its CALL depth, which PPSSPP
gets right by counting plain CALLs only back to the last signal CALL. The GE nests plain CALLs
just two deep and skips a third (gpu/callstack/depth, and Soul Calibur: Broken Destiny, #7395).On hardware, a
sceGeListEnQueueHeadthat fails with0x800001FEstill uses up one of the 64
lists until the nextsceGeBreak(1). Not emulated, and not worth it.
Shader Compilation (`docs/shader-compilation.md`)
Shader compilation stutter
Where the time goes when a game compiles its shaders and pipelines, mainly on the Mac (Vulkan through
MoltenVK), and how to measure it. Measured in October 2026 on an M1 with MoltenVK 1.4.2.
The path a shader takes
- The shader generators (
GPU/Common/VertexShaderGenerator.cpp,FragmentShaderGenerator.cpp)
write GLSL from aVShaderID/FShaderID. - glslang compiles it to SPIR-V (
GLSLtoSPV()inCommon/GPU/Vulkan/VulkanContext.cpp), on a
dedicated thread per shader (CompileShaderModuleAsync()inGPU/Vulkan/ShaderManagerVulkan.cpp). vkCreateGraphicsPipelines()runs on the thread pool, whichVulkanRenderManager::CompileThreadFunc()
feeds, one task per vertex/fragment shader pair. On MoltenVK this is where the rest happens:
SPIRV-Cross turns SPIR-V into MSL, Metal compiles the MSL into anMTLLibrary, and then builds theMTLRenderPipelineState. MoltenVK'svkCreateShaderModule()only copies the SPIR-V.- The render thread waits for the pipeline in
VulkanQueueRunner(BlockUntilReady()) when it
reaches the first draw that uses it. That wait is the stutter.
Where the time goes
Wipeout Pure from a savestate, starting with no PPSSPP cache and an empty Metal cache. MoltenVK's own
counters (see below):
| Stage | Time each | Count |
|---|---|---|
| glslang, GLSL to SPIR-V | ~1 ms | 27 |
| SPIRV-Cross, SPIR-V to MSL | 0.5 ms | 40 |
Metal, MSL to MTLLibrary |
85-110 ms (max ~470) | 43 |
Metal, MTLFunctions to a pipeline state |
5 ms | 63 |
Nearly all the cost is Metal compiling MSL, and it happens about once per distinct shader, not once
per pipeline. Pipelines that differ only in blend, cull, depth or stencil state cost about 5 ms each
once their shaders' libraries exist. On the Mac, then:
- Speeding up the shader generators or glslang gains nothing worth having.
- Cutting the pipeline count (more dynamic state, e.g. cull mode and depth/stencil through
VK_EXT_extended_dynamic_state) helps little. In this run it would have taken 58 pipelines down to
43 that differ in anything Metal bakes in, and the expensive step doesn't repeat for those anyway. - Fewer distinct shaders (moving ID bits into uniforms) is the lever that matters, with the runtime
cost that ubershaders bring, mostly in fragment shaders.
The shared `VkPipelineCache`
We used to create every pipeline with the same VkPipelineCache, and MoltenVK serializes the MSL
compiles of pipelines that share one: its "retrieve shader library from the cache" time, which includes
waiting for another thread's compile, averaged 142 ms. The 3.7 s of compiling blocked the render thread
for 2.9 s in total, so there was almost no parallelism. The cache is only held in memory (we don't save
its data), and on MoltenVK all it saves is the SPIR-V to MSL step, so on Apple platforms we now passVK_NULL_HANDLE (CreateVulkanPipeline() in GPU/Vulkan/PipelineManagerVulkan.cpp).
| Cold Wipeout Pure, two runs each | With the cache | Without |
|---|---|---|
| Render thread blocked on pipelines, total | 2.9 s | 1.2 s |
| Longest single stall | 1.0-1.1 s | 0.3 s |
| Average wait inside MoltenVK's cache lookup | 142 ms | 11 ms |
MVK_CONFIG_SHOULD_MAXIMIZE_CONCURRENT_COMPILATION=1 made no measurable difference.
The caches
- PPSSPP's per-game cache,
PSP/SYSTEM/CACHE/<DISCID>.vkshadercache. It holds the shader IDs and
pipeline keys the game used, plus their SPIR-V, andGPU_Vulkan::LoadCache()recompiles all of it
while the game boots. With it, Wipeout's 62 pipelines took 140 ms in total. - macOS's Metal cache, keyed by the MSL source. With it warm, even a run without PPSSPP's cache
took about 100 ms for all 62 pipelines. So on the Mac, a second boot is cheap with or without our
cache, and a shader one game compiled is cheap in the next game too. Only the first time a shader
is ever seen (and possibly after an OS or app update) is it slow.
How game-specific are the shaders?
Less than expected. Across the 37 .vkshadercache files of one Mac's memory stick (at the currentCACHE_VERSION), for the seven biggest:
| Game | VS | Also in another game | FS | Also in another game |
|---|---|---|---|---|
| UCES00002 (Ridge Racer) | 12 | 92% | 47 | 51% |
| UCES01250 | 13 | 85% | 36 | 67% |
| UCES00001 (Wipeout Pure) | 14 | 93% | 25 | 80% |
| UCES00465 | 12 | 100% | 22 | 86% |
| UCES00842 (God of War: Chains of Olympus) | 11 | 82% | 21 | 76% |
| ULES00502 (GTA: Vice City Stories) | 10 | 90% | 20 | 70% |
| ULES01376 (Tekken 6) | 11 | 73% | 20 | 65% |
All 37 games together use only 48 distinct vertex shader IDs. The IDs depend on the device's GPU
features and the user's settings, and some of those caches come from short runs, so a list shipped with
the app would match less well than one gathered on the device. A per-device list of shaders seen in
several games, precompiled at boot, looks worth trying.
Ideas not done yet
- Don't block the render thread on a pipeline that isn't ready: skip the draw, or keep the previous
pipeline, for the few frames until it is. Visible (objects pop in), so probably an option, but it
would remove the remaining stalls on every backend. - Precompile cross-game shaders at boot, from the list above.
- A native Metal backend could use
MTLBinaryArchive. Not measured. See
metal-backend.md.
Measuring
--shader-cache=false turns off loading and saving PPSSPP's per-game cache for one run (headless or
the app; it isn't saved to the ini). On the Mac that isn't a cold start, since Metal's cache still
hits. Headless is an unbundled binary and shares Metal's cache with other command-line tools, in$(getconf DARWIN_USER_CACHE_DIR)com.apple.metal/. For a real cold start, move the GPU's folders
there aside (on the M1, 16777235_322 and 32024, the two big ones) and put them back afterwards.MTL_SHADER_CACHE_DISABLE and similar environment variables don't turn the cache off.
build-release/PPSSPPHeadless "$HOME/PSP/Wipeout Pure.cso" --memstick=$HOME/.config/ppsspp \
--state=$HOME/.config/ppsspp/PSP/PPSSPP_STATE/UCES00001_1.01_0.ppst \
--graphics=vulkan --timeout-wall=30 --timeout-emulated=20 --log --shader-cache=false- Each pipeline's creation time is logged at INFO level, as
Pipeline (x/N) time on .... - MoltenVK's breakdown: set
MVK_CONFIG_PERFORMANCE_TRACKING=1andMVK_CONFIG_ACTIVITY_PERFORMANCE_LOGGING_STYLE=2. The summary prints when the device is destroyed,
at the end of the run. - The render thread's stalls aren't logged. Time
BlockUntilReady()inVulkanQueueRunner.cpp
temporarily to see them; the sum of pipeline times overstates the stall, since compiles overlap. - Run each configuration at least twice. Single cold runs varied by about 15%.
Translations (`docs/translations.md`)
Translated UI strings (assets/lang)
When implementing new UI, translations come last, in their own commit after everything else is
done. Write the English strings, get the feature built and working, commit that - then stop and ask
the user to check the English wording before translating anything. The English string is what all ~47
languages get derived from, so rewording it afterwards means redoing the whole sweep.
One .ini file per language, keyed by section and key against assets/lang/en_US.ini. Don't hand-edit
the ~47 files, and don't run the AI commands in Tools/langtool either - you can read the call site,
which its fixed prompt can't. Do the translating yourself and let the tool do the file surgery. Run it
from Tools/langtool:
- Work out what the string actually means before translating it: find where it's used in the C++,
what any%1/%dplaceholders get substituted with, how long it can be without breaking the
layout, and how neighboring keys are already phrased in each language (that's your style guide). - Write a scratch file, one line per language, named after the ini file minus the extension, with
the English string underen_US:For a key that already exists, theini[Single] en_US = Test string sv_SE = Teststräng lt-LT = Testeilutėen_USline has to be the current English text character for
character - it overwritesen_US.inilike any other language, so a stray reword there quietly
changes the string all the others were just translated from. Diffen_US.iniafterwards to check
it didn't move.
No trailing# commentson those lines, they'd end up inside the translation. Placeholders have to
survive verbatim. If you don't know a language well enough, leave it out - step 4 gives it the
English string as a placeholder, which is much better than a confident guess. If a language
deliberately keeps the English string (a term like "Vsync" that isn't
translated), do include it with the English text - it gets written with a# same as English
comment, which stops langtool from trying to translate it again on every later run. cargo run -- import-single <scratch-file> <Section> "<Key>"writes them all in, including a new
key in en_US.ini, tagged# AI translatedexcept for the en_US line, which is the string the rest
were translated from. Note it overwrites existing values for that key, so take care with keys that
already have human translations, and that the section has to exist already - langtool won't create
one.cargo run -- add-new-key <Section> "<Key>"gives every language you skipped the English string
as a placeholder -Key = Key, with no# same as Englishmarker, so it still reads as
outstanding work - and leaves the translated ones untouched. Always do this: every language file
should end up with the key. A missing key falls back to English at runtime anyway, but in the
files it's indistinguishable from a translated one, so translators can't see what's left.copy-missing-linesdoes the same job across every key in the repo at once, which is a different
job - it'll bury a two-string change under several hundred lines of unrelated housekeeping.cargo run -- validateat the end, always. It checks that placeholders survived and exits
non-zero if anything is off.
The other mechanical jobs (renaming and moving keys, sorting sections) are langtool commands too -
prefer them over editing the ini files by hand.
Emu Api Protocol (`docs/emu-api/protocol.md`)
Emulator API - raw protocol
This documents the raw sceIoDevctl-based protocol behind PPSSPP's emulator API. Most homebrew
should just use the C wrapper in ppsspp_emu_api.h instead (see the
README and the website docs
for that) - read this page if you're extending the wrapper, writing a binding for another language,
or just curious how it works under the hood.
Important: None of this will work on the real PSP! Always check IS_EMULATOR (see below) before
relying on any of it, and keep a working fallback path for real hardware.
How it works
The whole API is exposed through a single, existing PSP syscall: sceIoDevctl. PPSSPP recognizes
two special, fake device names that don't correspond to any real device:
"emulator:""kemulator:"
Both names are currently handled identically (there's no user/kernel distinction enforced), so
either works from a user-mode homebrew app. The implementation lives in sceIoDevctl() inCore/HLE/sceIo.cpp - search
for "emulator:" if you want to see exactly what each command does, or if you're adding a new one.
The standard sceIoDevctl signature is used, just with PPSSPP-specific cmd numbers and
argument/output block layouts:
int sceIoDevctl(const char *devicename, unsigned int cmd, void *indata, int inlen, void *outdata, int outlen);devicename-"emulator:"or"kemulator:".cmd- one of theEMULATOR_DEVCTL__*values below.indata/inlen- input block, meaning depends oncmd. Some commands instead just check whetherindatais NULL/non-NULL as a boolean flag.outdata/outlen- output block, meaning depends oncmd. Most commands write a singleu32orfloathere.
If cmd doesn't match any known command, sceIoDevctl returns an error (UNKNOWN PARAMETERS)
rather than crashing, so probing for support is safe.
Commands
| Command | Value | Direction | Purpose |
|---|---|---|---|
EMULATOR_DEVCTL__GET_HAS_DISPLAY |
1 | out: u32 |
Writes 1 if there's a real display (normal PPSSPP), 0 if running headless (PPSSPPHeadless). Useful to skip presentation/vblank-dependent work when there's nothing to show. |
EMULATOR_DEVCTL__SEND_OUTPUT |
2 | in: bytes | Sends a raw block of text straight to PPSSPP's debug/log output (and to headless's collected output buffer, if any). Handy for logging from homebrew without going through sceIoWrite to a real file. |
EMULATOR_DEVCTL__IS_EMULATOR |
3 | out: u32 |
Writes 1. This is the one to call first: if the sceIoDevctl call itself fails, you're not running under PPSSPP (or a build that doesn't implement this API), and none of the rest of this page applies. |
EMULATOR_DEVCTL__VERIFY_STATE |
4 | none | Asks PPSSPP to do an internal savestate round-trip (save to memory, then verify it reads back correctly) as a consistency check. Runs asynchronously - it doesn't report the pass/fail result back to your code, it just gets logged on the PPSSPP side. Mainly useful for automated testing of the emulator itself. |
EMULATOR_DEVCTL__EMIT_SCREENSHOT |
0x20 | none | Grabs the current framebuffer and delivers it through PPSSPP's internal debug-screenshot hook, which is used by things like the pspautotests/frametest infrastructure to collect result images. Not useful as a general "save a screenshot to memstick" feature - it doesn't write a file. |
EMULATOR_DEVCTL__TOGGLE_FASTFORWARD |
0x30 | in: bool (NULL/non-NULL) | Turns PPSSPP's fast-forward mode on (non-NULL indata) or off (NULL). |
EMULATOR_DEVCTL__GET_ASPECT_RATIO |
0x31 | out: float |
Writes the display's current aspect ratio. Only correct in landscape orientation right now. |
EMULATOR_DEVCTL__GET_SCALE |
0x32 | out: float |
Writes the current display scale factor. Only correct in landscape orientation right now. |
EMULATOR_DEVCTL__GET_AXIS |
0x33 | in: axis index (as indata value, not a pointer), out: float |
Reads an analog axis value that a PPSSPP-side input plugin has injected (see below), by JOYSTICK_AXIS_* index. |
EMULATOR_DEVCTL__GET_VKEY |
0x34 | in: key code (as indata value, not a pointer), out: u8 |
Reads whether a virtual key that a PPSSPP-side input plugin has injected is currently pressed, by PPSSPP's internal key code (see below). |
A couple of notes on quirks that are easy to trip over:
- For
GET_AXISandGET_VKEY, the "input" isn't theindatabuffer contents - it's theindatapointer value itself, used directly as an integer index. This matches how the current PPSSPP implementation reads it (argAddris compared against the axis/key range and used directly), so pass the index as if it were a pointer, e.g.sceIoDevctl("emulator:", EMULATOR_DEVCTL__GET_AXIS, (void *)JOYSTICK_AXIS_X, 0, &value, sizeof(value)). GET_AXISandGET_VKEYdon't read normal controller input (sceCtrl*already does that) - they read state from PPSSPP's HLE plugin system, i.e. values that a native PPSSPP-side plugin PRX has explicitly set for your homebrew to pick up. If no plugin is active, expect these to just come back as 0/unpressed.- The axis index matches PPSSPP's internal
JOYSTICK_AXIS_*enum (Common/Input/KeyCodes.h), and the key code matches PPSSPP's internalNKCODE_*enum (same file), not any PSP SDK enum. These mostly mirror Android's key/axis codes. A handful of the more useful ones:NKCODE_DPAD_UP/DOWN/LEFT/RIGHT,NKCODE_BUTTON_CROSS/CIRCLE/SQUARE/TRIANGLE,JOYSTICK_AXIS_X/Y. See the header for the full list if you need something more obscure. - You'll sometimes see an
EMULATOR_DEVCTL__SEND_CTRLDATA(0x10) constant referenced in older test code. It is not currently implemented by PPSSPP - calling it just gets you the generic "unknown parameters" error. Don't rely on it.
Raw example
#include <pspiofilemgr.h>
#define EMULATOR_DEVCTL__IS_EMULATOR 3
#define EMULATOR_DEVCTL__SEND_OUTPUT 2
int runningOnPPSSPP = sceIoDevctl("emulator:", EMULATOR_DEVCTL__IS_EMULATOR, NULL, 0, NULL, 0) == 0;
if (runningOnPPSSPP) {
const char *msg = "Hello from homebrew, running under PPSSPP!\n";
sceIoDevctl("emulator:", EMULATOR_DEVCTL__SEND_OUTPUT, (void *)msg, strlen(msg), NULL, 0);
}--- METRICS ---
- Files Extracted: 39
- Estimated Token Budget: ~107302 tokens
- Canonical Reference: https://codewiki.google/github.com/hrydgard/ppsspp