Merge pull request #22002 from hrydgard/ai-autotest-workflow

AI pspautotests workflow description, and a few fixes that came out of it. Plus some manual sceReg implementation
This commit is contained in:
Henrik Rydgård
2026-07-30 10:01:51 +02:00
committed by GitHub
7 changed files with 416 additions and 16 deletions
+4
View File
@@ -45,6 +45,10 @@ when making cross platform changes.
New unit tests are added by listing them in availableTests in unittest.cpp. If they are large, put them in
separate files in the unittest subdirectory. Remember to update both CMakeLists.txt and the visual studio project.
pspautotests are a large set of tests of the PSP OS's API surface, and thus tests our HLE implementation.
See docs/pspautotests.md for a workflow for running pspautotests and improving PPSSPP with the results.
## Adding HLE modules
HLE module implementations live in `Core/HLE/sce<ModuleName>.cpp` / `.h` (e.g. `sceOpenPSID.cpp`, `scePauth.cpp` are good
+60 -3
View File
@@ -560,12 +560,69 @@ int sceKernelReferMbxStatus(SceUID id, u32 infoAddr) {
if (!info.IsValid())
return hleLogError(Log::sceKernel, -1, "invalid pointer");
u32 packet = m->nmb.packetListHead;
// The PSP's ReferMbxStatus doesn't just read packetListHead — it traverses
// the linked list and *updates* firstMessage to handle test programs that
// corrupt the kernel-managed ->next pointers in PSP memory. This behavior
// was confirmed from pspautotests expected output.
//
// The mailbox uses a circular linked list of NativeMbxPacket nodes:
// head -> msg1 -> msg2 -> ... -> msgN -> head
// A single message points to itself (head -> msg -> msg).
// numMessages tracks the intended count separately from the chain.
//
// Three corruption patterns are handled:
//
// 1. NULL in the chain (msgK->next = 0):
// The list is broken. The PSP sets firstMessage = NULL.
// count stays unchanged (kernel owns it, user can't modify it).
//
// 2. Self-pointer with count mismatch (msgK->next = msgK, N > 1):
// The test set a message's next to itself. The PSP detects this and
// sets firstMessage = that message. Subsequent ReceiveMessage calls
// then find a self-pointer where count > 1 and return PSP_MBX_ERROR_DUPLICATE_MSG.
//
// 3. Extra nodes beyond count (count=N but chain has M > N nodes):
// The count-bound walk doesn't return to the original head. The PSP
// keeps walking until it finds the node whose ->next == original_head
// (the wrap point), then sets firstMessage = that node.
// This happens when test code manually splices extra messages into the
// circular chain after a normal sceKernelSendMbx (which only tracks
// count of kernel-sent messages but writes ->next into PSP memory).
u32 head = m->nmb.packetListHead;
u32 packet = head;
for (int i = 0, n = m->nmb.numMessages; i < n; ++i) {
if (packet == 0 || !Memory::IsValidAddress(packet)) {
return hleLogError(Log::sceKernel, -1, "invalid packet list head");
m->nmb.packetListHead = 0;
packet = 0;
break;
}
u32 next = Memory::ReadUnchecked_U32(packet);
if (next == 0) {
m->nmb.packetListHead = 0;
packet = 0;
break;
}
if (next == packet) {
if (n > 1)
m->nmb.packetListHead = packet;
break;
}
packet = next;
}
if (packet != 0 && packet != head) {
// At most numMessages more steps to complete the circle back to head.
for (int i = 0, n = m->nmb.numMessages; i < n; ++i) {
if (packet == 0 || !Memory::IsValidAddress(packet))
break;
u32 next = Memory::ReadUnchecked_U32(packet);
if (next == head) {
m->nmb.packetListHead = packet;
break;
}
if (next == 0 || next == packet)
break;
packet = next;
}
packet = Memory::ReadUnchecked_U32(packet);
}
HLEKernel::CleanupWaitingThreads(WAITTYPE_MBX, id, m->waitingThreads);
+48 -6
View File
@@ -944,10 +944,10 @@ static const KeyValue tree_REGISTRY[] = {
// There might be more categories.
const KeyValue ROOT[] = {
{ "REGISTRY", ValueType::DIR, "", ARRAY_SIZE(tree_REGISTRY), tree_REGISTRY },
{ "CONFIG", ValueType::DIR, "", ARRAY_SIZE(tree_CONFIG), tree_CONFIG },
{ "DATA", ValueType::DIR, "", ARRAY_SIZE(tree_DATA), tree_DATA },
{ "SYSPROFILE", ValueType::DIR, "", ARRAY_SIZE(tree_SYSPROFILE), tree_SYSPROFILE },
{ "CONFIG", ValueType::DIR, "", ARRAY_SIZE(tree_CONFIG), tree_CONFIG },
{ "REGISTRY", ValueType::DIR, "", ARRAY_SIZE(tree_REGISTRY), tree_REGISTRY },
};
// Updater checks for CONFIG/SYSTEM/XMB.
@@ -1308,7 +1308,8 @@ int sceRegGetKeyValueByName(int catHandle, const char *name, u32 bufAddr, u32 si
Memory::MemcpyUnchecked(bufAddr, keyval.strValue, std::min(size, (u32)keyval.intValue));
return hleLogInfo(Log::sceReg, 0, "value: '%s'", keyval.strValue);
case ValueType::INT:
Memory::WriteUnchecked_U32(keyval.intValue, bufAddr);
if (size >= sizeof(u32))
Memory::WriteUnchecked_U32(keyval.intValue, bufAddr);
return hleLogInfo(Log::sceReg, 0, "value: %d (0x%08x)", keyval.intValue, keyval.intValue);
case ValueType::DIR:
case ValueType::FAIL:
@@ -1332,6 +1333,48 @@ int sceRegRemoveKey(int catHandle, int key) {
return hleLogError(Log::sceReg, 0);
}
int sceRegGetCategoryNumAtRoot(int regHandle, u32 numCategoriesPtr) {
if (regHandle != 0) {
return hleLogError(Log::sceReg, 0, "Not found");
}
constexpr int numCategories = ARRAY_SIZE(ROOT);
static_assert(numCategories == 4);
if (!Memory::IsValid4AlignedAddress(numCategoriesPtr)) {
return hleLogError(Log::sceReg, -1, "Invalid pointer");
}
Memory::WriteUnchecked_U32(numCategories, numCategoriesPtr);
return hleLogInfo(Log::sceReg, 0);
}
int sceRegGetCategoryListAtRoot(int regHandle, u32 bufPtr, int numCategories) {
if (regHandle != 0) {
return hleLogError(Log::sceReg, 0, "Not found");
}
if (numCategories > ARRAY_SIZE(ROOT)) {
WARN_LOG(Log::sceReg, "numCategories too large");
numCategories = ARRAY_SIZE(ROOT);
}
if (!Memory::IsValidRange(bufPtr, numCategories * 27)) {
return hleLogError(Log::sceReg, -1, "bad output addr");
}
for (int i = 0; i < numCategories; i++) {
const KeyValue &kv = ROOT[i];
_dbg_assert_msg_(kv.type == ValueType::DIR, "Unexpected non-dir in ROOT");
char *dest = (char *)Memory::GetPointerWrite(bufPtr + i * 27);
if (dest) {
strncpy(dest, kv.name.c_str(), 27);
}
}
return hleLogInfo(Log::sceReg, 0);
}
const HLEFunction sceReg[] = {
{ 0x92E41280, &WrapI_UIU<sceRegOpenRegistry>, "sceRegOpenRegistry", 'i', "xix" },
{ 0xFA8A5739, &WrapI_I<sceRegCloseRegistry>, "sceRegCloseRegistry", 'i', "i" },
@@ -1351,9 +1394,8 @@ const HLEFunction sceReg[] = {
{ 0x4CA16893, &WrapI_IC<sceRegRemoveCategory>, "sceRegRemoveCategory", 'i', "i" },
{ 0x3615BC87, &WrapI_II<sceRegRemoveKey>, "sceRegRemoveKey", 'i', "ii" },
{ 0x9B25EDF1, nullptr, "sceRegExit", 'i', "i" },
// TODO: Add test for these.
{ 0xBE8C1263, nullptr, "sceRegGetCategoryNumAtRoot", 'i', "ii" },
{ 0x835ECE6F, nullptr, "sceRegGetCategoryListAtRoot", 'i', "ipi" },
{ 0xBE8C1263, &WrapI_IU<sceRegGetCategoryNumAtRoot>, "sceRegGetCategoryNumAtRoot", 'i', "ii" },
{ 0x835ECE6F, &WrapI_IUI<sceRegGetCategoryListAtRoot>, "sceRegGetCategoryListAtRoot", 'i', "ipi" },
};
void Register_sceReg() {
+157 -2
View File
@@ -796,10 +796,165 @@ static int sceRtcTickAddYears(u32 destTickPtr, u32 srcTickPtr, int numYears)
return hleNoLog(0);
}
struct RtcParseResult {
ScePspDateTime date;
int tzOffsetMinutes;
bool ok;
};
static const char *rtcParseMonthName(const char *p, int &month) {
static const char *names[] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
for (int i = 0; i < 12; i++) {
if (p[0] == names[i][0] && p[1] == names[i][1] && p[2] == names[i][2]) {
month = i + 1;
return p + 3;
}
}
return nullptr;
}
static bool rtcParseDigits(const char *&p, int n, int &value) {
value = 0;
for (int i = 0; i < n; i++) {
if (p[i] < '0' || p[i] > '9') return false;
value = value * 10 + (p[i] - '0');
}
p += n;
return true;
}
static bool rtcParseRFC3339(const char *s, RtcParseResult &r) {
r.ok = false;
memset(&r.date, 0, sizeof(r.date));
int year, month, day, hour, minute, second, micro = 0;
// YYYY-MM-DD
if (!rtcParseDigits(s, 4, year) || *s++ != '-') return false;
if (!rtcParseDigits(s, 2, month) || *s++ != '-') return false;
if (!rtcParseDigits(s, 2, day)) return false;
if (*s++ != 'T') return false;
// HH:MM:SS
if (!rtcParseDigits(s, 2, hour) || *s++ != ':') return false;
if (!rtcParseDigits(s, 2, minute) || *s++ != ':') return false;
if (!rtcParseDigits(s, 2, second)) return false;
// Optional fractional seconds
if (*s == '.') {
s++;
int frac = 0;
int fracDigits = 0;
while (*s >= '0' && *s <= '9' && fracDigits < 6) {
frac = frac * 10 + (*s - '0');
fracDigits++;
s++;
}
// Scale to microseconds (6 digits)
while (fracDigits < 6) { frac *= 10; fracDigits++; }
while (fracDigits > 6) { frac /= 10; fracDigits--; }
micro = frac;
}
// Timezone
if (*s == 'Z') {
s++;
r.tzOffsetMinutes = 0;
} else if (*s == '+' || *s == '-') {
int tzSign = (*s == '+') ? 1 : -1;
s++;
int tzHour, tzMin;
if (!rtcParseDigits(s, 2, tzHour)) return false;
if (*s != ':') return false;
s++;
if (!rtcParseDigits(s, 2, tzMin)) return false;
r.tzOffsetMinutes = tzSign * (tzHour * 60 + tzMin);
} else {
return false;
}
// Must consume entire string
if (*s != '\0') return false;
r.date.year = year;
r.date.month = month;
r.date.day = day;
r.date.hour = hour;
r.date.minute = minute;
r.date.second = second;
r.date.microsecond = micro;
r.ok = true;
return true;
}
static bool rtcParseRFC2822(const char *s, RtcParseResult &r) {
r.ok = false;
memset(&r.date, 0, sizeof(r.date));
// Optional weekday prefix: [weekday,]
while (*s && *s != ' ' && *s != ',') s++;
if (*s == ',') {
s++;
if (*s != ' ') return false;
s++;
} else {
return false;
}
int day, month, year, hour, minute, second;
// DD
if (!rtcParseDigits(s, 2, day)) return false;
if (*s++ != ' ') return false;
// Mon (3-letter month)
if (!rtcParseMonthName(s, month)) return false;
s += 3;
if (*s++ != ' ') return false;
// YYYY
if (!rtcParseDigits(s, 4, year)) return false;
if (*s++ != ' ') return false;
// HH:MM:SS
if (!rtcParseDigits(s, 2, hour) || *s++ != ':') return false;
if (!rtcParseDigits(s, 2, minute) || *s++ != ':') return false;
if (!rtcParseDigits(s, 2, second)) return false;
if (*s++ != ' ') return false;
// Timezone: ±HHMM
if (*s != '+' && *s != '-') return false;
int tzSign = (*s == '+') ? 1 : -1;
s++;
int tzHour, tzMin;
if (!rtcParseDigits(s, 2, tzHour)) return false;
if (!rtcParseDigits(s, 2, tzMin)) return false;
r.tzOffsetMinutes = tzSign * (tzHour * 60 + tzMin);
if (*s != '\0') return false;
r.date.year = year;
r.date.month = month;
r.date.day = day;
r.date.hour = hour;
r.date.minute = minute;
r.date.second = second;
r.ok = true;
return true;
}
static int sceRtcParseDateTime(u32 destTickPtr, u32 dateStringPtr)
{
ERROR_LOG_REPORT(Log::sceRtc, "UNIMPL sceRtcParseDateTime(%d,%d)", destTickPtr, dateStringPtr);
return 0;
if (!Memory::IsValidAddress(destTickPtr) || !Memory::IsValidAddress(dateStringPtr))
return hleLogError(Log::sceRtc, -1, "bad address");
const char *s = (const char *)Memory::GetPointer(dateStringPtr);
if (!s) {
return hleLogError(Log::sceRtc, -1, "null string");
}
RtcParseResult r;
memset(&r, 0, sizeof(r));
if (rtcParseRFC3339(s, r) || rtcParseRFC2822(s, r)) {
u64 ticks = __RtcPspTimeToTicks(r.date);
s64 offsetUs = (s64)r.tzOffsetMinutes * 60 * 1000000;
ticks -= offsetUs;
Memory::Write_U64(ticks, destTickPtr);
return hleLogDebug(Log::sceRtc, 0);
}
// Parse failed - return -1 without modifying destTickPtr.
return hleLogDebug(Log::sceRtc, -1);
}
static int sceRtcGetLastAdjustedTime(u32 tickPtr)
+141
View File
@@ -0,0 +1,141 @@
# 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:
```
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
### Using test.py (recommended)
Make sure Python is available. On Windows the Microsoft Store alias may interfere; use `py` or the full path to `python.exe`.
- Run all good tests: `py test.py -g`
- Run all broken/next tests: `py test.py -b`
- Run all tests (good + next): `py test.py`
- Run a specific test (or space-separated list): `py test.py -g cpu/cpu_alu/cpu_alu`
- Run a group of tests by prefix match (add `-m`): `py test.py -g -m audio/atrac`
### Direct headless invocation
```
Windows/x64/Debug/PPSSPPHeadless.exe --root pspautotests/tests/../ --compare --timeout=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=N` — seconds per test before killing it (default 5).
- `--graphics=software` — uses software GPU backend (required for headless; no real GPU available).
### What you'll see
**Passing test:**
```
pspautotests/tests/audio/atrac/addstreamdata.prx:
audio/atrac/addstreamdata - passed!
1 tests passed, 0 tests failed, 0 tests missing.
```
**Failing test:**
```
pspautotests/tests/threads/mbx/refer/refer.prx:
O hi 0 prio=00 next=OTHER hi 1 prio=00 next=ITSELF ...
E hi 1 prio=00 next=ITSELF ...
+ ...
0 tests passed, 1 tests failed, 0 tests missing.
Failed tests:
threads/mbx/refer/refer
```
Lines prefixed with `O` are from the `.expected` file (real PSP), `E` is what PPSSPP produced, 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.
## 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`:
```
Windows/x64/Debug/PPSSPPHeadless.exe --root pspautotests/tests/../ --timeout=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.
+5 -4
View File
@@ -234,6 +234,7 @@ tests_good = [
"rtc/rtc",
"rtc/arithmetic",
"rtc/lookup",
"rtc/convert",
"string/string",
"sysmem/freesize",
"sysmem/memblock",
@@ -283,6 +284,8 @@ tests_good = [
"threads/mbx/poll/poll",
"threads/mbx/priority/priority",
"threads/mbx/receive/receive",
"threads/mbx/refer/refer",
"threads/mbx/send/send",
"threads/msgpipe/msgpipe",
"threads/msgpipe/cancel",
"threads/msgpipe/create",
@@ -328,6 +331,7 @@ tests_good = [
"threads/threads/threads",
"threads/tls/create",
"threads/tls/delete",
"threads/tls/get",
"threads/tls/free",
"threads/tls/priority",
"threads/tls/refer",
@@ -464,20 +468,17 @@ tests_next = [
"net/http/http",
"net/primary/ether",
"power/freq",
"rtc/convert",
"sysmem/partition",
"threads/callbacks/cancel",
"threads/callbacks/count",
"threads/callbacks/notify",
# These two mbx tests only appeared to work because they papered over bugs
"threads/mbx/refer/refer",
"threads/mbx/send/send",
"threads/scheduling/dispatch",
"threads/scheduling/scheduling",
"threads/threads/create",
"threads/threads/terminate",
"threads/tls/get",
"threads/vpl/create",
"umd/io/umd_io",
"umd/raw_access/raw_access",