ppsspp Documentation

A PSP emulator for Android, Windows, Mac, Linux and iOS, written in C++. Want to contribute? Join us on Discord at or just send pull requests / issues.

14,730 stars C++ C++ 39 files · ~107,381 tokens #emulators#ppsspp#psp
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy ppsspp on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name ppsspp --restart always hrydgard/ppsspp:latest

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

    • Support for "data.json" live status from adhoc relay servers (#21496, #21526)
    • RetroAchievements UI improvements around subsets (#21536)
    • Assorted ad-hoc UI improvements (#21530, #21510)
    • Fixes for The Warriors (#21464) and Street Riders (#21460) in relay mode
  • Texture upscaling

    • Fix old bug causing some textures to get stuck unscales on some hardware when using GPU texture upscaling (#21533)
    • Improved performance of MMPX-adv upscaler (#21555)
    • NNEDI3 and Spline36 GPU texture scaling (#21527)
  • Rendering fixes

    • Fix a bug in lens flare occlusion for the Syphon Filter games (#21511)
    • Fix a bug in the software renderer (#21648)
  • 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

    • Support aemu relay servers for AdHoc multiplayer (#21116, #21271)
  • 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

    • Added "Smooth" playback mode, which evens out glitches (while adding a very small amount of latency)
    • Remove DirectSound support (#20533), add support for ultra-low-latency streams on WASAPI, available on some devices (#20535)
  • Platform support

    • Windows
      • D3D9 support has been removed (#19951, #20490)
      • ARM64 is now officially supported, and works great (#20863)
      • Correct fullscreen mode in Xbox Fullscreen Experience (#21189)
      • Assorted improvements (#20778, #20774)
    • 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

    • RetroAchievements: Upgrade the support library with perf fixes (#21081), various fixes and improvements
    • Add workaround for infamous God of War crash (#21148)
    • Fix assorted minor UI bugs (#21042, ...)

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

    • Touch gliding support added (#19983)
    • Allow simultaneous DInput and XInput input (#20347)
  • 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)
  • 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:

text
msbuild Windows\PPSSPP.sln /p:Configuration=Debug /p:Platform=x64 /t:PPSSPPHeadless

Linux / macOS (CMake)

text
cmake -DHEADLESS=ON -B build-headless
cmake --build build-headless

Usage

text
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 stdout and stderr - sceIoWrite() to fd 1 and 2 (which is where a
    PSPSDK printf() ends up), and writes to a tty device. These go to our stdout and stderr
    respectively, byte for byte, with no prefix or sanitization.
  • The emulator: devctl channel - sceIoDevctl("emulator:", SEND_OUTPUT, ...), a PPSSPP
    extension. This goes to stdout. pspautotests uses this channel exclusively, and it is what
    --compare compares against the .expected file.

--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:

  1. The file is identified by the PPSSPPGE magic header.
  2. The GE commands are replayed through the selected GPU backend.
  3. The framebuffer is captured automatically (512×272 stride, 480×272 visible).
  4. 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

bash
# 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.ppdmp

The 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

bash
# 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.ppdmp

When 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).
  • --compare auto-path: For .ppdmp files, it replaces the extension with .png. For .prx/.elf files, 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 .prx files: same path with .expected extension.
  • 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:

bash
# 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:

yaml
- name: Headless tests
  run: |
    PPSSPPHeadless.exe --graphics=software --compare --timeout=10 @testlist.txt

Configuration

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:

    text
    sudo cp gas-preprocessor.pl /usr/bin/
      sudo chmod +rw /usr/bin/gas-preprocessor.pl
  • you 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:

text
git clone https://github.com/hrydgard/ppsspp.git

Change directory to the newly created ppsspp directory and run:

text
git submodule update --init

The 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):

text
./ios-build.sh

Change directory back up to the main ppsspp directory and do the following:

text
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:

bash
cargo run

(Or just move the binary out of target/ and run it directly).

To see command line usage, type:

bash
cargo run -- --help

Validating

bash
cargo run -- validate

Checks 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:

bash
cargo run -- --model claude-sonnet-5 finish-language-with-ai sv_SE
cargo run -- --model gpt-4o finish-language-with-ai sv_SE

The 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:

bash
cargo fmt

or 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:

bash
cargo run -- <port>

(Or build once with cargo build --release and run the binary directly from
target/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:

bash
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:

bash
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:

bash
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 in
input.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.

bash
(
  echo 'cpu.breakpoint.add address=134348800 enabled=true'
  echo 'cpu.resume'
  echo 'cpu.getAllRegs'
) | cargo run -- 12345 --sync

Script 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:

text
# 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
:quit

Don'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 mutes
stepping.

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 entirely
static inline functions over sceIoDevctl, so there's no library to build or link.

c
#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

c
#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:

text
<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 is
std::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.)

text
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 _:

text
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 dv
md 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 or
er 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 is
void (**)().

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 is
shCamera::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:

c
Son *__dt__3SonFv(Son *this, short flag);

Decompiled from a real one, the body is:

c
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
    keeps I3dClut__FlushCache from turning into I3dClut(long, ...).
  • e is a real parameter, not a parse failure. Printf__Q26shFont4FontFiiPCce ends 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 touching
Windows/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) - queues func to run on the CPU thread,
    blocking the caller until it's done. Use for mutations (breakpoint add/remove, register/memory
    writes, symbol map edits, stepping requests, thread wake/kill). Drained at the top of every
    Core_RunLoopUntil() iteration, so it's reached whether the CPU is running or stepping/paused;
    runs immediately if already called from the CPU thread. Two hard rules learned the hard way:
    never put a modal Win32 dialog call (MessageBox, DialogBoxParam, InputBox_GetString,
    SomeDialog::exec()) inside the queued lambda - it would block the CPU thread on user input, so
    split the function into "read/decide", "show modal", "mutate" pieces instead. And never call
    SendMessage() targeting one of your own GUI-thread windows from inside the lambda - the calling
    GUI thread is blocked waiting on the CPU thread rather than pumping messages, so a cross-thread
    SendMessage() back to it deadlocks; keep such calls outside the lambda instead.
  • g_frameMutex (Core.h/Core.cpp) - a plain std::mutex, held by NativeFrame()
    (UI/NativeApp.cpp) only across the span where it actually touches that state
    (g_breakpoints.Frame() through g_screenManager->render() - where Core_RunLoopUntil()/actual
    CPU stepping happens - through runImDebugger/renderImDebugger), not across input handling or
    the present/frame-pacing waits. Use for reads invoked very frequently (WM_PAINT, a list
    reload triggered on every debugger-state-changed notification) where routing through
    Core_RunOnCPUThread would be too slow/heavy. The legacy Win32 debugger windows do this now -
    see CtrlRegisterList::onPaint, CtrlDisAsmView::onPaint, CtrlMemView::onPaint,
    CtrlBreakpointList::reloadBreakpoints, CtrlThreadList::reloadThreads, etc. in
    Windows/Debugger/*.cpp.

Architecture fact that makes g_frameMutex correct: regardless of graphics backend, NativeFrame()
(and thus CPU emulation via Core_RunLoopUntil(), and the Dear ImGui debugger) always runs on the
same thread - see 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's
SortSymbols() calls (fired from System_Notify(BOOT_DONE)/System_Notify(SYMBOL_MAP_UPDATED))
turned out to already be safe without any change - both notifications are only ever fired from the
CPU/NativeFrame thread (UI/EmuScreen.cpp, Core/HLE/sceKernelModule.cpp), despite an old comment
there claiming reliance on the (now-removed) internal lock. Qt/mainwindow.cpp/Qt/QtMain.cpp
still poke at g_symbolMap directly and unguarded on the Qt UI thread - a pre-existing issue,
deliberately left alone since Qt isn't a maintained backend and is slated for removal; removing the
lock doesn't change SymbolMap's public API, so Qt still builds, just without that safety net.
GPU/Common/GPUDebugInterface.cpp (GE debugger expression evaluation) and Core/MemFault.cpp
(crash-time diagnostics) also touch g_symbolMap and were deliberately not audited this round -
different subsystem / best-effort-by-nature respectively, follow up if they ever come up.

Two more things found while doing this:

  • Core_RunOnCPUThread()'s queue is only drained where Core_RunLoopUntil() runs, which requires
    a game to be loaded
    (it's called from EmuScreen::render()). Calling Core_RunOnCPUThread()
    while at the main menu with nothing loaded used to hang forever. Fixed by also calling the
    (now public) Core_ProcessCPUQueue() directly from NativeFrame(), right before
    g_screenManager->render(), inside the same g_frameMutex-locked span - so it always runs, not
    just while a game is active.
  • Lock-ordering rule: because Core_ProcessCPUQueue() is called from inside NativeFrame()'s
    g_frameMutex-locked span, any Core_RunOnCPUThread() lambda that itself tries to lock
    g_frameMutex (directly, or indirectly - e.g. by calling something like
    CDisasm::NotifyMapLoaded(), which locks it internally) will deadlock. Keep such calls outside
    the queued lambda, same as the modal-dialog and SendMessage() rules above.

Lock ordering: `g_frameMutex` before `Core_LockAgainstShutdown()`, always

Core_LockAgainstShutdown() / CoreShutdownLock (a recursive_mutex, g_shutdownLock in
Core/Core.cpp) is held across CPU_Shutdown() and Memory::Reinit(), i.e. while the core is
going away. Take it on any thread other than the CPU thread before reading core state - emulated
memory, the symbol map, kernel objects - so none of it is freed mid-read. It was called
Memory::Lock() and only covered the memory map; the name misled people into thinking it was about
memory access. When a function needs both it and g_frameMutex, take g_frameMutex first.

The CPU thread's order is structural and can't be changed: NativeFrame() wraps everything below it
in g_frameMutex, and several things under there lock memory - Core_ProcessCPUQueue() running a
queued WebSocket handler, and runImDebugger() -> ImMemView -> DisassembleRange(). So the
GUI-thread side is the one that has to match. (Getting it backwards deadlocked for real: a paint
handler held the memory lock and waited for g_frameMutex while the CPU thread did the reverse.)

Also: a Core_RunOnCPUThread() callback does not need Core_LockAgainstShutdown() - teardown only happens
on the CPU thread itself (Memory::Shutdown() via CPU_Shutdown() <- PSP_Shutdown(), all callers
on that thread; Memory::Reinit() from Memory::DoState() on savestate load). Don't add one.

The general rule behind both of these: never make the CPU thread wait for a thread that is (or may
be) waiting on the CPU thread.
Core_RunOnCPUThread() blocks until the CPU thread drains the
queue, so anything the CPU thread might block on must not be held across such a call. The WebSocket
debugger's lifecycleLock hit exactly this - it was held across a whole event handler, and the CPU
thread took it in Core_NotifyLifecycle(STOPPING), so stopping a game with a debugger request in
flight hung both threads. That lock is gone now; the rule is what's left of it.

The WebSocket debugger no longer has any lock guarding it against startup/shutdown, and must not
grow one back.
The invariant instead is: a handler either does its emulator-state access inside
Core_RunOnCPUThread() - which serializes it against startup and teardown, since those run on the
CPU thread too - or touches only state that carries its own lock (the log ring buffer, ctrlMutex,
GPUStepping's pause-action rendezvous). When adding a subscriber, put the core access in the
queued callback, including the isAlive()/IsValidAddress() checks: answering those outside it
just means acting on an answer that may already be stale.

game.* and cpu.stepping/cpu.resume are pushed rather than polled - WebSocketDebuggerTick()
(called from Core_ProcessCPUQueue()) notices the transition on the CPU thread, formats the event
there, and drops it in a per-connection mailbox. Don't add a broadcaster that reads emulator state
from the connection's own thread; produce the event on the CPU thread and push it instead.

The Win32 debugger's GUI-thread readers do still need it, so don't "simplify" those away:
teardown is not yet fully inside the g_frameMutex span. EmuScreen::render()'s PSP_Shutdown() is
inside it, but the ones in EmuScreen::sendMessage() (REQUEST_GAME_RESET, loading a new game) run
from g_screenManager->sendMessage() in NativeFrame(), which sits above where the guard is
taken. Closing that hole - moving those shutdowns inside the span, or deferring them to render time -
is the prerequisite for dropping the shutdown lock from the debugger entirely.

Painting-problem design history, in case a similar tradeoff comes up elsewhere: routing every paint
through Core_RunOnCPUThread was rejected as too slow for something invoked continuously. A
per-window snapshot/cache with a per-row-rechecked Core_IsStepping() guard was tried first and
worked, but still had a narrow TOCTOU race (the CPU could resume between the check and that row's
reads) and the per-row-recheck pattern itself wasn't liked. Settled on g_frameMutex instead -
simpler, and actually race-free rather than just lower-risk. CtrlRegisterList shows live values
always now, grayed out by color alone (not cached) while the core is running, since a
constantly-moving value isn't meaningful to read closely anyway.


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 via
RegisterHLEModule("<name>", ARRAY_SIZE(table), table) inside a Register_<name>() function declared in the header.

  • FunctionWrappers.h has generic WrapX_YYY<func> templates for common signatures (return type X, args YYY) - add new
    wrappers there if you need a new signature.

  • A function that needs the CPU (registers beyond its arguments, the PC for a log line, PARAM()/RETURN()) takes
    MIPSState *mips as an extra first parameter, and the wrapper passes in the state the syscall runs on. Use that
    rather than currentMIPS. Calling such a function directly, or through hleCall, 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 with
    return hleLogError(Log::HLE, 0, "UNIMPL"); - an established pattern (see scePauth.cpp, sceOpenPSID.cpp).

  • New modules must be registered at the very end of the registration function in Core/HLE/HLETables.cpp (look for
    the // add new modules here. comment near the end of that function) - not inserted alphabetically/logically among
    the existing Register_*() calls. Module registration order affects numeric IDs used in savestates, so inserting a
    new module earlier in that list would break save-state compatibility for saves made with older builds.

  • 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, see GetSyscallOp() in Core/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/.c file to seven places: Core/CMakeLists.txt, Core/Core.vcxproj,
    Core/Core.vcxproj.filters, UWP/CoreUWP/CoreUWP.vcxproj, UWP/CoreUWP/CoreUWP.vcxproj.filters,
    android/jni/Android.mk, and libretro/Makefile.common. New .h files need the first five (everything except
    Android.mk/Makefile.common, which are plain compiled-source lists so headers don't go in them). Double check
    each by hand against how an existing neighboring file (e.g. sceVaudio.cpp) is listed. Forgetting the UWP entries
    is easy to miss - the CMake and MSBuild (Core.vcxproj) builds both succeed silently, and it only surfaces as a
    UWP-only build failure (this has happened for real: Core/MIPS/InterpreterDispatch.cpp landed without its UWP
    entries, and the omission wasn't caught until someone actually built the UWP project). Note: New files in the
    unittest project have to be updated in the unittest part in android/jni/Android.mk.

    Both the CMakeLists.txt change (via a Linux/Mac build) and the Core.vcxproj/UWP changes (via MSBuild on Windows)
    can actually be build-tested, not just eyeballed - see 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.mk and libretro/Makefile.common genuinely 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 through sceSysreg_driver and 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 in halt until 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

text
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.PBP

The 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 what
Core/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

text
+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 ciphertext

The 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:

text
[ 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 above

Call 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:

  1. 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.

  2. 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; 0x06000000 also 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 the kirk7 pass applied before use - that's what the
    scrambleKey flag on TAG_INFO is 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:

text
+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 directory

A 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 is
overhead + 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 recognise
flash0: 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, or
01g, 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 in
Core/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 in
flash0:/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 + 0x110 bytes 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=DIR unpacks one from the command line;
    --unpack-updater-model=01g..12g picks 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

text
__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
text
__0fLCHeapMemoryFAlloci          CHeapMemory::Alloc(int)
__0FHGetRandv                    GetRand()
__0oLCHeapMemorydtv              CHeapMemory::~CHeapMemory()
__0OnwUi                         operator new(unsigned int)
__0dLCHeapMemoryG__vtbl          CHeapMemory::__vtbl

Names

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.

text
LCHeapMemory   ->  L = 11  ->  "CHeapMemory"
FAlloc         ->  F = 5   ->  "Alloc"
bMatrixScaleRotationXZYTrans  ->  b = 27

A 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:

text
__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.

text
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.

text
__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 is
short (**)[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 ``
text
__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:

text
__TID_DstdIbad_cast    type id for std::bad_cast
__TID_v                type id for void

Secondary 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.
  • T is 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 under
assets/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:

bash
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.log

That 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:

  1. Don't add :wait cpu.stepping after cpu.runUntilTime (or any resume/step command).
    --sync already 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.
  2. --cpu=interpreter is what this path is known to work with, and breakpoints are most
    reliable there.
  3. Boot needs a real firmware dump in assets/flash0/ (and optionally assets/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: sceUmdActivate tells the shell's drive callback
    (SceVshMediaDetectUMD) there's no disc, and sceIoDevctl 0x01F20001 (get disc type) answers 0,
    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 with
    game.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 through sceKernelLoadExecVSHDisc (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, with mpeg_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 in
    sceMpegBaseCscAvc) is in pspautotests video/mpeg/avcdetail and video/mpeg/cscavc.

  • It then asked whether the region matched and we didn't answer. sceIoDevctl 0x01E18030 is
    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 device
    msstor0p1:/ (pspautotests io/msstor/msstor) and sceSdGetLastIndex without 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_lib is there, then stop at 8002013A on sceHttp functions 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 textured
DRAW PRIM TRI_STRIP - the wave background and icon quads), 41 texture binds, and full
PROJECTION/World/VIEW transform setup. opening_plugin_module, sceMpegVsh_library and
impose_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:

  • --timeout is 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. sceKernelLoadModuleVSH is 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 for sceKernelGetModel (seven) and sceImposeSetStatus (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: sceKernelLoadModuleVSH is the modulemgr.prx export whose callees are
    sceKernelIsIntrContext, sceIoOpen/Ioctl/Close and sceKernelGetUserLevel;
    sceKernelGetModel is whatever vshbridge.prx wraps in a sceKernelGetUserLevel() < 4 check
    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 sceResmgr keys. flash0:/vsh/etc/index_XXg.dat is 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 unsuffixed index.dat before 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 that
    PrxDecrypter.cpp already has a key for and check it matches byte for byte. Extracting the 6.6x
    triple this way reproduces keys_9DC14891_1/2/3 exactly, 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 every impose.prx from 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 with
    sceDisplaySetFrameBuf(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_version in the registry. Our sceReg serves a compiled-in snapshot of a 6.6x PSP's
    registry, /REGISTRY/category_version included. 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.

  • sceRegCloseRegistry dropping categories another opener still owned. See sceReg.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 real
rtc.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 as
0x531C9778 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 the
sceKernelStartModule 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 two
sceKernelCreateFpl + 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 and
wlanfirm_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 sceIoOpen in the whole boot succeeds: flash0:/font/*.pgf,
    opening_plugin.rco, system_plugin.rco / _bg / _fg, topmenu_plugin.rco,
    topmenu_icon.rco, impose_plugin.rco, and flash0:/vsh/etc/index_02g.dat. The only I/O
    failure in the entire trace is flash1:/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 sceGeListUpdateStallAddr by 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, sceKernelGetUserLevel and sceImposeChanges. So the trigger is a
    wrong return value we hand back, not a missing call - which makes it much harder to spot than
    the sceKernelLoadModuleVSH bug was.
  • sceImposeChanges is 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:

text
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 up

index_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, and
pspDecryptType9() 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 0x104 rather than 0x10C, 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, so PRXType9 simply
    leaves that field zero.
  • ecdsa_hash in 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 unresolved
ModuleMgrForKernel 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 a
8xxxxxxx=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}) so kd/syscon.prx's real
    handshake completes instead of spinning.
  • vsh_module alarm-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_MEMORY in 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. sceReg serves a compiled-in
    snapshot of some real PSP's registry (Core/HLE/sceReg.cpp) rather than reading the volume, and
    a genuine dump has only registry/system.dreg and .ireg. flash1:/registry/init.dat must
    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 calls
    sceRegRemoveRegistry and sceIoDevctl("flashfat1:", 0x5802), both UNIMPL, and the boot dies
    without starting a single plugin module. Its absence is the healthy path, and the resulting
    FILE NOT FOUND in 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
    made sceMgVideo_driver look like one massively hot call when it was the misattributed sum of
    many. Fixed by recovering stubAddr via g_lastSyscallPC and looking identity up in
    module->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:

  • ThreadManForKernel mutex NIDs (0xB7D098C6 sceKernelCreateMutex, 0xB011B11F
    sceKernelLockMutex, 0x6B30100F sceKernelUnlockMutex) - we implement all three for user
    mode already. Resolve them and the NAND and ID storage drivers init successfully, then
    sceIdStorage_Service polls the NAND controller at 0xbd101300 forever (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.
  • InterruptManagerForKernel interrupt 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 are
sceSysEventForKernel, 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 from
flash0:/reboot.bin. PPSSPP doesn't emulate that chain. --vsh loads
flash0:/vsh/module/vshmain.prx through the normal PRX loader, and
LoadVshKernelModules() (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 preloads
PSP_MODULE_AV_VAUDIO, AV_ATRAC3PLUS and AV_AVCODEC, and sets the io filepath to
ms0:/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 the
root 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 the
sceKernelStartThread. 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:

text
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 helpers

08950754 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. See
    docs/WebSocketDebugger.md for the catalog.
  • cpu.runUntilTime us=N is 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/G3D at debug produce ~200K lines in
    three seconds of boot. log.channel.set is session-only, never persisted. --loglevel=3 is a
    good default for the whole run: bare --log on 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/flash0 has 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 a
cpu.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. See
Tools/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 /debugger requests.
  • 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 - shared DebuggerRequest
    helper (parameter parsing, response/error helpers) and DebuggerSubscriber
    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 /debugger without 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:

json
{ "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:

json
{ "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:

json
-> { "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, an
error, 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 for
input.buttons.press that is exactly what the real, later answer looks like.

Errors look like this:

json
{ "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 (see
WebSocket/GameSubscriber.cpp):

json
{ "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 carries
pid (OS process id) and path (the executable/disc currently loaded, or
null), 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 with
broadcast.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 just
acknowledgeDeferred, 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 and
cpu.stepping carry file/line in the hit object, and hle.backtrace
carries them per frame - which is where it pays off most:

text
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:468

Both 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 its
app.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:

json
-> { "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, so
game.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.

json
{
  "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 the
breakpoint 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=true in ppsspp.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 in
    Core/CmdLine.cpp/.h (CmdLineMode::Both). ApplyToConfig() sets
    iRemoteISOPort/bRemoteDebuggerOnStartup for that run without persisting
    them to the config file.
    • A non-zero PORT is 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 the Listening on port N log 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
      forces coreParameter.startBreak = true, so the CPU halts before
      running anything - useful for setting breakpoints before launch.

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() in
Core/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 by RegisterServer() 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 the
    websocket-client pip package).
  • Tools/wsdbg/ - a small Rust CLI/REPL client for this session's work (see
    Tools/wsdbg/README.md): connects, does the version handshake, 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 in
android/build.gradle.kts), so a feature branch has a lower code than a longer branch. Installing
one over the other fails:

text
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:

text
adb shell dumpsys package org.ppsspp.ppsspp | grep versionName

A 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:

text
find android/.cxx -name git-version.cpp.o -delete

Forcing an activity recreate without touching the device

  • adb shell settings put system font_scale 1.15, then back. fontScale is not in the activity's
    configChanges, so each change destroys and recreates the activity. Independent of orientation.
  • adb shell wm density <n> also recreates - but only while density is absent from
    configChanges.
  • adb shell settings put system user_rotation 1 does 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:

text
adb shell am send-trim-memory org.ppsspp.ppsspp COMPLETE

then 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 by
versionName. 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:

text
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:

text
adb logcat -c && adb logcat -s PPSSPP:V PpssppActivity:V   # in the background, then start the app

VR (Quest)

  • The VR flavor is a separate package: ./gradlew :android:assembleVrDebug, installing as
    org.ppsspp.ppssppvr. It can sit alongside the normal build.
  • adb exec-out screencap returns 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 like
    VrApi: FPS=73/72 ... App=2.67ms proves 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 start makes the activity topResumedActivity, 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 fresh am start -S after a force-stop does 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:

text
adb shell grep -E '^(MemStickDirectory|GraphicsBackend)' /storage/emulated/0/PSP/SYSTEM/ppsspp.ini

A 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 reads
ppssppvr.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 in
Core/SaveState.cpp, rewind in Core/SaveStateRewind.cpp, and the file format code in
Common/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 an AndroidJNIThreadContext for 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 that Process() 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

  1. Emu thread, in Process(): SaveStart::DoState into a fresh buffer (the snapshot). The waits
    and FlushPendingDrawing() stay here, and so does the saveStateGeneration bump.
  2. Worker: compress (see Memory) and write the file, to <file>.tmp for a slot save, then for a slot save the rotation that SaveSlot's
    renameCallback does today (old file to .undo, .tmp to the real name), including the
    g_Config.Save() for the undo bookkeeping. Then set g_filesStale.
  3. 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

  1. Worker: read the header and check it, then stream the file through the zstd decompressor into
    the state buffer, while the game keeps running.
  2. 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 everything Process() does around it today
      (hasLoadedState, Core_ResetException(), AV dump restarts, g_lastSaveTime).
  3. Worker: compress and write the load undo snapshot to load_undo.ppst, and update
    sStateLoadUndoGame. This happens even when the apply failed: that's when the undo is most
    useful, since a failed load resets the game.
  4. 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 same Process(). 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): UndoSaveSlot and DeleteSlot. UndoLoad is 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_unserialize are in memory with no file I/O, and stay as
    they are. So does the freeze-frame feature (SaveToRam/LoadFromRam every 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 run Process(), 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's game.state.* with
    wait already 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), and
    FinishRestore() 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_compressStream2 into 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_decompressStream reads 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

  • MeasureAndSavePtr traverses 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 DoState logic 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 and
game.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 via
vswhere.exe (same tool/gotchas as described in the libretro section below) and build a specific project with /t:, e.g.:

powershell
$installPath = & "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath
$msbuild = "$installPath\MSBuild\Current\Bin\MSBuild.exe"
& $msbuild "Windows\PPSSPP.sln" /t:UnitTest /p:Configuration=Debug /p:Platform=<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 in
Bin\ 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 UnitTest project (unittest/UnitTests.vcxproj), then run Windows//Debug/UnitTest.exe all (`` being ARM64 or x64, whichever you built)
  • Linux/Mac: configure with -DUNITTEST=ON, then run build/PPSSPPUnitTest all

This runs all tests in availableTests in unittest/UnitTest.cpp. You can run one or more
specific tests by passing their names instead of all (space-separated, e.g. UnitTest.exe CmdLine Path Utf8); no arguments lists the available tests.

After a git stash/git stash pop that touches a header, do a /t:Rebuild rather than trusting
the incremental build. Stashing rewrites files with timestamps that can leave objects looking newer
than the header they were compiled against, so adding or removing a class member produces a binary
where translation units disagree on the object layout. That shows up as UnitTest.exe segfaulting
before it prints anything, for every test including ones unrelated to the change - which looks like
a catastrophic code bug and is not one. If a build starts crashing inexplicably right after a stash
cycle, rebuild before investigating anything else.

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:

powershell
& $msbuild "UWP\PPSSPP_UWP.sln" /t:PPSSPP_UWP /p:Configuration=Debug /p:Platform=<platform> /m

Android assets

The APK's assets/ directory is the repo-root assets/ directory, wired up by
assets.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 the NDK env var to android/ab.sh (POSIX).
    It should match the ndkVersion in android/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_headless executable ends up in android/libs/<abi>/.

tvOS

The tvOS app is the iOS app with the iPhone-only parts compiled out, checked with
PPSSPP_PLATFORM(TVOS) (tvOS also gets PPSSPP_PLATFORM(IOS)). The CMake side is the iOS toolchain
with -DIOS_PLATFORM=TVOS, which sets TVOS.

  • ./b-tvos.sh builds an unsigned app into build-tvos-xcode/Release-appletvos/.
    DEVELOPMENT_TEAM=<team> DEVICE= ./b-tvos.sh signs 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 in xcrun devicectl list devices until paired; find it with
    dns-sd -B _remotepairing-manual-pairing._tcp and xcrun devicectl device info details --device <name>,
    then xcrun devicectl manage pair --device and enter the code it shows.
  • ./b.sh --tvos --libretro (and ./b.sh --ios --libretro) build the libretro cores. tvOS has no
    sigaltstack, 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 (via
platform=windows_msvc2019_desktop_x64), but orchestrated through GNU Make running inside an MSYS2
shell (a plain MSYS2 install, not "Git Bash" - typically at C:\msys64, needs pacman -S make).

sh
cd libretro
make DEBUG=1 platform=windows_msvc2019_desktop_x64 -j32

(drop DEBUG=1 for a release build; -j count doesn't need to match logical CPUs exactly). To test the
result, copy ppsspp_libretro.* into wherever the local RetroArch install reads cores from (e.g. its
cores/ directory) and load it from within RetroArch.

An agent can drive this non-interactively by invoking C:\msys64\usr\bin\bash.exe -lc "..." directly
as a subprocess (the -l login-shell flag matters - it's what sets up MSYS2's own PATH, make,
cygpath, etc. correctly). In a sandboxed/agentic invocation (as opposed to a normal interactive MSYS2
terminal a human opens), two Windows environment variables the Makefile's VS-detection logic depends on
may not be inherited by the spawned process - COMSPEC (breaks the cmd //c "bash VSWhere.sh ..." call
used to locate Visual Studio) and ProgramFiles(x86) (which VSWhere.sh itself needs to find
vswhere.exe). If VS auto-detection fails this way, skip it by overriding VsInstallRoot directly on
the make command line (GNU Make command-line variables take precedence over the Makefile's own :=
assignment of the same name):

sh
make VsInstallRoot="/c/Program Files/Microsoft Visual Studio/<year>/<edition>" DEBUG=1 platform=windows_msvc2019_desktop_x64 -j32

(path in MSYS2/cygpath POSIX form, not a raw Windows path; find the real value via vswhere -latest -property installationPath if unsure of <year>/<edition>). This is a real full compile+link - prefer
it over trying to syntax-check libretro-specific files with a standalone cl.exe /Zs invocation, which
can miss real bugs (e.g. an include-order issue that leaves a platform macro like
VK_USE_PLATFORM_WIN32_KHR undefined before vulkan.h's first, include-guarded inclusion, since a
narrower manual include-path/define set used for a syntax-only check may not reproduce the actual build
step's ordering).

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 the
ppsspp_unittest target in the legacy Android build (android/jni/Android.mk, see above) the strictest check
we have: MSVC links an inline function defined in a .cpp anyway, clang correctly does not. So a test can
build and pass on Windows and fail to link only on Android CI, with an undefined symbol pointing at a header
line. Fix it by dropping the bogus inline from the definition, not by avoiding the call.

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):

bash
python test.py -g --graphics=software

The -g matters. test.py keeps two lists: tests_good (the regression set - these pass and must keep
passing, ~314 of them) and tests_next (work-in-progress tests that are expected to fail, i.e. the to-do list).
-g runs only tests_good; with no flag you get tests_next + tests_good and around a hundred failures that mean
nothing is wrong. Don't go hunting those, and don't report them as regressions - the only meaningful result from
-g is 0 tests failed. (-b runs only tests_next; -m prefix-filters whichever list is selected.)

Note the runner prints a debug-CRT "Detected memory leaks!" dump after the summary line on Windows debug builds.
That's normal and not a test failure - read the N tests passed, N tests failed line, which comes before it.

See docs/pspautotests.md for a workflow for running pspautotests and improving PPSSPP with the results.

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):

bash
./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=loongarch64

test.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, and
sudo 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 attach
gdb-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/LoongArch64 and their emitters) have no architecture guards, so the host compiler can check
    them: clang++ -fsyntax-only per file, with the -I/-D/-std/-isystem flags of any Core file from
    compile_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 $FLAGS variable, 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 of ppsspp_config.h in #ifndef PPSSPP_TEST_NO_SIMD, configure a separate
    build-nosimd with -DHEADLESS=ON and -DPPSSPP_TEST_NO_SIMD in CMAKE_C_FLAGS and CMAKE_CXX_FLAGS,
    and build PPSSPPHeadless there. Delete it and revert ppsspp_config.h afterwards, since test.py keeps
    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:

bash
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:

cpp
{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 given
Parse() 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 inside
CommandLineOptions::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: ext with pos + size past 32, and ins with msb below pos, asserted in UBFX/BFI.
  • ARM64 JIT: ApplyPrefixST read 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 (like E723 or M212) 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: sc into $zero wrote its result to an immediate and asserted. vmmul's SIMD path
    crashed on vmmul.s and on matrices that wrap around (MatrixWrapsAround()), which now go to
    the interpreter and the scalar path.
  • Native IR backends (all four): ll into $zero mapped $zero for writing and asserted.
  • ARM64 and x86-64 IR JITs: sc didn'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 a ctc1 in 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 ctc1 had been compiled (js.hasSetRounding),
    so a mode from a savestate made on another core, a core or thread switch, or an interpreted
    ctc1 was 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 interpreted ctc1 takes effect at once. CPURoundingMode tests all of
    this on every core, with and without JitDisableFlags.
  • ARM64 JIT: mfhi/mflo into $zero wrote 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/cfc1 of 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: vmfvc of a control register past the defined ones mapped the dest without
    MAP_DIRTY, so its 0 was never written.
  • IR: vmmov between transposed matrices whose rows wrap around (like E020) produced unaligned
    Vec4 moves, which crashed the x86 IR interpreter.
  • RISC-V and LoongArch64 JITs: see below.
  • The legacy JITs' Comp_Generic ran 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 $zero left it mapped, so after a call
    that clobbered it (the dot product routine), a store of $zero stored 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, bc1t and bc1f didn'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 and
denormal 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 made
0xFFC00000 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.

  1. srl/srlv with stray bits (shift_encodings, fixed). Only the low bit of the unused field
    counts: srl rotates when bit 21 is set and shifts when it is clear, whatever bits 22-25 hold,
    and srlv the 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 (see AllegrexBitOp()), and special3 function 0x18 is a no-op.

  2. 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.

  3. 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
    (vf2iu of 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 - 3 is 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.

  4. 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.

  5. 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 with vfpu_minmax_key(), an integer key compared instead of the floats
    (minmax_zero and minmax_tie pass everywhere).

  6. Generated NaNs (nan_gen). VFPU arithmetic never keeps a payload: every NaN it produces is
    0x7F800001 or 0xFF800001. 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
    branch cpu-equiv-4 fixes 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 0x7FC00000 from operands that aren't NaN
    (cpu/fpu/fpu_nan), which ARM64 does and x86 doesn't for add, sub and div. Also fixed on
    cpu-equiv-4.

  7. 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 including vcmov with 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).

  8. 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).

  9. 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.

  10. 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

  1. Divide-by-zero flags. Only the interpreter sets the cause and flag bits for div.s by zero
    (see Int_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, when
CreateNativeJit 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 libm ceilf/floorf don't flush a denormal input, so FlushForConversion()
    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. vh2f and vasin go 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 and
gcc-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=+ (under
QEMU_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: CanFLI took any NaN for FLI's canonical one, so vfim of a NaN lost its
    sign (Zfa).
  • LoongArch JIT: vsat0/vsat1 turned a NaN into the bound (FMIN/FMAX return the non-NaN
    operand), and vcmp NE was 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: vsgn of a negative NaN gave 1 on RISC-V, whose subtraction always makes the
    positive canonical NaN.
  • RISC-V emitter: vmv.s.x and vfmv.s.f were encoded as vmv.x.s and vfmv.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 to
vfpu_dot_reference(). In the interpreters, vfpu_dot() is vfpu_dot_scalar() on RISC-V and
vfpu_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 single
Vec4VfpuDots 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/fpu and cpu/fpu/denormal fail,
    63 lines from the PSP where x86 and ARM64 have 54). Both are in known_failures in test.py.
  • RISC-V never propagates a NaN operand: every NaN it makes is 0x7FC00000, which matches the
    PSP's FPU (cpu/fpu/fpu_nan passes) 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
    passes fpu_nan.
  • Every core gives the same results as on x86-64 in 20000 integer/FPU/memory cases but one, a
    div.s of 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 in
Core/Debugger/WebSocket.cpp and Core/Debugger/WebSocket/*Subscriber.cpp (one file per feature area, each
documented at the top). Enable it via Settings > Tools > Developer Tools > "Allow remote debugger",
RemoteDebuggerOnStartup in the config, or --debugger=PORT on the command line (0 = pick a port automatically) -
works on both the application and headless builds. On headless it also forces a break at start (startBreak), so the
CPU halts before anything runs. The bundled web GUI at /debugger/ comes from the assets/debugger submodule
(unknownbrackets/ppsspp-debugger, bundled branch).

The bundled branch only holds built output, so assets/debugger/static/js/main.*.js in this tree is minified -
don't try to answer "does the web GUI use this event/parameter?" by reading it, the identifiers are mangled and you
will guess wrong. The unminified source is at https://github.com/unknownbrackets/ppsspp-debugger (default branch,
not bundled) - fetch or grep that when you need to know what the official client actually sends, e.g. before
changing or removing part of the protocol.

Before touching this interface, read docs/WebSocketDebugger.md - it has the full protocol reference and event
catalog (including which events are read-only vs. require cpu.stepping first). Don't guess event names or
parameters from memory; the doc (and each *Subscriber.cpp file's per-handler comments) is the source of truth, and
new events get added over time (e.g. memory.search, hle.data.*).

When adding new commands, don't forget to update docs/WebSocketDebugger.md,

To quickly get a live session going for manual testing (e.g. after adding/changing an event): build PPSSPPWindows
(see 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 under
memstick/PSP/GAME/, ask the user for a .iso/.cso/.elf/EBOOT.PBP to boot, or to install one via the in-app
Homebrew Store. Watch the log output (--log=somefile.log) for the line Listening on port N, then point
Tools/wsdbg/ at that port (cargo run -- N <event> [key=value...] for one-shot, or cargo run -- N for a REPL).
Most mutating events (hle.func.*, hle.data.*, memory writes while paused, etc.) require the CPU to be stopped
first - send cpu.stepping and cpu.resume to pause/unpause.

Alternatively use the headless build, Windows/{arch}/Debug/PPSSPPHeadless.exe or build/PPSSPPHeadless on CMake-based
platforms. Where arch is x64 or ARM64.

Driving a headless debugger session (gotchas)

A working invocation, and the traps around it:

bash
./Windows/x64/Debug/PPSSPPHeadless.exe --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.txt
  • The timeouts apply to the whole session, not per test. --timeout-wall=N is real seconds and
    --timeout-emulated=N is emulated ones; both default to infinity, both can be set at once, and whichever
    is reached first ends the run (TIMEOUT or TIMEOUT (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. --timeout is 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 while IsDebuggerPresent(); 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=0 and scrape Listening on port N from that run's own log over hardcoding a port. Also
    taskkill //F //IM PPSSPPHeadless.exe between runs for hygiene (Git Bash here has no pkill) - leftover
    instances are easy to accumulate when a script leaves the CPU stopped at a breakpoint.

  • On Linux, kill leftovers with pkill -x PPSSPPHeadless, never pkill -f PPSSPPHeadless. -f matches full
    command lines, and the shell running your pkill has "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 with
    pkill -INT never 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 set SO_REUSEADDR, which on Winsock means "allow binding a port someone else is already listening on"
    (unlike POSIX, where it only covers TIME_WAIT). Two instances would both bind the same explicit port and both
    log Entering web server loop. Listening on port 34567, with the winner of any given connection undefined - so a
    client aimed at a fixed port could end up driving a leftover process running a different binary, CPU backend, or
    game. It's SO_EXCLUSIVEADDRUSE on Windows now, so the second bind fails honestly, and a non-zero --debugger=PORT
    that can't be honored is fatal in headless (exit 1) instead of falling back to a random port. If you still suspect
    you're talking to the wrong process, the version response carries pid and path - check them.

  • The headless build defaults to JIT (Headless.cpp, CPUCore cpuCore = CPUCore::JIT), despite
    g_Config.iCpuCore being force-set to INTERPRETER just above - ApplyToConfig() has the final say. Pass
    --cpu=interpreter for the interpreter. (-r is --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 a cpu.resume normally means the breakpoint you were counting on never tripped
    and the game ran to completion - not a transport problem.

  • Some events deliberately never respond while the CPU is stepping, so --sync will burn its full timeout on them:
    gpu.stats.get and gpu.stats.feed (documented - they answer after the next flip), gpu.record.dump, and
    input.buttons.press (waits for N frames). Send them with wsdbg's :nowait, and let the next line
    (a cpu.runUntilTime) run the CPU:
    :nowait input.buttons.press button=circle duration=4. duration counts vblanks, not seconds.

  • Don't send a bare cpu.resume in a --sync script. --sync waits for the CPU to stop again, which
    a free run never does, so it burns the whole --sync-timeout while headless runs unthrottled, which can
    be hundreds of emulated seconds. Use cpu.runUntilTime.

  • Log broadcasts drown scripted output. Pass --quiet to wsdbg, which turns them off.

  • Nested parameters work in wsdbg's key=value shorthand: 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.

  • --sync can only match a response to a request that carries a ticket, and wsdbg only assigns tickets to its
    key=value shorthand. A raw JSON line without a ticket isn'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 a
    cpu.runUntilTime. With headless, run --graphics=software for 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_DEBUGGER as false (only Windows/main.cpp implements it), so anything gated on
    it does nothing there - LoadSymbolsIfSupported() in Core/System.cpp, for instance, doesn't load .ppmap/.sym
    at all under headless. Gate new debugger-adjacent features on their own config flag, not on that property.

  • Headless defaults its memstick to memstick next to the executable (Headless.cpp). Pass --memstick=DIR to
    point it at a real one instead - e.g. the same directory the app build uses - rather than copying a game in.

  • Kill leftover instances (taskkill //F //IM PPSSPPHeadless.exe) before building - a running one makes the link
    step fail with LNK1168: cannot open ... for writing, which looks like a build problem and isn't.

  • Don't wrap a script that starts headless in timeout - when it fires it takes the emulator down with it, and if
    the emulator was stopped at the crash you were investigating, that state is gone. Let the launcher exit and leave
    the process running; wsdbg can reconnect to the same port as many times as you like.

  • Response field names are not uniform: memory.read_u32 answers with value, while cpu.getReg answers with
    uintValue. A parser defaulting a missing key to 0 will quietly report zeroes - read the handler's comment in
    Core/Debugger/WebSocket/*Subscriber.cpp rather than guessing.

  • broadcast.config.set accepts all five categories now (logger, input, game, stepping, breakpoint);
    it used to reject game and stepping until each had happened to fire once.

  • A script has to keep the connection open long enough for what it asked for to happen. cpu.runUntilTime
    followed immediately by :quit disconnects before the run even starts, and it looks exactly like the feature
    not working. End with a :wait cpu.stepping <seconds>.

  • Exception and crash messages do not reach the log in headless. It registers its own debug-output listener
    (SendDebugOutput in headless/Headless.cpp) that fwrites to stdout, which is block-buffered when you
    redirect it to a file - so the output sits in the CRT buffer while the process runs, and taskkill //F throws
    it away rather than flushing. To actually read a crash trace, give that run a short --timeout-wall and wait for
    the process to exit on its own.

  • 0xFFFFFFFF is not an invalid instruction - it decodes to vflush, a real Allegrex VFPU op, so writing it
    over code to test illegal-instruction handling just runs it. Check what an encoding actually is with
    memory.disasm before assuming it's garbage; the interpreter raises ExecExceptionType::ILLEGAL only when
    MIPSGetInstruction has no interpreter for it (tge/tlt/teq and friends).

  • To line input injection up with a wall-clock repro, use cpu.status's us field (emulated microseconds), not
    ticks. The PSP's clock frequency is changeable and games do change it - CrossCraft Classic runs at 333MHz, so
    ticks / 222000000 is off by a factor of 1.5. clockHz is reported alongside.

  • --graphics=software cannot 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
    calls gpu->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=software and 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;
    directx9 is not a valid value) and compare --screenshot-save= output, not the log.

  • wsdbg --launch only works with the headless build. The app build is a GUI-subsystem exe with no stdout, so
    the Listening on port N line never reaches the launcher and it gives up. Start it yourself with an explicit
    --debugger=PORT and point wsdbg at that port. Note also that --debugger-run is CmdLineMode::Headless; the
    app takes plain --debugger=PORT.

  • --launch needs --log to find the port. Without it, headless prints only emulated printfs, so the
    Listening on port N NOTICE never appears and --launch fails with "PPSSPP never reported a debugger port".
    --log --loglevel=3 keeps the port line while cutting the debug flood - full --log at 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:

  • --log is 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/-v set 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),
    so Windows//<config>/memstick, while the app derives its own from installed.txt or Documents
    (InitMemstickDirectory in Windows/main.cpp, which carries a TODO about sharing the derivation). Headless
    creates that directory, empty PSP/NAND/flash0 and 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 inside
    sceGeListEnQueue, which then dominates any profile of the emulator thread.
  • --graphics=vulkan renders offscreen, into images of its own with no window or swapchain, so it needs no
    display. On macOS it goes through MoltenVK.
  • --graphics=opengl renders 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 where dladdr finds 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 by
SuspendThread; RISC-V and LoongArch on Linux only) sample the host PC; the IR interpreter instead times every block, which used to be the
IR_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 reported
Read 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:

    text
    settings 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 handle needs 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 into
mesh.zig:163 in backtraces, crash traces, breakpoint hits and log lines, both call stack views, and the
disassembly status bar. Availability is narrow and worth knowing before relying on it: PRX conversion strips
every .debug section
, verified across all 437 pspautotests .prx and CrossCraft's own app.prx, and of 24
installed homebrew EBOOTs none carry debug info - CrossCraft only does because it ships app.elf separately.
So it's there for homebrew you're developing (or a plain .elf you built), never for a commercial game. DWARF 2
through 4 are decoded (psp-gcc emits 2, Zig 4); v5 re-encoded the file table and its units are skipped with a
warning rather than mis-parsed.

That same file is also a ground-truth oracle for anything the loader computes. It still has the symbol table (so
an address can be turned into a
function name) and the full .rel.* sections with symbol indices, which the PRX format throws away. That makes it
possible to check the emulator's work exhaustively offline - for the HI16/LO16 relocation bug, "does the address
this pairing produces land inside the section its symbol belongs to" turned a guess into a measurement over 8589
relocations, and immediately showed that the first fix attempt scored worse than the code it replaced.

Reach for that before trying to reason a fix out of a disassembly. A few dozen lines of Python over the ELF beats
re-running the game.

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 in
Core/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:

  1. 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.read of
    the decode buffer at a few cpu.runUntilTime points tells you when it starts changing.
  2. 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, then
    cpu.resume. Pick a pixel in the middle of the frame, not the first one, which clears also hit.
  3. hle.backtrace gives the function's entry address. Addresses come back
    as decimal in JSON; convert carefully (145311076 is 08a94564, not 08ab1564).
  4. memory.disasm the whole function and check that it really is what you're going to call it: argument
    order (a0 dest, a1 src, a2 size 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:

text
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
:quit
bash
Tools/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 reads
GPUMemcpy/..., 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:

text
<- 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". Its
    DATA.PSP is 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 if
ms0:/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 (those
g_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 own
EBOOT.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's
bbox_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, so
g_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:

  1. A PGD (key index 1, DRM type 0), opened by amctrl with 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.
  2. 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 at 0xEC and 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/8 in ext/libkirk/kirk_engine.c.
  • pspDecryptType3() and pspUnwrapDNASPGD() in Core/ELF/PrxDecrypter.cpp.
  • sceKernelLoadModuleDNAS() in Core/HLE/sceKernelModule.cpp takes the Fuse ID from the FuseID
    setting ([SystemParam] in ppsspp.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:

text
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) through
PPSSPPHeadless 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
    frametests git submodule (repo hrydgard/ppsspp-frametest-ci, pinned by
    commit - its config frametests.json lives 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

bash
# Build PPSSPPHeadless (see headless/README.md), then:
python3 frametests.py frametests/frametests.json

The 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 to
frametests/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.

json
{
	"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
    a gl variant can compare against the soft references:
json
"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>.png is
    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: PASS or FAIL
    (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-alpha to the headless binary to preserve
    it (e.g. via a variant). The MSE comparison ignores alpha either way.
  • Generated with --graphics=software they are fully deterministic: a
    subsequent run produces byte-identical output, so maxMse can 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 is
floor(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

text
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:

  1. headlessPath from the config file.
  2. The PPSSPP_HEADLESS environment variable.
  3. 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 (through
Tools/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.

bash
cd android && NDK=/path/to/ndk ./ab.sh APP_ABI=arm64-v8a HEADLESS=1 SKIPAPP=1 -j8 && cd ..
python3 frametests.py frametests/frametests.json --adb

The 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 arguments
devicectl 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.

bash
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 --ios

Keep 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 (each
devicectl 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:

bash
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.ppdmp

Relative 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:

yaml
- 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 in
frametests/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:

bash
python3 frametests.py /path/to/my-config.json

The 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

  1. Drop the dump into frametests/dumps/ (either as a .ppdmp or zipped).
  2. Run python3 frametests.py frametests/frametests.json --filter=<name> -
    the reference image(s) are generated and copied to <outputRoot>/generated/.
  3. Commit the dump and the reference images under frametests/ref/, and push
    them to the frametests submodule 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:

bash
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:

bash
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), saves
NAME-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's
gpu.record.dump through Tools/wsdbg. Its reply comes later, so wait for it:

bash
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.txt

The 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 through
HLEKernel::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 arrays
numFuncs (u16) and numVars (u8) times; sysclib_strcpy used it for a destination it then wrote
strlen(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 and
sceKernelCreateTlspl 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 passed
BlockAllocator::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

  • readyCallbacksCount bookkeeping. 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
    (__KernelRunCallbackOnThread zeroes notifyCount first), sceKernelDeleteCallback, and thread
    deletion. The thread-deletion path looks like it should double-decrement with a later
    sceKernelDeleteCallback, but PSPThread::Cleanup() destroys the callbacks immediately after the
    decrement loop, so the second lookup fails.
  • u32 error; uninitialized before kernelObjects.Get, then if (error) return;. Safe -
    Get writes outError on both paths, 0 on success.
  • Kernel object name copies. Every strncpy(x.name, name, KERNELOBJECT_MAX_NAME_LENGTH) writes
    into a char[KERNELOBJECT_MAX_NAME_LENGTH + 1] and follows with an explicit NUL. Correct.
  • strncpy(moduleName, modinfo->name, ARRAY_SIZE(module->nm.name)) in sceKernelModule.cpp
    mixes two buffers' sizes but is safe: 28 into a zero-initialised 29.
  • VPL and FPL allocation failure handling. Both check for (u32)-1 properly; the heap was the
    outlier.
  • The unchecked ReadUnchecked_U32 sites in sceKernelThread.cpp (register restore, extended
    stack restore, the exit-callback parameter area) all have IsValid4AlignedRange checks a few
    lines above.
  • sceKernelCreateHeap's alloc.Init(address + 128, size - 128) looks like it underflows for a
    heap smaller than 128 bytes, but can't: BlockAllocator::AllocAligned rounds the caller's size
    variable up by reference and every partition allocator is constructed with grain 256.
  • VTimer scheduling arithmetic. The base + schedule - current u64->s64 conversion wraps
    negative on underflow, which the goalUs < minGoalUs clamp 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 zero
freeSize 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

  • __KernelStartThread doesn't bound argSize. sp -= (argSize + 0xf) & ~0xf with argSize
    up to INT_MAX pushes the stack pointer below the thread's stack. The copy itself goes through
    the validating Memory::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. The
    argSize + 0xf signed 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 as
GPUBackend::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_y makes 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 subpassInput for shader blending when
    FS_BIT_USE_FRAMEBUFFER_FETCH is set, and SPIRV-Cross (use_framebuffer_fetch_subpasses) turns it into
    [[color(0)]]. Only Apple GPUs report framebufferFetchSupported.
  • 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 of VulkanQueueRunner::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 through DeferRelease,
    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, BGR5A1Unorm and
    ABGR4Unorm with 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 RGBA8Unorm with Depth32Float_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=metal renders 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 with NSZombieEnabled=YES too.
  • 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 for GLSLtoSPV, 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 nullptr framebuffer. BindFramebufferAsRenderTarget(nullptr),
    GetFramebufferDimensions(nullptr), CopyFramebufferToMemory(nullptr), Present(), the
    LOST_BACKBUFFER/GOT_BACKBUFFER events and the present-mode caps all mean "the" swapchain
    (Common/GPU/thin3d.h).
  • Vulkan: VulkanContext holds one surface_/swapchain_/presentation_ and the surface-derived
    state (format, extent, caps, transform, present modes). The render manager has one
    FrameDataShared::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_ and bbDepth_ in MetalRenderManager. Backbuffer passes are
    merged against a single BACKBUFFER_TARGET sentinel.
  • D3D11: the platform context (D3D11Context) owns the one IDXGISwapChain and hands its RTV in
    through GOT_BACKBUFFER.
  • OpenGL: one context, one default FBO (g_defaultFBO), one swapFunction_.
  • Frontend: one global g_display, read in about 250 places, from layout to input scaling.
    ScreenManager::Render is the only place that binds the backbuffer. EmuScreen::render draws 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.

text
/* Detailed source-code truncated for AI context efficiency. */
  • nullptr is an alias for the primary, so nothing breaks. The direct callers use
    GetPrimarySwapchain(). Some code still passes a Framebuffer * that may be null (non-buffered
    rendering, for one), so nullptr can only become an assert after that's audited. Backends that
    support one swapchain map the primary to their old nullptr path (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::InitSurface still creates
    the primary, so platform code doesn't change.

  • One frame, many presents. BeginFrame/EndFrame and 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: CreateSwapchain and DestroySwapchain run 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 CAMetalLayer is always vsynced (displaySyncEnabled is 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

  1. Split VulkanContext's surface state into VulkanSurfaceSwapchain: surface, swapchain,
    format, extent, caps, pre-transform, present modes, cbGetDrawSize. Move InitSurface,
    ReinitSurface, InitSwapchain, DestroySwapchain and DestroySurface onto it. VulkanContext
    keeps a primary one, and its current accessors forward to it, so callers don't change.
  2. Make the real swapchain a VulkanPresentation too. Today it's the presentation_ == 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.
  3. Per-output state in the render manager. FrameDataShared::swapchainImages_ and the queue
    runner's backbuffer_, framebuffers_ and depth_ move into a VKROutput (presentation, images,
    views, semaphores, framebuffers, depth, out-of-date counter). The per-frame acquireSemaphore,
    curSwapchainImage, hasAcquired and skipSwap become a small array indexed by output (cap of
    4). Output 0 is the primary.
  4. Steps. A RENDER step with no framebuffer gets an output index. SanityCheckPassesOnAdd changes
    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.
  5. 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 single vkQueuePresentKHR with swapchainCount = N, and
    read the per-swapchain results from pResults. Other presentations get their own QueuePresent.
  6. Queue support. CreateSwapchain checks vkGetPhysicalDeviceSurfaceSupportKHR for 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.
  7. Rotation. The pre-transform is per surface, and so is the rect rotation that reads
    g_display.rotation (VulkanRenderManager.cpp, RotateRectToDisplay in the queue runner). The
    rotation goes into VKROutput and 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. The
BACKBUFFER_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 IDXGISwapChain moves from D3D11Context into the draw
    context, one per HWND, and Present() presents each one.
  • OpenGL: multipleSwapchains = false for now. Desktop GL could use one context with
    MakeCurrent(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:

cpp
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:

cpp
// 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:

  1. PreRender for every screen, as now. Emulation and post-shader passes run here.
  2. For each secondary output, bind its swapchain and ask the stack from the top down. The first
    screen whose renderOutput returns 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).
    • EmuScreen claims Game outputs. It draws CopyDisplayToOutput with that output's
      OutputParams and 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.
  3. The primary pass, as now. While EmuScreen sends the game to another output, its render leaves
    out its own CopyDisplayToOutput and draws the touch controls over black. Optionally it draws a
    small game preview, which is a second CopyToOutput.

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 and
UpdateDisplaySize, 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_matrix in CopyToOutput and the post-shader passes
  • the DisplayLayoutConfig choice (GetDeviceOrientation())

These become an OutputParams { width, height, rotation, insets, orientation } handed to
CopyDisplayToOutput. 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 Game output.

iOS

  • Getting the scene: up to iOS 26, a UIWindowSceneSessionRoleExternalDisplayNonInteractive
    configuration in Info.plist, with ExternalDisplaySceneDelegate. From iOS 27, that's ignored, and the
    app has to register from a view controller instead (registerSceneAccessory: with
    UISceneAccessory 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, EmuScreen running), the main thread creates
    a UIWindow on the scene with a CAMetalLayer-backed view (contentsScale = screen.nativeScale).
    It then queues a request to the render thread, which calls CreateSwapchain(METAL_EXT, layer) at a
    frame boundary and registers a Game output.
  • TV mode off, disconnect, or resign active: the render thread destroys the swapchain first, and
    the main thread waits for that (the same handshake as requestExitVulkanRenderLoop). 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 a Mirror output 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 → CreateSwapchain
    • Renderer_RenderWindow → BindFramebufferAsRenderTarget(swapchain) and draw
    • Renderer_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 to xrAcquireSwapchainImage/xrWaitSwapchainImage, present to xrReleaseSwapchainImage),
    and the desktop window stays primary. VR is GL-only today, so this also needs a Vulkan OpenXR path.

  • Android external display: a Presentation dialog with its own Surface, as a Game output on
    Vulkan.

Phases

  1. OutputParams refactor of PresentationCommon and CopyDisplayToOutput. No change in
    behaviour, checked with frametests.
  2. thin3d Swapchain API plus Metal, the iOS default. Single-output path unchanged: unit tests,
    pspautotests, frametests.
  3. Dev client on macOS SDL: a --game-window option opens a second SDL window as a Game output
    (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.
  4. iOS TV mode, on Metal, tested with the Apple TV.
  5. 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 with
newline='' 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 in
ms0:/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

text
+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 contents
text
+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 signatures

Extended 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 equivalently
header[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):

text
+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  padding

Filenames 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() in
Core/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:

text
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 header

The 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) and
PSP_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_VER ranges 6.10 to 6.60.

  • Every single PBOOT.PBP uses PRX tag 0x2E5E10F0 in its DATA.PSP. PPSSPP already has
    that key - Core/ELF/PrxDecrypter.cpp, in the TAG_INFO2 table, 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.PBP to EBOOT.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.PBP

    It logs Decrypting tag 2E5E10F0, loads the ELF (tag=ELF/PdvApp), resolves its imports, and
    reads Diva2Data.cpk / Diva2Script.cpk / Diva2Sound.cpk off disc0:. Runs without error.
    (--mount is what makes that work: Load_PSP_ELF_PBP in Core/PSPLoaders.cpp mounts the ISO
    on disc0:, umd: and umd1: 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.cpp reads a package: header, item table, both PARAM.SFOs, and the
    decryption. PkgReader::Open() gives you a PkgInfo with the disc ID, disc version and patch
    version; InstallPkg() writes the payload out. Sits next to PSARUnpack.cpp, and like it needs
    nothing but the AES already in ext/libkirk.
  • UI/InstallPkgScreen.cpp is what opening a .pkg gets you, the same way a .zip gets
    InstallZipScreen - 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.
  • GameScreen shows an installed update in the info pane, and offers "Delete Game Update" in
    its context menu. Deleting takes the whole PSP/GAME/<DISC_ID> folder when the update is all
    that's in it, and only PBOOT.PBP when a digital game shares the folder - nothing records what
    an install wrote, so the executable is the only part still identifiable afterwards.
  • FindGameUpdatePBOOT() in Core/PSPLoaders.cpp is the boot-time half. Starting a disc looks
    for ms0:/PSP/GAME/<DISC_ID>/PBOOT.PBP, and boots that instead of disc0:/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 picks
PSP/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.PBP and the game reads its CPKs off disc0:.
  • LittleBigPlanet (UCES01264): install the v2.05 update, boot the v1.01 UMD, and the patched game
    opens ms0:/PSP/GAME/UCES01264/PATCH.ARC alongside the disc's own lbp_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:

text
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 0x42b7dc

So 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 with
sceKernelLoadModuleNpDrm. 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:

text
+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 0x7C

sceKernelLoadModule() 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:

  1. sceNpDrmGetFixedKey(kirk, key, edat+0x10, 0x01000000 | edat[0x08]) - already in
    ext/libkirk/amctrl.c, and identical to JPCSP's hleNpDrmGetFixedKey: our key_363C is its
    drmFixedKey, our key_357C[0/1/2] are its drmEncKey1/2/3, and its AES-CBC under an all-zero
    IV over one block is our AES_encrypt. Nothing had called this function before.
  2. If edat[0x0F] & 1: XOR the licensee key the game passed to sceNpDrmSetLicenseeKey(), which
    this file already kept but never used. The game sets it before it loads the module.
  3. If edat[0x0F] & 2: XOR the 16 bytes at edat+0x40. None seen here use it.
  4. 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 plain AES_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 is
buf[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 with
KL4E: 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 in ElfReader::LoadRelocations2 (issue
    #8075), where two luis sharing one addiu got 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's PATCH.ARC covers 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, pspsh and usbhostfs_pc.
    On macOS it lives in ~/pspdev by default and is not on PATH, so prefix commands with
    PATH="$HOME/pspdev/bin:$PATH" or export it once per shell. On macOS you also need
    brew 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:

bash
ioreg -p IOUSB -l -w 0 | grep -i "USB Product Name"     # macOS; want "PSP Type B"

The three moving parts

text
PSP (PSPLink)  <--USB-->  usbhostfs_pc  <--TCP 3000-->  pspsh / gentest.py
  • usbhostfs_pc -b 3000 bridges USB to a TCP port and serves the PC filesystem to the PSP as
    host0:/. Start it from the pspautotests root, because host0:/ is literally its working
    directory, and that's where a test's output files land.
  • pspsh -p 3000 is 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.py builds a test, runs it through pspsh, waits for it to finish and copies the
    output over the .expected file. It starts usbhostfs_pc itself 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:

bash
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 directory

If 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 starts
usbhostfs_pc in the current directory, so run it from pspautotests in place of the
usbhostfs_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 and
stderr 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:

make
TARGETS = shortname

COMMON_DIR = ../../../common
include $(COMMON_DIR)/common.mk

TARGETS 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.

c
#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:

bash
python3 gentest.py io/shortname/shortname       # path under tests/, without .prx

It runs make in the test's directory first, then runs it on the PSP and writes
tests/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:

bash
python3 test.py --graphics=software io/shortname/shortname

Design 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. sceIoDread returns a real FAT directory in
    creation order and a host directory in whatever order the host filesystem gives; if you're
    testing names, qsort by 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 .expected quietly lies.

Gotchas

  • gentest.py runs make for 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 under tests/
    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) skips make entirely and
    you can build your own target by hand with make yourtest.prx.
  • When you touch one test, rebuild every .prx in 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_t is 64-bit now, so rtc/convert's
    sceRtcSetTime_t(&pt, 62135596800ULL) marshals differently, and in tests/intr the SDK's
    ModuleMgrForUser stubs, linked next to the directory's own import of that library, silently did
    nothing - waits printed sceKernelGetModuleId's result for sceKernelStartModule. 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 plain make in the
    directory, record each test on hardware, and commit the binaries and .expected files together.
    (The binaries also grow by roughly a third: testgp.prx went from 117 KB to 191 KB.)
  • Diff every re-recorded .expected against 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 of sceKernelSetCompiledSdkVersion
    (intr/registersub depends 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 .expected files have CRLF
    line endings (they were recorded on Windows) while a fresh run writes LF, so compare with
    diff <(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/Start fails with 0x80020190 (out of memory) - which looks exactly like a hung test.
    pspsh -p 3000 -e reset between 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/directory was recorded on Windows and
    does not match on macOS, where .. reports a different size and short names come back as
    1.txt rather than 1.TXT. Anything testing FAT semantics has to run against ms0: - create a
    scratch directory on the real memory stick and clean it up - and anything reading host0: will
    only reproduce on the OS it was recorded on.
  • Start usbhostfs_pc yourself before the first gentest.py run in a scripted session. When
    gentest.py has 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 .expected file has been written. With the port already open it returns at once.
  • A test that hangs leaves the PSP wedged. gentest.py issues pspsh -e reset after 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, run pspsh -p 3000 -e ls in the background and give up after 10 seconds (macOS has
    no timeout command). gentest.py -t doesn't cap the run either: its recovery pspsh -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), kill
    usbhostfs_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 next reset hangs 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_generate in gentest.py - the list used when you pass no arguments - is stale;
    several paths in it no longer exist. Always name the test you want.
  • --sdkver matters for some APIs. gentest.py --sdkver=6060010 --sdkver-func=606 makes the
    test call sceKernelSetCompiledSdkVersion606() at startup, and -a/--all-versions sweeps every
    known version reporting which ones behave differently - a fast way to find version-gated
    behavior. A test can also call sceKernelSetCompiledSdkVersion*() 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. Set
    HAS_DISPLAY = 0 (extern "C" int HAS_DISPLAY;, from common.c) after setting up the display.
  • The module must be named TESTMODULE (common.c does this) or gentest.py prints 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:

make
TARGETS = partition
EXTRA_OBJS = tlspl-imports.o
COMMON_KERNEL = 1

COMMON_DIR = ../../../../common
include $(COMMON_DIR)/common.mk

Three things make this work, and all three took a while to find, so don't undo them casually:

  • USE_KERNEL_LIBS, set automatically by common.mk. The stock crt0_prx.o references
    __libcglue_init, which pulls in the whole of libcglue, which imports sceNetInet,
    sceUtility and the ForUser IO libraries. A kernel module that imports any of those fails to
    load with 8002013C (library not found), and no amount of trimming LIBS helps because the
    reference comes from the startup object itself. USE_KERNEL_LIBS selects a different startup -
    and brings -nostdlib, so common/kernelglue.c supplies the newlib support hooks that go
    missing: _sbrk over a fixed heap, the _read/_write/_close stubs, no-op retargetable
    locks, and module_start.
  • Nothing may import a ForUser library. ThreadManForUser is fine, but SysMemUserForUser
    is not, which is why common.c compiles the sceKernelSetCompiledSdkVersion helpers 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 For lists what you're importing. Beware also
    that ThreadManForKernel does not export the Tlspl calls - importing them from there links
    cleanly and then returns 8002013A, 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 KB schedfBuffer and a 4 KB heap instead of the user build's
    64 KB and 21 MB. psp-size yourtest.elf plus the .prx file size against meminfo'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 feeds
sceIoDevctl("emulator:", SEND_OUTPUT) as well as writing the file, and calls
sceKernelExitGame 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:

  1. Commit inside pspautotests/ (the new test directory, including the built .prx and the
    generated .expected), rebase it onto origin/master, and push it straight to master - no
    branch, no PR, and no need to ask first.
  2. Commit in the PPSSPP repo, including both the test.py change 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 .expected files 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:

text
MSBuild.exe -noAutoResponse Windows/PPSSPP.sln -target:PPSSPPHeadless -property:Configuration=Debug -property:Platform=x64 -maxCpuCount

The 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

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

text
Windows/x64/Debug/PPSSPPHeadless.exe --root pspautotests/tests/../ --compare --timeout-wall=5 --graphics=software pspautotests/tests/audio/atrac/addstreamdata.prx

Instead 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 above tests/ so the headless can find the expected directory layout.
  • --compare — enables output comparison against .expected files.
  • --timeout-wall=N — real seconds per test before killing it (default 5). --timeout-emulated=N is the
    same idea in emulated time, and both can be set at once. --timeout is 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:

text
pspautotests/tests/audio/atrac/addstreamdata.prx:
  audio/atrac/addstreamdata - passed!
1 tests passed, 0 tests failed, 0 tests missing.

Failing test:

text
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/refer

Lines 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]:

text
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

text
O [x]   GetCharInfo on open: 00000000
E [r]   GetCharInfo on open: 00000000

is 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

  1. Pick a test from tests_next in test.py.
  2. Run it with headless to confirm failure and see what differs (O vs E lines).
  3. Read the test source (.c/.cpp) and the .expected file to understand the API being tested.
    The .expected file 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.
  4. 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.
  5. 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.
  6. Rebuild PPSSPPHeadless and re-run the test.
  7. Run the full tests_good suite (py test.py -g) to check for regressions. A correct fix
    should not break any previously passing tests.
  8. Rinse and repeat until it passes, then move it from tests_next to tests_good in test.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:
    text
    Windows/x64/Debug/PPSSPPHeadless.exe --root pspautotests/tests/../ --timeout-wall=5 --graphics=software path/to/test.prx
    There's another trick too, --print-equal-lines, which prints matching lines with a '=' prefix, so you can see the full output with context.
  • 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) — because sceKernelReferMbxStatus on a real PSP updates
    firstMessage during 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:
    • O line = present in PPSSPP's output but not in expected (PPSSPP-only).
    • E line = 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 (UNIMPL in 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's sceRtcParseDateTime was 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 --init from the repo root to fetch the pspautotests submodule.
  • PPSSPPHeadless exits immediately / "CPU not started" — the test PRX may be missing or the --root path is wrong. Ensure --root points to the parent of tests/.
  • MSBuild error MSB1008 — the MSBuild.rsp response file may be interfering. Always pass -noAutoResponse on 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

bash
./build/PPSSPPHeadless --memstick ~/.config/ppsspp \
    --re-module flash0:/kd/libmp3.prx \
    --re-out /tmp/re

To 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 writes v0.
  • <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, for
a0-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:
text
;   a0   READ before written  -> used as a parameter here
;   a1   never read, but live across a call -> FORWARDED from our caller

That 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 from
hle.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 (0x08000000 for 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.prx and loadcore.prx among them - have modinfo pointers 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 told
SCE_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 literally
sceAudioSRCChReserve(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:

c
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:

c
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 what
tests/audio/blocking/contend pins down.

The same flag locks out sceAudioChRelease, sceAudioSetChannelDataLen and
sceAudioChangeChannelConfig, 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:

c
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

c
sceAudioGetChannelRestLen(ch)     = remaining + (waiting ? sampleCount : 0)
sceAudioGetChannelRestLength(ch)  = (sampleAddress ? remaining : 0) + (waiting ? sampleCount : 0)
sceAudioOutput2GetRestSample()    = (armed descriptor count) * current sampleCount

Both 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 after
sceAudioOutput2ChangeLength(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 to
sceAudioGetChannelRestLen 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/frequency and
    audio/output2/rest are in tests_next for 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 why
tests/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 told
80000021 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, with
PSP_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:

  1. The list, stall, context and stack addresses all have to be 4-byte aligned (0x80000103), and
    a stack depth of 256 or more is 0x80000104.
  2. Games built with SDK version 0x01FFFFFF or below get a 32-entry stack they didn't ask for,
    and the next step can't fail for them. Otherwise the stack is whatever PspGeListArgs says,
    which may well be none.
  3. 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. After sceGeBreak(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.
  4. 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 sceGeContinue to run it.

"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.
  • sceGeContinue returns 0x80000021. So does sceGeBreak(0).
  • sceGeListUpdateStallAddr returns 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, and
    sceGeBreak(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:

  1. The list becomes COMPLETED. It is still at the head of the queue.
  2. 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, sceGeListSync on the next list
    says QUEUED, sceGeDrawSync(1) says DRAWING if there is a next list and COMPLETED if not,
    sceGeListDeQueue on the finished list says 0x80000021, and sceGeListUpdateStallAddr on
    it says 0x80000020.
  3. If the list had a context, the GE state is restored from it.
  4. The list leaves the queue.
  5. 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.
  6. If the queue is empty instead, threads waiting in sceGeDrawSync(0) are woken.
  7. 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 a
HANDLER_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/jumps and gpu/signals/simple ask for a list's state the moment
    sceGeListUpdateStallAddr returns. 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.

  • IgnoreEnqueue in compat.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:
    sceGeListUpdateStallAddr always updates the list's, but only a RUNNING list's update reaches
    the GE. PPSSPP has just DisplayList::stall for 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
    a HANDLER_SUSPEND callback, 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/handlercalls shows it, and fails on it.
    Fixing it means giving GPUCommon a stall address of its own for execution, which touches the
    fast paths in GPUCommonHW.cpp and 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 sceGeListEnQueueHead that fails with 0x800001FE still uses up one of the 64
    lists until the next sceGeBreak(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

  1. The shader generators (GPU/Common/VertexShaderGenerator.cpp, FragmentShaderGenerator.cpp)
    write GLSL from a VShaderID / FShaderID.
  2. glslang compiles it to SPIR-V (GLSLtoSPV() in Common/GPU/Vulkan/VulkanContext.cpp), on a
    dedicated thread per shader (CompileShaderModuleAsync() in GPU/Vulkan/ShaderManagerVulkan.cpp).
  3. vkCreateGraphicsPipelines() runs on the thread pool, which VulkanRenderManager::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 an MTLLibrary, and then builds the
    MTLRenderPipelineState. MoltenVK's vkCreateShaderModule() only copies the SPIR-V.
  4. 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 pass
VK_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, and GPU_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 current
CACHE_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.

bash
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=1 and
    MVK_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() in VulkanQueueRunner.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:

  1. Work out what the string actually means before translating it: find where it's used in the C++,
    what any %1/%d placeholders 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).
  2. Write a scratch file, one line per language, named after the ini file minus the extension, with
    the English string under en_US:
    ini
    [Single]
    en_US = Test string
    sv_SE = Teststräng
    lt-LT = Testeilutė
    For a key that already exists, the en_US line has to be the current English text character for
    character - it overwrites en_US.ini like any other language, so a stray reword there quietly
    changes the string all the others were just translated from. Diff en_US.ini afterwards to check
    it didn't move.
    No trailing # comments on 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.
  3. cargo run -- import-single <scratch-file> <Section> "<Key>" writes them all in, including a new
    key in en_US.ini, tagged # AI translated except 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.
  4. cargo run -- add-new-key <Section> "<Key>" gives every language you skipped the English string
    as a placeholder - Key = Key, with no # same as English marker, 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-lines does 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.
  5. cargo run -- validate at 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() in
Core/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:

c
int sceIoDevctl(const char *devicename, unsigned int cmd, void *indata, int inlen, void *outdata, int outlen);
  • devicename - "emulator:" or "kemulator:".
  • cmd - one of the EMULATOR_DEVCTL__* values below.
  • indata/inlen - input block, meaning depends on cmd. Some commands instead just check whether indata is NULL/non-NULL as a boolean flag.
  • outdata/outlen - output block, meaning depends on cmd. Most commands write a single u32 or float here.

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_AXIS and GET_VKEY, the "input" isn't the indata buffer contents - it's the indata pointer value itself, used directly as an integer index. This matches how the current PPSSPP implementation reads it (argAddr is 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_AXIS and GET_VKEY don'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 internal NKCODE_* 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

c
#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 ---