From 8d0f209a8ade0f8201aab54fbfa4261f0cb1ec41 Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:32:16 -0700 Subject: [PATCH 01/21] fix(libc): convert scanf integers as FreeBSD does on Windows --- .../libs/prx/libc/include/WindowsScanning.hpp | 26 +++++++++++++++-- core/libs/tests/Formatting.cpp | 29 ++++++++++++++++++- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/core/libs/prx/libc/include/WindowsScanning.hpp b/core/libs/prx/libc/include/WindowsScanning.hpp index 77bbf1541e..aa71506809 100644 --- a/core/libs/prx/libc/include/WindowsScanning.hpp +++ b/core/libs/prx/libc/include/WindowsScanning.hpp @@ -16,6 +16,14 @@ inline int ScanWindowsArguments_nid_no_patch(const char* format, const void* sou std::vector pointers; std::vector suppressedEnds(static_cast(std::count(format, format + std::strlen(format), '%')), -1); size_t suppressedCount = 0; + struct NarrowInteger { + void* destination; + size_t size; + long long value; + int end; + }; + std::vector narrowIntegers; + narrowIntegers.reserve(suppressedEnds.size()); FormatArguments args(source); while (*format) { const char value = *format++; @@ -26,7 +34,7 @@ inline int ScanWindowsArguments_nid_no_patch(const char* format, const void* sou if (suppressed) translated += *format++; while (*format >= '0' && *format <= '9') translated += *format++; std::string length; - if (*format && std::strchr("hljztL", *format)) { + if (*format && std::strchr("hljztLq", *format)) { length += *format++; if ((length == "h" && *format == 'h') || (length == "l" && *format == 'l')) length += *format++; } @@ -37,7 +45,8 @@ inline int ScanWindowsArguments_nid_no_patch(const char* format, const void* sou return EOF; } ++format; - if (std::strchr("diouxXn", conversion) && (length == "l" || length == "j" || length == "z" || length == "t")) + const bool narrow = !suppressed && std::strchr("diouxX", conversion) && (length.empty() || length == "h" || length == "hh"); + if (narrow || (std::strchr("diouxXn", conversion) && (length == "l" || length == "j" || length == "z" || length == "t" || length == "q"))) translated += "ll"; else translated += length; translated += conversion; @@ -48,13 +57,24 @@ inline int ScanWindowsArguments_nid_no_patch(const char* format, const void* sou if (*format != ']') { errno = 22; return EOF; } translated += *format++; } - if (!suppressed) pointers.push_back(args.Next()); + if (narrow) { + narrowIntegers.push_back({args.Next(), length.empty() ? sizeof(int) : length == "h" ? sizeof(short) : sizeof(char), 0, -1}); + translated += "%n"; + pointers.push_back(&narrowIntegers.back().value); + pointers.push_back(&narrowIntegers.back().end); + } else if (!suppressed) pointers.push_back(args.Next()); else if (conversion != 'n') { translated += "%n"; pointers.push_back(&suppressedEnds[suppressedCount++]); } } const int result = scan(translated.c_str(), reinterpret_cast(pointers.data())); + for (const auto& integer : narrowIntegers) { + if (integer.end < 0) continue; + if (integer.size == sizeof(char)) *static_cast(integer.destination) = static_cast(integer.value); + else if (integer.size == sizeof(short)) *static_cast(integer.destination) = static_cast(integer.value); + else *static_cast(integer.destination) = static_cast(integer.value); + } if (result == EOF && std::any_of(suppressedEnds.begin(), suppressedEnds.begin() + suppressedCount, [](int end) { return end >= 0; })) return 0; return result; diff --git a/core/libs/tests/Formatting.cpp b/core/libs/tests/Formatting.cpp index 1b2fdf7b3b..681655761c 100644 --- a/core/libs/tests/Formatting.cpp +++ b/core/libs/tests/Formatting.cpp @@ -286,4 +286,31 @@ static void CheckAllocatingFormat() { free_nid_postfix(allocated); } -int main() { RunChecks(); CheckAllocatingFormat(); return CheckWidePrecision() ? 0 : 1; } +static void CheckIntegerScanWidths() { + int value = 7; + *__error_nid_postfix() = 0; + Require(sscanf_nid_postfix("2147483648", "%d", &value) == 1 && value == -2147483647 - 1 && *__error_nid_postfix() == 0); + Require(sscanf_nid_postfix("0xFFFFFFFF", "%i", &value) == 1 && value == -1 && *__error_nid_postfix() == 0); + Require(sscanf_nid_postfix("99999999999999999999", "%d", &value) == 1 && value == -1 && *__error_nid_postfix() == 34); + unsigned unsignedValue = 7; + Require(sscanf_nid_postfix("4294967296", "%u", &unsignedValue) == 1 && unsignedValue == 0); + Require(sscanf_nid_postfix("100000001", "%x", &unsignedValue) == 1 && unsignedValue == 1); + Require(sscanf_nid_postfix("40000000001", "%o", &unsignedValue) == 1 && unsignedValue == 1); + short shortValue = 7; + Require(sscanf_nid_postfix("5000000000", "%hd", &shortValue) == 1 && shortValue == -3584); + signed char charValue = 7; + Require(sscanf_nid_postfix("300", "%hhd", &charValue) == 1 && charValue == 44); + long long quad = 7; + Require(sscanf_nid_postfix("3000000000", "%qd", &quad) == 1 && quad == 3000000000ll); + unsigned long long unsignedQuad = 7; + Require(sscanf_nid_postfix("ffffffffff", "%qx", &unsignedQuad) == 1 && unsignedQuad == 0xffffffffffull); + int first = 7; + int second = 7; + int position = 0; + Require(sscanf_nid_postfix("12 34", "%d %d%n", &first, &second, &position) == 2 && first == 12 && second == 34 && position == 5); + value = 7; + Require(sscanf_nid_postfix("x", "%d", &value) == 0 && value == 7); + Require(sscanf_nid_postfix("1 x", "%d %d", &first, &value) == 1 && first == 1 && value == 7); +} + +int main() { RunChecks(); CheckAllocatingFormat(); CheckIntegerScanWidths(); return CheckWidePrecision() ? 0 : 1; } From a7f3f9cff1eac16ec1ef4fbba70eb186784b2c5d Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:40:45 -0700 Subject: [PATCH 02/21] test(libc): run CheckIntegerScanWidths from RunChecks #5070 adds its own check before main() and rewrites the one-line main(), so defining CheckIntegerScanWidths there and calling it from main() made the two pull requests conflict. It now sits before RunChecks and runs after the other scanf checks. --- core/libs/tests/Formatting.cpp | 57 +++++++++++++++++----------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/core/libs/tests/Formatting.cpp b/core/libs/tests/Formatting.cpp index 681655761c..c6712ec5a4 100644 --- a/core/libs/tests/Formatting.cpp +++ b/core/libs/tests/Formatting.cpp @@ -122,6 +122,33 @@ static bool CheckWidePrecision() { return correct; } +static void CheckIntegerScanWidths() { + int value = 7; + *__error_nid_postfix() = 0; + Require(sscanf_nid_postfix("2147483648", "%d", &value) == 1 && value == -2147483647 - 1 && *__error_nid_postfix() == 0); + Require(sscanf_nid_postfix("0xFFFFFFFF", "%i", &value) == 1 && value == -1 && *__error_nid_postfix() == 0); + Require(sscanf_nid_postfix("99999999999999999999", "%d", &value) == 1 && value == -1 && *__error_nid_postfix() == 34); + unsigned unsignedValue = 7; + Require(sscanf_nid_postfix("4294967296", "%u", &unsignedValue) == 1 && unsignedValue == 0); + Require(sscanf_nid_postfix("100000001", "%x", &unsignedValue) == 1 && unsignedValue == 1); + Require(sscanf_nid_postfix("40000000001", "%o", &unsignedValue) == 1 && unsignedValue == 1); + short shortValue = 7; + Require(sscanf_nid_postfix("5000000000", "%hd", &shortValue) == 1 && shortValue == -3584); + signed char charValue = 7; + Require(sscanf_nid_postfix("300", "%hhd", &charValue) == 1 && charValue == 44); + long long quad = 7; + Require(sscanf_nid_postfix("3000000000", "%qd", &quad) == 1 && quad == 3000000000ll); + unsigned long long unsignedQuad = 7; + Require(sscanf_nid_postfix("ffffffffff", "%qx", &unsignedQuad) == 1 && unsignedQuad == 0xffffffffffull); + int first = 7; + int second = 7; + int position = 0; + Require(sscanf_nid_postfix("12 34", "%d %d%n", &first, &second, &position) == 2 && first == 12 && second == 34 && position == 5); + value = 7; + Require(sscanf_nid_postfix("x", "%d", &value) == 0 && value == 7); + Require(sscanf_nid_postfix("1 x", "%d %d", &first, &value) == 1 && first == 1 && value == 7); +} + __attribute__((noinline)) static void APS5_VABI RunChecks() { char buffer[1024]; Require(FormatList(buffer, sizeof(buffer), "Mount requested: %d", 0) == 18); @@ -270,6 +297,7 @@ __attribute__((noinline)) static void APS5_VABI RunChecks() { Require(ScanListS("123 test", "%d %s", &scanNum, scanWord, 8u) == 2 && scanNum == 123 && std::strcmp(scanWord, "test") == 0); char tooSmall[4] = {'x', 'x', 'x', 'x'}; Require(ScanListS("abcdef", "%s", tooSmall, 4u) == 0 && tooSmall[0] == '\0'); + CheckIntegerScanWidths(); std::puts("Formatting checks passed: 10000 iterations"); } @@ -286,31 +314,4 @@ static void CheckAllocatingFormat() { free_nid_postfix(allocated); } -static void CheckIntegerScanWidths() { - int value = 7; - *__error_nid_postfix() = 0; - Require(sscanf_nid_postfix("2147483648", "%d", &value) == 1 && value == -2147483647 - 1 && *__error_nid_postfix() == 0); - Require(sscanf_nid_postfix("0xFFFFFFFF", "%i", &value) == 1 && value == -1 && *__error_nid_postfix() == 0); - Require(sscanf_nid_postfix("99999999999999999999", "%d", &value) == 1 && value == -1 && *__error_nid_postfix() == 34); - unsigned unsignedValue = 7; - Require(sscanf_nid_postfix("4294967296", "%u", &unsignedValue) == 1 && unsignedValue == 0); - Require(sscanf_nid_postfix("100000001", "%x", &unsignedValue) == 1 && unsignedValue == 1); - Require(sscanf_nid_postfix("40000000001", "%o", &unsignedValue) == 1 && unsignedValue == 1); - short shortValue = 7; - Require(sscanf_nid_postfix("5000000000", "%hd", &shortValue) == 1 && shortValue == -3584); - signed char charValue = 7; - Require(sscanf_nid_postfix("300", "%hhd", &charValue) == 1 && charValue == 44); - long long quad = 7; - Require(sscanf_nid_postfix("3000000000", "%qd", &quad) == 1 && quad == 3000000000ll); - unsigned long long unsignedQuad = 7; - Require(sscanf_nid_postfix("ffffffffff", "%qx", &unsignedQuad) == 1 && unsignedQuad == 0xffffffffffull); - int first = 7; - int second = 7; - int position = 0; - Require(sscanf_nid_postfix("12 34", "%d %d%n", &first, &second, &position) == 2 && first == 12 && second == 34 && position == 5); - value = 7; - Require(sscanf_nid_postfix("x", "%d", &value) == 0 && value == 7); - Require(sscanf_nid_postfix("1 x", "%d %d", &first, &value) == 1 && first == 1 && value == 7); -} - -int main() { RunChecks(); CheckAllocatingFormat(); CheckIntegerScanWidths(); return CheckWidePrecision() ? 0 : 1; } +int main() { RunChecks(); CheckAllocatingFormat(); return CheckWidePrecision() ? 0 : 1; } From b19b1c817ec599ac334a60061f5b6ba4dc7278cc Mon Sep 17 00:00:00 2001 From: Federico Marras Date: Wed, 21 Oct 2026 21:21:03 +0200 Subject: [PATCH 03/21] fix(libSceAvPlayer): refuse sceAvPlayerSetTrickSpeed without a source and sceAvPlayerSetAvailableBandwidth after one --- core/libs/prx/libSceAvPlayer/Export.cpp | 2 +- core/libs/prx/libSceAvPlayer/include/AvPlayer.hpp | 1 + core/libs/prx/libSceAvPlayer/src/Player.cpp | 10 ++++++++-- core/libs/tests/GuestAvPlayer.cpp | 3 +++ docs/dev/debt/libSceAvPlayer.md | 2 +- 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/core/libs/prx/libSceAvPlayer/Export.cpp b/core/libs/prx/libSceAvPlayer/Export.cpp index 2cdb89887d..fcd67b2e16 100644 --- a/core/libs/prx/libSceAvPlayer/Export.cpp +++ b/core/libs/prx/libSceAvPlayer/Export.cpp @@ -151,7 +151,7 @@ int APS5_VABI sceAvPlayerSetAvailableBandwidth(AvPlayerInternal* h, uint32_t sta (void)minimum_bandwidth; (void)maximum_bandwidth; if (!h) return SCE_AVPLAYER_ERROR_INVALID_PARAMS; - return SCE_OK; + return ToPlayer(h)->SetAvailableBandwidth(); } int APS5_VABI sceAvPlayerSetLogCallback(void* callback, void* user_data) { diff --git a/core/libs/prx/libSceAvPlayer/include/AvPlayer.hpp b/core/libs/prx/libSceAvPlayer/include/AvPlayer.hpp index 49bf029391..4ff8e6de39 100644 --- a/core/libs/prx/libSceAvPlayer/include/AvPlayer.hpp +++ b/core/libs/prx/libSceAvPlayer/include/AvPlayer.hpp @@ -110,6 +110,7 @@ public: int JumpToTime(std::uint64_t milliseconds); int SetLooping(bool enabled); int SetTrickSpeed(std::int32_t trickSpeed); + int SetAvailableBandwidth(); int SetAvSyncMode(std::uint32_t mode); bool GetVideoData(AvPlayerFrameInfoEx& info); bool GetVideoData(AvPlayerFrameInfo& info); diff --git a/core/libs/prx/libSceAvPlayer/src/Player.cpp b/core/libs/prx/libSceAvPlayer/src/Player.cpp index 191a185847..bc6eb44066 100644 --- a/core/libs/prx/libSceAvPlayer/src/Player.cpp +++ b/core/libs/prx/libSceAvPlayer/src/Player.cpp @@ -334,14 +334,20 @@ int Player::SetLooping(bool enabled) { } int Player::SetTrickSpeed(std::int32_t trickSpeed) { - if (trickSpeed == 0) return SCE_AVPLAYER_ERROR_INVALID_PARAMS; std::lock_guard lock(mutex); + if (!source) return SCE_AVPLAYER_ERROR_OPERATION_FAILED; + if (trickSpeed == 0) return SCE_AVPLAYER_ERROR_INVALID_PARAMS; if (state == State::Stop || state == State::EndOfFile || state == State::Error) return SCE_AVPLAYER_ERROR_OPERATION_FAILED; speed = trickSpeed; - if (source) source->SetSpeed(trickSpeed); + source->SetSpeed(trickSpeed); return SCE_OK; } +int Player::SetAvailableBandwidth() { + std::lock_guard lock(mutex); + return source ? SCE_AVPLAYER_ERROR_OPERATION_FAILED : SCE_OK; +} + int Player::SetAvSyncMode(std::uint32_t mode) { if (mode != SyncModeDefault && mode != SyncModeNone) return SCE_AVPLAYER_ERROR_INVALID_PARAMS; std::lock_guard lock(mutex); diff --git a/core/libs/tests/GuestAvPlayer.cpp b/core/libs/tests/GuestAvPlayer.cpp index 68ef734c2f..eb9d2e150d 100644 --- a/core/libs/tests/GuestAvPlayer.cpp +++ b/core/libs/tests/GuestAvPlayer.cpp @@ -833,6 +833,8 @@ void TestPlayback() { Check(sceAvPlayerPostInit(player, &post) == 0, "post init failed"); Check(sceAvPlayerPostInit(player, nullptr) == InvalidParams, "post init accepted null data"); Check(sceAvPlayerStreamCount(player) == OperationFailed, "stream count without a source"); + Check(sceAvPlayerSetTrickSpeed(player, 100) == OperationFailed, "trick speed accepted without a source"); + Check(sceAvPlayerSetTrickSpeed(player, 0) == OperationFailed, "trick speed checked the speed before the source"); Check(!sceAvPlayerIsActive(player), "active without a source"); Check(sceAvPlayerAddSource(player, "/app0/missing.mp4") == OperationFailed, "missing file accepted"); Check(sceAvPlayerAddSource(player, "/app0/missing/../avplayer.mp4") == OperationFailed, "missing parent accepted"); @@ -1068,6 +1070,7 @@ void TestExtendedExports() { Check(sceAvPlayerSetAvailableBandwidth(player, 4000, 1000, 8000) == 0, "bandwidth rejected"); Check(sceAvPlayerSetAvailableBandwidth(nullptr, 4000, 1000, 8000) == InvalidParams, "bandwidth accepted for a null player"); Check(sceAvPlayerAddSource(player, "/app0/avplayer.mp4") == 0, "add source failed"); + Check(sceAvPlayerSetAvailableBandwidth(player, 4000, 1000, 8000) == OperationFailed, "bandwidth accepted after a source"); Check(WaitFor([&] { return events.Seen(EventReady); }), "ready event missing"); CheckStreamInfoEx(player); Check(sceAvPlayerEnableStream(player, VideoStream) == 0, "enable video failed"); diff --git a/docs/dev/debt/libSceAvPlayer.md b/docs/dev/debt/libSceAvPlayer.md index 5a2f1941ed..2e7cd27eee 100644 --- a/docs/dev/debt/libSceAvPlayer.md +++ b/docs/dev/debt/libSceAvPlayer.md @@ -2,7 +2,7 @@ ### Silent stubs -- [libSceAvPlayer](../../../core/libs/prx/libSceAvPlayer/Export.cpp): `sceAvPlayerSetLogCallback` accepts a callback that is never called, as the player produces no log messages, and `sceAvPlayerSetAvailableBandwidth` has no effect, as it governs HLS sources, which `sceAvPlayerAddSource` does not implement. +- [libSceAvPlayer](../../../core/libs/prx/libSceAvPlayer/Export.cpp): `sceAvPlayerSetLogCallback` accepts a callback that is never called, as the player produces no log messages, and `sceAvPlayerSetAvailableBandwidth` has no effect, as it governs HLS sources, which `sceAvPlayerAddSource` does not implement; after a source is added it returns `OPERATION_FAILED`, as [prosper's reading of the module](https://github.com/mattias800/prosper/blob/8dcb5dfdb9997ede7c76783995010d6abd1222f8/prosper/src/hle/video/avplayer.cpp#L960-L972) describes. - [sceAvPlayerSetTrickSpeed](../../../core/libs/prx/libSceAvPlayer/src/Source.cpp) (libSceAvPlayer) with a negative speed runs the clock backwards but delivers no frames; when a forward speed is set again, playback resumes from the rewound time. ### Unknown function info From b39956b4e1d8f86180843c1da76879d39d321606 Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:04:54 -0700 Subject: [PATCH 04/21] fix(libc): keep the signedness of narrow scanf conversions on Windows A %u, %o or %x scanned through the 64-bit temporary now writes an unsigned long long and is stored through an unsigned destination, as the rewritten %llu, %llo and %llx require. --- core/libs/prx/libc/include/WindowsScanning.hpp | 16 ++++++++++++---- core/libs/tests/Formatting.cpp | 9 +++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/core/libs/prx/libc/include/WindowsScanning.hpp b/core/libs/prx/libc/include/WindowsScanning.hpp index aa71506809..420d8ba000 100644 --- a/core/libs/prx/libc/include/WindowsScanning.hpp +++ b/core/libs/prx/libc/include/WindowsScanning.hpp @@ -19,7 +19,9 @@ inline int ScanWindowsArguments_nid_no_patch(const char* format, const void* sou struct NarrowInteger { void* destination; size_t size; + bool isUnsigned; long long value; + unsigned long long unsignedValue; int end; }; std::vector narrowIntegers; @@ -58,10 +60,12 @@ inline int ScanWindowsArguments_nid_no_patch(const char* format, const void* sou translated += *format++; } if (narrow) { - narrowIntegers.push_back({args.Next(), length.empty() ? sizeof(int) : length == "h" ? sizeof(short) : sizeof(char), 0, -1}); + const bool isUnsigned = std::strchr("ouxX", conversion) != nullptr; + narrowIntegers.push_back({args.Next(), length.empty() ? sizeof(int) : length == "h" ? sizeof(short) : sizeof(char), isUnsigned, 0, 0, -1}); translated += "%n"; - pointers.push_back(&narrowIntegers.back().value); - pointers.push_back(&narrowIntegers.back().end); + auto& integer = narrowIntegers.back(); + pointers.push_back(isUnsigned ? static_cast(&integer.unsignedValue) : static_cast(&integer.value)); + pointers.push_back(&integer.end); } else if (!suppressed) pointers.push_back(args.Next()); else if (conversion != 'n') { translated += "%n"; @@ -71,7 +75,11 @@ inline int ScanWindowsArguments_nid_no_patch(const char* format, const void* sou const int result = scan(translated.c_str(), reinterpret_cast(pointers.data())); for (const auto& integer : narrowIntegers) { if (integer.end < 0) continue; - if (integer.size == sizeof(char)) *static_cast(integer.destination) = static_cast(integer.value); + if (integer.isUnsigned) { + if (integer.size == sizeof(char)) *static_cast(integer.destination) = static_cast(integer.unsignedValue); + else if (integer.size == sizeof(short)) *static_cast(integer.destination) = static_cast(integer.unsignedValue); + else *static_cast(integer.destination) = static_cast(integer.unsignedValue); + } else if (integer.size == sizeof(char)) *static_cast(integer.destination) = static_cast(integer.value); else if (integer.size == sizeof(short)) *static_cast(integer.destination) = static_cast(integer.value); else *static_cast(integer.destination) = static_cast(integer.value); } diff --git a/core/libs/tests/Formatting.cpp b/core/libs/tests/Formatting.cpp index e185038394..3b5f4e37c6 100644 --- a/core/libs/tests/Formatting.cpp +++ b/core/libs/tests/Formatting.cpp @@ -145,6 +145,15 @@ static void CheckIntegerScanWidths() { Require(sscanf_nid_postfix("5000000000", "%hd", &shortValue) == 1 && shortValue == -3584); signed char charValue = 7; Require(sscanf_nid_postfix("300", "%hhd", &charValue) == 1 && charValue == 44); + Require(sscanf_nid_postfix("4294967295", "%u", &unsignedValue) == 1 && unsignedValue == 4294967295u); + unsigned short unsignedShort = 7; + Require(sscanf_nid_postfix("40000", "%hu", &unsignedShort) == 1 && unsignedShort == 40000); + Require(sscanf_nid_postfix("70000", "%hu", &unsignedShort) == 1 && unsignedShort == 4464); + Require(sscanf_nid_postfix("ffff", "%hx", &unsignedShort) == 1 && unsignedShort == 0xffff); + unsigned char unsignedChar = 7; + Require(sscanf_nid_postfix("200", "%hhu", &unsignedChar) == 1 && unsignedChar == 200); + Require(sscanf_nid_postfix("300", "%hhu", &unsignedChar) == 1 && unsignedChar == 44); + Require(sscanf_nid_postfix("377", "%hho", &unsignedChar) == 1 && unsignedChar == 255); long long quad = 7; Require(sscanf_nid_postfix("3000000000", "%qd", &quad) == 1 && quad == 3000000000ll); unsigned long long unsignedQuad = 7; From 6cf0937ccfd128f17612d348ba21a3d0113acf43 Mon Sep 17 00:00:00 2001 From: Kraken <10617703+UnhealthyKraken@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:02:41 +0100 Subject: [PATCH 05/21] fix(libkernel): let variant timed joins use the canonical provider --- core/libs/prx/libkernel/CMakeLists.txt | 9 ++ .../prx/libkernel_sys/Export.Discovery.cpp | 5 -- .../tests/test_windows_timed_join_imports.py | 86 +++++++++++++++++++ 3 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 core/libs/tests/test_windows_timed_join_imports.py diff --git a/core/libs/prx/libkernel/CMakeLists.txt b/core/libs/prx/libkernel/CMakeLists.txt index 641988178c..1c4c41c926 100644 --- a/core/libs/prx/libkernel/CMakeLists.txt +++ b/core/libs/prx/libkernel/CMakeLists.txt @@ -1917,6 +1917,15 @@ if(BUILD_TESTING) target_include_directories(guest_timed_join_tests PRIVATE ${LIBS_INCLUDE_DIR}) target_link_libraries(guest_timed_join_tests PRIVATE libkernel libc ${CMAKE_DL_LIBS}) configure_windows_unwind(guest_timed_join_tests) + if(WIN32 AND Python3_Interpreter_FOUND) + foreach(joinProvider IN ITEMS libkernel_sys libkernel_web) + add_test(NAME guest_timed_join_import_${joinProvider} COMMAND ${Python3_EXECUTABLE} + ${LIBS_INCLUDE_DIR}/tests/test_windows_timed_join_imports.py $ ${PATCHED_DIR} + ${joinProvider}.prx $) + set_tests_properties(guest_timed_join_import_${joinProvider} PROPERTIES TIMEOUT 25 + ENVIRONMENT_MODIFICATION "PATH=path_list_prepend:$") + endforeach() + endif() foreach(joinMode IN ITEMS normal coarse) add_test(NAME guest_timed_join_${joinMode} COMMAND guest_timed_join_tests $) set_tests_properties(guest_timed_join_${joinMode} PROPERTIES TIMEOUT 15) diff --git a/core/libs/prx/libkernel_sys/Export.Discovery.cpp b/core/libs/prx/libkernel_sys/Export.Discovery.cpp index 8b3da583e4..99b7da3626 100644 --- a/core/libs/prx/libkernel_sys/Export.Discovery.cpp +++ b/core/libs/prx/libkernel_sys/Export.Discovery.cpp @@ -523,11 +523,6 @@ int APS5_VABI pthread_switch_delete_np_nid_postfix(void) { return 0; } -int APS5_VABI pthread_timedjoin_np_nid_postfix(void) { - NotImplemented_nid_no_patch(__func__); - return 0; -} - int APS5_VABI ptrace_nid_postfix(void) { NotImplemented_nid_no_patch(__func__); return 0; diff --git a/core/libs/tests/test_windows_timed_join_imports.py b/core/libs/tests/test_windows_timed_join_imports.py new file mode 100644 index 0000000000..4a5eeef09c --- /dev/null +++ b/core/libs/tests/test_windows_timed_join_imports.py @@ -0,0 +1,86 @@ +import os +import shutil +import struct +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'relinker' / 'relinker' / 'tests')) +from guest_import_fixtures import executable, fixture_directory + + +def call(code, slot): + code.extend(b'\xff\x15' + struct.pack(' Date: Thu, 22 Oct 2026 10:28:32 +0200 Subject: [PATCH 06/21] docs(libSceAvPlayer): record the unmeasured order of the trick speed checks after the source check --- docs/dev/debt/libSceAvPlayer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/debt/libSceAvPlayer.md b/docs/dev/debt/libSceAvPlayer.md index 2e7cd27eee..9f68138b00 100644 --- a/docs/dev/debt/libSceAvPlayer.md +++ b/docs/dev/debt/libSceAvPlayer.md @@ -3,7 +3,7 @@ ### Silent stubs - [libSceAvPlayer](../../../core/libs/prx/libSceAvPlayer/Export.cpp): `sceAvPlayerSetLogCallback` accepts a callback that is never called, as the player produces no log messages, and `sceAvPlayerSetAvailableBandwidth` has no effect, as it governs HLS sources, which `sceAvPlayerAddSource` does not implement; after a source is added it returns `OPERATION_FAILED`, as [prosper's reading of the module](https://github.com/mattias800/prosper/blob/8dcb5dfdb9997ede7c76783995010d6abd1222f8/prosper/src/hle/video/avplayer.cpp#L960-L972) describes. -- [sceAvPlayerSetTrickSpeed](../../../core/libs/prx/libSceAvPlayer/src/Source.cpp) (libSceAvPlayer) with a negative speed runs the clock backwards but delivers no frames; when a forward speed is set again, playback resumes from the rewound time. +- [sceAvPlayerSetTrickSpeed](../../../core/libs/prx/libSceAvPlayer/src/Source.cpp) (libSceAvPlayer) with a negative speed runs the clock backwards but delivers no frames; when a forward speed is set again, playback resumes from the rewound time. With a source, a zero speed returns `INVALID_PARAMS` before the `OPERATION_FAILED` of a stopped, finished or failed player, the order AnyPS5 had before; that order is unmeasured. In [prosper's reading of the module](https://github.com/mattias800/prosper/blob/8dcb5dfdb9997ede7c76783995010d6abd1222f8/prosper/src/hle/video/avplayer.cpp#L929-L959), only the missing source is checked first. A zero speed is refused with `NOT_SUPPORTED`, with every other speed from -399 to 399 except 100, and no player state is checked. ### Unknown function info From 6b1916a2edf869d3553aee815890fa65e5e878c0 Mon Sep 17 00:00:00 2001 From: harisibrar1 <207127467+harisibrar1@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:51:39 -0400 Subject: [PATCH 07/21] test(relinker): decode relinker output as UTF-8 in the path-checking tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relinker writes paths to stdout, stderr and its patch manifest as UTF-8, but seven test scripts read them with the locale encoding. On Windows that is the ANSI code page, so with a build directory such as C:/Users/José/AnyPS5/build (the tests keep their temporary files under the build tree) the decoded paths no longer match and game_patches, guest_needed_modules, guest_symbol_names, input_file, input_preservation, relinker_executables and tls_diagnostics fail. Co-Authored-By: Claude Opus 5.5 --- .../relinker/tests/test_executables.py | 2 +- .../relinker/tests/test_game_patches.py | 8 ++++---- .../tests/test_guest_needed_modules.py | 10 +++++----- .../relinker/tests/test_guest_symbol_names.py | 10 +++++----- .../relinker/tests/test_input_file.py | 2 +- .../relinker/tests/test_input_preservation.py | 20 +++++++++---------- .../relinker/tests/test_tls_diagnostics.py | 2 +- 7 files changed, 27 insertions(+), 27 deletions(-) diff --git a/core/relinker/relinker/tests/test_executables.py b/core/relinker/relinker/tests/test_executables.py index 844c33eb10..b58acc9317 100644 --- a/core/relinker/relinker/tests/test_executables.py +++ b/core/relinker/relinker/tests/test_executables.py @@ -27,7 +27,7 @@ def main(): output = case / "out" / ("app.exe" if windows else "app.elf") output.parent.mkdir(exist_ok=True) result = subprocess.run([str(relinker), *(["--windows"] if windows else []), "--registry", *options, str(source), str(output)], - capture_output=True, text=True, timeout=60) + capture_output=True, text=True, encoding="utf-8", timeout=60) return result, output for windows in (False, True): diff --git a/core/relinker/relinker/tests/test_game_patches.py b/core/relinker/relinker/tests/test_game_patches.py index 4fb2fd064a..960ea15692 100644 --- a/core/relinker/relinker/tests/test_game_patches.py +++ b/core/relinker/relinker/tests/test_game_patches.py @@ -36,7 +36,7 @@ def main(): work = Path(directory) def run(*arguments): - return subprocess.run([str(relinker), *arguments], capture_output=True, text=True, timeout=60) + return subprocess.run([str(relinker), *arguments], capture_output=True, text=True, encoding="utf-8", timeout=60) def case(name): root = work / name @@ -64,7 +64,7 @@ def main(): assert f"Patch: eboot.bin [flip_bit] va={address:#x} off=" in result.stdout, result.stdout assert f"{expected:02x} -> {expected ^ 1:02x} (flip one bit)\n" in result.stdout, result.stdout manifest = output.parent / "app.patches.toml" - text = manifest.read_text() + text = manifest.read_text(encoding="utf-8") assert 'main_target = "eboot.bin"' in text and 'name = "flip_bit"' in text, text assert 'main_target = "eboot.bin"\nlinux_base = 0x400000\n' in text, text assert f"Patch manifest: {manifest}\n" in result.stdout @@ -154,7 +154,7 @@ def main(): def relocation_overlap(relinker, work): def run(*arguments): - return subprocess.run([str(relinker), *arguments], capture_output=True, text=True, timeout=60) + return subprocess.run([str(relinker), *arguments], capture_output=True, text=True, encoding="utf-8", timeout=60) def patch_file(path, target, address, expected): path.write_text(f'format = 1\n\n[[patch]]\ntarget = "{target}"\nva = {address:#x}\nexpected = "{expected}"\nvalue = "ffff"\n') @@ -196,7 +196,7 @@ def relocation_overlap(relinker, work): def writer_overlap(relinker, work): def run(*arguments): - return subprocess.run([str(relinker), *arguments], capture_output=True, text=True, timeout=60) + return subprocess.run([str(relinker), *arguments], capture_output=True, text=True, encoding="utf-8", timeout=60) def attempt(name, module, options, address, expected, value="ff", target="sce_module/sample.prx"): case = work / name diff --git a/core/relinker/relinker/tests/test_guest_needed_modules.py b/core/relinker/relinker/tests/test_guest_needed_modules.py index 6b2b18f2c8..35739524aa 100644 --- a/core/relinker/relinker/tests/test_guest_needed_modules.py +++ b/core/relinker/relinker/tests/test_guest_needed_modules.py @@ -63,7 +63,7 @@ def check_guest_needed(relinker, work): (case / "prx" / "consumer.prx").write_bytes(module_with_needed(names, entries)) output = case / ("output.exe" if windows else "output.elf") result = subprocess.run([str(relinker), *(["--windows"] if windows else []), str(source), str(output)], - capture_output=True, text=True, timeout=30) + capture_output=True, text=True, encoding="utf-8", timeout=30) assert result.returncode == 0, (label, windows, result.stdout, result.stderr) artifact = case / "app0" / "prx" / "consumer.prx.guest.prx" assert artifact.read_bytes().startswith(b"MZ" if windows else b"\x7fELF"), artifact @@ -81,7 +81,7 @@ def check_guest_needed(relinker, work): module.write_bytes(module_with_needed((first, invalid), (0, 0, 1))) output = case / ("output.exe" if windows else "output.elf") result = subprocess.run([str(relinker), *(["--windows"] if windows else []), str(source), str(output)], - capture_output=True, text=True, timeout=30) + capture_output=True, text=True, encoding="utf-8", timeout=30) assert result.returncode == 2 and f"{module}: Invalid dependency: {invalid.decode()}\n" in result.stderr, result.stderr assert not output.exists() and not (case / "app0").exists(), output @@ -99,7 +99,7 @@ def main(): source.write_bytes(executable_with_needed(needed, repeats)) output = case / ("output.exe" if windows else "output.elf") result = subprocess.run([str(relinker), *(["--windows"] if windows else []), str(source), str(output)], - capture_output=True, text=True, timeout=30) + capture_output=True, text=True, encoding="utf-8", timeout=30) return result, output for windows in (False, True): @@ -118,7 +118,7 @@ def main(): assert layout in result.stdout, result.stdout for spelling in (os.path.join(".", output.name), os.path.join("..", case.name, output.name)): relative = subprocess.run([str(relinker), *(["--windows"] if windows else []), "input.elf", spelling], cwd=case, - capture_output=True, text=True, timeout=30) + capture_output=True, text=True, encoding="utf-8", timeout=30) assert relative.returncode == 0 and layout in relative.stdout, (spelling, relative.stdout, relative.stderr) if windows and os.name == "nt": run = subprocess.run([str(output)], capture_output=True, text=True, timeout=30) @@ -203,7 +203,7 @@ def main(): source = case / "input.elf" source.write_bytes(executable_with_needed(b"libSceVideoOut.prx", 2)) result = subprocess.run([str(relinker), "--macos", str(source), str(case / "output")], - capture_output=True, text=True, timeout=30) + capture_output=True, text=True, encoding="utf-8", timeout=30) assert result.returncode == 0, (result.stdout, result.stderr) print("Guest needed module tests passed") diff --git a/core/relinker/relinker/tests/test_guest_symbol_names.py b/core/relinker/relinker/tests/test_guest_symbol_names.py index 3580b83e32..e41660d237 100644 --- a/core/relinker/relinker/tests/test_guest_symbol_names.py +++ b/core/relinker/relinker/tests/test_guest_symbol_names.py @@ -148,7 +148,7 @@ def relink(relinker, case, executable, modules): for name, image in modules.items(): (case / "sce_module" / name).write_bytes(image) output = case / "output.elf" - result = subprocess.run([str(relinker), str(source), str(output)], capture_output=True, text=True, timeout=30) + result = subprocess.run([str(relinker), str(source), str(output)], capture_output=True, text=True, encoding="utf-8", timeout=30) assert result.returncode == 0, (result.stdout, result.stderr) return output @@ -192,7 +192,7 @@ def main(): for name, image in twins.items(): (work / "needed-both" / "sce_module" / name).write_bytes(image) result = subprocess.run([str(relinker), str(work / "needed-both" / "input.elf"), str(work / "needed-both" / "output.elf")], - capture_output=True, text=True, timeout=30) + capture_output=True, text=True, encoding="utf-8", timeout=30) assert result.returncode != 0 and "Ambiguous guest import AAAAAAAAAAA" in result.stderr, (result.stdout, result.stderr) guests = relink(relinker, work / "guest-declared", importing_executable([]), { @@ -222,7 +222,7 @@ def main(): needed_module_import("YYYYYYYYYYY", "XXXXXXXXXXX#A#B", "libc.prx", (tag,))) output = case / ("output.exe" if windows else "output.elf") result = subprocess.run([str(relinker), *(["--windows"] if windows else []), str(case / "input.elf"), str(output)], - capture_output=True, text=True, timeout=30) + capture_output=True, text=True, encoding="utf-8", timeout=30) assert result.returncode == 0, (windows, hex(tag), result.stdout, result.stderr) if not windows: modules = case / "app0" / "sce_module" @@ -240,7 +240,7 @@ def main(): module = case / "sce_module" / "libc.prx" module.write_bytes(needed_module_import("XXXXXXXXXXX", "YYYYYYYYYYY#A#B", "libkernel.prx", (0x6100000F, 0x61000045))) result = subprocess.run([str(relinker), str(case / "input.elf"), str(case / "output.elf")], - capture_output=True, text=True, timeout=30) + capture_output=True, text=True, encoding="utf-8", timeout=30) assert result.returncode == 2 and f"{module}: Guest module mixes PS4 and PS5 needed-module tags\n" in result.stderr, result.stderr assert not (case / "output.elf").exists() @@ -250,7 +250,7 @@ def main(): module = case / "sce_module" / "libc.prx" module.write_bytes(needed_module_import("XXXXXXXXXXX", "YYYYYYYYYYY#A#C", "libkernel.prx")) result = subprocess.run([str(relinker), str(case / "input.elf"), str(case / "output.elf")], - capture_output=True, text=True, timeout=30) + capture_output=True, text=True, encoding="utf-8", timeout=30) assert result.returncode == 2 and "Unknown import module ID: YYYYYYYYYYY#A#C\n" in result.stderr \ and f"Input: {module}\n" in result.stderr, result.stderr assert not (case / "output.elf").exists() diff --git a/core/relinker/relinker/tests/test_input_file.py b/core/relinker/relinker/tests/test_input_file.py index 6146103953..2124c7395e 100644 --- a/core/relinker/relinker/tests/test_input_file.py +++ b/core/relinker/relinker/tests/test_input_file.py @@ -21,7 +21,7 @@ def main(): output = work / (name + ".out") registry = work / (name + ".registry.json") result = subprocess.run([str(relinker), "--skip-sce-module", "--registry", *flags, str(source), str(output)], - capture_output=True, text=True, timeout=20) + capture_output=True, text=True, encoding="utf-8", timeout=20) assert result.returncode == 2 and error in result.stderr and "bad_alloc" not in result.stderr, (name, flags, result) assert not output.exists() and not registry.exists(), (name, flags, result) diff --git a/core/relinker/relinker/tests/test_input_preservation.py b/core/relinker/relinker/tests/test_input_preservation.py index 241dd1f4bc..4fb96462f0 100644 --- a/core/relinker/relinker/tests/test_input_preservation.py +++ b/core/relinker/relinker/tests/test_input_preservation.py @@ -30,7 +30,7 @@ def check_executable_hardlinks(relinker, work): links = protected.stat().st_nlink try: result = subprocess.run([str(relinker), *command], cwd=case, - capture_output=True, text=True, timeout=20) + capture_output=True, text=True, encoding="utf-8", timeout=20) assert result.returncode == 2 and "output would overwrite" in result.stderr, ( windows, output_name, input_name, readonly, result.stdout, result.stderr) for path, original in inputs.items(): @@ -61,7 +61,7 @@ def check_executable_hardlinks(relinker, work): path.chmod(stat.S_IREAD) try: result = subprocess.run([str(relinker), *command], cwd=case, - capture_output=True, text=True, timeout=20) + capture_output=True, text=True, encoding="utf-8", timeout=20) assert result.returncode == 0, (readonly, result.stdout, result.stderr) for name in ("executable", "secondary-one", "secondary-two"): assert outputs[name].read_bytes().startswith(b"MZ") @@ -111,7 +111,7 @@ def check_diagnostic_inputs(relinker, work): options.append("--registry") result = subprocess.run([str(relinker), "--windows", "--windows-diagnostics", *options, str(source), str(output)], cwd=diagnostics.parent, - capture_output=True, text=True, timeout=20) + capture_output=True, text=True, encoding="utf-8", timeout=20) owner = "an input module" if bundled else "the input executable" assert result.returncode == 2 and f"Windows import diagnostics output would overwrite {owner}" in result.stderr, (result.stdout, result.stderr) assert str(diagnostics) in result.stderr, result.stderr @@ -134,7 +134,7 @@ def check_diagnostic_inputs(relinker, work): options = ["--windows-diagnostics"] if enabled else [] result = subprocess.run([str(relinker), "--windows", "--skip-sce-module", *options, str(source), str(output)], cwd=case, - capture_output=True, text=True, timeout=20) + capture_output=True, text=True, encoding="utf-8", timeout=20) assert result.returncode == 0 and output.read_bytes().startswith(b"MZ"), (result.stdout, result.stderr) assert source.read_bytes() == original assert diagnostics.read_bytes() == (b"" if enabled else original) @@ -177,7 +177,7 @@ def check_registry_inputs(relinker, work, windows, needed=False): previous_output = b"previous executable" output.write_bytes(previous_output) result = subprocess.run([str(relinker), *options, "--registry", str(source), str(output)], - capture_output=True, text=True, timeout=20) + capture_output=True, text=True, encoding="utf-8", timeout=20) kind = "Needed libraries" if needed else "Registry" expected = f"{kind} output would overwrite an input module" if bundled else f"{kind} output would overwrite the input executable" assert result.returncode == 2 and expected in result.stderr, (result.returncode, result.stdout, result.stderr) @@ -204,7 +204,7 @@ def check_registry_after_failed_patch(relinker, work): for registry in registries: registry.write_bytes(previous_registry) result = subprocess.run([str(relinker), "--windows", "--registry", str(source), str(output)], - capture_output=True, text=True, timeout=20) + capture_output=True, text=True, encoding="utf-8", timeout=20) assert result.returncode == 2 and "Invalid Windows icon PNG" in result.stderr, (result.stdout, result.stderr) assert output.read_bytes() == previous_output assert all(registry.read_bytes() == previous_registry for registry in registries) @@ -225,7 +225,7 @@ def check_output_directory(relinker, work): output = str(target) if existing else str(target) + os.sep options = ["--windows"] if windows else [] result = subprocess.run([str(relinker), *options, str(source), output], cwd=case, - capture_output=True, text=True, timeout=20) + capture_output=True, text=True, encoding="utf-8", timeout=20) assert result.returncode == 2 and "Executable output is a directory" in result.stderr, (result.stdout, result.stderr) assert not (case / "app0").exists() and not (target / "app0").exists() assert target.exists() == existing @@ -273,7 +273,7 @@ def check_desktop_inputs(relinker, work): registry_path.write_bytes(previous_registry) options = ["--registry"] if registry else [] result = subprocess.run([str(relinker), *options, str(source), str(output)], - capture_output=True, text=True, timeout=20) + capture_output=True, text=True, encoding="utf-8", timeout=20) kind = "Desktop entry" if desktop else "Icon" owner = "an input module" if bundled else "the input executable" assert result.returncode == 2 and f"{kind} output would overwrite {owner}" in result.stderr, (result.stdout, result.stderr) @@ -332,7 +332,7 @@ def main(): arguments = [] if bundled else ["--skip-sce-module"] result = subprocess.run([str(relinker), *options, *arguments, "--registry", str(source), str(output)], - capture_output=True, text=True, timeout=20) + capture_output=True, text=True, encoding="utf-8", timeout=20) expected = "Executable output would overwrite an input module" if bundled else "Executable output would overwrite the input executable" if (result.returncode != 2 or expected not in result.stderr or source.read_bytes() != original @@ -365,7 +365,7 @@ def main(): if existing: output.write_bytes(b"previous output") result = subprocess.run([str(relinker), *options, "--registry", str(source), str(output)], - capture_output=True, text=True, timeout=20) + capture_output=True, text=True, encoding="utf-8", timeout=20) converted = case / "app0" / "sce_module" / "sample.prx.guest.prx" magic = b"MZ" if windows else b"\x7fELF" if (result.returncode != 0 or not output.read_bytes().startswith(magic) diff --git a/core/relinker/relinker/tests/test_tls_diagnostics.py b/core/relinker/relinker/tests/test_tls_diagnostics.py index 51c9614bba..16a566ab08 100644 --- a/core/relinker/relinker/tests/test_tls_diagnostics.py +++ b/core/relinker/relinker/tests/test_tls_diagnostics.py @@ -21,7 +21,7 @@ def main(): def check(input_path, offset, options): result = subprocess.run([str(relinker), "--windows", *options, str(source), str(output)], - capture_output=True, text=True, timeout=30) + capture_output=True, text=True, encoding="utf-8", timeout=30) assert result.returncode == 2, result assert "Unsupported Windows guest TLS instruction" in result.stderr, result.stderr assert f"(bytes: {TLS_INSTRUCTION.hex(' ')})" in result.stderr, result.stderr From 59693ae54ca6f169b69f21227856df2a6fd4f2c8 Mon Sep 17 00:00:00 2001 From: JJenkx Date: Wed, 21 Oct 2026 22:46:23 -0400 Subject: [PATCH 08/21] feat(tools): symbolize the Windows samplers' stacks The Windows samplers (APS5_SAMPLE_WORKER, APS5_SAMPLE_COMMITTER and APS5_SAMPLE_THREADS) write every frame as module+0xRVA, and BUILD.md left naming them to the reader ("for example with nm on the .prx"). Doing that by hand means finding each module's image base, choosing between COFF symbols and the export table, and keeping the files cumulative totals in mind. tools/symbolize_samples.py reads a collapsed-stack file or the address file, opens each module it finds in --libs as a PE32+ image, and names every frame module!function: - From the COFF symbol table that the MinGW-built .prx and .dll keep: external and static symbols in code sections, at the section's RVA plus the symbol value. Addresses outside a code section, or before the first symbol, stay module+0xRVA. - From the export table, prefixed with "~", for a module without symbols, such as the runtime DLLs. - A module whose SizeOfImage or TimeDateStamp differs from the sampler's "# module" line is left unsymbolized, with a warning: its addresses would name the wrong functions. --since subtracts an earlier copy of the same file, which keeps only the samples taken between the two copies (the files hold totals since start-up). --out writes the named stacks for speedscope or flamegraph.pl. --demangle uses c++filt when it is on PATH. It needs no binutils otherwise. test_symbolize_samples (registered with CTest) builds small PE32+ images with COFF symbols and with an export table, and covers short, string-table and static names, the ignored section and data symbols, addresses outside code, the export fallback, the size and timestamp check, --since with --out, the address-file format and rejected lines. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 2 + docs/dev/BUILD.md | 4 +- tools/symbolize_samples.py | 260 ++++++++++++++++++++++++++ tools/tests/test_symbolize_samples.py | 151 +++++++++++++++ 4 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 tools/symbolize_samples.py create mode 100644 tools/tests/test_symbolize_samples.py diff --git a/CMakeLists.txt b/CMakeLists.txt index b4bb291af5..f66df6f627 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -240,6 +240,8 @@ if(BUILD_TESTING) set_tests_properties(pad_timeline PROPERTIES TIMEOUT 20) add_test(NAME trace_digest COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_trace_digest.py) + add_test(NAME symbolize_samples COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_symbolize_samples.py) add_test(NAME conventions_bom_shebang COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_check_conventions_bom_shebang.py) set_tests_properties(conventions_bom_shebang PROPERTIES TIMEOUT 20) diff --git a/docs/dev/BUILD.md b/docs/dev/BUILD.md index 7778788a16..61cb860139 100644 --- a/docs/dev/BUILD.md +++ b/docs/dev/BUILD.md @@ -196,11 +196,11 @@ On a device that supports `VK_EXT_device_fault`, the first `VK_ERROR_DEVICE_LOST ## Worker sampler -Set `APS5_SAMPLE_WORKER=` to sample queue worker 0 every millisecond (in practice every timer tick). Every 20 s and at exit it writes ``, one `module offset self inclusive` line per sampled address, and `.collapsed.txt`, the worker's whole stacks in the collapsed format that speedscope.app and flamegraph.pl read: one `frame;...;frame count` line per distinct stack, root first, each frame as `module+0xRVA`. A walk that ends before the thread's root starts with `[truncated]`. The `#` lines give the samples, the walk depth (64 frames), the truncated walks, the mean and longest time the worker was suspended, and the size and PE timestamp of each module, to check the binary a file is symbolized against. A frame with `.pdata` is stepped by `RtlVirtualUnwind`; any other frame by its DWARF record in `.ehfram`, the unwind data the libraries are built with. Those steps read only inside the worker's stack, and a module is indexed after the worker resumes, the first time a walk meets it. +Set `APS5_SAMPLE_WORKER=` to sample queue worker 0 every millisecond (in practice every timer tick). Every 20 s and at exit it writes ``, one `module offset self inclusive` line per sampled address, and `.collapsed.txt`, the worker's whole stacks in the collapsed format that speedscope.app and flamegraph.pl read: one `frame;...;frame count` line per distinct stack, root first, each frame as `module+0xRVA`. A walk that ends before the thread's root starts with `[truncated]`. The `#` lines give the samples, the walk depth (64 frames), the truncated walks, the mean and longest time the worker was suspended, and the size and PE timestamp of each module, to check the binary a file is symbolized against. A frame with `.pdata` is stepped by `RtlVirtualUnwind`; any other frame by its DWARF record in `.ehfram`, the unwind data the libraries are built with. Those steps read only inside the worker's stack, and a module is indexed after the worker resumes, the first time a walk meets it. `python tools/symbolize_samples.py .collapsed.txt --libs ` names each frame from the COFF symbols of the module that ran, or from its export table when it has none, and leaves a module unsymbolized when its size or timestamp differs from the `# module` line; `--since ` keeps only the samples taken after that copy, `--out` writes the named stacks and `--demangle` uses `c++filt`. `APS5_SAMPLE_COMMITTER=` samples queue 0's draw committer the same way and writes the same two files. The committer records the draws the worker hands over (`APS5_PIPELINED_DRAWS`), so when the worker's stacks wait in `DrawPipeline::Enqueue`, its stacks show what bounds the frame. It starts with the first pipelined draw; with `APS5_PIPELINED_DRAWS=0` nothing is sampled. -`APS5_SAMPLE_THREADS=` samples every thread of the process every 2 ms and writes the 40 hottest addresses of each busy thread to ``, 12 frames deep and through `.pdata` only. The samplers exist only on Windows; the addresses are symbolized offline, for example with `nm` on the `.prx`. +`APS5_SAMPLE_THREADS=` samples every thread of the process every 2 ms and writes the 40 hottest addresses of each busy thread to ``, 12 frames deep and through `.pdata` only. The samplers exist only on Windows; the addresses are symbolized offline with `tools/symbolize_samples.py`. ## Performance trace diff --git a/tools/symbolize_samples.py b/tools/symbolize_samples.py new file mode 100644 index 0000000000..e0254f0fa4 --- /dev/null +++ b/tools/symbolize_samples.py @@ -0,0 +1,260 @@ +import argparse +import bisect +import collections +import shutil +import struct +import subprocess +import sys +from pathlib import Path + +DESCRIPTION = """Symbolize the output of the Windows samplers (APS5_SAMPLE_WORKER, APS5_SAMPLE_COMMITTER and +APS5_SAMPLE_THREADS) against the modules that ran. Each sampled frame is written as module+0xRVA; this reads the +COFF symbol table of each module found in --libs (the MinGW-built .prx and .dll files keep one), or its export +table when it has none, and names every frame module!function. + +The '# module name size timestamp' header lines the samplers write are compared with the SizeOfImage and +TimeDateStamp of the file that is found; a module whose file does not match is left unsymbolized, since its +addresses would name the wrong functions. + +The sampler rewrites its files every 20 s with totals since start-up. --since takes an earlier copy of the same +file and subtracts it, which keeps only the samples taken between the two copies (a steady-state window). + +Input: a collapsed-stack file (.collapsed.txt, 'frame;...;frame count' lines) or the address file +('module offset self inclusive' lines). Output: the hottest functions by self and by inclusive samples, and with +--out a symbolized collapsed-stack file for speedscope.app or flamegraph.pl.""" + +IMAGE_SCN_CNT_CODE = 0x20 +IMAGE_SYM_CLASS_EXTERNAL = 2 +IMAGE_SYM_CLASS_STATIC = 3 + + +class PeModule: + def __init__(self, path): + data = Path(path).read_bytes() + if data[:2] != b"MZ": + raise ValueError(f"{path}: not a PE file") + pe = struct.unpack_from("> 4) != 2 and value == 0: + continue + if name[:4] == b"\0\0\0\0": + at = strings + struct.unpack_from(" 0}) + + +def summarize(symbolizer, stacks, addresses, top): + self_counts = collections.Counter() + inclusive = collections.Counter() + total = 0 + if stacks: + for frames, count in stacks.items(): + names = [symbolizer.frame(frame) for frame in frames.split(";")] + total += count + self_counts[names[-1]] += count + for name in set(names): + inclusive[name] += count + else: + for (module, offset), count in addresses.items(): + total += count + self_counts[symbolizer.frame(f"{module}+{offset}")] += count + lines = [f"{total} samples"] + for title, counter in (("self", self_counts), ("inclusive", inclusive)): + if not counter: + continue + lines.append(f"hottest by {title}:") + for name, count in counter.most_common(top): + lines.append(f" {count:8d} {100.0 * count / total if total else 0.0:5.1f}% {name}") + return lines + + +def main(argv=None): + parser = argparse.ArgumentParser(description=DESCRIPTION, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("samples", help="collapsed-stack file or address file written by a Windows sampler") + parser.add_argument("--libs", action="append", required=True, help="directory holding the modules that ran (repeatable)") + parser.add_argument("--since", help="an earlier copy of the same file, subtracted to keep the samples taken after it") + parser.add_argument("--out", help="write the symbolized collapsed stacks to this file") + parser.add_argument("--top", type=int, default=30, help="functions listed per table (default 30)") + parser.add_argument("--demangle", action="store_true", help="demangle C++ names with c++filt when it is on PATH") + args = parser.parse_args(argv) + headers, stacks, addresses = load(args.samples) + if args.since: + earlier_headers, earlier_stacks, earlier_addresses = load(args.since) + stacks = subtract(stacks, earlier_stacks) + addresses = subtract(addresses, earlier_addresses) + headers = {**earlier_headers, **headers} + symbolizer = Symbolizer(args.libs, headers, args.demangle, warn=lambda text: print("warning: " + text, file=sys.stderr)) + for line in summarize(symbolizer, stacks, addresses, args.top): + print(line) + if args.out: + if not stacks: + parser.error("--out needs a collapsed-stack file") + with open(args.out, "w", encoding="utf-8") as output: + for frames, count in stacks.items(): + output.write(";".join(symbolizer.frame(frame) for frame in frames.split(";")) + f" {count}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/tests/test_symbolize_samples.py b/tools/tests/test_symbolize_samples.py new file mode 100644 index 0000000000..c5c1c15a29 --- /dev/null +++ b/tools/tests/test_symbolize_samples.py @@ -0,0 +1,151 @@ +import contextlib +import io +import struct +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import symbolize_samples + +TIMESTAMP = 0x6AD92AC4 +SIZE_OF_IMAGE = 0x3000 + + +def symbol(name, value, section, kind, storage, aux=0): + return struct.pack("<8sIhHBB", name, value, section, kind, storage, aux) + + +def build_pe(path, with_symbols=True, with_exports=False): + image = bytearray(0xC00) + image[0:2] = b"MZ" + struct.pack_into(" Date: Thu, 22 Oct 2026 00:36:07 -0400 Subject: [PATCH 09/21] fix(tools): bound symbol lookups to their section, keep inclusive counts and refuse unrecorded modules Review of #9572 found three gaps: - PeModule.name() checked that an RVA was in some code section, then bisected one list of every symbol, so an address in a later code section before that section's first symbol took the last symbol of an earlier section. Each code section now keeps its own sorted list. - Address files keep both counters ('module offset self inclusive'): the inclusive count is kept and subtracted by --since, and the summary lists functions by inclusive samples as well, summed over each function's addresses (an upper bound when a function is on one stack more than once). The total comes from the '# N samples' line or from the 'thread samples ' headings of APS5_SAMPLE_THREADS, which the loader previously misread as a stack line; --thread keeps one thread. - The address writers of both samplers wrote no '# module' records, so those files skipped the size and timestamp check. Both now write them (through the helper the collapsed-stack writer uses), and the tool leaves a module unsymbolized when the samples have no record for it, unless --unverified is given. Five new cases cover a second code section, inclusive totals, --since on an address file, thread headings with --thread, and the missing record with and without --unverified. Co-Authored-By: Claude Opus 5.5 --- .../Execution/src/WorkerSampler.cpp | 20 +++- docs/dev/BUILD.md | 4 +- tools/symbolize_samples.py | 110 ++++++++++++------ tools/tests/test_symbolize_samples.py | 73 ++++++++++-- 4 files changed, 154 insertions(+), 53 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/Execution/src/WorkerSampler.cpp b/core/libs/prx/libSceAgcDriver/Execution/src/WorkerSampler.cpp index 7bbca06017..82a909461c 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/src/WorkerSampler.cpp +++ b/core/libs/prx/libSceAgcDriver/Execution/src/WorkerSampler.cpp @@ -162,18 +162,29 @@ struct Sampler { return frame; } + static void writeModules(std::FILE* file, const std::map& loaded) { + for (const auto& [base, name] : loaded) { + const auto* dos = reinterpret_cast(base); + const auto* nt = reinterpret_cast(base + dos->e_lfanew); + std::fprintf(file, "# module %s 0x%lx 0x%lx\n", name.c_str(), static_cast(nt->OptionalHeader.SizeOfImage), static_cast(nt->FileHeader.TimeDateStamp)); + } + } + void write() { std::lock_guard lock(mutex); std::FILE* file = std::fopen(path.c_str(), "w"); if (file == nullptr) return; std::fprintf(file, "# %llu samples\n", static_cast(samples)); + std::map loaded; for (const auto& [address, count] : counts) { HMODULE module = nullptr; char name[MAX_PATH] = "?"; if (GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, reinterpret_cast(address), &module)) GetModuleFileNameA(module, name, sizeof(name)); const char* base = std::strrchr(name, '\\'); + if (module != nullptr) loaded.emplace(reinterpret_cast(module), base ? base + 1 : name); std::fprintf(file, "%s 0x%llx %llu %llu\n", base ? base + 1 : name, static_cast(address - reinterpret_cast(module)), static_cast(count.first), static_cast(count.second)); } + writeModules(file, loaded); std::fclose(file); if (keepStacks) writeStacks(); } @@ -194,11 +205,7 @@ struct Sampler { const double tickUs = 1e6 / static_cast(frequency.QuadPart); std::fprintf(file, "# samples=%llu depth=%d truncated=%llu suspended_us_mean=%.1f suspended_us_max=%.1f\n", static_cast(samples), depth, static_cast(stacks.Truncated()), samples != 0 ? static_cast(suspendedTicks) * tickUs / static_cast(samples) : 0.0, static_cast(longestSuspendedTicks) * tickUs); - for (const auto& [base, name] : loaded) { - const auto* dos = reinterpret_cast(base); - const auto* nt = reinterpret_cast(base + dos->e_lfanew); - std::fprintf(file, "# module %s 0x%lx 0x%lx\n", name.c_str(), static_cast(nt->OptionalHeader.SizeOfImage), static_cast(nt->FileHeader.TimeDateStamp)); - } + writeModules(file, loaded); const auto text = stacks.Format([&](std::uint64_t address) { return names.at(address); }); const bool written = std::fwrite(text.data(), 1, text.size(), file) == text.size(); if (std::fclose(file) != 0 || !written) return; @@ -245,6 +252,7 @@ struct ProcessSampler { std::FILE* file = std::fopen(path.c_str(), "w"); if (file == nullptr) return; std::fprintf(file, "# %llu rounds\n", static_cast(rounds)); + std::map loaded; for (auto& [id, thread] : threads) { std::lock_guard lock(thread.mutex); std::vector>> hot(thread.counts.begin(), thread.counts.end()); @@ -259,9 +267,11 @@ struct ProcessSampler { char name[MAX_PATH] = "?"; if (GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, reinterpret_cast(address), &module)) GetModuleFileNameA(module, name, sizeof(name)); const char* base = std::strrchr(name, '\\'); + if (module != nullptr) loaded.emplace(reinterpret_cast(module), base ? base + 1 : name); std::fprintf(file, " %s 0x%llx %llu %llu\n", base ? base + 1 : name, static_cast(address - reinterpret_cast(module)), static_cast(hot[i].second.first), static_cast(hot[i].second.second)); } } + Sampler::writeModules(file, loaded); std::fclose(file); } }; diff --git a/docs/dev/BUILD.md b/docs/dev/BUILD.md index 61cb860139..63390e032d 100644 --- a/docs/dev/BUILD.md +++ b/docs/dev/BUILD.md @@ -196,11 +196,11 @@ On a device that supports `VK_EXT_device_fault`, the first `VK_ERROR_DEVICE_LOST ## Worker sampler -Set `APS5_SAMPLE_WORKER=` to sample queue worker 0 every millisecond (in practice every timer tick). Every 20 s and at exit it writes ``, one `module offset self inclusive` line per sampled address, and `.collapsed.txt`, the worker's whole stacks in the collapsed format that speedscope.app and flamegraph.pl read: one `frame;...;frame count` line per distinct stack, root first, each frame as `module+0xRVA`. A walk that ends before the thread's root starts with `[truncated]`. The `#` lines give the samples, the walk depth (64 frames), the truncated walks, the mean and longest time the worker was suspended, and the size and PE timestamp of each module, to check the binary a file is symbolized against. A frame with `.pdata` is stepped by `RtlVirtualUnwind`; any other frame by its DWARF record in `.ehfram`, the unwind data the libraries are built with. Those steps read only inside the worker's stack, and a module is indexed after the worker resumes, the first time a walk meets it. `python tools/symbolize_samples.py .collapsed.txt --libs ` names each frame from the COFF symbols of the module that ran, or from its export table when it has none, and leaves a module unsymbolized when its size or timestamp differs from the `# module` line; `--since ` keeps only the samples taken after that copy, `--out` writes the named stacks and `--demangle` uses `c++filt`. +Set `APS5_SAMPLE_WORKER=` to sample queue worker 0 every millisecond (in practice every timer tick). Every 20 s and at exit it writes ``, one `module offset self inclusive` line per sampled address, and `.collapsed.txt`, the worker's whole stacks in the collapsed format that speedscope.app and flamegraph.pl read: one `frame;...;frame count` line per distinct stack, root first, each frame as `module+0xRVA`. A walk that ends before the thread's root starts with `[truncated]`. The `#` lines give the samples, the walk depth (64 frames), the truncated walks, the mean and longest time the worker was suspended, and the size and PE timestamp of each module, to check the binary a file is symbolized against. A frame with `.pdata` is stepped by `RtlVirtualUnwind`; any other frame by its DWARF record in `.ehfram`, the unwind data the libraries are built with. Those steps read only inside the worker's stack, and a module is indexed after the worker resumes, the first time a walk meets it. `python tools/symbolize_samples.py .collapsed.txt --libs ` names each frame from the COFF symbols of the module that ran, or from its export table when it has none, and leaves a module unsymbolized when its size or timestamp differs from its `# module` line or when it has none (`--unverified` symbolizes it anyway); `--since ` keeps only the samples taken after that copy, `--out` writes the named stacks and `--demangle` uses `c++filt`. Given ``, it lists functions by self and by inclusive samples, where a function's inclusive count is the sum of its addresses' and overcounts a function that is on one stack more than once. `APS5_SAMPLE_COMMITTER=` samples queue 0's draw committer the same way and writes the same two files. The committer records the draws the worker hands over (`APS5_PIPELINED_DRAWS`), so when the worker's stacks wait in `DrawPipeline::Enqueue`, its stacks show what bounds the frame. It starts with the first pipelined draw; with `APS5_PIPELINED_DRAWS=0` nothing is sampled. -`APS5_SAMPLE_THREADS=` samples every thread of the process every 2 ms and writes the 40 hottest addresses of each busy thread to ``, 12 frames deep and through `.pdata` only. The samplers exist only on Windows; the addresses are symbolized offline with `tools/symbolize_samples.py`. +`APS5_SAMPLE_THREADS=` samples every thread of the process every 2 ms and writes the 40 hottest addresses of each busy thread to ``, 12 frames deep and through `.pdata` only, under a `thread samples ` line per thread and with the same `# module` lines. The samplers exist only on Windows; the addresses are symbolized offline with `tools/symbolize_samples.py`, which adds up all threads or, with `--thread `, keeps one. ## Performance trace diff --git a/tools/symbolize_samples.py b/tools/symbolize_samples.py index e0254f0fa4..f61b586e53 100644 --- a/tools/symbolize_samples.py +++ b/tools/symbolize_samples.py @@ -19,9 +19,14 @@ addresses would name the wrong functions. The sampler rewrites its files every 20 s with totals since start-up. --since takes an earlier copy of the same file and subtracts it, which keeps only the samples taken between the two copies (a steady-state window). -Input: a collapsed-stack file (.collapsed.txt, 'frame;...;frame count' lines) or the address file -('module offset self inclusive' lines). Output: the hottest functions by self and by inclusive samples, and with ---out a symbolized collapsed-stack file for speedscope.app or flamegraph.pl.""" +A module with no such record is also left unsymbolized, since nothing shows which build it was; --unverified +symbolizes it anyway against the file found. + +Input: a collapsed-stack file (.collapsed.txt, 'frame;...;frame count' lines) or an address file +('module offset self inclusive' lines, under 'thread samples ' headings for APS5_SAMPLE_THREADS; --thread +keeps one thread). Output: the hottest functions by self and by inclusive samples, and with --out a symbolized +collapsed-stack file for speedscope.app or flamegraph.pl. For an address file, a function's inclusive count is the +sum of its addresses' counts, which overcounts the samples in which it is on the stack more than once.""" IMAGE_SCN_CNT_CODE = 0x20 IMAGE_SYM_CLASS_EXTERNAL = 2 @@ -54,8 +59,12 @@ class PeModule: if self.exported and export_rva: symbols = self.export_symbols(data, export_rva, export_size) symbols.sort() - self.code = [(start, start + size) for start, size, _, characteristics in self.sections if characteristics & IMAGE_SCN_CNT_CODE] - self.addresses = [address for address, _ in symbols] + self.code = [] + for start, size, _, characteristics in self.sections: + if not characteristics & IMAGE_SCN_CNT_CODE: + continue + inside = [(address, name) for address, name in symbols if start <= address < start + size] + self.code.append((start, start + size, [address for address, _ in inside], [name for _, name in inside])) self.names = [name for _, name in symbols] def offset(self, rva): @@ -106,16 +115,15 @@ class PeModule: return result def name(self, rva): - if not any(start <= rva < end for start, end in self.code): - return None - index = bisect.bisect_right(self.addresses, rva) - 1 - if index < 0: - return None - return ("~" if self.exported else "") + self.names[index] + for start, end, addresses, names in self.code: + if start <= rva < end: + index = bisect.bisect_right(addresses, rva) - 1 + return None if index < 0 else ("~" if self.exported else "") + names[index] + return None class Symbolizer: - def __init__(self, libs, headers, demangle=False, warn=print): + def __init__(self, libs, headers, demangle=False, warn=print, unverified=False): self.files = {} for directory in libs: for path in Path(directory).iterdir(): @@ -126,6 +134,7 @@ class Symbolizer: self.demangle = demangle and shutil.which("c++filt") is not None self.demangled = {} self.warn = warn + self.unverified = unverified def module(self, name): if name in self.modules: @@ -137,6 +146,9 @@ class Symbolizer: module = PeModule(path) except (ValueError, struct.error) as error: self.warn(f"{name}: {error}") + if module is not None and name not in self.headers and not self.unverified: + self.warn(f"{name}: the samples have no '# module' record for it, so {path} may be another build; its frames stay unsymbolized (--unverified symbolizes them anyway)") + module = None if module is not None and name in self.headers and self.headers[name] != (module.size, module.timestamp): size, timestamp = self.headers[name] self.warn(f"{name}: the sampled module had size {size:#x} and timestamp {timestamp:#x}, but {path} has {module.size:#x} and {module.timestamp:#x}; its frames stay unsymbolized") @@ -179,23 +191,42 @@ def read_headers(lines): return headers -def load(path): +class Samples: + def __init__(self): + self.headers = {} + self.stacks = collections.Counter() + self.self_counts = collections.Counter() + self.inclusive = collections.Counter() + self.total = collections.Counter() + + +def load(path, thread=None): lines = Path(path).read_text(encoding="utf-8-sig").splitlines() - headers = read_headers(lines) - stacks = collections.Counter() - addresses = collections.Counter() + samples = Samples() + samples.headers = read_headers(lines) + current = None for number, line in enumerate(lines, 1): + parts = line.split() + if len(parts) == 3 and parts[0] == "#" and parts[2] == "samples" and parts[1].isdigit(): + samples.total["samples"] += int(parts[1]) + continue if line.startswith("#") or not line.strip(): continue - parts = line.split() + if len(parts) == 4 and parts[0] == "thread" and parts[2] == "samples" and parts[1].isdigit() and parts[3].isdigit(): + current = parts[1] + if thread is None or current == thread: + samples.total["threads"] += int(parts[3]) + continue if len(parts) == 4 and parts[1].startswith("0x") and parts[2].isdigit() and parts[3].isdigit(): - addresses[(parts[0], parts[1])] += int(parts[2]) + if thread is None or current == thread: + samples.self_counts[(parts[0], parts[1])] += int(parts[2]) + samples.inclusive[(parts[0], parts[1])] += int(parts[3]) continue frames, _, count = line.rpartition(" ") if not frames or not count.isdigit(): - raise ValueError(f"{path}:{number}: expected 'frame;...;frame count' or 'module offset self inclusive'") - stacks[frames] += int(count) - return headers, stacks, addresses + raise ValueError(f"{path}:{number}: expected 'frame;...;frame count', 'module offset self inclusive' or 'thread samples '") + samples.stacks[frames] += int(count) + return samples def subtract(later, earlier): @@ -204,21 +235,31 @@ def subtract(later, earlier): return collections.Counter({key: count for key, count in result.items() if count > 0}) -def summarize(symbolizer, stacks, addresses, top): +def window(later, earlier): + result = Samples() + result.headers = {**earlier.headers, **later.headers} + for name in ("stacks", "self_counts", "inclusive", "total"): + setattr(result, name, subtract(getattr(later, name), getattr(earlier, name))) + return result + + +def summarize(symbolizer, samples, top): self_counts = collections.Counter() inclusive = collections.Counter() total = 0 - if stacks: - for frames, count in stacks.items(): + if samples.stacks: + for frames, count in samples.stacks.items(): names = [symbolizer.frame(frame) for frame in frames.split(";")] total += count self_counts[names[-1]] += count for name in set(names): inclusive[name] += count else: - for (module, offset), count in addresses.items(): - total += count + for (module, offset), count in samples.self_counts.items(): self_counts[symbolizer.frame(f"{module}+{offset}")] += count + for (module, offset), count in samples.inclusive.items(): + inclusive[symbolizer.frame(f"{module}+{offset}")] += count + total = samples.total["threads"] or samples.total["samples"] or sum(samples.self_counts.values()) lines = [f"{total} samples"] for title, counter in (("self", self_counts), ("inclusive", inclusive)): if not counter: @@ -237,21 +278,20 @@ def main(argv=None): parser.add_argument("--out", help="write the symbolized collapsed stacks to this file") parser.add_argument("--top", type=int, default=30, help="functions listed per table (default 30)") parser.add_argument("--demangle", action="store_true", help="demangle C++ names with c++filt when it is on PATH") + parser.add_argument("--thread", help="keep only this thread of an APS5_SAMPLE_THREADS file") + parser.add_argument("--unverified", action="store_true", help="also symbolize modules the samples have no '# module' record for") args = parser.parse_args(argv) - headers, stacks, addresses = load(args.samples) + samples = load(args.samples, args.thread) if args.since: - earlier_headers, earlier_stacks, earlier_addresses = load(args.since) - stacks = subtract(stacks, earlier_stacks) - addresses = subtract(addresses, earlier_addresses) - headers = {**earlier_headers, **headers} - symbolizer = Symbolizer(args.libs, headers, args.demangle, warn=lambda text: print("warning: " + text, file=sys.stderr)) - for line in summarize(symbolizer, stacks, addresses, args.top): + samples = window(samples, load(args.since, args.thread)) + symbolizer = Symbolizer(args.libs, samples.headers, args.demangle, warn=lambda text: print("warning: " + text, file=sys.stderr), unverified=args.unverified) + for line in summarize(symbolizer, samples, args.top): print(line) if args.out: - if not stacks: + if not samples.stacks: parser.error("--out needs a collapsed-stack file") with open(args.out, "w", encoding="utf-8") as output: - for frames, count in stacks.items(): + for frames, count in samples.stacks.items(): output.write(";".join(symbolizer.frame(frame) for frame in frames.split(";")) + f" {count}\n") return 0 diff --git a/tools/tests/test_symbolize_samples.py b/tools/tests/test_symbolize_samples.py index c5c1c15a29..5e000527dc 100644 --- a/tools/tests/test_symbolize_samples.py +++ b/tools/tests/test_symbolize_samples.py @@ -17,7 +17,7 @@ def symbol(name, value, section, kind, storage, aux=0): return struct.pack("<8sIhHBB", name, value, section, kind, storage, aux) -def build_pe(path, with_symbols=True, with_exports=False): +def build_pe(path, with_symbols=True, with_exports=False, second_code=False): image = bytearray(0xC00) image[0:2] = b"MZ" struct.pack_into(" Date: Fri, 23 Oct 2026 19:25:25 -0700 Subject: [PATCH 10/21] fix(libc): fail a q length in scanf as the console's libc does The console's own sscanf, run natively from three titles' libc.prx, returns 0 for %qd and stores nothing, where FreeBSD 11.4's source reads q as ll. A q directive now ends the scan there, and the count of conversions before it is returned. The test adds the console's rows for wrapping narrow conversions, saturating 64-bit ones with ERANGE, and %qd. --- .../libs/prx/libc/include/WindowsScanning.hpp | 9 +++++++-- core/libs/tests/Formatting.cpp | 19 +++++++++++++++---- docs/dev/debt/libc.md | 1 + 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/core/libs/prx/libc/include/WindowsScanning.hpp b/core/libs/prx/libc/include/WindowsScanning.hpp index 420d8ba000..ab3f6ef625 100644 --- a/core/libs/prx/libc/include/WindowsScanning.hpp +++ b/core/libs/prx/libc/include/WindowsScanning.hpp @@ -32,11 +32,16 @@ inline int ScanWindowsArguments_nid_no_patch(const char* format, const void* sou translated += value; if (value != '%') continue; if (*format == '%') { translated += *format++; continue; } + const size_t directive = translated.size() - 1; const bool suppressed = *format == '*'; if (suppressed) translated += *format++; while (*format >= '0' && *format <= '9') translated += *format++; + if (*format == 'q') { + translated.resize(directive); + break; + } std::string length; - if (*format && std::strchr("hljztLq", *format)) { + if (*format && std::strchr("hljztL", *format)) { length += *format++; if ((length == "h" && *format == 'h') || (length == "l" && *format == 'l')) length += *format++; } @@ -48,7 +53,7 @@ inline int ScanWindowsArguments_nid_no_patch(const char* format, const void* sou } ++format; const bool narrow = !suppressed && std::strchr("diouxX", conversion) && (length.empty() || length == "h" || length == "hh"); - if (narrow || (std::strchr("diouxXn", conversion) && (length == "l" || length == "j" || length == "z" || length == "t" || length == "q"))) + if (narrow || (std::strchr("diouxXn", conversion) && (length == "l" || length == "j" || length == "z" || length == "t"))) translated += "ll"; else translated += length; translated += conversion; diff --git a/core/libs/tests/Formatting.cpp b/core/libs/tests/Formatting.cpp index 3b5f4e37c6..d8b89b7283 100644 --- a/core/libs/tests/Formatting.cpp +++ b/core/libs/tests/Formatting.cpp @@ -154,10 +154,21 @@ static void CheckIntegerScanWidths() { Require(sscanf_nid_postfix("200", "%hhu", &unsignedChar) == 1 && unsignedChar == 200); Require(sscanf_nid_postfix("300", "%hhu", &unsignedChar) == 1 && unsignedChar == 44); Require(sscanf_nid_postfix("377", "%hho", &unsignedChar) == 1 && unsignedChar == 255); - long long quad = 7; - Require(sscanf_nid_postfix("3000000000", "%qd", &quad) == 1 && quad == 3000000000ll); - unsigned long long unsignedQuad = 7; - Require(sscanf_nid_postfix("ffffffffff", "%qx", &unsignedQuad) == 1 && unsignedQuad == 0xffffffffffull); + *__error_nid_postfix() = 0; + Require(sscanf_nid_postfix("-2147483649", "%d", &value) == 1 && value == 0x7fffffff && *__error_nid_postfix() == 0); + Require(sscanf_nid_postfix("-1", "%u", &unsignedValue) == 1 && unsignedValue == 0xffffffffu && *__error_nid_postfix() == 0); + Require(sscanf_nid_postfix("99999999999", "%x", &unsignedValue) == 1 && unsignedValue == 0x99999999u && *__error_nid_postfix() == 0); + Require(sscanf_nid_postfix("70000", "%hd", &shortValue) == 1 && shortValue == 0x1170 && *__error_nid_postfix() == 0); + long long quad = 0x5555555555555555ll; + Require(sscanf_nid_postfix("9223372036854775808", "%lld", &quad) == 1 && quad == 0x7fffffffffffffffll && *__error_nid_postfix() == 34); + unsigned long long unsignedQuad = 0x5555555555555555ull; + *__error_nid_postfix() = 0; + Require(sscanf_nid_postfix("18446744073709551616", "%llu", &unsignedQuad) == 1 && unsignedQuad == 0xffffffffffffffffull && *__error_nid_postfix() == 34); +#ifdef _WIN32 + quad = 0x5555555555555555ll; + *__error_nid_postfix() = 0; + Require(sscanf_nid_postfix("5", "%qd", &quad) == 0 && quad == 0x5555555555555555ll && *__error_nid_postfix() == 0); +#endif int first = 7; int second = 7; int position = 0; diff --git a/docs/dev/debt/libc.md b/docs/dev/debt/libc.md index 5014c4dd6f..047d2922cf 100644 --- a/docs/dev/debt/libc.md +++ b/docs/dev/debt/libc.md @@ -115,6 +115,7 @@ - [std::bad_optional_access and std::_Throw_bad_optional_access](../../../core/libs/prx/libc/src/exception/StandardExceptions.cpp) (libc) provide the exception type, constructor, destructor, and `what()` exports alongside `_Throw_bad_optional_access`, returning the `what()` string `bad_optional_access` matching Dinkumware and libc++ conventions; the console libc.prx string is unverified. PPSA26578 (issue #7624) and PPSA34015 import `_ZSt26_Throw_bad_optional_accessv` (`KwzEip7m6TM`) from `libc`, and fail to load on main with unresolved import (0xC0000139). - [The scanf family](../../../core/libs/prx/libc/src/Formatting.cpp) (libc), the `_s` forms included, decodes the input of the 16-bit wide conversions (`%ls`, `%lc`, `%l[`, `%S`, `%C`) on Linux as UTF-8, mirroring the narrow printf family, where FreeBSD uses the locale's `mbrtowc` (one byte per character in the C locale); the console's locale encoding is unverified. Input that is not UTF-8, a character outside the BMP, a scanset member outside ASCII, positional arguments next to a wide conversion and wide conversions in `fscanf` throw. Each character is stored as one 16-bit unit, widths and `_s` sizes count characters, and a `%lc` field cut short by the end of the input keeps what it read, as FreeBSD's `convert_wchar` does. Windows hands `%ls`, `%lc` and `%l[` to the host, whose `wchar_t` is 16 bits - [The scanf family](../../../core/libs/prx/libc/include/WindowsScanning.hpp) (libc) on Windows returns 0, as C17 and FreeBSD do, when input fails after only suppressed conversions have completed (`sscanf("12", "%*d%d", &value)`). Linux hands the format to glibc, which returns `EOF` there. On Windows the host stops a `%x`, `%X`, `%i` or `%p` after the `x` of a `0x` that no hex digit follows (or that the width cuts off), and a float conversion (`%e`, `%f`, `%g`, `%a`) after the `e` of an exponent with no digits (`1e`), where C17 and FreeBSD stop before the `x` or the `e` +- [The scanf family](../../../core/libs/prx/libc/src/Formatting.cpp) (libc) on Linux reads a `q` length as `ll`, as glibc does. On Windows a `q` directive ends the scan with nothing stored, as the console's own libc does for `%qd`; FreeBSD 11.4's source reads it as `ll`. - [Guest path resolution](../../../core/libs/prx/libc/src/General.cpp) falls back to ASCII case-insensitive component lookup, following [shadPS4's host filesystem](https://github.com/shadps4-emu/shadPS4/blob/945dbc3cc3eee80ac3e053b438502ed936fa6bb2/src/core/file_sys/backends/host_fs.cpp). PPSA03747 requests `Skyrim.INI`, `Data/Video` and lowercase archive/plugin names that differ from the dump's filenames. Exact host names take precedence; multiple fallback matches throw instead of selecting an arbitrary file. Existing parent names are resolved when creating a new file, and guest mount aliases use the same ASCII comparison. Results are not cached, so renames and newly created files remain visible. Unicode case folding and PS5 behaviour for case-colliding host entries are unverified; host roots are used as supplied. Explicit native Windows drive and UNC paths bypass guest parent-component validation.. A relinked module's `.guest.prx` is matched the same way when the original module is not beside it: Borderlands 4 (PPSA01494) loads `/app0/prx/libRenoirCore.PS5.prx`, which it ships as `librenoircore.ps5.prx`. A relinked module request that misses both the exact and the case-insensitive `.guest.prx` takes the sibling whose stem (the name without its last extension, ASCII case ignored) equals the requested stem and that ends with `.guest.prx`, shortest name first: Unity IL2CPP titles load `Media/Modules/.prx` and `Media/Plugins/.prx`, and the relinker names the converted module after the ELF it was built from, `.elf.guest.prx`. The console's module namespace is assumed to ignore case and the extension; several stem matches pick the shortest name instead of throwing, which is not measured on a console - [Colons in guest names](../../../core/libs/prx/libc/src/General.cpp) (libc) - on Windows (NTFS) a guest name containing `:` is not a plain file name: `name:stream` opens or creates the alternate data stream `stream` of the file `name`. Creating `/savedata0/C:file` makes an empty file `C` whose stream `file` holds the data, and `stat` of `/savedata0/C` then reports a regular file. `mkdir` of a name containing `:`, such as `C:` or `dir:x`, fails with EINVAL. `name:stream` already behaved this way before drive-letter components were kept under their mount - [tzname](../../../core/libs/prx/libc/src/Time.cpp) (libc) points at the host's zone names, read at load and again by `tzset`: the abbreviations a `TZ` string gives (`EST5EDT` is `EST` and `EDT` on both hosts), but on Windows without `TZ` the CRT's full names such as `GMT Standard Time`, which ICU's [uprv_tzname](https://github.com/unicode-org/icu/blob/457157a92aa053e632cc7fcfd0e12f8a943b2d11/icu4c/source/common/putil.cpp#L1244-L1264) (ES-DE in PPSA99169) cannot map to a zone ID. A zone with no daylight-saving time has `tzname[1]` empty on Windows and equal to `tzname[0]` on Linux, where FreeBSD leaves three spaces. The console's names are not known From 42961581e4c5dbc80fd860d493cc6d727ef2e318 Mon Sep 17 00:00:00 2001 From: harisibrar1 <207127467+harisibrar1@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:44:20 -0400 Subject: [PATCH 11/21] test(relinker): read the .desktop file as UTF-8 in input_preservation Co-Authored-By: Claude Opus 5.5 --- core/relinker/relinker/tests/test_input_preservation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/relinker/relinker/tests/test_input_preservation.py b/core/relinker/relinker/tests/test_input_preservation.py index 4fb96462f0..37f9ba92e9 100644 --- a/core/relinker/relinker/tests/test_input_preservation.py +++ b/core/relinker/relinker/tests/test_input_preservation.py @@ -375,7 +375,7 @@ def main(): raise AssertionError((windows, existing, result.returncode, result.stdout, result.stderr)) if not windows: assert Path(f"{output}.png").read_bytes() == image - assert Path(f"{output}.desktop").read_text().startswith("[Desktop Entry]\n") + assert Path(f"{output}.desktop").read_text(encoding="utf-8").startswith("[Desktop Entry]\n") print("Input preservation tests passed") From 65a98e7f31b7b6051004e8e5519b10d69c2a3a6f Mon Sep 17 00:00:00 2001 From: javierUDP Date: Wed, 21 Oct 2026 11:34:31 -0300 Subject: [PATCH 12/21] test(relinker): execute the moved scalar compare The stub that moves a hazard scalar compare out of line is checked through its bytes but never run, so nothing confirms that the compare still produces the mask it would inline. The x86-64 execution section now calls the stub for both outcomes of a CMPSS with an unsafe predicate and checks the destination's lanes. --- .../codegen/tests/Amd64OnlyConverterTests.cpp | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/core/relinker/codegen/tests/Amd64OnlyConverterTests.cpp b/core/relinker/codegen/tests/Amd64OnlyConverterTests.cpp index e18ac588f4..f752618c5f 100644 --- a/core/relinker/codegen/tests/Amd64OnlyConverterTests.cpp +++ b/core/relinker/codegen/tests/Amd64OnlyConverterTests.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -2071,6 +2072,45 @@ void xsaveoptExecution() { require(std::memcmp(area + 160 + 3 * 16, pattern, 16) == 0 && std::memcmp(area + 576 + 3 * 16, pattern + 16, 16) == 0, "XSAVE did not store YMM3 in the standard format"); require(std::memcmp(restored, pattern, sizeof(pattern)) == 0, "XRSTOR did not restore YMM3 from the area XSAVE wrote"); } + +void scalarCompareExecution() { + const auto matcher = Codegen::MakeAmd64OnlyInstructionMatcher(Codegen::Amd64OnlyTarget::Rosetta); + const Bytes site = {0xF3, 0x0F, 0xC2, 0xC1, 0x05}; + const auto match = matcher->Match(site.data(), site.size()); + require(match && match->Lowering == Codegen::Amd64OnlyLowering::Trampoline, "scalar compare stub was not produced"); + auto body = match->StubBody; + body[match->ReturnBranchOffset] = 0xC3; + void* code = mmap(nullptr, 4096, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + require(code != MAP_FAILED, "cannot map executable memory for the stub"); + std::memcpy(code, body.data(), body.size()); + struct Case { + float left; + float right; + std::uint32_t mask; + const char* name; + }; + for (const auto& item : std::vector{{309.0f, 309.0f, 0xFFFFFFFFu, "309 is not less than 309"}, + {309.0f, 310.0f, 0u, "309 is less than 310"}}) { + alignas(16) float left[4] = {item.left, 1.0f, 2.0f, 3.0f}; + alignas(16) float right[4] = {item.right, 0.0f, 0.0f, 0.0f}; + alignas(16) std::uint32_t result[4] = {}; + asm volatile( + "movaps (%[left]), %%xmm0\n\t" + "movaps (%[right]), %%xmm1\n\t" + "sub $128, %%rsp\n\t" + "call *%[code]\n\t" + "add $128, %%rsp\n\t" + "movaps %%xmm0, (%[result])\n\t" + : + : [left] "r"(left), [right] "r"(right), [result] "r"(result), [code] "r"(code) + : "memory", "xmm0", "xmm1"); + require(result[0] == item.mask, item.name); + require(result[1] == std::bit_cast(1.0f) && result[2] == std::bit_cast(2.0f) + && result[3] == std::bit_cast(3.0f), + "scalar compare stub changed the upper lanes of its destination"); + } + munmap(code, 4096); +} #else void reciprocalExecution() {} void registerFormExecution() {} @@ -2117,6 +2157,7 @@ void rdtscpExecution() { #else void rdtscpExecution() {} void wideStoreExecution() {} +void scalarCompareExecution() {} #endif void scannerZeroTail() { @@ -2178,6 +2219,7 @@ int main() { converterRosettaLegacyScalarCompare(); rdtscpExecution(); wideStoreExecution(); + scalarCompareExecution(); wideStoreSplit(); converterWideStoreWindows(); converterClaimedFollower(); From 0b60e97834ad06a57519f5154eedff0947fdeeda Mon Sep 17 00:00:00 2001 From: zaryabzip <110787259+zaryabzip@users.noreply.github.com> Date: Thu, 22 Oct 2026 05:57:17 +0500 Subject: [PATCH 13/21] feat(libkernel_sys): create threads suspended and resume them Implement pthread_attr_setcreatesuspend_np, pthread_resume_np, pthread_resume_all_np and pthread_multi_np with the sce forms scePthreadAttrSetcreatesuspend, scePthreadResume, scePthreadResumeAll and scePthreadMulti, following FreeBSD 11.4 libthr and the firmware 13.42 libkernel_sys module. A thread created suspended waits before its start routine, outside any cancellation point, until it is resumed. sceKernelRaiseException throws on Windows for a thread still suspended at creation. --- core/libs/CMakeLists.txt | 11 + .../libs/prx/libkernel/Pthread/Posix/Attr.cpp | 1 + .../prx/libkernel/Pthread/include/Pthread.hpp | 5 + core/libs/prx/libkernel/Pthread/src/Attr.cpp | 1 + .../libs/prx/libkernel/Pthread/src/Thread.cpp | 23 ++ .../prx/libkernel/System/src/Exception.cpp | 1 + .../prx/libkernel_sys/Export.Discovery.cpp | 44 +-- core/libs/prx/libkernel_sys/include/Api.hpp | 12 +- .../tests/GuestPthreadCreateSuspended.cpp | 276 ++++++++++++++++++ docs/dev/debt/libkernel.md | 1 + docs/dev/debt/libkernel_sys.md | 1 + 11 files changed, 350 insertions(+), 26 deletions(-) create mode 100644 core/libs/tests/GuestPthreadCreateSuspended.cpp diff --git a/core/libs/CMakeLists.txt b/core/libs/CMakeLists.txt index eea852c88a..ee3dfbd9f2 100644 --- a/core/libs/CMakeLists.txt +++ b/core/libs/CMakeLists.txt @@ -511,6 +511,17 @@ if(BUILD_TESTING) "PATH=path_list_prepend:$") endif() + add_test_executable(guest_pthread_create_suspended_tests tests/GuestPthreadCreateSuspended.cpp) + target_include_directories(guest_pthread_create_suspended_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + target_link_libraries(guest_pthread_create_suspended_tests PRIVATE libkernel_sys libkernel libc) + configure_windows_unwind(guest_pthread_create_suspended_tests) + add_test(NAME guest_pthread_create_suspended COMMAND guest_pthread_create_suspended_tests) + set_tests_properties(guest_pthread_create_suspended PROPERTIES TIMEOUT 20) + if(WIN32) + set_tests_properties(guest_pthread_create_suspended PROPERTIES ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$") + endif() + add_test_executable(guest_np_party_tests tests/GuestNpParty.cpp) target_include_directories(guest_np_party_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(guest_np_party_tests PRIVATE libSceNpParty libc) diff --git a/core/libs/prx/libkernel/Pthread/Posix/Attr.cpp b/core/libs/prx/libkernel/Pthread/Posix/Attr.cpp index a71ae71597..35a9259df9 100644 --- a/core/libs/prx/libkernel/Pthread/Posix/Attr.cpp +++ b/core/libs/prx/libkernel/Pthread/Posix/Attr.cpp @@ -30,6 +30,7 @@ int APS5_VABI pthread_attr_get_np_nid_postfix(Pthread thread, PthreadAttr* attr) p->_detachstate = thread->_detached ? 1 : 0; p->_schedpriority = thread->priority.load(std::memory_order_relaxed); p->_schedpolicy = thread->schedPolicy.load(std::memory_order_relaxed); + p->_createSuspended = thread->createdSuspended; p->_affinity = thread->affinity.load(std::memory_order_relaxed); p->_affinityExplicit = true; p->_scope = thread->scope; diff --git a/core/libs/prx/libkernel/Pthread/include/Pthread.hpp b/core/libs/prx/libkernel/Pthread/include/Pthread.hpp index 0e83376197..097d609562 100644 --- a/core/libs/prx/libkernel/Pthread/include/Pthread.hpp +++ b/core/libs/prx/libkernel/Pthread/include/Pthread.hpp @@ -110,6 +110,7 @@ struct PthreadAttrPrivate { int _schedpriority; int _schedpolicy; int _inheritsched; + bool _createSuspended = false; KernelCpumask _affinity = DEFAULT_THREAD_AFFINITY; bool _affinityExplicit = false; std::size_t _guardsize = 0x1000; @@ -130,6 +131,8 @@ struct PthreadPrivate { std::atomic references{2}; std::atomic inWait{false}; std::atomic pendingException{0}; + std::atomic suspended{false}; + bool createdSuspended = false; std::atomic schedPolicy{1}; void* wakeEvent = nullptr; void* stackAddress = nullptr; @@ -179,6 +182,8 @@ void SetLiveGuestThreadsAffinity(KernelCpumask mask); KernelCpumask LiveGuestThreadsAffinity(); bool GuestThreadIsLive(const PthreadPrivate* thread); std::unique_lock LockLiveGuestThread(const PthreadPrivate* thread); +extern "C" int ResumeGuestThread_nid_no_patch(PthreadPrivate* thread); +extern "C" void ResumeAllGuestThreads_nid_no_patch(); bool LiveGuestThreadExists(std::int64_t tid); bool GetLiveGuestThreadAffinity_nid_no_patch(std::int64_t tid, KernelCpumask& mask); bool GetLiveGuestThreadSched_nid_no_patch(std::int64_t tid, int& policy, int& priority); diff --git a/core/libs/prx/libkernel/Pthread/src/Attr.cpp b/core/libs/prx/libkernel/Pthread/src/Attr.cpp index 0c26eb6762..9b7cb2e521 100644 --- a/core/libs/prx/libkernel/Pthread/src/Attr.cpp +++ b/core/libs/prx/libkernel/Pthread/src/Attr.cpp @@ -98,6 +98,7 @@ int APS5_VABI scePthreadAttrGet(Pthread thread, PthreadAttr* attr) { (*attr)->_schedpriority = thread->priority.load(std::memory_order_relaxed); (*attr)->_schedpolicy = thread->schedPolicy.load(std::memory_order_relaxed); (*attr)->_inheritsched = 4; + (*attr)->_createSuspended = thread->createdSuspended; (*attr)->_affinity = thread->affinity.load(std::memory_order_relaxed); (*attr)->_affinityExplicit = true; (*attr)->_scope = thread->scope; diff --git a/core/libs/prx/libkernel/Pthread/src/Thread.cpp b/core/libs/prx/libkernel/Pthread/src/Thread.cpp index d4505b8d1b..71680fca25 100644 --- a/core/libs/prx/libkernel/Pthread/src/Thread.cpp +++ b/core/libs/prx/libkernel/Pthread/src/Thread.cpp @@ -555,6 +555,26 @@ bool GuestThreadIsLive(const PthreadPrivate* thread) { return LockLiveGuestThread(thread).owns_lock(); } +extern "C" int ResumeGuestThread_nid_no_patch(PthreadPrivate* thread) { + constexpr int GuestEsrch = 3; + const auto live = LockLiveGuestThread(thread); + if (!live.owns_lock()) return GuestEsrch; + thread->suspended.store(false, std::memory_order_release); + thread->suspended.notify_all(); + return 0; +} + +extern "C" void ResumeAllGuestThreads_nid_no_patch() { + const auto* self = CurrentGuestState().current; + auto& registry = CpuClocks(); + std::lock_guard lock(registry.lock); + for (const auto& [id, thread] : registry.threads) { + if (thread == self) continue; + thread->suspended.store(false, std::memory_order_release); + thread->suspended.notify_all(); + } +} + bool GetLiveGuestThreadAffinity_nid_no_patch(std::int64_t tid, KernelCpumask& mask) { auto& registry = CpuClocks(); std::lock_guard lock(registry.lock); @@ -775,6 +795,7 @@ static void RunThread(std::unique_ptr args) { const sigset_t signalMask = args->signalMask; #endif args.reset(); + self->suspended.wait(true, std::memory_order_acquire); #ifndef _WIN32 pthread_sigmask(SIG_SETMASK, &signalMask, nullptr); #endif @@ -950,6 +971,8 @@ int APS5_VABI scePthreadCreate(Pthread* thread, const PthreadAttr* attr, Pthread p->scope = CurrentGuestState().current->scope; } if (name) p->name = name; + p->createdSuspended = attr && (*attr)->_createSuspended; + p->suspended.store(p->createdSuspended, std::memory_order_relaxed); std::promise start; auto args = std::make_unique(ThreadArgs{entry, arg, p.get()}); #ifdef _WIN32 diff --git a/core/libs/prx/libkernel/System/src/Exception.cpp b/core/libs/prx/libkernel/System/src/Exception.cpp index 8882083584..73b40090f1 100644 --- a/core/libs/prx/libkernel/System/src/Exception.cpp +++ b/core/libs/prx/libkernel/System/src/Exception.cpp @@ -681,6 +681,7 @@ int APS5_VABI sceKernelRaiseException(Pthread thread, int signum) { const auto handler = Handler(signum); if (handler == nullptr) throw std::runtime_error("sceKernelRaiseException: no handler installed for the signal"); #ifdef _WIN32 + if (thread->suspended.load(std::memory_order_acquire)) throw std::runtime_error("sceKernelRaiseException: the target thread is still suspended at creation"); return RaiseOn(thread, handler, signum) ? 0 : SCE_KERNEL_ERROR_ESRCH; #else return RaiseOn(thread) ? 0 : SCE_KERNEL_ERROR_ESRCH; diff --git a/core/libs/prx/libkernel_sys/Export.Discovery.cpp b/core/libs/prx/libkernel_sys/Export.Discovery.cpp index 3addcda651..e728785242 100644 --- a/core/libs/prx/libkernel_sys/Export.Discovery.cpp +++ b/core/libs/prx/libkernel_sys/Export.Discovery.cpp @@ -1,3 +1,5 @@ +#include "prx/libkernel/KernelErrors.hpp" +#include "prx/libkernel/Pthread/include/Pthread.hpp" #include "prx/libkernel_sys/include/Api.hpp" #include "prx/libkernel/Pthread/include/Atfork.hpp" #include "prx/libkernel/include/Api.hpp" @@ -287,8 +289,10 @@ int APS5_VABI pthread_attr_setaffinity_np_nid_postfix(void) { return 0; } -int APS5_VABI pthread_attr_setcreatesuspend_np_nid_postfix(void) { - NotImplemented_nid_no_patch(__func__); +int APS5_VABI pthread_attr_setcreatesuspend_np_nid_postfix(PthreadAttr* attr) { + constexpr int GuestEinval = 22; + if (!attr || !*attr) return GuestEinval; + (*attr)->_createSuspended = true; return 0; } @@ -323,7 +327,7 @@ int APS5_VABI pthread_getstack_np_nid_postfix(void) { } int APS5_VABI pthread_multi_np_nid_postfix(void) { - NotImplemented_nid_no_patch(__func__); + pthread_resume_all_np_nid_postfix(); return 0; } @@ -357,14 +361,15 @@ int APS5_VABI pthread_mutexattr_setgen_np_nid_postfix(void) { return 0; } -int APS5_VABI pthread_resume_all_np_nid_postfix(void) { - NotImplemented_nid_no_patch(__func__); - return 0; +void APS5_VABI pthread_resume_all_np_nid_postfix(void) { + ResumeAllGuestThreads_nid_no_patch(); + scePthreadTestcancel(); } -int APS5_VABI pthread_resume_np_nid_postfix(void) { - NotImplemented_nid_no_patch(__func__); - return 0; +int APS5_VABI pthread_resume_np_nid_postfix(Pthread thread) { + constexpr int GuestEinval = 22; + if (!thread) return GuestEinval; + return ResumeGuestThread_nid_no_patch(thread); } int APS5_VABI pthread_resume_user_context_np_nid_postfix(void) { @@ -2144,9 +2149,9 @@ int APS5_VABI scePthreadAttrGetscope(const PthreadAttr* attr, int* scope) { return SceResult(pthread_attr_getscope_nid_postfix(attr, scope)); } -int APS5_VABI scePthreadAttrSetcreatesuspend(void) { - NotImplemented_nid_no_patch(__func__); - return 0; +int APS5_VABI scePthreadAttrSetcreatesuspend(PthreadAttr* attr) { + const int error = pthread_attr_setcreatesuspend_np_nid_postfix(attr); + return error == 0 ? 0 : SceKernelError(error); } int APS5_VABI scePthreadAttrSetscope(PthreadAttr* attr, int scope) { @@ -2197,8 +2202,8 @@ int APS5_VABI scePthreadMain(void) { } int APS5_VABI scePthreadMulti(void) { - NotImplemented_nid_no_patch(__func__); - return 0; + const int error = pthread_multi_np_nid_postfix(); + return error == 0 ? 0 : SceKernelError(error); } int APS5_VABI scePthreadMutexGetprioceiling(void) { @@ -2246,14 +2251,13 @@ int APS5_VABI scePthreadMutexattrSetkind(void) { return 0; } -int APS5_VABI scePthreadResume(void) { - NotImplemented_nid_no_patch(__func__); - return 0; +int APS5_VABI scePthreadResume(Pthread thread) { + const int error = pthread_resume_np_nid_postfix(thread); + return error == 0 ? 0 : SceKernelError(error); } -int APS5_VABI scePthreadResumeAll(void) { - NotImplemented_nid_no_patch(__func__); - return 0; +void APS5_VABI scePthreadResumeAll(void) { + pthread_resume_all_np_nid_postfix(); } int APS5_VABI scePthreadSetBesteffort(void) { diff --git a/core/libs/prx/libkernel_sys/include/Api.hpp b/core/libs/prx/libkernel_sys/include/Api.hpp index 938bc8178b..9e555cbae1 100644 --- a/core/libs/prx/libkernel_sys/include/Api.hpp +++ b/core/libs/prx/libkernel_sys/include/Api.hpp @@ -124,7 +124,7 @@ int APS5_VABI profil_nid_postfix(void); int APS5_VABI pthread_attr_getaffinity_np_nid_postfix(void); int APS5_VABI pthread_attr_getsolosched_np_nid_postfix(void); int APS5_VABI pthread_attr_setaffinity_np_nid_postfix(void); -int APS5_VABI pthread_attr_setcreatesuspend_np_nid_postfix(void); +int APS5_VABI pthread_attr_setcreatesuspend_np_nid_postfix(PthreadAttr* attr); int APS5_VABI pthread_attr_setstackaddr_nid_postfix(void); int APS5_VABI pthread_barrier_setname_np_nid_postfix(void); int APS5_VABI pthread_cond_setname_np_nid_postfix(void); @@ -139,8 +139,8 @@ int APS5_VABI pthread_mutex_setname_np_nid_postfix(void); int APS5_VABI pthread_mutex_setprioceiling_nid_postfix(void); int APS5_VABI pthread_mutexattr_getgen_np_nid_postfix(void); int APS5_VABI pthread_mutexattr_setgen_np_nid_postfix(void); -int APS5_VABI pthread_resume_all_np_nid_postfix(void); -int APS5_VABI pthread_resume_np_nid_postfix(void); +void APS5_VABI pthread_resume_all_np_nid_postfix(void); +int APS5_VABI pthread_resume_np_nid_postfix(Pthread thread); int APS5_VABI pthread_resume_user_context_np_nid_postfix(void); int APS5_VABI pthread_rwlock_setname_np_nid_postfix(void); int APS5_VABI pthread_rwlockattr_settype_np_nid_postfix(void); @@ -497,7 +497,7 @@ int APS5_VABI sceKernelYieldCpumode(void); int APS5_VABI sceLibcMspaceCreateForMonoMutex(void); int APS5_VABI scePthreadAtfork(void (APS5_VABI* prepare)(), void (APS5_VABI* parent)(), void (APS5_VABI* child)()); int APS5_VABI scePthreadAttrGetscope(const PthreadAttr* attr, int* scope); -int APS5_VABI scePthreadAttrSetcreatesuspend(void); +int APS5_VABI scePthreadAttrSetcreatesuspend(PthreadAttr* attr); int APS5_VABI scePthreadAttrSetscope(PthreadAttr* attr, int scope); int APS5_VABI scePthreadBarrierDestroy(PthreadBarrierPrivate** barrier); int APS5_VABI scePthreadBarrierInit(PthreadBarrierPrivate** barrier, PthreadBarrierattrPrivate* const* attr, unsigned count, const char* name); @@ -519,8 +519,8 @@ int APS5_VABI scePthreadMutexattrGetkind(void); int APS5_VABI scePthreadMutexattrInitForInternalLibc(void); int APS5_VABI scePthreadMutexattrSetgen(void); int APS5_VABI scePthreadMutexattrSetkind(void); -int APS5_VABI scePthreadResume(void); -int APS5_VABI scePthreadResumeAll(void); +int APS5_VABI scePthreadResume(Pthread thread); +void APS5_VABI scePthreadResumeAll(void); int APS5_VABI scePthreadSetBesteffort(void); int APS5_VABI scePthreadSetconcurrency(int level); int APS5_VABI scePthreadSingle(void); diff --git a/core/libs/tests/GuestPthreadCreateSuspended.cpp b/core/libs/tests/GuestPthreadCreateSuspended.cpp new file mode 100644 index 0000000000..cadde5c8e2 --- /dev/null +++ b/core/libs/tests/GuestPthreadCreateSuspended.cpp @@ -0,0 +1,276 @@ +#include "prx/libkernel/include/Api.hpp" +#include "prx/libkernel_sys/include/Api.hpp" +#include "SceTypes.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +static constexpr int SCE_OK = 0; +static constexpr int SCE_KERNEL_ERROR_EINVAL = static_cast(0x80020016u); +static constexpr int SCE_KERNEL_ERROR_ESRCH = static_cast(0x80020003u); +static constexpr int GUEST_ESRCH = 3; +static constexpr int GUEST_EINVAL = 22; +static constexpr int GUEST_SIGUSR1 = 30; +static constexpr int GUEST_CREATE_DETACHED = 1; +static constexpr int DETACHED_ROUNDS = 200; +static constexpr auto SUSPENDED_WINDOW = std::chrono::milliseconds(50); +static void* const CANCELED = reinterpret_cast(std::uintptr_t{1}); +static void* const FINISHED = reinterpret_cast(std::uintptr_t{7}); + +static void Require(bool condition, const char* message) { + if (!condition) { + std::fprintf(stderr, "%s\n", message); + std::abort(); + } +} + +struct Started { + std::atomic ran{false}; + std::atomic returned{false}; +}; + +static std::atomic release{false}; +static std::atomic stopResuming{false}; +static std::atomic raised{0}; +static std::atomic raisedOn{nullptr}; + +static void* APS5_VABI Record(void* opaque) { + static_cast(opaque)->ran.store(true); + return FINISHED; +} + +static void* APS5_VABI CancelAfterResume(void* opaque) { + auto* started = static_cast(opaque); + started->ran.store(true); + scePthreadTestcancel(); + started->returned.store(true); + return FINISHED; +} + +static void* APS5_VABI CancelInResumeAll(void* opaque) { + auto* started = static_cast(opaque); + started->ran.store(true); + Require(scePthreadCancel(scePthreadSelf()) == SCE_OK, "scePthreadCancel of the calling thread failed"); + pthread_resume_all_np_nid_postfix(); + started->returned.store(true); + return FINISHED; +} + +static void* APS5_VABI ResumeAllUntilStopped(void*) { + while (!stopResuming.load()) pthread_resume_all_np_nid_postfix(); + return FINISHED; +} + +static void* APS5_VABI WaitForRelease(void*) { + while (!release.load()) std::this_thread::sleep_for(std::chrono::milliseconds(1)); + return FINISHED; +} + +static void APS5_VABI Handler(int, void*) { + raisedOn.store(scePthreadSelf()); + raised.fetch_add(1); +} + +static void CreateSuspendedPosix(Pthread* thread, PthreadEntry entry, void* arg) { + PthreadAttr attr = nullptr; + Require(pthread_attr_init_nid_postfix(&attr) == 0, "pthread_attr_init failed"); + Require(pthread_attr_setcreatesuspend_np_nid_postfix(&attr) == 0, "pthread_attr_setcreatesuspend_np failed"); + Require(pthread_create_nid_postfix(thread, &attr, entry, arg) == 0, "pthread_create of a suspended thread failed"); + Require(pthread_attr_destroy_nid_postfix(&attr) == 0, "pthread_attr_destroy failed"); +} + +static void CreateSuspendedSce(Pthread* thread, PthreadEntry entry, void* arg) { + PthreadAttr attr = nullptr; + Require(scePthreadAttrInit(&attr) == SCE_OK, "scePthreadAttrInit failed"); + Require(scePthreadAttrSetcreatesuspend(&attr) == SCE_OK, "scePthreadAttrSetcreatesuspend failed"); + Require(scePthreadCreate(thread, &attr, entry, arg, "suspended") == SCE_OK, "scePthreadCreate of a suspended thread failed"); + Require(scePthreadAttrDestroy(&attr) == SCE_OK, "scePthreadAttrDestroy failed"); +} + +static void ReportsErrors() { + Require(pthread_attr_setcreatesuspend_np_nid_postfix(nullptr) == GUEST_EINVAL, "pthread_attr_setcreatesuspend_np accepted a null attribute"); + Require(scePthreadAttrSetcreatesuspend(nullptr) == SCE_KERNEL_ERROR_EINVAL, "scePthreadAttrSetcreatesuspend accepted a null attribute"); + PthreadAttr destroyed = nullptr; + Require(scePthreadAttrInit(&destroyed) == SCE_OK, "scePthreadAttrInit failed"); + Require(scePthreadAttrDestroy(&destroyed) == SCE_OK, "scePthreadAttrDestroy failed"); + Require(pthread_attr_setcreatesuspend_np_nid_postfix(&destroyed) == GUEST_EINVAL, "pthread_attr_setcreatesuspend_np accepted a destroyed attribute"); + Require(scePthreadAttrSetcreatesuspend(&destroyed) == SCE_KERNEL_ERROR_EINVAL, "scePthreadAttrSetcreatesuspend accepted a destroyed attribute"); + Require(pthread_resume_np_nid_postfix(nullptr) == GUEST_EINVAL, "pthread_resume_np accepted a null thread"); + Require(scePthreadResume(nullptr) == SCE_KERNEL_ERROR_EINVAL, "scePthreadResume accepted a null thread"); + Pthread joined = nullptr; + Started started; + Require(scePthreadCreate(&joined, nullptr, Record, &started, "joined") == SCE_OK, "scePthreadCreate failed"); + Require(scePthreadJoin(joined, nullptr) == SCE_OK, "scePthreadJoin failed"); + Require(pthread_resume_np_nid_postfix(joined) == GUEST_ESRCH, "pthread_resume_np found a joined thread"); + Require(scePthreadResume(joined) == SCE_KERNEL_ERROR_ESRCH, "scePthreadResume found a joined thread"); +} + +static void ResumesOneThread(bool sce) { + Started started; + Pthread thread = nullptr; + if (sce) CreateSuspendedSce(&thread, Record, &started); + else CreateSuspendedPosix(&thread, Record, &started); + std::this_thread::sleep_for(SUSPENDED_WINDOW); + Require(!started.ran.load(), "a thread created suspended ran before it was resumed"); + Require((sce ? scePthreadResume(thread) : pthread_resume_np_nid_postfix(thread)) == 0, "resuming a suspended thread failed"); + void* result = nullptr; + Require((sce ? scePthreadJoin(thread, &result) : pthread_join_nid_postfix(thread, &result)) == 0, "joining a resumed thread failed"); + Require(result == FINISHED && started.ran.load(), "a resumed thread did not run its start routine"); +} + +static void ResumesRunningAndCallingThreads() { + Require(pthread_resume_np_nid_postfix(scePthreadSelf()) == 0, "pthread_resume_np of the calling thread failed"); + release.store(false); + Pthread running = nullptr; + Require(scePthreadCreate(&running, nullptr, WaitForRelease, nullptr, "running") == SCE_OK, "scePthreadCreate failed"); + Require(pthread_resume_np_nid_postfix(running) == 0, "pthread_resume_np of a running thread failed"); + release.store(true); + void* result = nullptr; + Require(scePthreadJoin(running, &result) == SCE_OK && result == FINISHED, "joining a running thread failed"); +} + +static void ResumesAllThreads(void (*resumeAll)()) { + Started first; + Started second; + Pthread firstThread = nullptr; + Pthread secondThread = nullptr; + CreateSuspendedPosix(&firstThread, Record, &first); + CreateSuspendedSce(&secondThread, Record, &second); + std::this_thread::sleep_for(SUSPENDED_WINDOW); + Require(!first.ran.load() && !second.ran.load(), "a thread created suspended ran before it was resumed"); + resumeAll(); + void* result = nullptr; + Require(scePthreadJoin(firstThread, &result) == SCE_OK && result == FINISHED && first.ran.load(), "resuming all threads left the first one suspended"); + Require(scePthreadJoin(secondThread, &result) == SCE_OK && result == FINISHED && second.ran.load(), "resuming all threads left the second one suspended"); +} + +static void CancelsBeforeResume() { + Started started; + Pthread thread = nullptr; + CreateSuspendedSce(&thread, CancelAfterResume, &started); + Require(scePthreadCancel(thread) == SCE_OK, "scePthreadCancel of a suspended thread failed"); + Require(pthread_resume_np_nid_postfix(thread) == 0, "pthread_resume_np of a canceled thread failed"); + void* result = nullptr; + Require(scePthreadJoin(thread, &result) == SCE_OK, "joining a canceled thread failed"); + Require(result == CANCELED && started.ran.load() && !started.returned.load(), "a cancel requested while suspended did not take effect at the first cancellation point"); +} + +static void CancelsInResumeAll() { + Started started; + Pthread thread = nullptr; + Require(scePthreadCreate(&thread, nullptr, CancelInResumeAll, &started, "resume_all") == SCE_OK, "scePthreadCreate failed"); + void* result = nullptr; + Require(scePthreadJoin(thread, &result) == SCE_OK, "joining a canceled thread failed"); + Require(result == CANCELED && started.ran.load() && !started.returned.load(), "pthread_resume_all_np did not act on a pending cancel"); +} + +static void ReportsCreateSuspendedInAttrGet(int (APS5_VABI* get)(Pthread, PthreadAttr*)) { + Started first; + Started second; + Pthread suspended = nullptr; + CreateSuspendedPosix(&suspended, Record, &first); + PthreadAttr attr = nullptr; + Require(pthread_attr_init_nid_postfix(&attr) == 0, "pthread_attr_init failed"); + Require(get(suspended, &attr) == 0, "getting the attributes of a suspended thread failed"); + Pthread copy = nullptr; + Require(pthread_create_nid_postfix(©, &attr, Record, &second) == 0, "pthread_create with the attributes of a suspended thread failed"); + std::this_thread::sleep_for(SUSPENDED_WINDOW); + Require(!second.ran.load(), "a thread created with the attributes of a suspended thread ran before it was resumed"); + Require(pthread_resume_np_nid_postfix(copy) == 0 && pthread_resume_np_nid_postfix(suspended) == 0, "resuming a suspended thread failed"); + void* result = nullptr; + Require(pthread_join_nid_postfix(copy, &result) == 0 && result == FINISHED && second.ran.load(), "joining a resumed thread failed"); + Require(pthread_join_nid_postfix(suspended, &result) == 0 && result == FINISHED && first.ran.load(), "joining a resumed thread failed"); + release.store(false); + Pthread running = nullptr; + Require(scePthreadCreate(&running, nullptr, WaitForRelease, nullptr, "running") == SCE_OK, "scePthreadCreate failed"); + Require(pthread_attr_setcreatesuspend_np_nid_postfix(&attr) == 0, "pthread_attr_setcreatesuspend_np failed"); + Require(get(running, &attr) == 0, "getting the attributes of a running thread failed"); + Started third; + Pthread plain = nullptr; + Require(pthread_create_nid_postfix(&plain, &attr, Record, &third) == 0, "pthread_create with the attributes of a running thread failed"); + Require(pthread_join_nid_postfix(plain, &result) == 0 && result == FINISHED && third.ran.load(), "a thread created with the attributes of a running thread did not run"); + release.store(true); + Require(scePthreadJoin(running, nullptr) == SCE_OK, "joining a running thread failed"); + Require(pthread_attr_destroy_nid_postfix(&attr) == 0, "pthread_attr_destroy failed"); +} + +static void ResumesDetachedThreadsWhileTheyExit() { + stopResuming.store(false); + Pthread resumer = nullptr; + Require(scePthreadCreate(&resumer, nullptr, ResumeAllUntilStopped, nullptr, "resumer") == SCE_OK, "scePthreadCreate failed"); + for (int round = 0; round < DETACHED_ROUNDS; ++round) { + Started started; + PthreadAttr attr = nullptr; + Require(pthread_attr_init_nid_postfix(&attr) == 0, "pthread_attr_init failed"); + Require(pthread_attr_setcreatesuspend_np_nid_postfix(&attr) == 0, "pthread_attr_setcreatesuspend_np failed"); + Require(pthread_attr_setdetachstate_nid_postfix(&attr, GUEST_CREATE_DETACHED) == 0, "pthread_attr_setdetachstate failed"); + Pthread thread = nullptr; + Require(pthread_create_nid_postfix(&thread, &attr, Record, &started) == 0, "pthread_create of a detached suspended thread failed"); + Require(pthread_attr_destroy_nid_postfix(&attr) == 0, "pthread_attr_destroy failed"); + int result = 0; + while ((result = pthread_resume_np_nid_postfix(thread)) == 0) std::this_thread::yield(); + Require(result == GUEST_ESRCH && started.ran.load(), "a detached thread resumed while it exited did not run its start routine"); + } + stopResuming.store(true); + Require(scePthreadJoin(resumer, nullptr) == SCE_OK, "joining the resuming thread failed"); +} + +#ifdef _WIN32 +static void RefusesRaiseWhileSuspended() { + Require(sceKernelInstallExceptionHandler(GUEST_SIGUSR1, reinterpret_cast(&Handler)) == SCE_OK, "sceKernelInstallExceptionHandler failed"); + Started started; + Pthread thread = nullptr; + CreateSuspendedSce(&thread, Record, &started); + bool refused = false; + try { + sceKernelRaiseException(thread, GUEST_SIGUSR1); + } catch (const std::runtime_error& error) { + refused = std::string(error.what()).find("suspended") != std::string::npos; + } + Require(refused, "sceKernelRaiseException did not refuse a thread still suspended at creation"); + Require(pthread_resume_np_nid_postfix(thread) == 0, "pthread_resume_np failed"); + Require(scePthreadJoin(thread, nullptr) == SCE_OK && started.ran.load(), "joining a resumed thread failed"); + Require(raised.load() == 0, "the handler ran for a refused raise"); + Require(sceKernelRemoveExceptionHandler(GUEST_SIGUSR1) == SCE_OK, "sceKernelRemoveExceptionHandler failed"); +} +#else +static void DefersRaiseUntilResume() { + Require(sceKernelInstallExceptionHandler(GUEST_SIGUSR1, reinterpret_cast(&Handler)) == SCE_OK, "sceKernelInstallExceptionHandler failed"); + Started started; + Pthread thread = nullptr; + CreateSuspendedSce(&thread, Record, &started); + Require(sceKernelRaiseException(thread, GUEST_SIGUSR1) == SCE_OK, "sceKernelRaiseException on a suspended thread failed"); + std::this_thread::sleep_for(SUSPENDED_WINDOW); + Require(raised.load() == 0, "the handler ran on a thread still suspended at creation"); + Require(pthread_resume_np_nid_postfix(thread) == 0, "pthread_resume_np failed"); + Require(scePthreadJoin(thread, nullptr) == SCE_OK && started.ran.load(), "joining a resumed thread failed"); + Require(raised.load() == 1 && raisedOn.load() == thread, "the handler did not run on the thread once it was resumed"); + Require(sceKernelRemoveExceptionHandler(GUEST_SIGUSR1) == SCE_OK, "sceKernelRemoveExceptionHandler failed"); +} +#endif + +int main() { + ReportsErrors(); + ResumesOneThread(false); + ResumesOneThread(true); + ResumesRunningAndCallingThreads(); + ResumesAllThreads([] { Require(pthread_multi_np_nid_postfix() == 0, "pthread_multi_np failed"); }); + ResumesAllThreads([] { pthread_resume_all_np_nid_postfix(); }); + ResumesAllThreads([] { Require(scePthreadMulti() == SCE_OK, "scePthreadMulti failed"); }); + ResumesAllThreads([] { scePthreadResumeAll(); }); + CancelsBeforeResume(); + CancelsInResumeAll(); + ReportsCreateSuspendedInAttrGet(pthread_attr_get_np_nid_postfix); + ReportsCreateSuspendedInAttrGet(scePthreadAttrGet); + ResumesDetachedThreadsWhileTheyExit(); +#ifdef _WIN32 + RefusesRaiseWhileSuspended(); +#else + DefersRaiseUntilResume(); +#endif +} diff --git a/docs/dev/debt/libkernel.md b/docs/dev/debt/libkernel.md index fd41607f44..ee2381b5d5 100644 --- a/docs/dev/debt/libkernel.md +++ b/docs/dev/debt/libkernel.md @@ -184,6 +184,7 @@ - [pthread_sigmask](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) shares the process-wide mask of `sigprocmask` and `_sigprocmask`, which keeps only the first 32 signals; FreeBSD keeps a mask per thread - [sceKernelRaiseException](../../../core/libs/prx/libkernel/System/src/Exception.cpp) reaches a thread that waits in an alertable `TimedWait` wait through an APC. A thread blocked anywhere else, including all waits under `APS5_COARSE_TIMED_WAITS` and host waits inside the emulator, is redirected and runs the handler only when that wait returns - [sceKernelRaiseException](../../../core/libs/prx/libkernel/System/src/Exception.cpp) on Windows runs the handler wherever the target thread was interrupted. It waits only while the thread is inside winpthreads or restoring a context through `NtContinue`, and throws if that lasts 1 s, so a handler that calls into other host code the thread was interrupted in, such as the C runtime's heap or a lock of the emulator, can deadlock on it or reenter it. A thread that holds the address-wait lock of `sceKernelSyncOnAddressWait`, `sceKernelSyncOnAddressWake` and `_umtx_op`, or the park queue lock of the guest mutexes, gets the raise as an APC instead, which runs once it has released the lock or while it waits; the other locks of the emulator are not deferred this way +- [sceKernelRaiseException](../../../core/libs/prx/libkernel/System/src/Exception.cpp) on Windows throws for a target thread that was created suspended and is not resumed yet. FreeBSD 11.4 libthr starts such a thread with every signal but `SIGTRAP` blocked ([`thr_create.c`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/lib/libthr/thread/thr_create.c#L177-L183)) and restores its mask only once it is resumed ([`thread_start`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/lib/libthr/thread/thr_create.c#L252-L294)), so a signal sent to it stays pending until then. The console's behaviour is unverified - [sigaltstack](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) records each thread's alternate signal stack and reports it back, following FreeBSD's `kern_sigaltstack` (`EINVAL` for flags other than `SS_DISABLE`, `ENOMEM` below `MINSIGSTKSZ` 2048), but handlers never run on it, so `SS_ONSTACK` and `EPERM` are never reported - [sigpending](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) reports the intersection of the process-wide pending set of `sigtimedwait` and the blocked mask of `_sigprocmask`, as FreeBSD's `sys_sigpending` intersects the pending list with `td_sigmask`, and returns -1 with `EFAULT` for a null set. PPSA99420 imports it - [scePthreadJoin, scePthreadCreate](../../../core/libs/prx/libkernel/Pthread/src/Thread.cpp) (libkernel) - a joined thread is kept until `scePthreadCreate` publishes over the same handle storage, so one host object and its handle are retained per distinct guest storage location for the life of the process; a guest that joins into a fresh location every time keeps every one. A handle copied to a second storage and joined there retains the object under the original storage key, so a later `scePthreadCreate` over the original storage releases an object the copy still points at, and the copy reads freed memory. A detached thread is released when it ends, so joining it after it ended reads freed memory. A null entry crashed the payload and throws. Joining the calling thread returns `EDEADLK` and a zeroed attribute creates a default thread, as on the console. PPSA34015 once stopped with `Joining guest thread: Access is denied` before the join and detach checks followed the console diff --git a/docs/dev/debt/libkernel_sys.md b/docs/dev/debt/libkernel_sys.md index 5f015be839..74eb0e61c5 100644 --- a/docs/dev/debt/libkernel_sys.md +++ b/docs/dev/debt/libkernel_sys.md @@ -21,6 +21,7 @@ - [aio_read, aio_write, aio_fsync, aio_error, aio_return, aio_suspend, aio_waitcomplete, aio_cancel](../../../core/libs/prx/libkernel_sys/Aio.cpp) (libkernel_sys) - a request with `SIGEV_SIGNAL`, `SIGEV_THREAD_ID` or `SIGEV_KEVENT` completion, or on a socket, throws, because delivering a guest signal or kevent and socket aio are not modelled. One worker thread runs the process's requests one at a time in submission order, so a request is cancellable only until that thread takes it. A queued request keeps the descriptor number, not the open file: closing or reusing the descriptor before it runs changes what it reaches. Pipes fail with `EOPNOTSUPP`, as FreeBSD 11 does with `vfs.aio.enable_unsafe` 0; the console's setting is unverified. Waits are not interrupted by signals (`EINTR`), and timeouts under 1 ms round up to 1 ms (hz 1000). Otherwise the rules follow FreeBSD 11 `vfs_aio.c` - [readlinkat](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) passes `AT_FDCWD` (-100) and absolute paths to `readlink`. Any other relative path is joined to the descriptor's host directory, found the same way as for `fchdir`, so guest path aliases and case-insensitive name matching do not apply to it, and a `..` component is not kept inside the guest root. It returns `EBADF` for a descriptor that is not open and `ENOTDIR` for one that is not a directory. Console behaviour is unverified - [link](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) on Windows resolves a symbolic link source through the host, which follows longer chains than FreeBSD: a chain of 40 links is linked, where FreeBSD 11.4 returns `ELOOP` after 32 (`MAXSYMLINKS`). A loop returns `ELOOP` as on FreeBSD +- [pthread_attr_setcreatesuspend_np, pthread_resume_np, pthread_resume_all_np, pthread_multi_np, scePthreadAttrSetcreatesuspend, scePthreadResume, scePthreadResumeAll and scePthreadMulti](../../../core/libs/prx/libkernel_sys/Export.Discovery.cpp) (libkernel_sys) - when a thread created suspended stops relative to its signal mask is unmeasured on a console. Return values and errors follow FreeBSD 11.4 libthr ([`thr_attr.c`](https://github.com/freebsd/freebsd-src/blob/8c639945/lib/libthr/thread/thr_attr.c#L369-L381), [`thr_resume_np.c`](https://github.com/freebsd/freebsd-src/blob/8c639945/lib/libthr/thread/thr_resume_np.c#L47-L97), [`thr_multi_np.c`](https://github.com/freebsd/freebsd-src/blob/8c639945/lib/libthr/thread/thr_multi_np.c#L40-L51)) and the code of firmware 13.42's `libkernel_sys.sprx` ([#9573](https://github.com/boykopovar/AnyPS5/pull/9573#issuecomment-6393877843)), where the sce forms add `0x80020000` to a non-zero errno - [fstatat](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) passes `AT_FDCWD` (-100) and absolute paths to `stat`, or to `lstat` with `AT_SYMLINK_NOFOLLOW` (0x200). Any other relative path is joined to the descriptor's host directory, found the same way as for `fchdir`, so guest path aliases and case-insensitive name matching do not apply to it, and a `..` component is not kept inside the guest root. It returns `EBADF` for a descriptor that is not open and `ENOTDIR` for one that is not a directory, and flags other than `AT_SYMLINK_NOFOLLOW` return `EINVAL`. PPSA74126 imports it. Console behaviour is unverified - [unlinkat](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) passes `AT_FDCWD` (-100) and absolute paths to `rmdir` (with `AT_REMOVEDIR`, 0x800) or `unlink`. Any other relative path is joined to the descriptor's host directory, found the same way as for `fchdir`, so guest path aliases and case-insensitive name matching do not apply to it, and a `..` component is not kept inside the guest root. It returns `EBADF` for a descriptor that is not open and `ENOTDIR` for one that is not a directory. Removing a directory without `AT_REMOVEDIR` fails with `EPERM`, removing `.` or `..` with `AT_REMOVEDIR` fails with `EINVAL`, and flags other than `AT_REMOVEDIR` fail with `EINVAL`. Console behaviour is unverified - [renameat](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) passes `AT_FDCWD` (-100) and absolute paths to `rename`. Any other relative path is joined to its descriptor's host directory, found the same way as for `fchdir`, so guest path aliases and case-insensitive name matching do not apply to relative descriptors, and a `..` component is not kept inside the guest root. It returns `EBADF` for a descriptor that is not open and `ENOTDIR` for one that is not a directory. Console behaviour is unverified From e2096fa49a473d12016092de8bcb7509c84c9902 Mon Sep 17 00:00:00 2001 From: ntamas94 Date: Thu, 22 Oct 2026 07:42:55 +0200 Subject: [PATCH 14/21] feat(libSceAjm): run PS4 batch buffer jobs in the console's chunk layout PS4 titles build AJM batches with the SDK's inline helpers, which call sceAjmBatchJobControlBufferRa, sceAjmBatchJobRunBufferRa and sceAjmBatchJobRunSplitBufferRa and start the buffer with sceAjmBatchStartBuffer. The buffer belongs to the title, so it has to use the console's chunk layout rather than this library's private records. The builders write console jobs (job, return address, buffer and flags chunks) and return the position after them. StartBuffer checks the context, walks the chunks, skips inline data and runs each job as the library's existing control, run or statistics job. Chunk headers are read as the console's builders write them: bits 0-3 the kind, bit 4 a buffer's output direction, bits 6-25 the payload; the other bits keep a reused buffer's old bytes on a PS4. Malformed batches return MALFORMED_BATCH, unknown chunk kinds and unimplemented control flags throw. An ATRAC9 run with RUN_MULTIPLE_FRAMES reports which side ran out as a PS4 does (0x8 input, 0x10 room, 0x18 both), and the ATRAC9 format reports the frame's bitrate; both were measured on a PS4 with Knowledge is Power: Decades (Wwise) batch dumps and probe runs. The vibration test's exact-fit run expects 0x18 accordingly. The throwing stubs in libSceAjm and the libSceAjmi copy of sceAjmBatchJobRunBufferRa are dropped. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Fable 5.1 --- .../libs/prx/libSceAjm.native/include/Api.hpp | 4 + core/libs/prx/libSceAjm.native/src/Ajm.cpp | 217 ++++++++++++++++- core/libs/prx/libSceAjm/Export.cpp | 15 +- core/libs/prx/libSceAjmi/Export.cpp | 5 - core/libs/tests/GuestAjm.cpp | 227 +++++++++++++++++- core/libs/tests/GuestAjmVibration.cpp | 2 +- docs/dev/debt/libSceAjm.native.md | 6 +- 7 files changed, 438 insertions(+), 38 deletions(-) diff --git a/core/libs/prx/libSceAjm.native/include/Api.hpp b/core/libs/prx/libSceAjm.native/include/Api.hpp index 3c1f8608ed..6547522846 100644 --- a/core/libs/prx/libSceAjm.native/include/Api.hpp +++ b/core/libs/prx/libSceAjm.native/include/Api.hpp @@ -70,5 +70,9 @@ int APS5_VABI sceAjmBatchStart(uint32_t context, const AjmBatchInfo* info, int p int APS5_VABI sceAjmBatchWait(uint32_t context, uint32_t batch, uint32_t timeout, AjmBatchError* error); int APS5_VABI sceAjmBatchCancel(uint32_t context, uint32_t batch); int APS5_VABI sceAjmBatchErrorDump(const AjmBatchInfo* info, AjmBatchError* error); +void* APS5_VABI sceAjmBatchJobControlBufferRa(void* position, uint32_t instance, uint64_t flags, const void* sideband_input, size_t sideband_input_size, void* sideband_output, size_t sideband_output_size, void* return_address); +void* APS5_VABI sceAjmBatchJobRunBufferRa(void* position, uint32_t instance, uint64_t flags, const void* data_input, size_t data_input_size, void* data_output, size_t data_output_size, void* sideband_output, size_t sideband_output_size, void* return_address); +void* APS5_VABI sceAjmBatchJobRunSplitBufferRa(void* position, uint32_t instance, uint64_t flags, const AjmBuffer* input_buffers, size_t input_buffers_num, const AjmBuffer* output_buffers, size_t output_buffers_num, void* sideband_output, size_t sideband_output_size, void* return_address); +int APS5_VABI sceAjmBatchStartBuffer(uint32_t context, const uint8_t* batch, uint32_t batch_size, int priority, AjmBatchError* error, uint32_t* batch_id); } diff --git a/core/libs/prx/libSceAjm.native/src/Ajm.cpp b/core/libs/prx/libSceAjm.native/src/Ajm.cpp index 195ee36a3a..41baae55e0 100644 --- a/core/libs/prx/libSceAjm.native/src/Ajm.cpp +++ b/core/libs/prx/libSceAjm.native/src/Ajm.cpp @@ -36,6 +36,7 @@ constexpr int SCE_AJM_ERROR_INVALID_INSTANCE = static_cast(0x80930003); constexpr int SCE_AJM_ERROR_INVALID_BATCH = static_cast(0x80930004); constexpr int SCE_AJM_ERROR_INVALID_PARAMETER = static_cast(0x80930005); constexpr int SCE_AJM_ERROR_OUT_OF_RESOURCES = static_cast(0x80930007); +constexpr int SCE_AJM_ERROR_MALFORMED_BATCH = static_cast(0x80930011); constexpr std::int32_t AJM_RESULT_NOT_INITIALIZED = 0x00000001; constexpr std::int32_t AJM_RESULT_INVALID_DATA = 0x00000002; @@ -691,9 +692,17 @@ void EmitFrame(Instance& instance, PcmOutputs& outputs, const std::uint8_t* pcm, ResamplerProduce(instance, outputs, channels, encoding, sampleBytes); } +// A PS4 reports an ATRAC9 stream's bitrate from its superframe: 448-byte superframes of 4 frames of 256 +// samples at 48 kHz gave 168000 (Knowledge is Power: Decades, every run job's format sideband). +std::uint32_t At9Bitrate(const Instance& instance) { + const auto samples = static_cast(instance.info.framesInSuperframe) * static_cast(instance.info.frameSamples); + if (samples == 0) return 0; + return static_cast(static_cast(instance.info.superframeSize) * 8u * static_cast(instance.info.samplingRate) / samples); +} + SidebandFormat CurrentFormat(const Instance& instance) { switch (instance.codec) { - case CODEC_AT9: return {static_cast(instance.info.channels), ChannelMask(static_cast(instance.info.channels)), static_cast(instance.info.samplingRate), PcmEncoding(instance), 0, 0}; + case CODEC_AT9: return {static_cast(instance.info.channels), ChannelMask(static_cast(instance.info.channels)), static_cast(instance.info.samplingRate), PcmEncoding(instance), At9Bitrate(instance), 0}; case CODEC_MP3: return {instance.mp3Channels, ChannelMask(instance.mp3Channels), instance.mp3SampleRate, PcmEncoding(instance), instance.mp3Bitrate, 0}; case CODEC_OPUS: return {instance.opusChannels, ChannelMask(instance.opusChannels), instance.opusSampleRate, PcmEncoding(instance), 0, 0}; case CODEC_M4AAC: return {instance.aacChannels, ChannelMask(instance.aacChannels), instance.aacSampleRate, PcmEncoding(instance), instance.aacBitrate, 0}; @@ -768,12 +777,22 @@ void RunAt9(Instance& instance, const JobHeader& job, const AjmBuffer* inputs, c if (segmentEnded()) break; if (instance.superframeRemaining == 0) consumed += RiffDataOffset(input.data() + consumed, input.size() - consumed); const std::size_t needed = instance.superframeRemaining == 0 ? superframeSize : instance.superframeRemaining; + const bool roomForFrame = Resampling(instance) ? pcmOutputs.Room() >= channels * sampleBytes : pcmOutputs.Room() >= frameBytes; + // A PS4 reports which side ran out (probe runs of a title's ATRAC9 stream through libSceAjm's + // builders): PARTIAL_INPUT when the input ran out, on a superframe boundary or not, with room + // left; NOT_ENOUGH_ROOM when the room ran out with input left, after however many frames (and + // with none decoded for a zero room); both (0x18) when the input ended just as the output + // filled, which is every Wwise run job. A run without input (a format query) reports 0. + // Resampled runs were not measured and keep the earlier rule: a trailing partial superframe is + // partial input, a zero-output run is out of room. + const bool measured = !Resampling(instance); if (input.size() - consumed < needed) { - if (input.size() != consumed) result |= AJM_RESULT_PARTIAL_INPUT; + if (input.size() != consumed || (measured && !input.empty())) result |= AJM_RESULT_PARTIAL_INPUT; + if (measured && !input.empty() && !roomForFrame) result |= AJM_RESULT_NOT_ENOUGH_ROOM; break; } - if (Resampling(instance) ? pcmOutputs.Room() < channels * sampleBytes : pcmOutputs.Room() < frameBytes) { - if (frames == 0 && pcmOutputs.produced == 0) result |= AJM_RESULT_NOT_ENOUGH_ROOM; + if (!roomForFrame) { + if (measured || (frames == 0 && pcmOutputs.produced == 0)) result |= AJM_RESULT_NOT_ENOUGH_ROOM; break; } if (instance.superframeRemaining == 0) { @@ -1692,6 +1711,137 @@ void Execute(const JobHeader& job, const AjmBuffer* inputs, const AjmBuffer* out } } +int RegisterBatch(std::uint32_t context, AjmBatchError* error, std::uint32_t* batch) { + if (error) std::memset(error, 0, sizeof(*error)); + const auto id = g_nextBatch.fetch_add(1, std::memory_order_relaxed); + { + std::lock_guard lock(g_batchLock); + g_batches.emplace(id, context); + } + *batch = id; + return 0; +} + +// The PS4 batch buffer (sceAjmBatchStartBuffer) is not private: the PS4 SDK builds it with inline code +// in the title, so it has the console's layout, taken from shadPS4's reimplementation of the PS4 +// library. It is a run of chunks, each starting with a 32-bit word that holds the chunk kind in bits +// 0-5 and a payload in bits 6-25. A job chunk has the instance as payload and, in a second word, the +// byte count of the chunks after it that describe the job; an inline chunk holds that many bytes of data +// a job's control buffer may point to. A job's flags chunk has flags bits 32-51 as payload and bits 0-31 +// in its second word; its buffer chunks have a 32-bit size and a 64-bit address. +enum class ConsoleChunk : std::uint32_t { + Job = 0, + InputRunBuffer = 1, + InputControlBuffer = 2, + ControlFlags = 3, + RunFlags = 4, + ReturnAddress = 6, + InlineBuffer = 7, + OutputRunBuffer = 17, + OutputControlBuffer = 18, +}; + +constexpr std::uint32_t CONSOLE_STATISTICS_INSTANCE = 0x80000; +constexpr std::uint64_t CONSOLE_FLAGS_VERSION_AND_CODEC = 0x7FF; + +// The low header word as the PS4's libSceAjm builders write it (a trace of 32 545 batches a Wwise title +// started on a PS4): bits 0-3 the kind, bit 4 the output direction of a buffer chunk +// (0x11 output run, 0x12 output control), bits 6-25 the job's instance or the flags' bits 32-51. The +// builders leave bits 5, 26, 27 and 31 as they were, and a title that reuses its batch memory (Wwise +// does) carries the previous batch's bytes there: a 6-bit kind read job headers as 16, 32 or 48 and run +// flags as 20, 36 or 52, and a 26-bit payload read flags bit 52 set. +ConsoleChunk ConsoleChunkKind(std::uint32_t word) { + const std::uint32_t kind = word & 0xFu; + return static_cast(kind == 1 || kind == 2 ? kind | (word & 0x10u) : kind); +} + +std::uint32_t ConsoleChunkPayload(std::uint32_t word) { + return (word >> 6) & 0xFFFFFu; +} + +std::uint8_t* PutConsoleChunk(std::uint8_t* at, ConsoleChunk kind, std::uint32_t payload, std::uint32_t word) { + const std::uint32_t words[2] = {static_cast(kind) | ((payload & 0xFFFFFu) << 6), word}; + std::memcpy(at, words, sizeof(words)); + return at + sizeof(words); +} + +std::uint8_t* PutConsoleFlags(std::uint8_t* at, ConsoleChunk kind, std::uint64_t flags) { + return PutConsoleChunk(at, kind, static_cast(flags >> 32), static_cast(flags)); +} + +std::uint8_t* PutConsoleBuffer(std::uint8_t* at, ConsoleChunk kind, const void* address, std::size_t size) { + at = PutConsoleChunk(at, kind, 0, static_cast(size)); + std::memcpy(at, &address, sizeof(address)); + return at + sizeof(address); +} + +std::uint8_t* BeginConsoleJob(std::uint8_t* job, const void* returnAddress) { + std::uint8_t* at = job + 2 * sizeof(std::uint32_t); + return returnAddress ? PutConsoleBuffer(at, ConsoleChunk::ReturnAddress, returnAddress, 0) : at; +} + +void* EndConsoleJob(std::uint8_t* job, std::uint8_t* end, std::uint32_t instance) { + PutConsoleChunk(job, ConsoleChunk::Job, instance, static_cast(end - job) - 2 * sizeof(std::uint32_t)); + return end; +} + +// Runs one console job as the job of this file's kind it describes. The return address is only kept by +// the console for error reports. Returns false for a job whose chunks overrun it or that has no flags. +bool RunConsoleJob(std::uint32_t instance, const std::uint8_t* at, const std::uint8_t* end) { + JobHeader job{}; + job.instance = instance; + std::vector inputs; + std::vector outputs; + AjmBuffer controlInput{}; + AjmBuffer controlOutput{}; + ConsoleChunk flagsKind = ConsoleChunk::Job; + while (at < end) { + if (end - at < 8) return false; + std::uint32_t words[2]; + std::memcpy(words, at, sizeof(words)); + const auto kind = ConsoleChunkKind(words[0]); + if (kind == ConsoleChunk::ControlFlags || kind == ConsoleChunk::RunFlags) { + if (flagsKind != ConsoleChunk::Job) return false; + flagsKind = kind; + job.flags = (std::uint64_t{ConsoleChunkPayload(words[0])} << 32) | words[1]; + at += sizeof(words); + continue; + } + if (kind != ConsoleChunk::InputRunBuffer && kind != ConsoleChunk::InputControlBuffer && kind != ConsoleChunk::ReturnAddress && kind != ConsoleChunk::OutputRunBuffer && kind != ConsoleChunk::OutputControlBuffer) { + NotImplemented_nid_no_patch(("sceAjmBatchStartBuffer (job chunk kind " + std::to_string(static_cast(kind)) + ")").c_str()); + } + if (end - at < 16) return false; + AjmBuffer buffer{nullptr, words[1]}; + std::memcpy(&buffer.ptr, at + sizeof(words), sizeof(buffer.ptr)); + at += 16; + if (kind == ConsoleChunk::InputRunBuffer && buffer.size) inputs.push_back(buffer); + if (kind == ConsoleChunk::OutputRunBuffer && buffer.ptr && buffer.size) outputs.push_back(buffer); + if (kind == ConsoleChunk::InputControlBuffer) controlInput = buffer; + if (kind == ConsoleChunk::OutputControlBuffer) controlOutput = buffer; + } + if (flagsKind == ConsoleChunk::Job) return false; + job.sideband = controlOutput.ptr; + job.sidebandSize = controlOutput.size; + if (instance == CONSOLE_STATISTICS_INSTANCE) { + job.kind = JobKind::GetStatistics; + } else if (flagsKind == ConsoleChunk::ControlFlags) { + constexpr std::uint64_t implemented = CONSOLE_FLAGS_VERSION_AND_CODEC | CONTROL_RESET | CONTROL_INITIALIZE | SIDEBAND_GAPLESS_DECODE; + if ((job.flags & ~implemented) != 0) NotImplemented_nid_no_patch("sceAjmBatchStartBuffer (control job flags other than RESET, INITIALIZE and SIDEBAND_GAPLESS_DECODE)"); + if (!inputs.empty() || !outputs.empty()) NotImplemented_nid_no_patch("sceAjmBatchStartBuffer (control job with run buffers)"); + job.kind = JobKind::Control; + if (controlInput.size) inputs.push_back(controlInput); + } else { + if (controlInput.size) NotImplemented_nid_no_patch("sceAjmBatchStartBuffer (run job with an input control buffer)"); + job.kind = JobKind::Run; + } + job.inputCount = static_cast(inputs.size()); + job.outputCount = static_cast(outputs.size()); + AJM_TRACE("[ajm] console job: instance %u, %s flags 0x%llx, %u input and %u output buffers, control input %llu and output %llu bytes\n", instance, flagsKind == ConsoleChunk::ControlFlags ? "control" : "run", static_cast(job.flags), job.inputCount, job.outputCount, static_cast(controlInput.size), static_cast(controlOutput.size)); + inputs.insert(inputs.end(), outputs.begin(), outputs.end()); + Execute(job, inputs.data(), inputs.data() + job.inputCount); + return true; +} + } extern "C" { @@ -1923,14 +2073,7 @@ int APS5_VABI sceAjmBatchStart(uint32_t context, const AjmBatchInfo* info, int p remaining -= record.bytes; if (remaining != 0) cursor += record.bytes; } - if (error) std::memset(error, 0, sizeof(*error)); - const auto id = g_nextBatch.fetch_add(1, std::memory_order_relaxed); - { - std::lock_guard lock(g_batchLock); - g_batches.emplace(id, context); - } - *batch = id; - return 0; + return RegisterBatch(context, error, batch); } int APS5_VABI sceAjmBatchWait(uint32_t context, uint32_t batch, uint32_t timeout, AjmBatchError* error) { @@ -1959,4 +2102,54 @@ int APS5_VABI sceAjmBatchErrorDump(const AjmBatchInfo* info, AjmBatchError* erro return 0; } +void* APS5_VABI sceAjmBatchJobControlBufferRa(void* position, uint32_t instance, uint64_t flags, const void* sideband_input, size_t sideband_input_size, void* sideband_output, size_t sideband_output_size, void* return_address) { + auto* job = static_cast(position); + auto* at = BeginConsoleJob(job, return_address); + at = PutConsoleBuffer(at, ConsoleChunk::InputControlBuffer, sideband_input, sideband_input_size); + at = PutConsoleFlags(at, ConsoleChunk::ControlFlags, flags); + at = PutConsoleBuffer(at, ConsoleChunk::OutputControlBuffer, sideband_output, sideband_output_size); + return EndConsoleJob(job, at, instance); +} + +void* APS5_VABI sceAjmBatchJobRunSplitBufferRa(void* position, uint32_t instance, uint64_t flags, const AjmBuffer* input_buffers, size_t input_buffers_num, const AjmBuffer* output_buffers, size_t output_buffers_num, void* sideband_output, size_t sideband_output_size, void* return_address) { + if ((input_buffers_num && !input_buffers) || (output_buffers_num && !output_buffers)) NotImplemented_nid_no_patch("AJM batch job with a null buffer list and a nonzero buffer count"); + auto* job = static_cast(position); + auto* at = BeginConsoleJob(job, return_address); + for (std::size_t index = 0; index < input_buffers_num; ++index) at = PutConsoleBuffer(at, ConsoleChunk::InputRunBuffer, input_buffers[index].ptr, input_buffers[index].size); + at = PutConsoleFlags(at, ConsoleChunk::RunFlags, flags); + for (std::size_t index = 0; index < output_buffers_num; ++index) at = PutConsoleBuffer(at, ConsoleChunk::OutputRunBuffer, output_buffers[index].ptr, output_buffers[index].size); + at = PutConsoleBuffer(at, ConsoleChunk::OutputControlBuffer, sideband_output, sideband_output_size); + return EndConsoleJob(job, at, instance); +} + +void* APS5_VABI sceAjmBatchJobRunBufferRa(void* position, uint32_t instance, uint64_t flags, const void* data_input, size_t data_input_size, void* data_output, size_t data_output_size, void* sideband_output, size_t sideband_output_size, void* return_address) { + const AjmBuffer input{const_cast(data_input), data_input_size}; + const AjmBuffer output{data_output, data_output_size}; + return sceAjmBatchJobRunSplitBufferRa(position, instance, flags, &input, 1, &output, 1, sideband_output, sideband_output_size, return_address); +} + +int APS5_VABI sceAjmBatchStartBuffer(uint32_t context, const uint8_t* batch, uint32_t batch_size, int priority, AjmBatchError* error, uint32_t* batch_id) { + if (context == 0 || context >= g_nextContext.load(std::memory_order_relaxed)) return SCE_AJM_ERROR_INVALID_CONTEXT; + if (!batch_id || (!batch && batch_size)) return SCE_AJM_ERROR_INVALID_PARAMETER; + AJM_TRACE("[ajm] batch start buffer: context %u, priority %d, %u bytes\n", context, priority, batch_size); + if (batch_size % 8 != 0) return SCE_AJM_ERROR_MALFORMED_BATCH; + const std::uint8_t* at = batch; + const std::uint8_t* end = batch + batch_size; + while (at < end) { + if (end - at < 8) return SCE_AJM_ERROR_MALFORMED_BATCH; + std::uint32_t words[2]; + std::memcpy(words, at, sizeof(words)); + const std::uint8_t* body = at + sizeof(words); + if (words[1] > static_cast(end - body)) return SCE_AJM_ERROR_MALFORMED_BATCH; + const auto kind = ConsoleChunkKind(words[0]); + if (kind == ConsoleChunk::Job) { + if (!RunConsoleJob(ConsoleChunkPayload(words[0]), body, body + words[1])) return SCE_AJM_ERROR_MALFORMED_BATCH; + } else if (kind != ConsoleChunk::InlineBuffer) { + NotImplemented_nid_no_patch(("sceAjmBatchStartBuffer (batch chunk kind " + std::to_string(static_cast(kind)) + ")").c_str()); + } + at = body + words[1]; + } + return RegisterBatch(context, error, batch_id); +} + } diff --git a/core/libs/prx/libSceAjm/Export.cpp b/core/libs/prx/libSceAjm/Export.cpp index be23cfdc39..87830816c5 100644 --- a/core/libs/prx/libSceAjm/Export.cpp +++ b/core/libs/prx/libSceAjm/Export.cpp @@ -6,19 +6,6 @@ extern "C" { -int APS5_VABI sceAjmBatchJobControlBufferRa() { - NotImplemented_nid_no_patch(__func__); - return 0; -} - -int APS5_VABI sceAjmBatchJobRunSplitBufferRa() { - NotImplemented_nid_no_patch(__func__); - return 0; -} - -int APS5_VABI sceAjmBatchStartBuffer() { - NotImplemented_nid_no_patch(__func__); - return 0; -} +APS5_DUMMY_FUN } diff --git a/core/libs/prx/libSceAjmi/Export.cpp b/core/libs/prx/libSceAjmi/Export.cpp index 3941ee53aa..7932a6f3c8 100644 --- a/core/libs/prx/libSceAjmi/Export.cpp +++ b/core/libs/prx/libSceAjmi/Export.cpp @@ -13,9 +13,4 @@ int APS5_VABI sceAjmBatchJobInlineBuffer(void) { return 0; } -int APS5_VABI sceAjmBatchJobRunBufferRa(void) { - NotImplemented_nid_no_patch(__func__); - return 0; -} - } diff --git a/core/libs/tests/GuestAjm.cpp b/core/libs/tests/GuestAjm.cpp index 6032301a75..f850b844ff 100644 --- a/core/libs/tests/GuestAjm.cpp +++ b/core/libs/tests/GuestAjm.cpp @@ -825,7 +825,7 @@ void TestGetInfo(std::uint32_t context) { Require(sceAjmBatchJobGetInfo(&info, instance, &at9) == 0); Submit(context, info); Require(initResult[0] == 0); - Require(at9.result == 0 && at9.internalResult == 0 && at9.numChannels == 2 && at9.channelMask == 0x3 && at9.sampleRate == 48000 && at9.sampleEncoding == 0 && at9.reserved == 0); + Require(at9.result == 0 && at9.internalResult == 0 && at9.numChannels == 2 && at9.channelMask == 0x3 && at9.sampleRate == 48000 && at9.sampleEncoding == 0 && at9.bitrate == 1024 * 8 * 48000 / (4 * 256) && at9.reserved == 0); Require(sceAjmInstanceDestroy(context, instance) == 0); Require(sceAjmInstanceCreate(context, 0, 0, &instance) == 0); @@ -1051,7 +1051,8 @@ std::vector DecodeAt9Superframe(std::uint32_t context, const std:: Require(sceAjmBatchJobRun(&info, instance, sidebandStream | runMultipleFrames, superframe.data(), superframe.size(), pcm.data(), pcm.size() * sizeof(std::int16_t), &decoded, sizeof(decoded)) == 0); Submit(context, info); Require(initResult[0] == 0); - Require(decoded.stream.result == 0 && static_cast(decoded.stream.inputConsumed) == superframe.size()); + // one superframe into exactly its 1024 samples: PARTIAL_INPUT | NOT_ENOUGH_ROOM as on the console + Require(decoded.stream.result == 0x18 && static_cast(decoded.stream.inputConsumed) == superframe.size()); Require(static_cast(decoded.stream.outputWritten) == pcm.size() * sizeof(std::int16_t) && decoded.stream.totalDecodedSamples == 1024 && decoded.frames == 4); Require(sceAjmInstanceDestroy(context, instance) == 0); return pcm; @@ -1578,7 +1579,8 @@ ResampledStream Stream(std::uint32_t context, std::uint32_t instance, const std: Require(sceAjmBatchJobDecode(&info, instance, stream + offset, size - offset, pcm.data(), pcm.size() * sizeof(std::int16_t), &sideband) == 0); Submit(context, info); if (ratio > 0) Require(setResult[0] == 0); - Require(sideband.result == 0 || (offset == size && sideband.outputWritten == 0)); + // 0x8 / 0x10 / 0x18: which of input and room ran out (what the console reports, see TestAt9FlagRules) + Require(sideband.result == 0 || sideband.result == 0x8 || sideband.result == 0x10 || sideband.result == 0x18 || (offset == size && sideband.outputWritten == 0)); offset += static_cast(sideband.inputConsumed); if (offset < size && static_cast(sideband.outputWritten) < pcm.size() * sizeof(std::int16_t)) ++out.shortJobs; out.pcm.insert(out.pcm.end(), pcm.begin(), pcm.begin() + sideband.outputWritten / static_cast(sizeof(std::int16_t))); @@ -1798,7 +1800,7 @@ At9Run RunAt9Job(std::uint32_t context, std::uint32_t instance, std::uint64_t fl const std::size_t sidebandSize = (flags & runMultipleFrames) != 0 ? sizeof(decoded) : sizeof(decoded.stream); Require(sceAjmBatchJobRun(&info, instance, sidebandStream | flags, input, inputSize, pcm.data(), pcm.size() * sizeof(std::int16_t), &decoded, sidebandSize) == 0); Submit(context, info); - Require(decoded.stream.result == 0); + Require(decoded.stream.result == 0 || decoded.stream.result == 0x8 || decoded.stream.result == 0x18); // which side ran out, see TestAt9FlagRules return decoded; } @@ -1832,7 +1834,218 @@ void TestAt9RunDecodesWholeInput(std::uint32_t context) { RunControl(context, instance, CONTROL_START, &start, sizeof(start), result); Require(result[0] == 0); const auto run = RunAt9Job(context, instance, 0, stream.data(), stream.size()); - Require(static_cast(run.stream.inputConsumed) == stream.size() && run.stream.outputWritten == 1024 * 2); + // 1024 bytes decode into exactly the 1024 samples of room: the console reports PARTIAL_INPUT | NOT_ENOUGH_ROOM + Require(run.stream.result == 0x18 && static_cast(run.stream.inputConsumed) == stream.size() && run.stream.outputWritten == 1024 * 2); + Require(sceAjmInstanceDestroy(context, instance) == 0); +} + +// The result flags say which side ran out, as a PS4 reports them for a title's ATRAC9 stream: the input +// (PARTIAL_INPUT, even on a superframe boundary) with room left, the room (NOT_ENOUGH_ROOM) with input +// left after some frames or with a zero room before any, or both at once (0x18). +void TestAt9FlagRules(std::uint32_t context) { + constexpr std::uint64_t runMultipleFrames = 1ull << 12; + constexpr std::uint64_t sidebandStream = 1ull << 47; + std::vector stream(2048, 0); + std::memcpy(stream.data(), AT9_MONO_SILENT_SUPERFRAME, sizeof(AT9_MONO_SILENT_SUPERFRAME)); + std::memcpy(stream.data() + 1024, AT9_MONO_SILENT_SUPERFRAME, sizeof(AT9_MONO_SILENT_SUPERFRAME)); + const At9Control start{{0, 0, 0}, {0xFE, 0x70, 0x1F, 0xF0}, 0}; + const auto run = [&](std::size_t inputSize, std::size_t roomSamples, At9Run& decoded) { + std::uint32_t instance = 0; + Require(sceAjmInstanceCreate(context, 1, 0, &instance) == 0); + std::int32_t result[2] = {-1, -1}; + RunControl(context, instance, CONTROL_START, &start, sizeof(start), result); + Require(result[0] == 0); + std::vector batch(4096); + std::vector pcm(roomSamples + 1); + AjmBatchInfo info{}; + Require(sceAjmBatchInitialize(batch.data(), batch.size(), &info) == 0); + Require(sceAjmBatchJobRun(&info, instance, sidebandStream | runMultipleFrames, stream.data(), inputSize, pcm.data(), roomSamples * sizeof(std::int16_t), &decoded, sizeof(decoded)) == 0); + Submit(context, info); + Require(sceAjmInstanceDestroy(context, instance) == 0); + }; + At9Run inputRanOut{{-1, -1, 0, 0, 0}, 0, 0}; + run(1024, 2048, inputRanOut); + Require(inputRanOut.stream.result == 0x8 && inputRanOut.stream.inputConsumed == 1024 && inputRanOut.stream.outputWritten == 1024 * 2 && inputRanOut.frames == 4); + At9Run roomRanOut{{-1, -1, 0, 0, 0}, 0, 0}; + run(2048, 1024, roomRanOut); + Require(roomRanOut.stream.result == 0x10 && roomRanOut.stream.inputConsumed == 1024 && roomRanOut.stream.outputWritten == 1024 * 2 && roomRanOut.frames == 4); + At9Run halfRoom{{-1, -1, 0, 0, 0}, 0, 0}; + run(1024, 512, halfRoom); + Require(halfRoom.stream.result == 0x10 && halfRoom.stream.inputConsumed > 0 && halfRoom.stream.inputConsumed < 1024 && halfRoom.stream.outputWritten == 512 * 2 && halfRoom.frames == 2); + At9Run noRoom{{-1, -1, 0, 0, 0}, 0, 0}; + run(1024, 0, noRoom); + Require(noRoom.stream.result == 0x10 && noRoom.stream.inputConsumed == 0 && noRoom.stream.outputWritten == 0 && noRoom.frames == 0); + At9Run both{{-1, -1, 0, 0, 0}, 0, 0}; + run(1024, 1024, both); + Require(both.stream.result == 0x18 && both.stream.inputConsumed == 1024 && both.stream.outputWritten == 1024 * 2 && both.frames == 4); +} + +int StartBuffer(std::uint32_t context, const void* batch, std::size_t size) { + std::uint32_t id = 0; + AjmBatchError error{}; + const int started = sceAjmBatchStartBuffer(context, static_cast(batch), static_cast(size), 0, &error, &id); + if (started == 0) Require(sceAjmBatchWait(context, id, 0, &error) == 0); + return started; +} + +std::uint32_t BatchWord(const std::vector& batch, std::size_t offset) { + std::uint32_t word = 0; + std::memcpy(&word, batch.data() + offset, sizeof(word)); + return word; +} + +void TestConsoleBufferJobs(std::uint32_t context) { + constexpr std::uint64_t version = 1; + constexpr std::uint64_t runMultipleFrames = 1ull << 12; + constexpr std::uint64_t sidebandStream = 1ull << 47; + std::vector stream(2048, 0); + std::memcpy(stream.data(), AT9_MONO_SILENT_SUPERFRAME, sizeof(AT9_MONO_SILENT_SUPERFRAME)); + std::memcpy(stream.data() + 1024, AT9_MONO_SILENT_SUPERFRAME, sizeof(AT9_MONO_SILENT_SUPERFRAME)); + std::uint32_t instance = 0; + Require(sceAjmInstanceCreate(context, 1, 0, &instance) == 0); + const At9Control start{{0, 0, 0}, {0xFE, 0x70, 0x1F, 0xF0}, 0}; + std::int32_t result[2] = {-1, -1}; + std::vector pcm(2048, 0x55); + At9Run single{{-1, -1, 0, 0, 0}, 0, 0}; + At9Run split{{-1, -1, 0, 0, 0}, 0, 0}; + const AjmBuffer inputs[2] = {{stream.data() + 1024, 300}, {stream.data() + 1324, 724}}; + const AjmBuffer outputs[2] = {{pcm.data() + 1024, 600 * 2}, {pcm.data() + 1624, 424 * 2}}; + std::vector batch(4096, 0xCD); + + void* at = sceAjmBatchJobControlBufferRa(batch.data(), instance, version | CONTROL_START, &start, sizeof(start), result, sizeof(result), &context); + Require(at == batch.data() + 64); + Require(BatchWord(batch, 0) == instance << 6 && BatchWord(batch, 4) == 56); + Require(BatchWord(batch, 8) == 6 && BatchWord(batch, 24) == 2 && BatchWord(batch, 28) == sizeof(start)); + Require(BatchWord(batch, 40) == (3u | (0x2000u << 6)) && BatchWord(batch, 44) == 0x6001 && BatchWord(batch, 48) == 18); + at = sceAjmBatchJobRunBufferRa(at, instance, version | sidebandStream | runMultipleFrames, stream.data(), 1024, pcm.data(), 1024 * 2, &single, sizeof(single), nullptr); + Require(at == batch.data() + 128); + Require(BatchWord(batch, 68) == 56 && BatchWord(batch, 72) == 1 && BatchWord(batch, 88) == (4u | (0x8000u << 6)) && BatchWord(batch, 96) == 17 && BatchWord(batch, 112) == 18); + at = sceAjmBatchJobRunSplitBufferRa(at, instance, version | sidebandStream | runMultipleFrames, inputs, 2, outputs, 2, &split, sizeof(split), nullptr); + Require(at == batch.data() + 224 && BatchWord(batch, 132) == 88); + Require(StartBuffer(context, batch.data(), static_cast(at) - batch.data()) == 0); + + Require(result[0] == 0 && result[1] == 0); + // input and output both end with the fourth frame: the console reports PARTIAL_INPUT | NOT_ENOUGH_ROOM + constexpr std::int32_t exactFit = 0x18; + Require(single.stream.result == exactFit && single.stream.inputConsumed == 1024 && single.stream.outputWritten == 1024 * 2 && single.frames == 4 && single.stream.totalDecodedSamples == 1024); + Require(split.stream.result == exactFit && split.stream.inputConsumed == 1024 && split.stream.outputWritten == 1024 * 2 && split.frames == 4 && split.stream.totalDecodedSamples == 2048); + Require(std::all_of(pcm.begin(), pcm.end(), [](std::int16_t sample) { return sample == 0; })); + Require(sceAjmInstanceDestroy(context, instance) == 0); +} + +class ConsoleBatch { +public: + void Word(std::uint32_t word) { words.push_back(word); } + void Chunk(std::uint32_t kind, std::uint32_t payload, std::uint32_t word) { + Word(kind | (payload << 6)); + Word(word); + } + void Buffer(std::uint32_t kind, const void* address, std::size_t size) { + Chunk(kind, 0, static_cast(size)); + const auto value = static_cast(reinterpret_cast(address)); + Word(static_cast(value)); + Word(static_cast(value >> 32)); + } + const std::uint32_t* Data() const { return words.data(); } + std::size_t Bytes() const { return words.size() * sizeof(std::uint32_t); } + +private: + std::vector words; +}; + +void TestConsoleHandWrittenJobs(std::uint32_t context) { + std::vector stream(1024, 0); + std::memcpy(stream.data(), AT9_MONO_SILENT_SUPERFRAME, sizeof(AT9_MONO_SILENT_SUPERFRAME)); + std::uint32_t instance = 0; + Require(sceAjmInstanceCreate(context, 1, 0, &instance) == 0); + std::int32_t result[2] = {-1, -1}; + std::int32_t statistics[6] = {-1, -1, -1, -1, -1, -1}; + std::vector pcm(1024, 0x55); + At9Run decoded{{-1, -1, 0, 0, 0}, 0, 0}; + const std::uint8_t initialize[8] = {0xFE, 0x70, 0x1F, 0xF0, 0, 0, 0, 0}; + std::uint32_t inlined[2]; + std::memcpy(inlined, initialize, sizeof(initialize)); + + ConsoleBatch batch; + batch.Chunk(7, 0, sizeof(initialize)); + batch.Word(inlined[0]); + batch.Word(inlined[1]); + batch.Chunk(0, instance, 40); + batch.Buffer(2, nullptr, sizeof(initialize)); + batch.Chunk(3, 0, 0x6001); + batch.Buffer(18, result, sizeof(result)); + batch.Chunk(0, instance, 56); + batch.Buffer(1, stream.data(), stream.size()); + batch.Chunk(4, 0x8000, 0x1001); + batch.Buffer(17, pcm.data(), pcm.size() * sizeof(std::int16_t)); + batch.Buffer(18, &decoded, sizeof(decoded)); + batch.Chunk(0, 0x80000, 24); + batch.Chunk(3, 0, 0x8007); + batch.Buffer(18, statistics, sizeof(statistics)); + std::vector words(batch.Data(), batch.Data() + batch.Bytes() / sizeof(std::uint32_t)); + const auto inlineAddress = static_cast(reinterpret_cast(words.data() + 2)); + words[8] = static_cast(inlineAddress); + words[9] = static_cast(inlineAddress >> 32); + Require(StartBuffer(context, words.data(), words.size() * sizeof(std::uint32_t)) == 0); + Require(result[0] == 0 && result[1] == 0 && statistics[0] == 0 && statistics[1] == 0); + Require(decoded.stream.result == 0x18 && decoded.stream.inputConsumed == 1024 && decoded.stream.outputWritten == 1024 * 2 && decoded.frames == 4); + Require(std::all_of(pcm.begin(), pcm.end(), [](std::int16_t sample) { return sample == 0; })); + + constexpr int malformedBatch = static_cast(0x80930011); + Require(StartBuffer(context, words.data(), 12) == malformedBatch); + ConsoleBatch flagless; + flagless.Chunk(0, instance, 16); + flagless.Buffer(18, result, sizeof(result)); + Require(StartBuffer(context, flagless.Data(), flagless.Bytes()) == malformedBatch); + ConsoleBatch overrun; + overrun.Chunk(0, instance, 32); + overrun.Chunk(3, 0, 0x2001); + overrun.Buffer(18, result, sizeof(result)); + Require(StartBuffer(context, overrun.Data(), overrun.Bytes()) == malformedBatch); + ConsoleBatch unknown; + unknown.Chunk(0, instance, 16); + unknown.Chunk(3, 0, 0x2001); + unknown.Chunk(5, 0, 0); + bool unknownThrows = false; + try { + static_cast(StartBuffer(context, unknown.Data(), unknown.Bytes())); + } catch (const std::runtime_error&) { + unknownThrows = true; + } + Require(unknownThrows); + Require(sceAjmInstanceDestroy(context, instance) == 0); +} + +// A batch whose headers carry the previous batch's bytes in the bits the console's builders never write +// (a PS4 trace of Knowledge is Power: Decades, whose Wwise reuses one batch buffer): bit 5 and bits 26, +// 27 and 31 of a job header, bits 4, 5 and 26 of a flags header, bits 5-31 of a buffer header and bits +// 4-31 of a return address header. It must run exactly as the clean batch does. +void TestConsoleStaleHeaderBits(std::uint32_t context) { + std::vector stream(1024, 0); + std::memcpy(stream.data(), AT9_MONO_SILENT_SUPERFRAME, sizeof(AT9_MONO_SILENT_SUPERFRAME)); + std::uint32_t instance = 0; + Require(sceAjmInstanceCreate(context, 1, 0, &instance) == 0); + std::int32_t result[2] = {-1, -1}; + std::vector pcm(1024, 0x55); + At9Run decoded{{-1, -1, 0, 0, 0}, 0, 0}; + const std::uint8_t initialize[8] = {0xFE, 0x70, 0x1F, 0xF0, 0, 0, 0, 0}; + + ConsoleBatch batch; + batch.Chunk(0x8C000030u, instance, 56); + batch.Buffer(0xFFFFFFF6u, &context, 0); + batch.Buffer(0xFFFFFFE2u, initialize, sizeof(initialize)); + batch.Chunk(0x04000033u, 0, 0x6001); + batch.Buffer(0xFFFFFFF2u, result, sizeof(result)); + batch.Chunk(0x8C000020u, instance, 72); + batch.Buffer(0xFFFFFFF6u, &context, 0); + batch.Buffer(0xFFFFFFE1u, stream.data(), stream.size()); + batch.Chunk(0x04000034u, 0x8000, 0x1001); + batch.Buffer(0xFFFFFFF1u, pcm.data(), pcm.size() * sizeof(std::int16_t)); + batch.Buffer(0xFFFFFFF2u, &decoded, sizeof(decoded)); + Require(StartBuffer(context, batch.Data(), batch.Bytes()) == 0); + Require(result[0] == 0 && result[1] == 0); + Require(decoded.stream.result == 0x18 && decoded.stream.inputConsumed == 1024 && decoded.stream.outputWritten == 1024 * 2 && decoded.frames == 4); + Require(std::all_of(pcm.begin(), pcm.end(), [](std::int16_t sample) { return sample == 0; })); Require(sceAjmInstanceDestroy(context, instance) == 0); } @@ -2088,6 +2301,10 @@ int main() { TestResampleAt9(context); TestAt9GaplessSegments(context); TestAt9RunDecodesWholeInput(context); + TestAt9FlagRules(context); TestPcmResample(context); + TestConsoleBufferJobs(context); + TestConsoleHandWrittenJobs(context); + TestConsoleStaleHeaderBits(context); Require(sceAjmFinalize(context) == 0); } diff --git a/core/libs/tests/GuestAjmVibration.cpp b/core/libs/tests/GuestAjmVibration.cpp index b130f5fbd2..8157596435 100644 --- a/core/libs/tests/GuestAjmVibration.cpp +++ b/core/libs/tests/GuestAjmVibration.cpp @@ -98,7 +98,7 @@ std::vector Decode(std::uint32_t context, unsigned channels, unsigned encodin Require((stream.result & 2) != 0 && stream.consumed == 0 && stream.produced == 0, "invalid first-frame flag"); } const auto stream = run(packet, packet.size()); - Require(stream.result == 0 && stream.internalResult == 0, "decode vibration superframe"); + Require(stream.result == 0x18 && stream.internalResult == 0, "decode vibration superframe"); Require(stream.consumed == 128 && stream.produced == static_cast(pcm.size() * sizeof(T)), "consumed/produced accounting"); Require(stream.samples == static_cast(samples) && stream.frames == 4, "decoded sample/frame accounting"); Require(sceAjmInstanceDestroy(context, instance) == 0, "destroy instance"); diff --git a/docs/dev/debt/libSceAjm.native.md b/docs/dev/debt/libSceAjm.native.md index 8c0a9695c5..b4206370e0 100644 --- a/docs/dev/debt/libSceAjm.native.md +++ b/docs/dev/debt/libSceAjm.native.md @@ -6,7 +6,7 @@ - [sceAjmInstanceExtend, sceAjmInstanceSwitch](../../../core/libs/prx/libSceAjm.native/Export.cpp) (libSceAjm.native) - their four-argument signatures come only from [SharpProspero](https://github.com/SvenGDK/SharpProspero/blob/5dfdb4b0b166e83dc9ae275001afb695f12c7174/src/SharpProspero/Interop/Audio/Ajm.cs#L98-L104), whose comments do not describe them; shadPS4 [declares both without parameters](https://github.com/shadps4-emu/shadPS4/blob/15abf68c9b52c4b04d094f2837719fdb871021f3/src/core/libraries/ajm/ajm.h#L222-L223). Neither is measured on a console; these throwing declarations only resolve imports. - [sceAjmInstanceCreate](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - a full 14-bit instance slot namespace returns `0x80930007` (`OUT_OF_RESOURCES`), as [shadPS4's InstanceCreate](https://github.com/shadps4-emu/shadPS4/blob/15abf68c9b52c4b04d094f2837719fdb871021f3/src/core/libraries/ajm/ajm_context.cpp) does for its own instance pool (PS4). The 16384 slots per codec prefix are this runtime's existing ID layout, not a measured console capacity; the PS5 capacity and exhaustion result are unverified. - [ATRAC9 vibration decoding](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native): channel configurations 6 (mono) and 7 (dual mono) use the pinned [vgmstream decoder](https://github.com/vgmstream/vgmstream/blob/7c520e8f9b078dfde2f84f1b5bcbad0c6c415806/src/coding/libs/atrac9_dec.c), including its 12/24/48 kHz profile limits. Synthetic fixtures cover interleaving, reset after malformed input, partial superframes and PCM16/PCM32/float output; console PCM rounding and physical haptic output are unverified. Ordinary channel configurations and discrete multichannel streams still use LibAtrac9. Decoder creation and decoding run under the existing AJM instance lock because the dependency rebuilds shared Huffman tables during initialization. -- [sceAjmBatchJobGetInfo](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - writes the result and format sideband of a run job without buffers with the `SIDEBAND_FORMAT` flag (0x400000000000), as [KytyPS5](https://github.com/KytyPS5/KytyPS5) does, not confirmed on a PS5 title; the format reports bitrate 0 for ATRAC9 as in KytyPS5, while [shadPS4](https://github.com/shadps4-emu/shadPS4) derives it from the superframe size; before the instance is initialized it returns the not-initialized result (1) and no format, like the other run jobs here, while KytyPS5 returns result 0 with the format of the uninitialized decoder +- [sceAjmBatchJobGetInfo](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - writes the result and format sideband of a run job without buffers with the `SIDEBAND_FORMAT` flag (0x400000000000), as [KytyPS5](https://github.com/KytyPS5/KytyPS5) does, not confirmed on a PS5 title; the ATRAC9 format's bitrate is the superframe's bits per second (superframe bytes * 8 * sample rate / samples per superframe), as [shadPS4](https://github.com/shadps4-emu/shadPS4) derives it and as a PS4 reported it (168000 for 448-byte superframes at 48 kHz), where KytyPS5 reports 0; before the instance is initialized it returns the not-initialized result (1) and no format, like the other run jobs here, while KytyPS5 returns result 0 with the format of the uninitialized decoder - [sceAjmDecMp3ParseFrame](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - signature, result layout and rate tables taken from the [shadPS4](https://github.com/shadps4-emu/shadPS4) reimplementation of the PS4 library, not confirmed on a PS5 title: Layer I/II headers are accepted when no original-file-length (`parse_ofl`) lookup is requested, which is supported only for Layer III; MPEG-2.5 stops at 64 kbps. The `parse_ofl` layout (Xing/Info + LAME tag after side information, with the standard fixed Xing/Info offset retained for encoders such as [LAME](https://github.com/lameproject/lame/blob/master/libmp3lame/VbrTag.c#L854-L862), VBRI at byte 36 or after side information, Fraunhofer `0xB4` block with its CRC, encoder delay = samples per frame + LAME delay + 529) also comes from shadPS4 only, and the Fraunhofer block has no other public description. - [sceAjmBatchJobGetCodecInfo, sceAjmBatchJobGetGaplessDecode](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - the `RUN_GET_CODEC_INFO` flag (0x800), the ATRAC9 codec info layout and its next frame size are taken from the [shadPS4](https://github.com/shadps4-emu/shadPS4) and [RPCSX](https://github.com/RPCSX/rpcsx) reimplementations, not confirmed on a PS5 title (the one call in PPSA04354 passes a 24-byte result buffer, the 8-byte result plus the 16-byte ATRAC9 codec info written here; the call does not show the codec or the fields); a run job without buffers returns result 0, as in shadPS4. The gapless sideband reports the total and skip counts the title set and does not count them down: RPCSX does the same, shadPS4 counts them down and BryKytyPS5 returns its current counts - [AJM Opus decoder](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native, codec 24) - the initialize parameter layout and little-endian `u16` packet byte count come from The Smurfs Dreams (`2, 48000, 0`) and Lost Soul Aside (`4, 48000, 1`). The third word is interpreted as the mapping family, as in [KytyPS5's initialize structure](https://github.com/KytyPS5/KytyPS5/blob/984cfcebd141b211aa9e0a8cde63b0ebe652d07a/src/libs/ajm/opus_decoder.h#L11-L15). Family 1 uses the conventional stream counts, coupled counts and channel map from [libopus 1.5.2's surround layout](https://github.com/xiph/opus/blob/v1.5.2/src/opus_multistream_encoder.c#L47-L55), passed to FFmpeg through an [RFC 7845 section 5.1](https://datatracker.ietf.org/doc/html/rfc7845#section-5.1) header with zero pre-skip and gain; gapless skipping remains controlled by AJM sideband parameters. FFmpeg returns PCM in its native speaker order. The implicit stream layout, PCM channel order and invalid family-0 channel count result are not verified on a console. NHL 27 (PPSA34063) passes `1, 48000, 255`. Family 255 with one channel is decoded as one mono stream, the same as family 0; this is assumed, since the stream count, coupled stream count and channel mapping that a family-255 Opus header carries ([RFC 7845 section 5.1.1](https://datatracker.ietf.org/doc/html/rfc7845#section-5.1.1)) are not passed. Only 48000 Hz, mapping families 0 and 1, and family 255 with one channel are implemented; other rates and families, and family 255 with more channels, throw @@ -17,6 +17,10 @@ - [sceAjmBatchJobControl](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - initialize parameters are 8 bytes for ATRAC9 and M4AAC, 12 for Opus and none for MP3, as one title passes them; other codecs and sizes, and an output other than the 8-byte result, throw - [sceAjmBatchJobControl](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - SIDEBAND_GAPLESS_DECODE with RESET overwrites both counts as in [RPCSX](https://github.com/RPCSX/rpcsx); shadPS4 ignores a zero count when the other is nonzero. Without RESET they differ, so it throws - [sceAjmBatchJobSetResampleParameters, sceAjmBatchJobGetResampleInfo](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm) - the ratio is taken as decoded samples per output sample and num_samples as the decoded samples the resampler holds, both inferred from FMOD (Hades II); the console's interpolation is unknown, a 4-point Catmull-Rom is used. The flags argument is ignored, as in shadPS4 and Kyty +- [sceAjmBatchJobControlBufferRa, sceAjmBatchJobRunBufferRa, sceAjmBatchJobRunSplitBufferRa, sceAjmBatchStartBuffer](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - the batch layout (chunk kinds, 20-bit payload, flags bits 32-51 as the flags chunk's payload, the builders' chunk order) follows [shadPS4](https://github.com/shadps4-emu/shadPS4/blob/854b291c63360ff24e590809aff54cf5ab2dd4c9/src/core/libraries/ajm/ajm_batch.cpp) and was confirmed byte for byte for 64-byte control and 80-byte run jobs by a PS4 trace of Knowledge is Power: Decades (CUSA12435, Wwise, 32 545 batches dumped by a GoldHEN plugin). The console's builders write only bits 0-3 (kind), bit 4 (a buffer chunk's output direction) and bits 6-25 (payload) of the header word and leave bits 5, 26, 27 and 31 as the previous batch left them, so the kind is read as 4 bits plus the direction bit, not as 6 bits +- [sceAjmBatchStartBuffer](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - the statistics instance 0x80000 and the inline chunk come from shadPS4 only: the traced title builds every batch with the builders and a return address, and no title was seen writing an inline chunk. Flags are kept as passed. Unknown chunk kinds, control flags other than RESET, INITIALIZE and SIDEBAND_GAPLESS_DECODE, control jobs with run buffers and run jobs with a control input throw; a size not a multiple of 8, an overrunning chunk or a job without flags return 0x80930011 after the jobs before it ran +- [ATRAC9 run results](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - probe runs of a title's ATRAC9 stream through the console's builders on a PS4 showed the run result naming the side that ran out: `PARTIAL_INPUT` (0x8) when the input ran out with room left, on a superframe boundary or not; `NOT_ENOUGH_ROOM` (0x10) when the room ran out with input left, after any number of frames, and with nothing consumed for a zero room; both (0x18) when the input ended just as the output filled, which is every Wwise run job. ATRAC9 run jobs with `RUN_MULTIPLE_FRAMES` follow those rules here, vibration streams included, which were not traced; resampled runs and the other codecs keep the earlier rule (a trailing partial frame is partial input, a run that produced nothing is out of room) +- [ATRAC9 run without RUN_MULTIPLE_FRAMES](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - the same PS4 probe decoded exactly one frame and reported 0 for such a run; here it still decodes its whole input, as the PS5 title in the gapless entry needs, so a PS4 title that relies on one frame per run would get more. The console also rejected the tests' synthetic silent superframe after three frames (`CODEC_ERROR | FATAL`, internal result -577) where LibAtrac9 decodes four, so that vector proves job plumbing, not decoding; on the title's stream LibAtrac9 matched the console's PCM within 4 LSB, 83.5% of the samples exactly ### Functional From 6559a413b0cce2112af92a94c24e65fe5ec1edfb Mon Sep 17 00:00:00 2001 From: ntamas94 Date: Thu, 22 Oct 2026 11:38:41 +0200 Subject: [PATCH 15/21] fix(libSceAjm): keep the libSceAjmi stub and refuse null batch buffers as a PS4 does Review of the PS4 batch buffer change. The libSceAjmi stub of sceAjmBatchJobRunBufferRa stays, declared with the _nid_disambig1 suffix so that both libraries export the NID without defining one name. sceAjmBatchStartBuffer reads the whole batch before running any job, as probe batches on a PS4 showed: a buffer chunk of size zero is dropped whatever its address, and a null address with a nonzero size refuses the batch with INVALID_ADDRESS in the error record together with the job chunk, the chunk's offset in the job and the job's return address, with nothing of the batch run. Inputs and outputs share that rule. The console-measured result flags move from test comments to the debt entries. Co-Authored-By: Claude Fable 5.1 --- core/libs/prx/libSceAjm.native/src/Ajm.cpp | 97 ++++++++++++++++------ core/libs/prx/libSceAjmi/Export.cpp | 5 ++ core/libs/tests/GuestAjm.cpp | 86 ++++++++++++++++--- docs/dev/debt/libSceAjm.native.md | 3 +- docs/dev/debt/libSceAjmi.md | 1 + 5 files changed, 154 insertions(+), 38 deletions(-) diff --git a/core/libs/prx/libSceAjm.native/src/Ajm.cpp b/core/libs/prx/libSceAjm.native/src/Ajm.cpp index 41baae55e0..6860c6ca37 100644 --- a/core/libs/prx/libSceAjm.native/src/Ajm.cpp +++ b/core/libs/prx/libSceAjm.native/src/Ajm.cpp @@ -37,6 +37,7 @@ constexpr int SCE_AJM_ERROR_INVALID_BATCH = static_cast(0x80930004); constexpr int SCE_AJM_ERROR_INVALID_PARAMETER = static_cast(0x80930005); constexpr int SCE_AJM_ERROR_OUT_OF_RESOURCES = static_cast(0x80930007); constexpr int SCE_AJM_ERROR_MALFORMED_BATCH = static_cast(0x80930011); +constexpr int SCE_AJM_ERROR_INVALID_ADDRESS = static_cast(0x80930016); constexpr std::int32_t AJM_RESULT_NOT_INITIALIZED = 0x00000001; constexpr std::int32_t AJM_RESULT_INVALID_DATA = 0x00000002; @@ -1785,17 +1786,38 @@ void* EndConsoleJob(std::uint8_t* job, std::uint8_t* end, std::uint32_t instance return end; } -// Runs one console job as the job of this file's kind it describes. The return address is only kept by -// the console for error reports. Returns false for a job whose chunks overrun it or that has no flags. -bool RunConsoleJob(std::uint32_t instance, const std::uint8_t* at, const std::uint8_t* end) { - JobHeader job{}; - job.instance = instance; +struct ConsoleJob { + JobHeader header; + std::vector buffers; +}; + +// What sceAjmBatchStartBuffer reports in the AjmBatchError when it refuses a batch: the error, the job +// chunk, the offending chunk's offset in the job and the job's return address, as a PS4 fills them for a +// buffer chunk with a null address and a nonzero size (INVALID_ADDRESS, nothing of the batch run). The +// other refusals here report MALFORMED_BATCH the same way; they were not measured. +struct ConsoleRefusal { + std::int32_t code = SCE_AJM_ERROR_MALFORMED_BATCH; + const std::uint8_t* job = nullptr; + std::uint32_t offset = 0; + const void* returnAddress = nullptr; +}; + +// Reads one console job: the chunks between the job header at `job` and `end`. A buffer chunk of size +// zero is dropped whatever its address, as a PS4 does. Returns false with the refusal filled for a null +// address with a nonzero size, for chunks that overrun the job and for a job without flags. +bool ReadConsoleJob(std::uint32_t instance, const std::uint8_t* job, const std::uint8_t* end, ConsoleJob& out, ConsoleRefusal& refusal) { + out.header = JobHeader{}; + out.header.instance = instance; std::vector inputs; std::vector outputs; AjmBuffer controlInput{}; AjmBuffer controlOutput{}; ConsoleChunk flagsKind = ConsoleChunk::Job; + refusal = ConsoleRefusal{}; + refusal.job = job; + const std::uint8_t* at = job + 2 * sizeof(std::uint32_t); while (at < end) { + refusal.offset = static_cast(at - job); if (end - at < 8) return false; std::uint32_t words[2]; std::memcpy(words, at, sizeof(words)); @@ -1803,7 +1825,7 @@ bool RunConsoleJob(std::uint32_t instance, const std::uint8_t* at, const std::ui if (kind == ConsoleChunk::ControlFlags || kind == ConsoleChunk::RunFlags) { if (flagsKind != ConsoleChunk::Job) return false; flagsKind = kind; - job.flags = (std::uint64_t{ConsoleChunkPayload(words[0])} << 32) | words[1]; + out.header.flags = (std::uint64_t{ConsoleChunkPayload(words[0])} << 32) | words[1]; at += sizeof(words); continue; } @@ -1814,34 +1836,44 @@ bool RunConsoleJob(std::uint32_t instance, const std::uint8_t* at, const std::ui AjmBuffer buffer{nullptr, words[1]}; std::memcpy(&buffer.ptr, at + sizeof(words), sizeof(buffer.ptr)); at += 16; - if (kind == ConsoleChunk::InputRunBuffer && buffer.size) inputs.push_back(buffer); - if (kind == ConsoleChunk::OutputRunBuffer && buffer.ptr && buffer.size) outputs.push_back(buffer); + if (kind == ConsoleChunk::ReturnAddress) { + refusal.returnAddress = buffer.ptr; + continue; + } + if (buffer.size == 0) continue; + if (!buffer.ptr) { + refusal.code = SCE_AJM_ERROR_INVALID_ADDRESS; + return false; + } + if (kind == ConsoleChunk::InputRunBuffer) inputs.push_back(buffer); + if (kind == ConsoleChunk::OutputRunBuffer) outputs.push_back(buffer); if (kind == ConsoleChunk::InputControlBuffer) controlInput = buffer; if (kind == ConsoleChunk::OutputControlBuffer) controlOutput = buffer; } + refusal.offset = static_cast(end - job); if (flagsKind == ConsoleChunk::Job) return false; - job.sideband = controlOutput.ptr; - job.sidebandSize = controlOutput.size; + JobHeader& header = out.header; + header.sideband = controlOutput.ptr; + header.sidebandSize = controlOutput.size; if (instance == CONSOLE_STATISTICS_INSTANCE) { - job.kind = JobKind::GetStatistics; + header.kind = JobKind::GetStatistics; } else if (flagsKind == ConsoleChunk::ControlFlags) { constexpr std::uint64_t implemented = CONSOLE_FLAGS_VERSION_AND_CODEC | CONTROL_RESET | CONTROL_INITIALIZE | SIDEBAND_GAPLESS_DECODE; - if ((job.flags & ~implemented) != 0) NotImplemented_nid_no_patch("sceAjmBatchStartBuffer (control job flags other than RESET, INITIALIZE and SIDEBAND_GAPLESS_DECODE)"); + if ((header.flags & ~implemented) != 0) NotImplemented_nid_no_patch("sceAjmBatchStartBuffer (control job flags other than RESET, INITIALIZE and SIDEBAND_GAPLESS_DECODE)"); if (!inputs.empty() || !outputs.empty()) NotImplemented_nid_no_patch("sceAjmBatchStartBuffer (control job with run buffers)"); - job.kind = JobKind::Control; + header.kind = JobKind::Control; if (controlInput.size) inputs.push_back(controlInput); } else { if (controlInput.size) NotImplemented_nid_no_patch("sceAjmBatchStartBuffer (run job with an input control buffer)"); - job.kind = JobKind::Run; + header.kind = JobKind::Run; } - job.inputCount = static_cast(inputs.size()); - job.outputCount = static_cast(outputs.size()); - AJM_TRACE("[ajm] console job: instance %u, %s flags 0x%llx, %u input and %u output buffers, control input %llu and output %llu bytes\n", instance, flagsKind == ConsoleChunk::ControlFlags ? "control" : "run", static_cast(job.flags), job.inputCount, job.outputCount, static_cast(controlInput.size), static_cast(controlOutput.size)); - inputs.insert(inputs.end(), outputs.begin(), outputs.end()); - Execute(job, inputs.data(), inputs.data() + job.inputCount); + header.inputCount = static_cast(inputs.size()); + header.outputCount = static_cast(outputs.size()); + AJM_TRACE("[ajm] console job: instance %u, %s flags 0x%llx, %u input and %u output buffers, control input %llu and output %llu bytes\n", instance, flagsKind == ConsoleChunk::ControlFlags ? "control" : "run", static_cast(header.flags), header.inputCount, header.outputCount, static_cast(controlInput.size), static_cast(controlOutput.size)); + out.buffers = std::move(inputs); + out.buffers.insert(out.buffers.end(), outputs.begin(), outputs.end()); return true; } - } extern "C" { @@ -2132,23 +2164,38 @@ int APS5_VABI sceAjmBatchStartBuffer(uint32_t context, const uint8_t* batch, uin if (context == 0 || context >= g_nextContext.load(std::memory_order_relaxed)) return SCE_AJM_ERROR_INVALID_CONTEXT; if (!batch_id || (!batch && batch_size)) return SCE_AJM_ERROR_INVALID_PARAMETER; AJM_TRACE("[ajm] batch start buffer: context %u, priority %d, %u bytes\n", context, priority, batch_size); - if (batch_size % 8 != 0) return SCE_AJM_ERROR_MALFORMED_BATCH; + std::vector jobs; + ConsoleRefusal refusal; const std::uint8_t* at = batch; const std::uint8_t* end = batch + batch_size; - while (at < end) { - if (end - at < 8) return SCE_AJM_ERROR_MALFORMED_BATCH; + bool accepted = batch_size % 8 == 0; + while (accepted && at < end) { + refusal = ConsoleRefusal{}; + refusal.job = at; + if (end - at < 8) { + accepted = false; + break; + } std::uint32_t words[2]; std::memcpy(words, at, sizeof(words)); const std::uint8_t* body = at + sizeof(words); - if (words[1] > static_cast(end - body)) return SCE_AJM_ERROR_MALFORMED_BATCH; + if (words[1] > static_cast(end - body)) { + accepted = false; + break; + } const auto kind = ConsoleChunkKind(words[0]); if (kind == ConsoleChunk::Job) { - if (!RunConsoleJob(ConsoleChunkPayload(words[0]), body, body + words[1])) return SCE_AJM_ERROR_MALFORMED_BATCH; + accepted = ReadConsoleJob(ConsoleChunkPayload(words[0]), at, body + words[1], jobs.emplace_back(), refusal); } else if (kind != ConsoleChunk::InlineBuffer) { NotImplemented_nid_no_patch(("sceAjmBatchStartBuffer (batch chunk kind " + std::to_string(static_cast(kind)) + ")").c_str()); } at = body + words[1]; } + if (!accepted) { + if (error) *error = AjmBatchError{refusal.code, refusal.job, refusal.offset, refusal.returnAddress}; + return SCE_AJM_ERROR_MALFORMED_BATCH; + } + for (const ConsoleJob& job : jobs) Execute(job.header, job.buffers.data(), job.buffers.data() + job.header.inputCount); return RegisterBatch(context, error, batch_id); } diff --git a/core/libs/prx/libSceAjmi/Export.cpp b/core/libs/prx/libSceAjmi/Export.cpp index 7932a6f3c8..bdb3e1159d 100644 --- a/core/libs/prx/libSceAjmi/Export.cpp +++ b/core/libs/prx/libSceAjmi/Export.cpp @@ -13,4 +13,9 @@ int APS5_VABI sceAjmBatchJobInlineBuffer(void) { return 0; } +int APS5_VABI sceAjmBatchJobRunBufferRa_nid_disambig1(void) { + NotImplemented_nid_no_patch("sceAjmBatchJobRunBufferRa"); + return 0; +} + } diff --git a/core/libs/tests/GuestAjm.cpp b/core/libs/tests/GuestAjm.cpp index f850b844ff..92312ea2d2 100644 --- a/core/libs/tests/GuestAjm.cpp +++ b/core/libs/tests/GuestAjm.cpp @@ -1051,7 +1051,6 @@ std::vector DecodeAt9Superframe(std::uint32_t context, const std:: Require(sceAjmBatchJobRun(&info, instance, sidebandStream | runMultipleFrames, superframe.data(), superframe.size(), pcm.data(), pcm.size() * sizeof(std::int16_t), &decoded, sizeof(decoded)) == 0); Submit(context, info); Require(initResult[0] == 0); - // one superframe into exactly its 1024 samples: PARTIAL_INPUT | NOT_ENOUGH_ROOM as on the console Require(decoded.stream.result == 0x18 && static_cast(decoded.stream.inputConsumed) == superframe.size()); Require(static_cast(decoded.stream.outputWritten) == pcm.size() * sizeof(std::int16_t) && decoded.stream.totalDecodedSamples == 1024 && decoded.frames == 4); Require(sceAjmInstanceDestroy(context, instance) == 0); @@ -1579,7 +1578,6 @@ ResampledStream Stream(std::uint32_t context, std::uint32_t instance, const std: Require(sceAjmBatchJobDecode(&info, instance, stream + offset, size - offset, pcm.data(), pcm.size() * sizeof(std::int16_t), &sideband) == 0); Submit(context, info); if (ratio > 0) Require(setResult[0] == 0); - // 0x8 / 0x10 / 0x18: which of input and room ran out (what the console reports, see TestAt9FlagRules) Require(sideband.result == 0 || sideband.result == 0x8 || sideband.result == 0x10 || sideband.result == 0x18 || (offset == size && sideband.outputWritten == 0)); offset += static_cast(sideband.inputConsumed); if (offset < size && static_cast(sideband.outputWritten) < pcm.size() * sizeof(std::int16_t)) ++out.shortJobs; @@ -1800,7 +1798,7 @@ At9Run RunAt9Job(std::uint32_t context, std::uint32_t instance, std::uint64_t fl const std::size_t sidebandSize = (flags & runMultipleFrames) != 0 ? sizeof(decoded) : sizeof(decoded.stream); Require(sceAjmBatchJobRun(&info, instance, sidebandStream | flags, input, inputSize, pcm.data(), pcm.size() * sizeof(std::int16_t), &decoded, sidebandSize) == 0); Submit(context, info); - Require(decoded.stream.result == 0 || decoded.stream.result == 0x8 || decoded.stream.result == 0x18); // which side ran out, see TestAt9FlagRules + Require(decoded.stream.result == 0 || decoded.stream.result == 0x8 || decoded.stream.result == 0x18); return decoded; } @@ -1834,14 +1832,10 @@ void TestAt9RunDecodesWholeInput(std::uint32_t context) { RunControl(context, instance, CONTROL_START, &start, sizeof(start), result); Require(result[0] == 0); const auto run = RunAt9Job(context, instance, 0, stream.data(), stream.size()); - // 1024 bytes decode into exactly the 1024 samples of room: the console reports PARTIAL_INPUT | NOT_ENOUGH_ROOM Require(run.stream.result == 0x18 && static_cast(run.stream.inputConsumed) == stream.size() && run.stream.outputWritten == 1024 * 2); Require(sceAjmInstanceDestroy(context, instance) == 0); } -// The result flags say which side ran out, as a PS4 reports them for a title's ATRAC9 stream: the input -// (PARTIAL_INPUT, even on a superframe boundary) with room left, the room (NOT_ENOUGH_ROOM) with input -// left after some frames or with a zero room before any, or both at once (0x18). void TestAt9FlagRules(std::uint32_t context) { constexpr std::uint64_t runMultipleFrames = 1ull << 12; constexpr std::uint64_t sidebandStream = 1ull << 47; @@ -1925,7 +1919,6 @@ void TestConsoleBufferJobs(std::uint32_t context) { Require(StartBuffer(context, batch.data(), static_cast(at) - batch.data()) == 0); Require(result[0] == 0 && result[1] == 0); - // input and output both end with the fourth frame: the console reports PARTIAL_INPUT | NOT_ENOUGH_ROOM constexpr std::int32_t exactFit = 0x18; Require(single.stream.result == exactFit && single.stream.inputConsumed == 1024 && single.stream.outputWritten == 1024 * 2 && single.frames == 4 && single.stream.totalDecodedSamples == 1024); Require(split.stream.result == exactFit && split.stream.inputConsumed == 1024 && split.stream.outputWritten == 1024 * 2 && split.frames == 4 && split.stream.totalDecodedSamples == 2048); @@ -2016,10 +2009,78 @@ void TestConsoleHandWrittenJobs(std::uint32_t context) { Require(sceAjmInstanceDestroy(context, instance) == 0); } -// A batch whose headers carry the previous batch's bytes in the bits the console's builders never write -// (a PS4 trace of Knowledge is Power: Decades, whose Wwise reuses one batch buffer): bit 5 and bits 26, -// 27 and 31 of a job header, bits 4, 5 and 26 of a flags header, bits 5-31 of a buffer header and bits -// 4-31 of a return address header. It must run exactly as the clean batch does. +int StartBufferRefused(std::uint32_t context, const void* batch, std::size_t size, AjmBatchError& error) { + std::uint32_t id = 0; + error = AjmBatchError{}; + return sceAjmBatchStartBuffer(context, static_cast(batch), static_cast(size), 0, &error, &id); +} + +void TestConsoleBufferEdges(std::uint32_t context) { + constexpr int malformedBatch = static_cast(0x80930011); + constexpr int invalidAddress = static_cast(0x80930016); + std::vector stream(1024, 0); + std::memcpy(stream.data(), AT9_MONO_SILENT_SUPERFRAME, sizeof(AT9_MONO_SILENT_SUPERFRAME)); + const std::uint8_t initialize[8] = {0xFE, 0x70, 0x1F, 0xF0, 0, 0, 0, 0}; + std::uint32_t instance = 0; + Require(sceAjmInstanceCreate(context, 1, 0, &instance) == 0); + std::vector pcm(1024, 0x55); + std::int32_t result[2] = {-1, -1}; + const auto control = [&](ConsoleBatch& batch) { + result[0] = result[1] = -1; + batch.Chunk(0, instance, 40); + batch.Buffer(2, initialize, sizeof(initialize)); + batch.Chunk(3, 0, 0x6001); + batch.Buffer(18, result, sizeof(result)); + }; + + At9Run dropped{{-1, -1, 0, 0, 0}, 0, 0}; + ConsoleBatch zeroSizes; + control(zeroSizes); + zeroSizes.Chunk(0, instance, 104); + zeroSizes.Buffer(1, stream.data(), 0); + zeroSizes.Buffer(1, stream.data(), stream.size()); + zeroSizes.Chunk(4, 0x8000, 0x1001); + zeroSizes.Buffer(17, pcm.data(), 0); + zeroSizes.Buffer(17, pcm.data(), pcm.size() * sizeof(std::int16_t)); + zeroSizes.Buffer(17, nullptr, 0); + zeroSizes.Buffer(18, &dropped, sizeof(dropped)); + Require(StartBuffer(context, zeroSizes.Data(), zeroSizes.Bytes()) == 0); + Require(result[0] == 0 && dropped.stream.result == 0x18 && dropped.stream.inputConsumed == 1024 && dropped.stream.outputWritten == 1024 * 2 && dropped.frames == 4); + Require(std::all_of(pcm.begin(), pcm.end(), [](std::int16_t sample) { return sample == 0; })); + + At9Run noRoom{{-1, -1, 0, 0, 0}, 0, 0}; + std::fill(pcm.begin(), pcm.end(), std::int16_t{0x55}); + ConsoleBatch zeroRoom; + control(zeroRoom); + zeroRoom.Chunk(0, instance, 56); + zeroRoom.Buffer(1, stream.data(), stream.size()); + zeroRoom.Chunk(4, 0x8000, 0x1001); + zeroRoom.Buffer(17, pcm.data(), 0); + zeroRoom.Buffer(18, &noRoom, sizeof(noRoom)); + Require(StartBuffer(context, zeroRoom.Data(), zeroRoom.Bytes()) == 0); + Require(result[0] == 0 && noRoom.stream.result == 0x10 && noRoom.stream.inputConsumed == 0 && noRoom.stream.outputWritten == 0 && noRoom.frames == 0); + Require(std::all_of(pcm.begin(), pcm.end(), [](std::int16_t sample) { return sample == 0x55; })); + + const void* returnAddress = &stream; + for (const bool nullInput : {true, false}) { + At9Run untouched{{-1, -1, 0, 0, 0}, 0, 0}; + ConsoleBatch refused; + control(refused); + refused.Chunk(0, instance, 72); + refused.Buffer(6, returnAddress, 0); + refused.Buffer(1, nullInput ? nullptr : stream.data(), stream.size()); + refused.Chunk(4, 0x8000, 0x1001); + refused.Buffer(17, nullInput ? pcm.data() : nullptr, pcm.size() * sizeof(std::int16_t)); + refused.Buffer(18, &untouched, sizeof(untouched)); + AjmBatchError error{}; + Require(StartBufferRefused(context, refused.Data(), refused.Bytes(), error) == malformedBatch); + Require(error.error_code == invalidAddress && error.job_addr == reinterpret_cast(refused.Data()) + 48); + Require(error.cmd_offset == (nullInput ? 24u : 48u) && error.job_ra == returnAddress); + Require(result[0] == -1 && untouched.stream.result == -1); + } + Require(sceAjmInstanceDestroy(context, instance) == 0); +} + void TestConsoleStaleHeaderBits(std::uint32_t context) { std::vector stream(1024, 0); std::memcpy(stream.data(), AT9_MONO_SILENT_SUPERFRAME, sizeof(AT9_MONO_SILENT_SUPERFRAME)); @@ -2305,6 +2366,7 @@ int main() { TestPcmResample(context); TestConsoleBufferJobs(context); TestConsoleHandWrittenJobs(context); + TestConsoleBufferEdges(context); TestConsoleStaleHeaderBits(context); Require(sceAjmFinalize(context) == 0); } diff --git a/docs/dev/debt/libSceAjm.native.md b/docs/dev/debt/libSceAjm.native.md index b4206370e0..7e299c624a 100644 --- a/docs/dev/debt/libSceAjm.native.md +++ b/docs/dev/debt/libSceAjm.native.md @@ -18,7 +18,8 @@ - [sceAjmBatchJobControl](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - SIDEBAND_GAPLESS_DECODE with RESET overwrites both counts as in [RPCSX](https://github.com/RPCSX/rpcsx); shadPS4 ignores a zero count when the other is nonzero. Without RESET they differ, so it throws - [sceAjmBatchJobSetResampleParameters, sceAjmBatchJobGetResampleInfo](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm) - the ratio is taken as decoded samples per output sample and num_samples as the decoded samples the resampler holds, both inferred from FMOD (Hades II); the console's interpolation is unknown, a 4-point Catmull-Rom is used. The flags argument is ignored, as in shadPS4 and Kyty - [sceAjmBatchJobControlBufferRa, sceAjmBatchJobRunBufferRa, sceAjmBatchJobRunSplitBufferRa, sceAjmBatchStartBuffer](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - the batch layout (chunk kinds, 20-bit payload, flags bits 32-51 as the flags chunk's payload, the builders' chunk order) follows [shadPS4](https://github.com/shadps4-emu/shadPS4/blob/854b291c63360ff24e590809aff54cf5ab2dd4c9/src/core/libraries/ajm/ajm_batch.cpp) and was confirmed byte for byte for 64-byte control and 80-byte run jobs by a PS4 trace of Knowledge is Power: Decades (CUSA12435, Wwise, 32 545 batches dumped by a GoldHEN plugin). The console's builders write only bits 0-3 (kind), bit 4 (a buffer chunk's output direction) and bits 6-25 (payload) of the header word and leave bits 5, 26, 27 and 31 as the previous batch left them, so the kind is read as 4 bits plus the direction bit, not as 6 bits -- [sceAjmBatchStartBuffer](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - the statistics instance 0x80000 and the inline chunk come from shadPS4 only: the traced title builds every batch with the builders and a return address, and no title was seen writing an inline chunk. Flags are kept as passed. Unknown chunk kinds, control flags other than RESET, INITIALIZE and SIDEBAND_GAPLESS_DECODE, control jobs with run buffers and run jobs with a control input throw; a size not a multiple of 8, an overrunning chunk or a job without flags return 0x80930011 after the jobs before it ran +- [sceAjmBatchStartBuffer](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - the statistics instance 0x80000 and the inline chunk come from shadPS4 only: the traced title builds every batch with the builders and a return address, and no title was seen writing an inline chunk. Flags are kept as passed. Unknown chunk kinds, control flags other than RESET, INITIALIZE and SIDEBAND_GAPLESS_DECODE, control jobs with run buffers and run jobs with a control input throw +- [sceAjmBatchStartBuffer refusals](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - probe batches through the console's builders on a PS4 showed that a run buffer chunk of size zero is dropped whatever its address (the job runs as without it: 0x10 and nothing consumed for no room, 0 for no input), and that a null address with a nonzero size refuses the batch before any of its jobs runs, with 0x80930011 returned and the error record holding 0x80930016, the job chunk's address, the chunk's offset in the job and the job's return address. Control buffer chunks follow the same rule here, not measured. A size not a multiple of 8, a chunk that overruns its job or the batch, or a job without flags are refused the same way with 0x80930011 in the record; the console's report for those was not measured - [ATRAC9 run results](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - probe runs of a title's ATRAC9 stream through the console's builders on a PS4 showed the run result naming the side that ran out: `PARTIAL_INPUT` (0x8) when the input ran out with room left, on a superframe boundary or not; `NOT_ENOUGH_ROOM` (0x10) when the room ran out with input left, after any number of frames, and with nothing consumed for a zero room; both (0x18) when the input ended just as the output filled, which is every Wwise run job. ATRAC9 run jobs with `RUN_MULTIPLE_FRAMES` follow those rules here, vibration streams included, which were not traced; resampled runs and the other codecs keep the earlier rule (a trailing partial frame is partial input, a run that produced nothing is out of room) - [ATRAC9 run without RUN_MULTIPLE_FRAMES](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - the same PS4 probe decoded exactly one frame and reported 0 for such a run; here it still decodes its whole input, as the PS5 title in the gapless entry needs, so a PS4 title that relies on one frame per run would get more. The console also rejected the tests' synthetic silent superframe after three frames (`CODEC_ERROR | FATAL`, internal result -577) where LibAtrac9 decodes four, so that vector proves job plumbing, not decoding; on the title's stream LibAtrac9 matched the console's PCM within 4 LSB, 83.5% of the samples exactly diff --git a/docs/dev/debt/libSceAjmi.md b/docs/dev/debt/libSceAjmi.md index d3ac224c47..a1e8ac5685 100644 --- a/docs/dev/debt/libSceAjmi.md +++ b/docs/dev/debt/libSceAjmi.md @@ -3,3 +3,4 @@ ### Unknown function info - [libSceAjmi pending exports](../../../core/libs/prx/libSceAjmi/Export.cpp) - 5 named callable exports were identified in the PS5 3.20 export table and cross-checked against firmware 13.60. Signatures and behavior remain unverified; declarations use the unknown-signature convention and throw through `NotImplemented_nid_no_patch`. Evidence: [3.20 export list](https://github.com/DNNDHH/PS5-3.20_Libs/blob/b2a77c09cd71c01f34c790cfbe98c751a13262ad/libSceAjmi.c). +- [sceAjmBatchJobRunBufferRa](../../../core/libs/prx/libSceAjmi/Export.cpp) (libSceAjmi) - declared as `sceAjmBatchJobRunBufferRa_nid_disambig1`, which the NID patcher exports under the name's NID, because libSceAjm implements the same name for PS4 titles; a title importing it from libSceAjmi still gets the not-implemented error at call time From e3917fa218d9e63bd6807647e9b025c2e1af1a3a Mon Sep 17 00:00:00 2001 From: ntamas94 Date: Thu, 22 Oct 2026 11:40:40 +0200 Subject: [PATCH 16/21] docs(libSceAjm): align the gapless and libSceAjmi debt entries with the PS4 probe The gapless entry no longer says the one-frame behaviour is unverified, since the probe measured it, and the libSceAjmi entry names the two exports its file declares. Co-Authored-By: Claude Fable 5.1 --- docs/dev/debt/libSceAjm.native.md | 4 ++-- docs/dev/debt/libSceAjmi.md | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/dev/debt/libSceAjm.native.md b/docs/dev/debt/libSceAjm.native.md index 7e299c624a..944c0c111c 100644 --- a/docs/dev/debt/libSceAjm.native.md +++ b/docs/dev/debt/libSceAjm.native.md @@ -21,7 +21,7 @@ - [sceAjmBatchStartBuffer](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - the statistics instance 0x80000 and the inline chunk come from shadPS4 only: the traced title builds every batch with the builders and a return address, and no title was seen writing an inline chunk. Flags are kept as passed. Unknown chunk kinds, control flags other than RESET, INITIALIZE and SIDEBAND_GAPLESS_DECODE, control jobs with run buffers and run jobs with a control input throw - [sceAjmBatchStartBuffer refusals](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - probe batches through the console's builders on a PS4 showed that a run buffer chunk of size zero is dropped whatever its address (the job runs as without it: 0x10 and nothing consumed for no room, 0 for no input), and that a null address with a nonzero size refuses the batch before any of its jobs runs, with 0x80930011 returned and the error record holding 0x80930016, the job chunk's address, the chunk's offset in the job and the job's return address. Control buffer chunks follow the same rule here, not measured. A size not a multiple of 8, a chunk that overruns its job or the batch, or a job without flags are refused the same way with 0x80930011 in the record; the console's report for those was not measured - [ATRAC9 run results](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - probe runs of a title's ATRAC9 stream through the console's builders on a PS4 showed the run result naming the side that ran out: `PARTIAL_INPUT` (0x8) when the input ran out with room left, on a superframe boundary or not; `NOT_ENOUGH_ROOM` (0x10) when the room ran out with input left, after any number of frames, and with nothing consumed for a zero room; both (0x18) when the input ended just as the output filled, which is every Wwise run job. ATRAC9 run jobs with `RUN_MULTIPLE_FRAMES` follow those rules here, vibration streams included, which were not traced; resampled runs and the other codecs keep the earlier rule (a trailing partial frame is partial input, a run that produced nothing is out of room) -- [ATRAC9 run without RUN_MULTIPLE_FRAMES](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - the same PS4 probe decoded exactly one frame and reported 0 for such a run; here it still decodes its whole input, as the PS5 title in the gapless entry needs, so a PS4 title that relies on one frame per run would get more. The console also rejected the tests' synthetic silent superframe after three frames (`CODEC_ERROR | FATAL`, internal result -577) where LibAtrac9 decodes four, so that vector proves job plumbing, not decoding; on the title's stream LibAtrac9 matched the console's PCM within 4 LSB, 83.5% of the samples exactly +- [ATRAC9 run without RUN_MULTIPLE_FRAMES](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - the same PS4 probe decoded exactly one frame and reported 0 for such a run; here it still decodes its whole input, as FMOD in PPSA29141 needs (it puts two run jobs in one batch with the second's input right after the whole of the first's), so a PS4 title that relies on one frame per run would get more; MP3 and Opus run jobs still stop after one frame. The console also rejected the tests' synthetic silent superframe after three frames (`CODEC_ERROR | FATAL`, internal result -577) where LibAtrac9 decodes four, so that vector proves job plumbing, not decoding; on the title's stream LibAtrac9 matched the console's PCM within 4 LSB, 83.5% of the samples exactly ### Functional @@ -29,7 +29,7 @@ - [AJM batch contexts](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native): start, wait and cancel reject context IDs not allocated by initialization; batches belong to their creating context, as in [shadPS4](https://github.com/shadps4-emu/shadPS4/blob/0ebea15c24253b60f514fc37a348dabc8d0bcb90/src/core/libraries/ajm/ajm.cpp). This is not verified on PS5. Finalization still does not invalidate contexts or release their instances and outstanding batches; jobs execute synchronously and cancellation does not interrupt them. - [sceAjmBatchStart](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp): console results for malformed batch descriptors and corrupted jobs are unverified. A null batch buffer or an offset beyond its capacity returns the existing invalid-parameter error; a truncated or inconsistent private packed job throws before its payload is read. The packed representation belongs to this implementation. -- [ATRAC9 gapless segments](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - a run job stops after the gapless total of output samples counted from the start of the segment, leaving the rest of its input unconsumed, and the next run starts the next segment with the decoder as it is and without a second skip. FMOD in PPSA29141 sets a total shorter than a sound's frames and reads the frames after it on later runs; with the output held at the total its stream reader waited for them forever and the last buffer repeated. The console's handling of the frames after the total is not documented; a reset (`SetGaplessDecode` with the reset flag, `ClearContext`, initialization) starts a new segment. An ATRAC9 run job without `RUN_MULTIPLE_FRAMES` decodes its whole input as one with it: FMOD in PPSA29141 puts two run jobs in one batch with the second's input right after the whole of the first's. Whether the console stops after one frame there is not verified; MP3 and Opus run jobs still stop after one frame +- [ATRAC9 gapless segments](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - a run job stops after the gapless total of output samples counted from the start of the segment, leaving the rest of its input unconsumed, and the next run starts the next segment with the decoder as it is and without a second skip. FMOD in PPSA29141 sets a total shorter than a sound's frames and reads the frames after it on later runs; with the output held at the total its stream reader waited for them forever and the last buffer repeated. The console's handling of the frames after the total is not documented; a reset (`SetGaplessDecode` with the reset flag, `ClearContext`, initialization) starts a new segment. - [AJM M4AAC decoder](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native, codec 2) - Ghost of Yotei (PPSA26344) initializes it through `sceAjmBatchJobControl`. The initialize parameters (`u32` config type, 1 ADTS or 2 raw, then `u32` sampling frequency index), instance flags (channels in bits 3-6, `ENABLE_SBR_DECODE` bit 32, `ENABLE_NONDELAY_OUTPUT` bit 33, surround interleave order bits 34-35), `{heaac, reserved}` codec info and decoder error result (`CODEC_ERROR | FATAL`) follow [shadPS4](https://github.com/shadps4-emu/shadPS4/blob/443f022450f6857256e780daa3d5a14029a748f7/src/core/libraries/ajm/ajm_aac.cpp) (PS4, fdk-aac) and are unverified on PS5; decoding uses FFmpeg's AAC decoder. Without `ENABLE_NONDELAY_OUTPUT`, the first two frames after initialization or reset are consumed without output. Raw input takes its channel configuration from the instance (0 means stereo) and is decoded as one access unit per run job. The format reports the last frame's bitrate. Invalid config type or raw sampling frequency index above 11 returns invalid-parameter result 4. The implementation throws for ADTS without a frame header, profiles other than AAC LC, multiple raw data blocks per ADTS frame, unsupported channel configurations, more channels than the instance supports, HE-AAC without `ENABLE_SBR_DECODE`, raw input with `RUN_MULTIPLE_FRAMES`, output room smaller than the previous frame length, and unknown codec flags. - [AJM](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp), [APR](../../../core/libs/prx/libkernel/Apr/src/Apr.cpp) and [NGS2](../../../core/libs/prx/libSceNgs2.native/src/System.cpp) state is allocated with `new` and never freed on purpose: title threads still call into these libraries while `std::exit` runs static destructors, so ordinary statics would be destroyed under them. Keep them leaked; `guest_ajm_teardown` covers the AJM case on Unix. - [AJM Opus mapping family 255 with several channels](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native): the initialization words carry no stream count or channel mapping, so the decoder opens on the first packet. It counts the self-delimited streams of that multistream packet ([RFC 6716 appendix B](https://datatracker.ietf.org/doc/html/rfc6716#appendix-B)) and takes each stream's stereo flag from its TOC byte as coupled, then decodes with an identity channel mapping. A packet whose streams do not add up to the channel count throws. The layout is inferred, not read from the console; unverified on hardware. diff --git a/docs/dev/debt/libSceAjmi.md b/docs/dev/debt/libSceAjmi.md index a1e8ac5685..0a5d293464 100644 --- a/docs/dev/debt/libSceAjmi.md +++ b/docs/dev/debt/libSceAjmi.md @@ -2,5 +2,4 @@ ### Unknown function info -- [libSceAjmi pending exports](../../../core/libs/prx/libSceAjmi/Export.cpp) - 5 named callable exports were identified in the PS5 3.20 export table and cross-checked against firmware 13.60. Signatures and behavior remain unverified; declarations use the unknown-signature convention and throw through `NotImplemented_nid_no_patch`. Evidence: [3.20 export list](https://github.com/DNNDHH/PS5-3.20_Libs/blob/b2a77c09cd71c01f34c790cfbe98c751a13262ad/libSceAjmi.c). -- [sceAjmBatchJobRunBufferRa](../../../core/libs/prx/libSceAjmi/Export.cpp) (libSceAjmi) - declared as `sceAjmBatchJobRunBufferRa_nid_disambig1`, which the NID patcher exports under the name's NID, because libSceAjm implements the same name for PS4 titles; a title importing it from libSceAjmi still gets the not-implemented error at call time +- [libSceAjmi pending exports](../../../core/libs/prx/libSceAjmi/Export.cpp) - of the 5 named callable exports identified in the PS5 3.20 export table and cross-checked against firmware 13.60, 2 are declared here: `sceAjmBatchJobInlineBuffer` and `sceAjmBatchJobRunBufferRa`, the latter as `sceAjmBatchJobRunBufferRa_nid_disambig1`, which the NID patcher exports under the name's NID, because libSceAjm implements the same name for PS4 titles; a title importing it from libSceAjmi still gets the not-implemented error at call time. Signatures and behavior remain unverified; the declarations use the unknown-signature convention and throw through `NotImplemented_nid_no_patch`. Evidence: [3.20 export list](https://github.com/DNNDHH/PS5-3.20_Libs/blob/b2a77c09cd71c01f34c790cfbe98c751a13262ad/libSceAjmi.c). From 46f0e90b925430dea558c4b8c0dd545700ad270e Mon Sep 17 00:00:00 2001 From: ntamas94 Date: Thu, 22 Oct 2026 15:32:59 +0200 Subject: [PATCH 17/21] fix(libSceAjm): reject a null PS4 batch address sceAjmBatchStartBuffer let a null batch of zero bytes through its parameter check and formed the end pointer from it. A null batch now returns INVALID_PARAMETER whatever the size, as the PS5 builders' sceAjmBatchInitialize does; a batch of zero bytes still starts, runs nothing and completes at once. A test covers both; neither was measured on a console, which the debt entry says. Co-Authored-By: Claude Fable 5.1 --- core/libs/prx/libSceAjm.native/src/Ajm.cpp | 2 +- core/libs/tests/GuestAjm.cpp | 16 ++++++++++++++++ docs/dev/debt/libSceAjm.native.md | 1 + 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/core/libs/prx/libSceAjm.native/src/Ajm.cpp b/core/libs/prx/libSceAjm.native/src/Ajm.cpp index 6860c6ca37..deb893d345 100644 --- a/core/libs/prx/libSceAjm.native/src/Ajm.cpp +++ b/core/libs/prx/libSceAjm.native/src/Ajm.cpp @@ -2162,7 +2162,7 @@ void* APS5_VABI sceAjmBatchJobRunBufferRa(void* position, uint32_t instance, uin int APS5_VABI sceAjmBatchStartBuffer(uint32_t context, const uint8_t* batch, uint32_t batch_size, int priority, AjmBatchError* error, uint32_t* batch_id) { if (context == 0 || context >= g_nextContext.load(std::memory_order_relaxed)) return SCE_AJM_ERROR_INVALID_CONTEXT; - if (!batch_id || (!batch && batch_size)) return SCE_AJM_ERROR_INVALID_PARAMETER; + if (!batch_id || !batch) return SCE_AJM_ERROR_INVALID_PARAMETER; AJM_TRACE("[ajm] batch start buffer: context %u, priority %d, %u bytes\n", context, priority, batch_size); std::vector jobs; ConsoleRefusal refusal; diff --git a/core/libs/tests/GuestAjm.cpp b/core/libs/tests/GuestAjm.cpp index 92312ea2d2..894715ca9a 100644 --- a/core/libs/tests/GuestAjm.cpp +++ b/core/libs/tests/GuestAjm.cpp @@ -2081,6 +2081,21 @@ void TestConsoleBufferEdges(std::uint32_t context) { Require(sceAjmInstanceDestroy(context, instance) == 0); } +void TestConsoleEmptyBatch(std::uint32_t context) { + constexpr int invalidParameter = static_cast(0x80930005); + constexpr int invalidBatch = static_cast(0x80930004); + std::uint32_t id = 0; + AjmBatchError error{}; + Require(sceAjmBatchStartBuffer(context, nullptr, 0, 0, &error, &id) == invalidParameter); + Require(sceAjmBatchStartBuffer(context, nullptr, 16, 0, &error, &id) == invalidParameter); + const std::uint64_t words[2] = {0, 0}; + Require(sceAjmBatchStartBuffer(context, reinterpret_cast(words), 0, 0, &error, nullptr) == invalidParameter); + error.error_code = -1; + Require(sceAjmBatchStartBuffer(context, reinterpret_cast(words), 0, 0, &error, &id) == 0); + Require(error.error_code == 0 && id != 0 && sceAjmBatchWait(context, id, 0, &error) == 0); + Require(sceAjmBatchWait(context, id, 0, &error) == invalidBatch); +} + void TestConsoleStaleHeaderBits(std::uint32_t context) { std::vector stream(1024, 0); std::memcpy(stream.data(), AT9_MONO_SILENT_SUPERFRAME, sizeof(AT9_MONO_SILENT_SUPERFRAME)); @@ -2367,6 +2382,7 @@ int main() { TestConsoleBufferJobs(context); TestConsoleHandWrittenJobs(context); TestConsoleBufferEdges(context); + TestConsoleEmptyBatch(context); TestConsoleStaleHeaderBits(context); Require(sceAjmFinalize(context) == 0); } diff --git a/docs/dev/debt/libSceAjm.native.md b/docs/dev/debt/libSceAjm.native.md index 944c0c111c..d37af30120 100644 --- a/docs/dev/debt/libSceAjm.native.md +++ b/docs/dev/debt/libSceAjm.native.md @@ -20,6 +20,7 @@ - [sceAjmBatchJobControlBufferRa, sceAjmBatchJobRunBufferRa, sceAjmBatchJobRunSplitBufferRa, sceAjmBatchStartBuffer](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - the batch layout (chunk kinds, 20-bit payload, flags bits 32-51 as the flags chunk's payload, the builders' chunk order) follows [shadPS4](https://github.com/shadps4-emu/shadPS4/blob/854b291c63360ff24e590809aff54cf5ab2dd4c9/src/core/libraries/ajm/ajm_batch.cpp) and was confirmed byte for byte for 64-byte control and 80-byte run jobs by a PS4 trace of Knowledge is Power: Decades (CUSA12435, Wwise, 32 545 batches dumped by a GoldHEN plugin). The console's builders write only bits 0-3 (kind), bit 4 (a buffer chunk's output direction) and bits 6-25 (payload) of the header word and leave bits 5, 26, 27 and 31 as the previous batch left them, so the kind is read as 4 bits plus the direction bit, not as 6 bits - [sceAjmBatchStartBuffer](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - the statistics instance 0x80000 and the inline chunk come from shadPS4 only: the traced title builds every batch with the builders and a return address, and no title was seen writing an inline chunk. Flags are kept as passed. Unknown chunk kinds, control flags other than RESET, INITIALIZE and SIDEBAND_GAPLESS_DECODE, control jobs with run buffers and run jobs with a control input throw - [sceAjmBatchStartBuffer refusals](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - probe batches through the console's builders on a PS4 showed that a run buffer chunk of size zero is dropped whatever its address (the job runs as without it: 0x10 and nothing consumed for no room, 0 for no input), and that a null address with a nonzero size refuses the batch before any of its jobs runs, with 0x80930011 returned and the error record holding 0x80930016, the job chunk's address, the chunk's offset in the job and the job's return address. Control buffer chunks follow the same rule here, not measured. A size not a multiple of 8, a chunk that overruns its job or the batch, or a job without flags are refused the same way with 0x80930011 in the record; the console's report for those was not measured +- [sceAjmBatchStartBuffer null and empty batches](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - a null batch address returns 0x80930005 whatever the size, as the PS5 builders' sceAjmBatchInitialize does for a null buffer, and a batch of zero bytes is accepted, runs nothing and completes at once; neither was measured on a console (the probe that tried a null batch hung the probe app before writing a result) - [ATRAC9 run results](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - probe runs of a title's ATRAC9 stream through the console's builders on a PS4 showed the run result naming the side that ran out: `PARTIAL_INPUT` (0x8) when the input ran out with room left, on a superframe boundary or not; `NOT_ENOUGH_ROOM` (0x10) when the room ran out with input left, after any number of frames, and with nothing consumed for a zero room; both (0x18) when the input ended just as the output filled, which is every Wwise run job. ATRAC9 run jobs with `RUN_MULTIPLE_FRAMES` follow those rules here, vibration streams included, which were not traced; resampled runs and the other codecs keep the earlier rule (a trailing partial frame is partial input, a run that produced nothing is out of room) - [ATRAC9 run without RUN_MULTIPLE_FRAMES](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - the same PS4 probe decoded exactly one frame and reported 0 for such a run; here it still decodes its whole input, as FMOD in PPSA29141 needs (it puts two run jobs in one batch with the second's input right after the whole of the first's), so a PS4 title that relies on one frame per run would get more; MP3 and Opus run jobs still stop after one frame. The console also rejected the tests' synthetic silent superframe after three frames (`CODEC_ERROR | FATAL`, internal result -577) where LibAtrac9 decodes four, so that vector proves job plumbing, not decoding; on the title's stream LibAtrac9 matched the console's PCM within 4 LSB, 83.5% of the samples exactly From 414985e0736550dfd8adcb69155ecf724ba9b544 Mon Sep 17 00:00:00 2001 From: ntamas94 Date: Thu, 22 Oct 2026 15:38:22 +0200 Subject: [PATCH 18/21] docs(libSceAjm): say the null batch probe froze the console Co-Authored-By: Claude Fable 5.1 --- docs/dev/debt/libSceAjm.native.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/debt/libSceAjm.native.md b/docs/dev/debt/libSceAjm.native.md index d37af30120..14b4f3a476 100644 --- a/docs/dev/debt/libSceAjm.native.md +++ b/docs/dev/debt/libSceAjm.native.md @@ -20,7 +20,7 @@ - [sceAjmBatchJobControlBufferRa, sceAjmBatchJobRunBufferRa, sceAjmBatchJobRunSplitBufferRa, sceAjmBatchStartBuffer](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - the batch layout (chunk kinds, 20-bit payload, flags bits 32-51 as the flags chunk's payload, the builders' chunk order) follows [shadPS4](https://github.com/shadps4-emu/shadPS4/blob/854b291c63360ff24e590809aff54cf5ab2dd4c9/src/core/libraries/ajm/ajm_batch.cpp) and was confirmed byte for byte for 64-byte control and 80-byte run jobs by a PS4 trace of Knowledge is Power: Decades (CUSA12435, Wwise, 32 545 batches dumped by a GoldHEN plugin). The console's builders write only bits 0-3 (kind), bit 4 (a buffer chunk's output direction) and bits 6-25 (payload) of the header word and leave bits 5, 26, 27 and 31 as the previous batch left them, so the kind is read as 4 bits plus the direction bit, not as 6 bits - [sceAjmBatchStartBuffer](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - the statistics instance 0x80000 and the inline chunk come from shadPS4 only: the traced title builds every batch with the builders and a return address, and no title was seen writing an inline chunk. Flags are kept as passed. Unknown chunk kinds, control flags other than RESET, INITIALIZE and SIDEBAND_GAPLESS_DECODE, control jobs with run buffers and run jobs with a control input throw - [sceAjmBatchStartBuffer refusals](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - probe batches through the console's builders on a PS4 showed that a run buffer chunk of size zero is dropped whatever its address (the job runs as without it: 0x10 and nothing consumed for no room, 0 for no input), and that a null address with a nonzero size refuses the batch before any of its jobs runs, with 0x80930011 returned and the error record holding 0x80930016, the job chunk's address, the chunk's offset in the job and the job's return address. Control buffer chunks follow the same rule here, not measured. A size not a multiple of 8, a chunk that overruns its job or the batch, or a job without flags are refused the same way with 0x80930011 in the record; the console's report for those was not measured -- [sceAjmBatchStartBuffer null and empty batches](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - a null batch address returns 0x80930005 whatever the size, as the PS5 builders' sceAjmBatchInitialize does for a null buffer, and a batch of zero bytes is accepted, runs nothing and completes at once; neither was measured on a console (the probe that tried a null batch hung the probe app before writing a result) +- [sceAjmBatchStartBuffer null and empty batches](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - a null batch address returns 0x80930005 whatever the size, as the PS5 builders' sceAjmBatchInitialize does for a null buffer, and a batch of zero bytes is accepted, runs nothing and completes at once; neither was measured on a console (the probe that passed a null batch froze the console before a result was written, so it is not retried on hardware) - [ATRAC9 run results](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - probe runs of a title's ATRAC9 stream through the console's builders on a PS4 showed the run result naming the side that ran out: `PARTIAL_INPUT` (0x8) when the input ran out with room left, on a superframe boundary or not; `NOT_ENOUGH_ROOM` (0x10) when the room ran out with input left, after any number of frames, and with nothing consumed for a zero room; both (0x18) when the input ended just as the output filled, which is every Wwise run job. ATRAC9 run jobs with `RUN_MULTIPLE_FRAMES` follow those rules here, vibration streams included, which were not traced; resampled runs and the other codecs keep the earlier rule (a trailing partial frame is partial input, a run that produced nothing is out of room) - [ATRAC9 run without RUN_MULTIPLE_FRAMES](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - the same PS4 probe decoded exactly one frame and reported 0 for such a run; here it still decodes its whole input, as FMOD in PPSA29141 needs (it puts two run jobs in one batch with the second's input right after the whole of the first's), so a PS4 title that relies on one frame per run would get more; MP3 and Opus run jobs still stop after one frame. The console also rejected the tests' synthetic silent superframe after three frames (`CODEC_ERROR | FATAL`, internal result -577) where LibAtrac9 decodes four, so that vector proves job plumbing, not decoding; on the title's stream LibAtrac9 matched the console's PCM within 4 LSB, 83.5% of the samples exactly From 8c9c50b0b2d4add1532ffa9d021c00b9d54e42c1 Mon Sep 17 00:00:00 2001 From: ntamas94 Date: Fri, 23 Oct 2026 02:19:16 +0200 Subject: [PATCH 19/21] fix(libSceAjm): decode one ATRAC9 frame per PS4 run job without RUN_MULTIPLE_FRAMES A probe on a PS4 decoded exactly one frame and reported 0 for an ATRAC9 run job without RUN_MULTIPLE_FRAMES. Jobs read from a PS4 batch buffer now do the same. Jobs from the PS5 builders keep decoding their whole input, which FMOD in PPSA29141 relies on. Co-Authored-By: Claude Opus 5.5 --- core/libs/prx/libSceAjm.native/src/Ajm.cpp | 3 +++ core/libs/tests/GuestAjm.cpp | 23 ++++++++++++++++++++++ docs/dev/debt/libSceAjm.native.md | 2 +- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/core/libs/prx/libSceAjm.native/src/Ajm.cpp b/core/libs/prx/libSceAjm.native/src/Ajm.cpp index deb893d345..72284a8281 100644 --- a/core/libs/prx/libSceAjm.native/src/Ajm.cpp +++ b/core/libs/prx/libSceAjm.native/src/Ajm.cpp @@ -218,6 +218,7 @@ struct JobHeader { std::uint32_t inputCount; std::uint32_t outputCount; std::uint8_t parameters[16]; + bool console; }; struct PackedJob { @@ -817,6 +818,7 @@ void RunAt9(Instance& instance, const JobHeader& job, const AjmBuffer* inputs, c ++frames; EmitFrame(instance, pcmOutputs, pcm.data(), frameSamples, channels, sampleBytes); + if (job.console && (job.flags & RUN_MULTIPLE_FRAMES) == 0) break; } if (segmentEnded()) instance.segmentSamples = 0; @@ -1808,6 +1810,7 @@ struct ConsoleRefusal { bool ReadConsoleJob(std::uint32_t instance, const std::uint8_t* job, const std::uint8_t* end, ConsoleJob& out, ConsoleRefusal& refusal) { out.header = JobHeader{}; out.header.instance = instance; + out.header.console = true; std::vector inputs; std::vector outputs; AjmBuffer controlInput{}; diff --git a/core/libs/tests/GuestAjm.cpp b/core/libs/tests/GuestAjm.cpp index 894715ca9a..32522e33fd 100644 --- a/core/libs/tests/GuestAjm.cpp +++ b/core/libs/tests/GuestAjm.cpp @@ -2096,6 +2096,28 @@ void TestConsoleEmptyBatch(std::uint32_t context) { Require(sceAjmBatchWait(context, id, 0, &error) == invalidBatch); } +void TestConsoleSingleFrameRun(std::uint32_t context) { + constexpr std::uint64_t version = 1; + constexpr std::uint64_t sidebandStream = 1ull << 47; + std::vector stream(1024, 0); + std::memcpy(stream.data(), AT9_MONO_SILENT_SUPERFRAME, sizeof(AT9_MONO_SILENT_SUPERFRAME)); + std::uint32_t instance = 0; + Require(sceAjmInstanceCreate(context, 1, 0, &instance) == 0); + const At9Control start{{0, 0, 0}, {0xFE, 0x70, 0x1F, 0xF0}, 0}; + std::int32_t result[2] = {-1, -1}; + std::vector pcm(1024, 0x55); + DecodeSideband single{-1, -1, 0, 0, 0}; + std::vector batch(4096, 0xCD); + void* at = sceAjmBatchJobControlBufferRa(batch.data(), instance, version | CONTROL_START, &start, sizeof(start), result, sizeof(result), nullptr); + at = sceAjmBatchJobRunBufferRa(at, instance, version | sidebandStream, stream.data(), stream.size(), pcm.data(), pcm.size() * sizeof(std::int16_t), &single, sizeof(single), nullptr); + Require(StartBuffer(context, batch.data(), static_cast(at) - batch.data()) == 0); + Require(result[0] == 0 && single.result == 0 && single.inputConsumed > 0 && single.inputConsumed < 1024); + Require(single.outputWritten == 256 * 2 && single.totalDecodedSamples == 256); + Require(std::all_of(pcm.begin(), pcm.begin() + 256, [](std::int16_t sample) { return sample == 0; })); + Require(std::all_of(pcm.begin() + 256, pcm.end(), [](std::int16_t sample) { return sample == 0x55; })); + Require(sceAjmInstanceDestroy(context, instance) == 0); +} + void TestConsoleStaleHeaderBits(std::uint32_t context) { std::vector stream(1024, 0); std::memcpy(stream.data(), AT9_MONO_SILENT_SUPERFRAME, sizeof(AT9_MONO_SILENT_SUPERFRAME)); @@ -2383,6 +2405,7 @@ int main() { TestConsoleHandWrittenJobs(context); TestConsoleBufferEdges(context); TestConsoleEmptyBatch(context); + TestConsoleSingleFrameRun(context); TestConsoleStaleHeaderBits(context); Require(sceAjmFinalize(context) == 0); } diff --git a/docs/dev/debt/libSceAjm.native.md b/docs/dev/debt/libSceAjm.native.md index 14b4f3a476..b955d26688 100644 --- a/docs/dev/debt/libSceAjm.native.md +++ b/docs/dev/debt/libSceAjm.native.md @@ -22,7 +22,7 @@ - [sceAjmBatchStartBuffer refusals](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - probe batches through the console's builders on a PS4 showed that a run buffer chunk of size zero is dropped whatever its address (the job runs as without it: 0x10 and nothing consumed for no room, 0 for no input), and that a null address with a nonzero size refuses the batch before any of its jobs runs, with 0x80930011 returned and the error record holding 0x80930016, the job chunk's address, the chunk's offset in the job and the job's return address. Control buffer chunks follow the same rule here, not measured. A size not a multiple of 8, a chunk that overruns its job or the batch, or a job without flags are refused the same way with 0x80930011 in the record; the console's report for those was not measured - [sceAjmBatchStartBuffer null and empty batches](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - a null batch address returns 0x80930005 whatever the size, as the PS5 builders' sceAjmBatchInitialize does for a null buffer, and a batch of zero bytes is accepted, runs nothing and completes at once; neither was measured on a console (the probe that passed a null batch froze the console before a result was written, so it is not retried on hardware) - [ATRAC9 run results](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - probe runs of a title's ATRAC9 stream through the console's builders on a PS4 showed the run result naming the side that ran out: `PARTIAL_INPUT` (0x8) when the input ran out with room left, on a superframe boundary or not; `NOT_ENOUGH_ROOM` (0x10) when the room ran out with input left, after any number of frames, and with nothing consumed for a zero room; both (0x18) when the input ended just as the output filled, which is every Wwise run job. ATRAC9 run jobs with `RUN_MULTIPLE_FRAMES` follow those rules here, vibration streams included, which were not traced; resampled runs and the other codecs keep the earlier rule (a trailing partial frame is partial input, a run that produced nothing is out of room) -- [ATRAC9 run without RUN_MULTIPLE_FRAMES](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - the same PS4 probe decoded exactly one frame and reported 0 for such a run; here it still decodes its whole input, as FMOD in PPSA29141 needs (it puts two run jobs in one batch with the second's input right after the whole of the first's), so a PS4 title that relies on one frame per run would get more; MP3 and Opus run jobs still stop after one frame. The console also rejected the tests' synthetic silent superframe after three frames (`CODEC_ERROR | FATAL`, internal result -577) where LibAtrac9 decodes four, so that vector proves job plumbing, not decoding; on the title's stream LibAtrac9 matched the console's PCM within 4 LSB, 83.5% of the samples exactly +- [ATRAC9 run without RUN_MULTIPLE_FRAMES](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - the same PS4 probe decoded exactly one frame and reported 0 for such a run, and a job read from a PS4 batch buffer (sceAjmBatchStartBuffer) does the same here. A job from the PS5 builders still decodes its whole input, as FMOD in PPSA29141 needs (it puts two run jobs in one batch with the second's input right after the whole of the first's); whether a PS5 stops after one frame is not measured. MP3 and Opus run jobs stop after one frame on both paths. The console also rejected the tests' synthetic silent superframe after three frames (`CODEC_ERROR | FATAL`, internal result -577) where LibAtrac9 decodes four, so that vector proves job plumbing, not decoding; on the title's stream LibAtrac9 matched the console's PCM within 4 LSB, 83.5% of the samples exactly ### Functional From 43c82acb0f2d08eaa9bcac4e682e1fa99b9fefdf Mon Sep 17 00:00:00 2001 From: ntamas94 Date: Sat, 24 Oct 2026 02:08:19 +0200 Subject: [PATCH 20/21] docs(libSceAjm): say the empty PS4 batch contract needs a non-null address A null batch address is refused whatever the size, so the accepted batch of zero bytes is one at a real address, which is what TestConsoleEmptyBatch starts. Co-Authored-By: Claude Opus 5.5 --- docs/dev/debt/libSceAjm.native.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/debt/libSceAjm.native.md b/docs/dev/debt/libSceAjm.native.md index b955d26688..5c693e0f85 100644 --- a/docs/dev/debt/libSceAjm.native.md +++ b/docs/dev/debt/libSceAjm.native.md @@ -20,7 +20,7 @@ - [sceAjmBatchJobControlBufferRa, sceAjmBatchJobRunBufferRa, sceAjmBatchJobRunSplitBufferRa, sceAjmBatchStartBuffer](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - the batch layout (chunk kinds, 20-bit payload, flags bits 32-51 as the flags chunk's payload, the builders' chunk order) follows [shadPS4](https://github.com/shadps4-emu/shadPS4/blob/854b291c63360ff24e590809aff54cf5ab2dd4c9/src/core/libraries/ajm/ajm_batch.cpp) and was confirmed byte for byte for 64-byte control and 80-byte run jobs by a PS4 trace of Knowledge is Power: Decades (CUSA12435, Wwise, 32 545 batches dumped by a GoldHEN plugin). The console's builders write only bits 0-3 (kind), bit 4 (a buffer chunk's output direction) and bits 6-25 (payload) of the header word and leave bits 5, 26, 27 and 31 as the previous batch left them, so the kind is read as 4 bits plus the direction bit, not as 6 bits - [sceAjmBatchStartBuffer](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - the statistics instance 0x80000 and the inline chunk come from shadPS4 only: the traced title builds every batch with the builders and a return address, and no title was seen writing an inline chunk. Flags are kept as passed. Unknown chunk kinds, control flags other than RESET, INITIALIZE and SIDEBAND_GAPLESS_DECODE, control jobs with run buffers and run jobs with a control input throw - [sceAjmBatchStartBuffer refusals](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - probe batches through the console's builders on a PS4 showed that a run buffer chunk of size zero is dropped whatever its address (the job runs as without it: 0x10 and nothing consumed for no room, 0 for no input), and that a null address with a nonzero size refuses the batch before any of its jobs runs, with 0x80930011 returned and the error record holding 0x80930016, the job chunk's address, the chunk's offset in the job and the job's return address. Control buffer chunks follow the same rule here, not measured. A size not a multiple of 8, a chunk that overruns its job or the batch, or a job without flags are refused the same way with 0x80930011 in the record; the console's report for those was not measured -- [sceAjmBatchStartBuffer null and empty batches](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - a null batch address returns 0x80930005 whatever the size, as the PS5 builders' sceAjmBatchInitialize does for a null buffer, and a batch of zero bytes is accepted, runs nothing and completes at once; neither was measured on a console (the probe that passed a null batch froze the console before a result was written, so it is not retried on hardware) +- [sceAjmBatchStartBuffer null and empty batches](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - a null batch address returns 0x80930005 whatever the size, as the PS5 builders' sceAjmBatchInitialize does for a null buffer, and a batch of zero bytes at a non-null address is accepted, runs nothing and completes at once (a null address with size 0 is refused like any null address); neither was measured on a console (the probe that passed a null batch froze the console before a result was written, so it is not retried on hardware) - [ATRAC9 run results](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - probe runs of a title's ATRAC9 stream through the console's builders on a PS4 showed the run result naming the side that ran out: `PARTIAL_INPUT` (0x8) when the input ran out with room left, on a superframe boundary or not; `NOT_ENOUGH_ROOM` (0x10) when the room ran out with input left, after any number of frames, and with nothing consumed for a zero room; both (0x18) when the input ended just as the output filled, which is every Wwise run job. ATRAC9 run jobs with `RUN_MULTIPLE_FRAMES` follow those rules here, vibration streams included, which were not traced; resampled runs and the other codecs keep the earlier rule (a trailing partial frame is partial input, a run that produced nothing is out of room) - [ATRAC9 run without RUN_MULTIPLE_FRAMES](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - the same PS4 probe decoded exactly one frame and reported 0 for such a run, and a job read from a PS4 batch buffer (sceAjmBatchStartBuffer) does the same here. A job from the PS5 builders still decodes its whole input, as FMOD in PPSA29141 needs (it puts two run jobs in one batch with the second's input right after the whole of the first's); whether a PS5 stops after one frame is not measured. MP3 and Opus run jobs stop after one frame on both paths. The console also rejected the tests' synthetic silent superframe after three frames (`CODEC_ERROR | FATAL`, internal result -577) where LibAtrac9 decodes four, so that vector proves job plumbing, not decoding; on the title's stream LibAtrac9 matched the console's PCM within 4 LSB, 83.5% of the samples exactly From 8065d65f01c8494d3a0bd10e4afea9e08b20ecd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Franch?= Date: Sun, 25 Oct 2026 18:13:38 +0100 Subject: [PATCH 21/21] fix(libSceAjm): declare each batch export in one header The public header audit found the three PS4 batch exports declared in libSceAjm's Api.hpp and in libSceAjm.native's, and the libSceAjmi run stub under its plain name. Keep them in libSceAjm.native and declare the libSceAjmi stub as sceAjmBatchJobRunBufferRa_nid_disambig1, as its definition is. Co-Authored-By: Claude Haiku 5.5 --- core/libs/prx/libSceAjm/include/Api.hpp | 4 ---- core/libs/prx/libSceAjmi/include/Api.hpp | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/core/libs/prx/libSceAjm/include/Api.hpp b/core/libs/prx/libSceAjm/include/Api.hpp index 1be3b9a23e..2cc01a795b 100644 --- a/core/libs/prx/libSceAjm/include/Api.hpp +++ b/core/libs/prx/libSceAjm/include/Api.hpp @@ -4,8 +4,4 @@ extern "C" { -int APS5_VABI sceAjmBatchJobControlBufferRa(); -int APS5_VABI sceAjmBatchJobRunSplitBufferRa(); -int APS5_VABI sceAjmBatchStartBuffer(); - } diff --git a/core/libs/prx/libSceAjmi/include/Api.hpp b/core/libs/prx/libSceAjmi/include/Api.hpp index cf36eb1119..f12efb729c 100644 --- a/core/libs/prx/libSceAjmi/include/Api.hpp +++ b/core/libs/prx/libSceAjmi/include/Api.hpp @@ -5,6 +5,6 @@ extern "C" { int APS5_VABI sceAjmBatchJobInlineBuffer(void); -int APS5_VABI sceAjmBatchJobRunBufferRa(void); +int APS5_VABI sceAjmBatchJobRunBufferRa_nid_disambig1(void); }