Files
ppsspp/docs/building.md
T
libretroadmin f0b97a2e90 Remove the standalone frontends
The libretro core is the frontend, and the headless tools are the test and
debugging hosts. The Windows, UWP, Android, iOS, macOS and SDL apps, the UI
framework and imgui go, along with what only they used: the Win32 debugger
hooks (g_frameMutex, the shutdown lock), JNI thread attachment, the Android
text drawer, RAIntegration, Discord, recent files, game installs, tilt input,
the audio resamplers, the control mapper, memstick moving and the remote ISO
and upload server modes. The web server keeps the debugger. The MSVC, Gradle
and ndk-build projects go too.
2026-10-09 04:56:10 +02:00

9.9 KiB

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

The supported targets are the libretro core and the headless tools (PPSSPPHeadless and the unit tests). For the core, make -C libretro -j32 (platform= picks the target, see libretro/Makefile) or ./b.sh --libretro; for the headless tools, ./b.sh --headless --unittest PPSSPPHeadless PPSSPPUnitTest.

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:

  • 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. PPSSPPUnitTest CmdLine Path Utf8); no arguments lists the available tests.

After a header change, or a git stash/git stash pop that touches a header, build the libretro core clean: its Makefile doesn't track header dependencies, so adding or removing a class member produces a binary where translation units disagree on the object layout. That shows up as crashes that look like a catastrophic code bug and are not one.

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.

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

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

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

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, listed in CMakeLists.txt.

A unit test is often the first thing to call a given function from outside its own .cpp: MSVC links an inline function defined in a .cpp anyway, clang correctly does not, so a test can link on one and not the other, with an undefined symbol pointing at a header line. Fix it by dropping the bogus inline from the definition, not by avoiding the call.

The compilers also disagree about floating point contraction, which matters for any test asserting that a JIT is bit-identical to its C++ reference. Clang folds a * b + c into a single fused multiply-add by default; MSVC never does, under /fp:precise, in Debug or Release. So on arm64, where the JITs emit FMLA, a reference written as a * b + c matches on Mac, Linux and Android and is off by one ULP on Windows on ARM. Don't leave it to the compiler: write fmaf(a, b, c) when the fused result is wanted (MSVC compiles it to a single fmadd), and a * b + c when it isn't. PrescaleUV in GPU/Common/VertexDecoderCommon.cpp picks per architecture, matching what each JIT does. x86 doesn't have the problem, since the SSE2 baseline has no FMA instruction to contract into.

pspautotests are a large set of tests of the PSP OS's API surface, and thus tests our HLE implementation.

To check for regressions, run them exactly the way CI does (see .github/workflows/build.yml):

python test.py -g --graphics=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.)

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

LoongArch64 and RISC-V JITs under qemu

CI tests these JITs by cross-building headless and running pspautotests under qemu-user, and you can do the same on Linux (packages: gcc-14-loongarch64-linux-gnu g++-14-loongarch64-linux-gnu qemu-user, or the riscv64 equivalents):

./b.sh --loongarch64 PPSSPPHeadless     # builds into build-loongarch64/
mkdir -p build-qemu
printf '#!/bin/bash\nexec qemu-loongarch64 -L /usr/loongarch64-linux-gnu "$(dirname "$0")/../build-loongarch64/PPSSPPHeadless" "$@"\n' > build-qemu/PPSSPPHeadless
chmod +x build-qemu/PPSSPPHeadless
python3 test.py -g --graphics=software --cpu=jit-ir --timeout=60 --known-failures=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.

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:

cd build ; make -j32; cd ..