PPSSPP decided whether a caller was privileged with hleIsKernelMode(), which reports whether the syscall being executed is itself a kernel-only export. That's a different question from the one the hardware answers: on a PSP the privilege belongs to the calling module, and a kernel module reaches sceKernelCreateTlspl through the ordinary ThreadManForUser NID like anything else. So a kernel module asking for partition 1, 3 or 4 got ILLEGAL_PERM where a real PSP hands it over, which the new threads/tls/kernel/partition test shows directly. BlockAllocatorFromID now also accepts a caller whose thread belongs to a kernel module, via a new __KernelCurThreadIsKernelMode(). It checks the thread's own attribute first and then the owning module, because a kernel module's main thread isn't necessarily flagged kernel - the attribute comes from PSP_MAIN_THREAD_ATTR, which needn't set it. That mirrors how sceKernelCreateThread already works out allowKernel. This only ever widens access, and only for threads belonging to kernel modules, so games are unaffected - they run in user modules and see exactly what they saw before.
15 KiB
Writing pspautotests and running them on a real PSP
docs/pspautotests.md covers running the existing tests against PPSSPPHeadless.
This document covers the other half: connecting a real PSP over USB, running a test on it, and
recording its output as the .expected file that becomes the ground truth.
You need this whenever the answer to "what does the hardware actually do?" isn't already in an
.expected file - which is most of the time when you're implementing something new.
What you need
- A PSP with custom firmware and a USB cable. (Verified against firmware 6.61 / PSPLink v3.0.)
- PSPLink installed on the PSP, in
PSP/GAME/psplink, and running - launched from the game menu. This is not the same as connecting the PSP in USB mass-storage mode; if the PC sees "PSP Type A" you're in USB mode, and you want "PSP Type B". - The pspdev toolchain on the PC, which supplies
psp-gcc,pspshandusbhostfs_pc. On macOS it lives in~/pspdevby default and is not onPATH, so prefix commands withPATH="$HOME/pspdev/bin:$PATH"or export it once per shell. On macOS you also needbrew install libusb-compat; on Windows, the libusbK driver via Zadig.
Ask the user to connect the PSP and start PSPLink - you can't do it for them. To confirm it's there before touching anything else:
ioreg -p IOUSB -l -w 0 | grep -i "USB Product Name" # macOS; want "PSP Type B"
The three moving parts
PSP (PSPLink) <--USB--> usbhostfs_pc <--TCP 3000--> pspsh / gentest.py
usbhostfs_pc -b 3000bridges USB to a TCP port and serves the PC filesystem to the PSP ashost0:/. Start it from thepspautotestsroot, becausehost0:/is literally its working directory, and that's where a test's output files land.pspsh -p 3000is a shell on the PSP.pspsh -p 3000 -e "<cmd>"runs one command and exits. Handy ones:ls,pwd,reset(reboot PSPLink after a hung test),pspver,power,scrshot.gentest.pybuilds a test, runs it throughpspsh, waits for it to finish and copies the output over the.expectedfile. It startsusbhostfs_pcitself if the port isn't already open.
Bringing it up, once, from the repo root:
export PATH="$HOME/pspdev/bin:$PATH"
cd pspautotests
(cd common && make) # builds libcommon.a; gentest.py refuses to run without it
usbhostfs_pc -b 3000 & # leave running; prints "Connected to device"
pspsh -p 3000 -e ls # sanity check - should list the pspautotests 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.
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:
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.
#include <common.h>
#include <pspiofilemgr.h>
int main(int argc, char **argv) {
printf("result: %08x\n", sceIoSomething());
return 0;
}
Two things common.h gives you that are easy to miss: ARRAY_SIZE, and checkpoint(), which
prefixes each line with [r]/[x] to record whether a reschedule happened - see
docs/pspautotests.md for what those markers mean. Use plain printf when
scheduling isn't what you're testing.
Then generate the expected output, from the pspautotests root:
python3 gentest.py io/shortname/shortname # path under tests/, without .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:
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.
sceIoDreadreturns a real FAT directory in creation order and a host directory in whatever order the host filesystem gives; if you're testing names,qsortby name and the difference disappears. - Make it repeatable. If the test creates files, delete them at the start and the end - an aborted run otherwise leaves state that changes the next run's output.
- Choose an encoding with no ambiguity. When dumping a buffer, don't render NUL as
.if the data can contain a literal.. Pick a character the data can't contain (|is forbidden in FAT names, so it works there) - otherwise the.expectedquietly lies.
Gotchas
gentest.pyrunsmakefor the whole test directory, not just your test, so a neighbour that doesn't compile stops your test before it ever reaches the PSP. Everything undertests/builds with pspdev GCC 15 as of the "Make the tests build with a current pspdev toolchain" commit; if you hit a broken one anyway,gentest.py -k(--keep) skipsmakeentirely and you can build your own target by hand withmake yourtest.prx.- Rebuilding a
.prxis not free, so don't regenerate one you didn't change. The binaries are committed and were built with a much older SDK. Rebuilding with the current toolchain grows them by roughly a third (testgp.prx: 117 KB to 191 KB), and it can change what a test does:time_tis 64-bit now, sortc/convert'ssceRtcSetTime_t(&pt, 62135596800ULL)marshals differently than the committed binary and stops matching its own.expected. Checkgit statusand revert any.prxyou didn't mean to touch. - A
.prxyou did rebuild deserves a hardware run before you commit it. Build it, run it, and diff the output against the committed.expected- if it differs, decide whether the test genuinely changed or whether the toolchain did. Note the committed.expectedfiles have CRLF line endings (they were recorded on Windows) while a fresh run writes LF, so compare withdiff <(tr -d '\r' < __testoutput.txt) <(tr -d '\r' < the.expected). - Each test PRX stays resident after it runs. Run a handful back to back and the next
Load/Startfails with0x80020190(out of memory) - which looks exactly like a hung test.pspsh -p 3000 -e resetbetween runs, and wait for the PSP to come back before the next one. host0:is not a FAT volume, and it isn't even the same across hosts. It's PSPLink's bridge to the PC, so it inherits the PC's filesystem:tests/io/directorywas recorded on Windows and does not match on macOS, where..reports a different size and short names come back as1.txtrather than1.TXT. Anything testing FAT semantics has to run againstms0:- create a scratch directory on the real memory stick and clean it up - and anything readinghost0:will only reproduce on the OS it was recorded on.- A test that hangs leaves the PSP wedged.
gentest.pyissuespspsh -e resetafter a timeout, but if you ran the PRX by hand, do that yourself. Default timeout is 10s; raise it with-t SECONDS. tests_to_generateingentest.py- the list used when you pass no arguments - is stale; several paths in it no longer exist. Always name the test you want.--sdkvermatters for some APIs.gentest.py --sdkver=6060010 --sdkver-func=606makes the test callsceKernelSetCompiledSdkVersion606()at startup, and-a/--all-versionssweeps every known version reporting which ones behave differently - a fast way to find version-gated behavior. A test can also callsceKernelSetCompiledSdkVersion*()mid-run to cover several versions in one.expected.- The module must be named
TESTMODULE(common.cdoes this) orgentest.pyprints the load line as an unexpected result.
Kernel-mode tests
Some behaviour differs by privilege - sceKernelCreateTlspl accepts partitions 1, 3 and 4 from a
kernel module and rejects them with ILLEGAL_PERM from user mode - so occasionally a test has to
run as a kernel module. tests/threads/tls/kernel is the working example. Set COMMON_KERNEL = 1
in the Makefile before including common.mk and you get a kernel build:
TARGETS = partition
EXTRA_OBJS = tlspl-imports.o
COMMON_KERNEL = 1
COMMON_DIR = ../../../../common
include $(COMMON_DIR)/common.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 bycommon.mk. The stockcrt0_prx.oreferences__libcglue_init, which pulls in the whole of libcglue, which importssceNetInet,sceUtilityand theForUserIO libraries. A kernel module that imports any of those fails to load with8002013C(library not found), and no amount of trimmingLIBShelps because the reference comes from the startup object itself.USE_KERNEL_LIBSselects a different startup - and brings-nostdlib, socommon/kernelglue.csupplies the newlib support hooks that go missing:_sbrkover a fixed heap, the_read/_write/_closestubs, no-op retargetable locks, andmodule_start.- Nothing may import a
ForUserlibrary.ThreadManForUseris fine, butSysMemUserForUseris not, which is whycommon.ccompiles thesceKernelSetCompiledSdkVersionhelpers out of kernel builds - a function pointer table referencing them is enough to pull the stubs in. Watch for this:psp-strings yourtest.prx | grep Forlists what you're importing. Beware also thatThreadManForKerneldoes not export the Tlspl calls - importing them from there links cleanly and then returns8002013A, library not yet linked, from every call. - Size. The kernel partition has about 285 KB free with PSPLink resident, 242 KB of it
contiguous, and loading over the cable needs room for the file and the loaded image at once.
A kernel build therefore gets a 4 KB
schedfBufferand a 4 KB heap instead of the user build's 64 KB and 21 MB.psp-size yourtest.elfplus the.prxfile size againstmeminfo's MAXFREE tells you whether it will fit;800200D9(memblock alloc failed) means it didn't.
Output works normally - printf, checkpoint and schedf all behave - but a kernel build writes
to host0: with sceIo directly rather than through newlib's FILE, since that's what dragged
libcglue in. Formatting still goes through newlib's vsnprintf, so the usual format specifiers
are all available.
Output reaches an emulator the same way it reaches the cable: a kernel build 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:
- Commit and push inside
pspautotests/(the new test directory, including the built.prxand the generated.expected). - Commit in the PPSSPP repo, including both the
test.pychange and the bumped submodule pointer (git add pspautotests) - otherwise CI checks out the old submodule and the test doesn't exist.