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 0001/1059] 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 6bdbad4cab31ed4d9f4cef70327eb4b838500441 Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:16:23 -0700 Subject: [PATCH 0002/1059] fix(libSceNet): follow FreeBSD inet text rules sceNetInetPton and sceNetInetNtop called Winsock's InetPtonA and InetNtopA on Windows. Those print IPv4-translated and ISATAP addresses with a dotted tail, print ::ffff:0.0.0.1 as ::ffff:0:1, and accept a leading zero in a dotted tail, where FreeBSD does neither. Both now convert with libkernel's inet_pton and inet_ntop, which follow the FreeBSD 11.4 rules since #3530. --- core/libs/prx/libSceNet/Export.cpp | 29 ++++++----------------------- core/libs/tests/GuestSceNet.cpp | 12 ++++++++++++ docs/dev/debt/libSceNet.md | 1 + 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/core/libs/prx/libSceNet/Export.cpp b/core/libs/prx/libSceNet/Export.cpp index 1ea3b505ab..c267be65e6 100644 --- a/core/libs/prx/libSceNet/Export.cpp +++ b/core/libs/prx/libSceNet/Export.cpp @@ -46,7 +46,6 @@ constexpr int NET_EBADF = 9; constexpr int NET_EACCES = 13; constexpr int NET_EFAULT = 14; constexpr int NET_EINVAL = 22; -constexpr int NET_ENOSPC = 28; constexpr int NET_EDOM = 33; constexpr int NET_EAGAIN = 35; constexpr int NET_EINPROGRESS = 36; @@ -326,6 +325,8 @@ int* errno_slot() { } extern "C" int* APS5_VABI __error_nid_postfix(); +extern "C" int APS5_VABI __inet_pton_nid_postfix(int family, const char* text, void* destination); +extern "C" const char* APS5_VABI __inet_ntop_nid_postfix(int family, const void* source, char* destination, std::uint32_t capacity); void set_errno(int err) { *errno_slot() = err; @@ -1211,11 +1212,7 @@ int APS5_VABI sceNetInetPton(int af, const char* src, void* dst) { if (af != NET_AF_INET && af != NET_AF_INET6) { return fail(NET_EAFNOSUPPORT); } -#ifdef _WIN32 - return InetPtonA(af == NET_AF_INET ? AF_INET : AF_INET6, src, dst); -#else - return ::inet_pton(af == NET_AF_INET ? AF_INET : AF_INET6, src, dst); -#endif + return __inet_pton_nid_postfix(af, src, dst); } const char* APS5_VABI sceNetInetNtop(int af, const void* src, char* dst, uint32_t size) { @@ -1227,23 +1224,9 @@ const char* APS5_VABI sceNetInetNtop(int af, const void* src, char* dst, uint32_ set_errno(NET_EAFNOSUPPORT); return nullptr; } - char text[INET6_ADDRSTRLEN]; -#ifdef _WIN32 - const auto* result = InetNtopA(af == NET_AF_INET ? AF_INET : AF_INET6, const_cast(src), text, sizeof(text)); -#else - const auto* result = ::inet_ntop(af == NET_AF_INET ? AF_INET : AF_INET6, src, text, sizeof(text)); -#endif - if (!result) { - set_errno(native_error()); - return nullptr; - } - const auto length = std::strlen(text) + 1; - if (length > size) { - set_errno(NET_ENOSPC); - return nullptr; - } - std::memcpy(dst, text, length); - return dst; + const char* result = __inet_ntop_nid_postfix(af, src, dst, size); + if (!result) set_errno(*__error_nid_postfix()); + return result; } int APS5_VABI sceNetEtherNtostr(const NetEtherAddr* n, char* str, size_t len) { diff --git a/core/libs/tests/GuestSceNet.cpp b/core/libs/tests/GuestSceNet.cpp index 2ad9a8a605..901c052dcf 100644 --- a/core/libs/tests/GuestSceNet.cpp +++ b/core/libs/tests/GuestSceNet.cpp @@ -379,6 +379,13 @@ static bool HostIpv6LoopbackUnavailable() { #endif } +static void CheckRejectedAddressText(const char* text) { + std::array address; + address.fill(0xa5); + Require(sceNetInetPton(28, text, address.data()) == 0); + Require(address[0] == 0xa5 && address[15] == 0xa5); +} + static void CheckLinkLocalAllNodesIpv6() { Require(reinterpret_cast(sce_net_in6addr_linklocal_allnodes) % alignof(std::uint32_t) == 0); std::array address{}; @@ -479,6 +486,11 @@ int main() { CheckAddressText(2, "127.0.0.1"); CheckAddressText(2, "255.255.255.255"); CheckAddressText(28, "::1"); + CheckAddressText(28, "::ffff:0.0.0.1"); + CheckAddressText(28, "::ffff:0:102:304"); + CheckAddressText(28, "fe80::5efe:102:304"); + CheckRejectedAddressText("::01.2.3.4"); + CheckRejectedAddressText("::ffff:01.2.3.4"); CheckAddressText(28, "1234:5678:9abc:def0:1234:5678:9abc:def0"); CheckLinkLocalAllNodesIpv6(); CheckUnspecifiedIpv6(); diff --git a/docs/dev/debt/libSceNet.md b/docs/dev/debt/libSceNet.md index c70d4323dc..733b160c3d 100644 --- a/docs/dev/debt/libSceNet.md +++ b/docs/dev/debt/libSceNet.md @@ -5,6 +5,7 @@ - PPSA01530 and PPSA13356 imports declared without parameters, signatures unknown, added without an implementation because the titles fail to load without them: [sceNetShowIfconfig](../../../core/libs/prx/libSceNet/Export.cpp) (libSceNet), [sce::Json::Value::setNullAccessCallBack](../../../core/libs/prx/libSceJson2/Export.cpp) (libSceJson2), and [scePadUnknown_n3kSX62fgNo](../../../core/libs/prx/libScePad/Export.cpp) (libScePad), whose real name is unknown. [_sceUltWaitingQueueResourcePoolCreate, _sceUltSemaphoreCreate, _sceUltMutexOptParamInitialize, _sceUltMutexCreate, _sceUltQueueDataResourcePoolCreate and _sceUltQueueCreate](../../../core/libs/prx/libSceUlt/Export.cpp) (libSceUlt) forward to the exports without the underscore; that their argument lists match is inferred from the other underscore Ult exports, not measured. - [sceHttp2GetMemoryPoolStats, sceSslGetMemoryPoolStats, sceNetGetMemoryPoolStats](../../../core/libs/prx/libSceNet/Export.cpp) (libSceHttp2, libSceSsl, libSceNet) - the signatures `(int id, stats*)`, the layouts (pool size, peak and current use, and for Http2 and Ssl a 32-bit reserved field) and the error codes were measured on a PS5 (firmware 13.60). The pool size is the one given at init or pool creation; nothing is allocated from these pools, so the peak and current use are 0, where the console already reports use right after initialization (0x15C0 bytes for Ssl, 0x1640 for Http2 and 4 for a Net pool) - [sceNetShowNetstat](../../../core/libs/prx/libSceNet/Export.cpp) (libSceNet) - imported by PPSA11386 and not called during its start-up; unknown signature, declared without parameters +- [sceNetInetPton, sceNetInetNtop](../../../core/libs/prx/libSceNet/Export.cpp) (libSceNet) - convert the text with libkernel's [inet_pton and inet_ntop](../../../core/libs/prx/libkernel/Socket/src/AddressConversion.cpp), so they take the same FreeBSD 11.4 text forms, which are unverified on a console. The checks and errors before the conversion are libSceNet's own and unchanged ### Functional 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 0003/1059] 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 8f9758fec02d9cba64f1f1ac6922ac5757a11b7a Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:25:02 -0700 Subject: [PATCH 0004/1059] docs(libSceNet): list the inet text debt before sceNetShowNetstat #5124 adds an entry after sceNetShowNetstat at the end of the unknown function info list, so adding the inet entry there made the two pull requests conflict. --- docs/dev/debt/libSceNet.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/debt/libSceNet.md b/docs/dev/debt/libSceNet.md index 733b160c3d..38f8a77a25 100644 --- a/docs/dev/debt/libSceNet.md +++ b/docs/dev/debt/libSceNet.md @@ -4,8 +4,8 @@ - PPSA01530 and PPSA13356 imports declared without parameters, signatures unknown, added without an implementation because the titles fail to load without them: [sceNetShowIfconfig](../../../core/libs/prx/libSceNet/Export.cpp) (libSceNet), [sce::Json::Value::setNullAccessCallBack](../../../core/libs/prx/libSceJson2/Export.cpp) (libSceJson2), and [scePadUnknown_n3kSX62fgNo](../../../core/libs/prx/libScePad/Export.cpp) (libScePad), whose real name is unknown. [_sceUltWaitingQueueResourcePoolCreate, _sceUltSemaphoreCreate, _sceUltMutexOptParamInitialize, _sceUltMutexCreate, _sceUltQueueDataResourcePoolCreate and _sceUltQueueCreate](../../../core/libs/prx/libSceUlt/Export.cpp) (libSceUlt) forward to the exports without the underscore; that their argument lists match is inferred from the other underscore Ult exports, not measured. - [sceHttp2GetMemoryPoolStats, sceSslGetMemoryPoolStats, sceNetGetMemoryPoolStats](../../../core/libs/prx/libSceNet/Export.cpp) (libSceHttp2, libSceSsl, libSceNet) - the signatures `(int id, stats*)`, the layouts (pool size, peak and current use, and for Http2 and Ssl a 32-bit reserved field) and the error codes were measured on a PS5 (firmware 13.60). The pool size is the one given at init or pool creation; nothing is allocated from these pools, so the peak and current use are 0, where the console already reports use right after initialization (0x15C0 bytes for Ssl, 0x1640 for Http2 and 4 for a Net pool) -- [sceNetShowNetstat](../../../core/libs/prx/libSceNet/Export.cpp) (libSceNet) - imported by PPSA11386 and not called during its start-up; unknown signature, declared without parameters - [sceNetInetPton, sceNetInetNtop](../../../core/libs/prx/libSceNet/Export.cpp) (libSceNet) - convert the text with libkernel's [inet_pton and inet_ntop](../../../core/libs/prx/libkernel/Socket/src/AddressConversion.cpp), so they take the same FreeBSD 11.4 text forms, which are unverified on a console. The checks and errors before the conversion are libSceNet's own and unchanged +- [sceNetShowNetstat](../../../core/libs/prx/libSceNet/Export.cpp) (libSceNet) - imported by PPSA11386 and not called during its start-up; unknown signature, declared without parameters ### Functional From 3d1b4d460b70764d8e51272c2b018f74d6ed7a55 Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:26:16 -0700 Subject: [PATCH 0005/1059] fix(libkernel): block a signal while its handler runs and honour SA_RESETHAND A guest signal handler ran with the mask it was raised under, so a handler that raised its own signal again re-entered itself at once, sigprocmask inside the handler did not report the signal or the handler's sa_mask as blocked, and SA_RESETHAND left the handler installed for the next delivery. FreeBSD 11.4 forms the handler's frame and then, in postsig_done() (sys/kern/kern_sig.c), blocks the handler's sa_mask and, unless SA_NODEFER is set, the signal itself, and resets the disposition to SIG_DFL when SA_RESETHAND is set; sigreturn restores the previous mask, so a signal raised meanwhile stays pending until the handler returns. sigaction keeps reporting SA_RESETHAND afterwards, since sigdflt() does not clear ps_sigreset. The handler now runs with its signal (unless SA_NODEFER) and its mask added to the guest mask, which is restored when it returns, and pending signals that the restored mask unblocks are delivered when raise returns. SA_RESETHAND resets the handler to the default before it runs. --- core/libs/prx/libkernel/CMakeLists.txt | 10 ++ .../libs/prx/libkernel/System/src/Signals.cpp | 53 ++++++++-- core/libs/tests/GuestSignalHandlerMask.cpp | 98 +++++++++++++++++++ 3 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 core/libs/tests/GuestSignalHandlerMask.cpp diff --git a/core/libs/prx/libkernel/CMakeLists.txt b/core/libs/prx/libkernel/CMakeLists.txt index f3fb4d0806..1b62620e2a 100644 --- a/core/libs/prx/libkernel/CMakeLists.txt +++ b/core/libs/prx/libkernel/CMakeLists.txt @@ -624,6 +624,16 @@ if(BUILD_TESTING) set_tests_properties(guest_signals PROPERTIES ENVIRONMENT_MODIFICATION "PATH=path_list_prepend:$") endif() + add_test_executable(guest_signal_handler_mask_tests ${LIBS_INCLUDE_DIR}/tests/GuestSignalHandlerMask.cpp) + target_include_directories(guest_signal_handler_mask_tests PRIVATE ${LIBS_INCLUDE_DIR}) + target_link_libraries(guest_signal_handler_mask_tests PRIVATE libkernel libc) + configure_windows_unwind(guest_signal_handler_mask_tests) + add_test(NAME guest_signal_handler_mask COMMAND guest_signal_handler_mask_tests) + set_tests_properties(guest_signal_handler_mask PROPERTIES TIMEOUT 20) + if(WIN32) + set_tests_properties(guest_signal_handler_mask PROPERTIES ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$") + endif() add_test_executable(guest_sigtimedwait_tests ${LIBS_INCLUDE_DIR}/tests/GuestSigtimedwait.cpp) target_include_directories(guest_sigtimedwait_tests PRIVATE ${LIBS_INCLUDE_DIR}) target_link_libraries(guest_sigtimedwait_tests PRIVATE libkernel libc) diff --git a/core/libs/prx/libkernel/System/src/Signals.cpp b/core/libs/prx/libkernel/System/src/Signals.cpp index 01226d305d..bcfa89969e 100644 --- a/core/libs/prx/libkernel/System/src/Signals.cpp +++ b/core/libs/prx/libkernel/System/src/Signals.cpp @@ -38,8 +38,13 @@ constexpr int MaxSignal = 128; constexpr int GuestSigkill = 9; constexpr int GuestSigstop = 17; constexpr int SaRestart = 0x2; +constexpr int SaResethand = 0x4; +constexpr int SaNodefer = 0x10; constexpr int SaSiginfo = 0x40; std::atomic handlers[MaxSignal + 1]{}; +std::atomic handlerMasks[MaxSignal + 1]{}; +std::atomic handlerFlags[MaxSignal + 1]{}; +std::atomic handlerReset[MaxSignal + 1]{}; static_assert(std::atomic::is_always_lock_free); std::atomic blockedMask{0}; static_assert(std::atomic::is_always_lock_free); @@ -138,6 +143,20 @@ int NativeSignal(int guest) { default: return 0; } } +void ResetToDefault(int guest) { + handlerReset[guest].store(true); + handlers[guest].store(nullptr); + if (const int native = NativeSignal(guest)) std::signal(native, SIG_DFL); +} +void RunHandler(int guest, GuestHandler handler) { + const int flags = handlerFlags[guest].load(); + std::uint32_t mask = handlerMasks[guest].load(); + if ((flags & SaNodefer) == 0 && guest <= 32) mask |= SignalBit(guest); + if ((flags & SaResethand) != 0) ResetToDefault(guest); + const std::uint32_t saved = blockedMask.fetch_or(mask); + handler(guest); + blockedMask.store(saved); +} void Dispatch(int native) { int guest = 0; for (int candidate : {2, 4, 6, 8, 11, 15}) @@ -152,12 +171,15 @@ void Dispatch(int native) { return; } const auto callback = handlers[guest].load(); - if (reinterpret_cast(callback) > 1) callback(guest); + if (reinterpret_cast(callback) > 1) RunHandler(guest, callback); } bool Delivered(const GuestSigaction& action) { return action.handler > 1 && (action.flags & SaSiginfo) == 0; } bool Install(int guest, const GuestSigaction& action) { + handlerReset[guest].store(false); + handlerMasks[guest].store(action.mask.bits[0]); + handlerFlags[guest].store(action.flags); const int native = NativeSignal(guest); if (!native) return true; const auto previous = handlers[guest].exchange(Delivered(action) ? reinterpret_cast(action.handler) : nullptr); @@ -172,8 +194,15 @@ bool Install(int guest, const GuestSigaction& action) { namespace { +void SettleReset(int guest) { + if (!handlerReset[guest].exchange(false)) return; + dispositions[guest].handler = 0; + dispositions[guest].flags &= ~SaSiginfo; +} + GuestSigaction Disposition(int guest) { std::lock_guard lock(registration); + SettleReset(guest); return dispositions[guest]; } @@ -205,12 +234,19 @@ bool DeliverPendingSignal(int guest) { return action.handler > 1; } if (action.handler > 1) { - reinterpret_cast(action.handler)(guest); + RunHandler(guest, reinterpret_cast(action.handler)); return true; } return false; } +void DeliverUnblockedPending() { + for (int guest = 1; guest <= MaxSignal; ++guest) { + if (SignalBlocked(guest) || !Deliverable(guest) || !TakePending(guest)) continue; + DeliverPendingSignal(guest); + } +} + int WaitForPendingInSet(const GuestSignalSet& set, bool waitForever, const GuestTimespec* timeout, int* guest) { auto& pending = Pending(); std::unique_lock lock(pending.lock); @@ -435,6 +471,7 @@ GuestHandler APS5_VABI signal_nid_postfix(int guest, GuestHandler handler) { const auto invalid = reinterpret_cast(static_cast(-1)); if (guest < 1 || guest > MaxSignal || ((guest == GuestSigkill || guest == GuestSigstop) && handler != nullptr)) { *__error_nid_postfix() = 22; return invalid; } std::lock_guard lock(registration); + SettleReset(guest); const GuestSigaction action{reinterpret_cast(handler), SaRestart, {}}; if (!Install(guest, action)) { *__error_nid_postfix() = 22; return invalid; } const auto previous = reinterpret_cast(dispositions[guest].handler); @@ -445,6 +482,7 @@ int APS5_VABI sigaction_nid_postfix(int guest, const GuestSigaction* action, Gue if (guest < 1 || guest > MaxSignal) { *__error_nid_postfix() = 22; return -1; } if (action && (guest == GuestSigkill || guest == GuestSigstop) && action->handler != 0) { *__error_nid_postfix() = 22; return -1; } std::lock_guard lock(registration); + SettleReset(guest); const GuestSigaction old = dispositions[guest]; if (action) { if (!Install(guest, *action)) { *__error_nid_postfix() = 22; return -1; } @@ -464,11 +502,13 @@ int APS5_VABI raise_nid_postfix(int guest) { const int native = NativeSignal(guest); if (native != 0) { const int result = std::raise(native); - if (result) *__error_nid_postfix() = 22; - return result ? -1 : 0; + if (result) { *__error_nid_postfix() = 22; return -1; } + DeliverUnblockedPending(); + return 0; } if (!CaughtByGuest(guest)) { *__error_nid_postfix() = 22; return -1; } DeliverPendingSignal(guest); + DeliverUnblockedPending(); return 0; } int APS5_VABI kill_nid_postfix(int pid, int guest) { @@ -520,10 +560,7 @@ int APS5_VABI _sigprocmask_nid_postfix(int how, const GuestSignalSet* set, Guest } } } - for (int guest = 1; guest <= MaxSignal; ++guest) { - if (SignalBlocked(guest) || !Deliverable(guest) || !TakePending(guest)) continue; - DeliverPendingSignal(guest); - } + DeliverUnblockedPending(); return 0; } diff --git a/core/libs/tests/GuestSignalHandlerMask.cpp b/core/libs/tests/GuestSignalHandlerMask.cpp new file mode 100644 index 0000000000..5ef9ecfd2a --- /dev/null +++ b/core/libs/tests/GuestSignalHandlerMask.cpp @@ -0,0 +1,98 @@ +#include "prx/libc/include/general/VabiMacros.hpp" +#include +#include + +extern "C" { +int APS5_VABI sigaction_nid_postfix(int, const void*, void*); +int APS5_VABI sigprocmask_nid_postfix(int, const void*, void*); +int APS5_VABI raise_nid_postfix(int); +} + +struct GuestSignalSet { + std::uint32_t bits[4]; +}; + +struct GuestSigaction { + std::uintptr_t handler; + int flags; + GuestSignalSet mask; +}; + +static constexpr int SigTerm = 15; +static constexpr int SigUsr1 = 30; +static constexpr int SigUsr2 = 31; +static constexpr int SaRestart = 0x2; +static constexpr int SaResethand = 0x4; +static constexpr int SaNodefer = 0x10; +static constexpr int SigBlock = 1; + +static std::uint32_t Bit(int signal) { return 1u << (signal - 1); } + +static void Require(bool value) { if (!value) std::abort(); } + +static int calls = 0; +static int callsAfterNestedRaise = 0; +static std::uint32_t maskInHandler = 0; + +static std::uint32_t CurrentMask() { + GuestSignalSet set{}; + Require(sigprocmask_nid_postfix(SigBlock, nullptr, &set) == 0); + return set.bits[0]; +} + +static void APS5_VABI RaiseAgain(int signal) { + if (++calls == 1) { + maskInHandler = CurrentMask(); + Require(raise_nid_postfix(signal) == 0); + callsAfterNestedRaise = calls; + } +} + +static void APS5_VABI Count(int) { + ++calls; +} + +static void Install(int signal, void (APS5_VABI* handler)(int), int flags, std::uint32_t mask) { + GuestSigaction action{reinterpret_cast(handler), flags, {{mask, 0, 0, 0}}}; + Require(sigaction_nid_postfix(signal, &action, nullptr) == 0); +} + +static void CheckDeferred(int signal) { + calls = 0; + callsAfterNestedRaise = 0; + Install(signal, RaiseAgain, SaRestart, Bit(SigUsr1)); + Require(raise_nid_postfix(signal) == 0); + Require(callsAfterNestedRaise == 1); + Require(calls == 2); + Require(maskInHandler == (Bit(signal) | Bit(SigUsr1))); + Require(CurrentMask() == 0); +} + +static void CheckNodefer() { + calls = 0; + callsAfterNestedRaise = 0; + Install(SigUsr2, RaiseAgain, SaRestart | SaNodefer, 0); + Require(raise_nid_postfix(SigUsr2) == 0); + Require(callsAfterNestedRaise == 2); + Require(maskInHandler == 0); + Require(CurrentMask() == 0); +} + +static void CheckResethand() { + calls = 0; + Install(SigUsr1, Count, SaResethand, 0); + Require(raise_nid_postfix(SigUsr1) == 0); + Require(calls == 1); + GuestSigaction current{}; + Require(sigaction_nid_postfix(SigUsr1, nullptr, ¤t) == 0); + Require(current.handler == 0); + Require((current.flags & SaResethand) != 0); +} + +int main() { + CheckDeferred(SigUsr2); + CheckDeferred(SigTerm); + CheckNodefer(); + CheckResethand(); + return 0; +} From ebb86e4c2e8c3b22c28316928af369d333716a4e Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:47:28 -0700 Subject: [PATCH 0006/1059] docs(libkernel): record the handler mask and SA_RESETHAND for sigaction The sigaction entry said that the mask and flags of a running handler are not applied. It now says what is: the signal itself unless SA_NODEFER, and the first 32 signals of sa_mask, are added to the process-wide mask while the handler runs, and SA_RESETHAND resets the disposition first. It also notes what is not: SA_ONSTACK, and the delivery of a signal left pending by a handler that a host signal ran asynchronously, which waits for the next raise or _sigprocmask. --- docs/dev/debt/libkernel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/debt/libkernel.md b/docs/dev/debt/libkernel.md index b54b27aba9..44c450c7b3 100644 --- a/docs/dev/debt/libkernel.md +++ b/docs/dev/debt/libkernel.md @@ -153,7 +153,7 @@ - [sigtimedwait](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) waits on a process-wide pending set instead of throwing: a signal raised while blocked stays pending and is taken by `sigwait`, `sigwaitinfo` or `sigtimedwait`, delivered to the handler when `_sigprocmask` unblocks it, or delivered by `pause`. The set covers all 128 signals and the lowest number that is pending and in the set is selected (FreeBSD specifies the lowest number only among real-time signals). Selection, `EAGAIN` on timeout, `EINVAL` for a `tv_nsec` outside 0 to 1000000000 while nothing is pending and `NULL` timeout blocking indefinitely follow FreeBSD's [sigtimedwait(2)](https://man.freebsd.org/cgi/man.cgi?query=sigtimedwait). `sigwait` returns 0 with the number in its output, as POSIX specifies, and `sigwaitinfo` and `sigtimedwait` return the number, as FreeBSD does. The guest `siginfo_t` follows FreeBSD 11's `sys/sys/siginfo.h`: 80 bytes, `si_signo` at 0, `si_code` at 8, `si_pid` at 12, `si_uid` at 16, `si_addr` at 24, `si_value` at 32 and `si_band` at 40; only `si_signo`, `si_code` (`SI_USER`) and `si_pid` (the guest pid) are written, `si_uid` stays 0 because no uid is modelled, and the console's layout is not verified. PPSA85300 imports `sigtimedwait` and fails to load without it; its only call site is VLC's `vlc_writev` ([filesystem.c](https://github.com/videolan/vlc/blob/6de05adcbaf2e8b85fe86aad4169393098628119/src/posix/filesystem.c#L243-L278)), which polls with a zero timeout to drain a `SIGPIPE` only after `writev` fails with `EPIPE`. The waits are not cancellation points and an unblocked caught signal does not interrupt them with `EINTR`, where FreeBSD's `sigwaitinfo` and `sigtimedwait` may return it. `SIGPIPE` has no host equivalent and is never raised by a host write here (see `read, write and vector I/O`), so VLC's drain finds nothing - [pause, sigwait, sigwaitinfo](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) take the pending set of `sigtimedwait` instead of throwing. `pause` waits for a pending signal that is not blocked and whose disposition can be delivered, runs its handler on the calling thread and returns -1 with `EINTR`; an ignored signal is discarded and the wait continues, and it never returns for a signal whose handler cannot run. A signal whose registered handler has `SA_SIGINFO` still throws, and one whose disposition is the default action and that has no host equivalent (every signal except 2, 4, 6, 8, 11 and 15) is neither delivered nor discarded: it stays pending until a wait takes it, where FreeBSD runs the default action, which terminates the process for most of them. PPSA68091, PPSA99169, PPSA99420 and PPSA97358 import them and fail to load without them. PPSA68091 calls `pause` at one site (0x8F7266), in a loop that waits while a global holds another thread's `errno` address. PPSA99169 only stores the address of `pause` in a table of symbol addresses (0x30DB1). PPSA99420 calls `pause` at 0x3BD80F6 and 0x3C9E220, `sigwait` at 0x3C9E4C7 and `sigwaitinfo` at 0x3C9E588. PPSA97358 calls `sigwait` from QEMU's `sigwait_compat` thread ([compatfd.c](https://github.com/ZiZc3/XPSemu/blob/8eec07b4e31f66dce8e0b05c1ca1281af303e933/util/compatfd.c#L28-L53)), which `qemu_signal_init` ([main-loop.c](https://github.com/ZiZc3/XPSemu/blob/8eec07b4e31f66dce8e0b05c1ca1281af303e933/util/main-loop.c#L110-L147)) starts for `SIGIO`, `SIGALRM` and `SIGBUS` when the main loop is created, so it reaches the wait at start-up, and from the vCPU thread of the dummy accelerator ([dummy-cpus.c](https://github.com/ZiZc3/XPSemu/blob/8eec07b4e31f66dce8e0b05c1ca1281af303e933/accel/dummy-cpus.c#L48-L56)). None of the four waits is a cancellation point - [getsid](../../../core/libs/prx/libkernel/System/src/Process.cpp) (libkernel) returns the calling process ID for pid 0 and the calling process, and `ESRCH` for other process IDs, assuming the single guest process is its own session leader. PPSA74126 imports it and fails to load without it -- [sigaction](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) records the handler, flags and mask of every signal from 1 to 128 and reports them back, following FreeBSD's `sigaction` (`EINVAL` for other signals, and for a handler on `SIGKILL` or `SIGSTOP`); `signal` records its handler with `SA_RESTART`, as FreeBSD's libc does. Only handlers without `SA_SIGINFO` on the signals `signal` already delivers (`SIGINT`, `SIGILL`, `SIGABRT`, `SIGFPE`, `SIGSEGV`, `SIGTERM`) run, the same way `signal` handlers do; their mask and flags are not applied. `SA_SIGINFO` handlers are recorded but never called, since that needs FreeBSD's `siginfo_t` and `ucontext_t`, and neither are the handlers of other signals (`SIGBUS`, `SIGHUP`, ...), whether they are set by `sigaction` or by `signal`, which records them too (every number from 1 to 128, with `SA_RESTART` and an empty mask replacing what `sigaction` stored; `SIGKILL` and `SIGSTOP` accept only the default handler in both calls, as in FreeBSD's `kern_sigaction`). `raise` and `kill` accept every number from 1 to 128 now: a blocked one stays pending on the set of `sigtimedwait`, an unblocked one whose handler is registered runs it on the calling thread, and an unblocked one that has no host equivalent and no handler still fails with `EINVAL`, where FreeBSD runs its default action. `raise` of an `SA_SIGINFO` handler still throws instead of terminating +- [sigaction](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) records the handler, flags and mask of every signal from 1 to 128 and reports them back, following FreeBSD's `sigaction` (`EINVAL` for other signals, and for a handler on `SIGKILL` or `SIGSTOP`); `signal` records its handler with `SA_RESTART`, as FreeBSD's libc does. Only handlers without `SA_SIGINFO` on the signals `signal` already delivers (`SIGINT`, `SIGILL`, `SIGABRT`, `SIGFPE`, `SIGSEGV`, `SIGTERM`) run, the same way `signal` handlers do. While a handler runs, its own signal (unless `SA_NODEFER`) and the first 32 signals of its mask are added to the process-wide mask, which is restored when it returns, and `SA_RESETHAND` resets the disposition to the default before it runs, as FreeBSD's `postsig_done` does; a signal raised meanwhile stays pending and is delivered when the handler returns to `raise`, but one left pending by a handler that a host signal ran asynchronously waits for the next `raise` or `_sigprocmask`. `SA_ONSTACK` is not applied. `SA_SIGINFO` handlers are recorded but never called, since that needs FreeBSD's `siginfo_t` and `ucontext_t`, and neither are the handlers of other signals (`SIGBUS`, `SIGHUP`, ...), whether they are set by `sigaction` or by `signal`, which records them too (every number from 1 to 128, with `SA_RESTART` and an empty mask replacing what `sigaction` stored; `SIGKILL` and `SIGSTOP` accept only the default handler in both calls, as in FreeBSD's `kern_sigaction`). `raise` and `kill` accept every number from 1 to 128 now: a blocked one stays pending on the set of `sigtimedwait`, an unblocked one whose handler is registered runs it on the calling thread, and an unblocked one that has no host equivalent and no handler still fails with `EINVAL`, where FreeBSD runs its default action. `raise` of an `SA_SIGINFO` handler still throws instead of terminating - [kill, killpg](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) follow FreeBSD's `kern_kill` and `killpg1` for the only guest process: `EINVAL` for signals outside 0 to 128 and for negative `killpg` process groups, signal 0 only checks that the target exists, and its own pid, 0 and minus its own pid (the guest is taken to lead its own process group) signal it through `raise`, so they share `raise`'s limits above. Any other pid, and -1, which skips the caller, report `ESRCH`, so `EPERM` is never reported - [sceKernelFcntl](../../../core/libs/prx/libkernel/File/src/Open.cpp) (libkernel) runs the POSIX `fcntl` below and returns its guest errno as `SCE_KERNEL_ERROR_*`, the form the other `sceKernel` file exports use. The return values and error codes are unverified on a console - [fcntl](../../../core/libs/prx/libkernel/File/src/DescriptorControl.cpp) (libkernel) follows FreeBSD 11.4's [`kern_fcntl`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/kern_descrip.c#L479-L799) and [`kern_dup`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/kern_descrip.c#L812) on file, directory, pipe and character device descriptors; a socket keeps the libSceNet path (`F_GETFL`, `F_SETFL` with `O_NONBLOCK`, `EINVAL` for every other command, `F_GETFD` and `F_SETFD` included). The close-on-exec flag and the status flags live in a guest table, the status flags shared by `dup`, `dup2` and `F_DUPFD` as in an open file description; a descriptor the table has not seen, such as 0 to 2, starts from the host's flags, since the console's are not known. `F_GETFL` takes the access mode from the host (the granted access of the handle on Windows), so pipe ends report `O_RDONLY` and `O_WRONLY` where FreeBSD's bidirectional pipes report `O_RDWR`. `F_DUPFD` and `F_DUPFD_CLOEXEC` return the lowest free host descriptor at or above the start, so the bound is the host table (`EMFILE` on Windows, `EINVAL` past `RLIMIT_NOFILE` on Linux), not the console's unknown `RLIMIT_NOFILE` and `kern.maxfilesperproc`. `O_NONBLOCK` switches a host pipe (`PIPE_NOWAIT` on Windows, where an empty read and a full write then report `EAGAIN`) and is only recorded on files, directories and the random device, as their reads never block; on a Windows console or other character device it throws. Changing `O_APPEND` on a Windows file, changing `O_FSYNC` on a file, `O_ASYNC` on a pipe or character device (`SIGIO` is not delivered), `F_GETOWN`, `F_SETOWN` and `F_SETLK_REMOTE` throw; `O_DIRECT`, `F_RDAHEAD` and `F_READAHEAD` are recorded as the hints they are. Record locks (`F_GETLK`, `F_SETLK`, `F_SETLKW` and their forms without `l_sysid`) use the host's POSIX locks on Linux, where `F_GETLK` with `F_UNLCK` throws. On Windows they follow [`kern_fcntl`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/kern_descrip.c#L607-L752) and [`lf_advlockasync`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/kern_lockf.c#L433-L487) for the one guest process, which owns every lock: `EOVERFLOW` for an `SEEK_CUR` start past `OFF_MAX`, then `EBADF` for `F_RDLCK` without read access or `F_WRLCK` without write access and `EINVAL` for another type, then `EINVAL` and `EOVERFLOW` for the range; as [`lf_blocks`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/kern_lockf.c#L866) never lets locks of one owner conflict, whichever descriptor or thread asks, `F_SETLK` and `F_SETLKW` then succeed without waiting and `F_GETLK` reports `F_UNLCK`, so no lock list is kept and merging, splitting and the release on close have nothing to change. Nothing is held on the host, since Windows byte-range locks are mandatory and would fail the guest's own reads and writes through other descriptors, so other host processes neither see these locks nor are seen; a lock from the end of a directory throws. The return values and error codes are unverified on a console From a2b849e10c3bcca45d4d7c7deb833678c36024ab Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:04:51 -0700 Subject: [PATCH 0007/1059] fix(libSceNet): report a stream whose peer closed as readable on Windows When the peer of a stream socket closes, Winsock's WSAPoll reports POLLHUP without POLLRDNORM, whether or not the socket's own sending side is shut down. sceNetEpollWait passed that on, so a socket whose peer had closed came back as SCE_NET_EPOLLHUP alone and never as readable: a loop that waits for SCE_NET_EPOLLIN to read the end of the stream never got it, while every wait returned at once. FreeBSD's sopoll_generic reports a socket that can receive no more as readable, and adds POLLHUP only once it can send no more either. Linux does the same, so the Linux path already behaved this way. On Windows, a stream socket's POLLHUP without POLLERR now reports POLLRDNORM when reading was asked for, and keeps POLLHUP only after sceNetShutdown has shut the socket's own sending side, which libSceNet now records. --- core/libs/prx/libSceNet/Export.cpp | 27 +++++++++++++++++++----- core/libs/tests/GuestSceNet.cpp | 34 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/core/libs/prx/libSceNet/Export.cpp b/core/libs/prx/libSceNet/Export.cpp index 7c34334844..a62c63a28f 100644 --- a/core/libs/prx/libSceNet/Export.cpp +++ b/core/libs/prx/libSceNet/Export.cpp @@ -241,6 +241,7 @@ struct Sock { bool bound = false; bool listening = false; bool aborted = false; + bool sending_shut = false; int rcv_timeout_us = 0; int snd_timeout_us = 0; }; @@ -445,6 +446,14 @@ int timeout_option(int optname) { return optname; } +#ifdef _WIN32 +short closed_stream_events(const Sock& socket, short native, short requested) { + if (socket.type != NET_SOCK_STREAM || (native & (POLLHUP | POLLERR)) != POLLHUP) return native; + native = static_cast(native | (requested & POLLRDNORM)); + return socket.sending_shut ? native : static_cast(native & ~POLLHUP); +} +#endif + std::int64_t message_length(const NetMsghdr* message) { if (!message) return fail(NET_EFAULT); if (message->iov_length < 0 || message->iov_length > NET_UIO_MAXIOV) return fail(NET_EMSGSIZE); @@ -939,7 +948,13 @@ int APS5_VABI sceNetShutdown(int s, int how) { if (it == g_socks.end()) return fail(NET_EBADF); socket = it->second; } - return ::shutdown(socket.native->value, how) == 0 ? 0 : fail(native_error()); + if (::shutdown(socket.native->value, how) != 0) return fail(native_error()); + if (how != 0) { + std::lock_guard lk(g_mutex); + const auto it = g_socks.find(s); + if (it != g_socks.end()) it->second.sending_shut = true; + } + return 0; } int APS5_VABI sceNetSetsockopt(int s, int level, int optname, const void* optval, uint32_t optlen) { @@ -1129,7 +1144,7 @@ int APS5_VABI sceNetEpollWait(int eid, NetEpollEvent* events, int maxevents, int : std::chrono::steady_clock::time_point::max(); for (;;) { std::vector registered_events; - std::vector> native_sockets; + std::vector sockets; { std::lock_guard lk(g_mutex); if (g_epolls.count(eid) == 0) return fail(NET_EBADF); @@ -1140,7 +1155,7 @@ int APS5_VABI sceNetEpollWait(int eid, NetEpollEvent* events, int maxevents, int const auto socket = g_socks.find(entry.first); if (socket == g_socks.end()) continue; registered_events.push_back({entry.second.events, 0, static_cast(entry.first), entry.second.data}); - native_sockets.push_back(socket->second.native); + sockets.push_back(socket->second); } } } @@ -1158,14 +1173,14 @@ int APS5_VABI sceNetEpollWait(int eid, NetEpollEvent* events, int maxevents, int #ifdef _WIN32 std::vector descriptors(registered_events.size()); for (std::size_t index = 0; index < descriptors.size(); ++index) { - descriptors[index].fd = native_sockets[index]->value; + descriptors[index].fd = sockets[index].native->value; if (registered_events[index].events & 1) descriptors[index].events |= POLLRDNORM; if (registered_events[index].events & 2) descriptors[index].events |= POLLWRNORM; } #else std::vector descriptors(registered_events.size()); for (std::size_t index = 0; index < descriptors.size(); ++index) { - descriptors[index].fd = native_sockets[index]->value; + descriptors[index].fd = sockets[index].native->value; if (registered_events[index].events & 1) descriptors[index].events |= POLLIN; if (registered_events[index].events & 2) descriptors[index].events |= POLLOUT; } @@ -1180,6 +1195,8 @@ int APS5_VABI sceNetEpollWait(int eid, NetEpollEvent* events, int maxevents, int } #ifdef _WIN32 const int ready_count = WSAPoll(descriptors.data(), static_cast(descriptors.size()), wait_ms); + for (std::size_t index = 0; index < descriptors.size(); ++index) + descriptors[index].revents = closed_stream_events(sockets[index], descriptors[index].revents, descriptors[index].events); #else const int ready_count = ::poll(descriptors.data(), descriptors.size(), wait_ms); #endif diff --git a/core/libs/tests/GuestSceNet.cpp b/core/libs/tests/GuestSceNet.cpp index e88dc22a86..8164147098 100644 --- a/core/libs/tests/GuestSceNet.cpp +++ b/core/libs/tests/GuestSceNet.cpp @@ -447,6 +447,39 @@ static void CheckEpollIdent() { Require(sceNetSocketClose(sender) == 0 && sceNetSocketClose(receiver) == 0); } +static void CheckEpollClosedStream() { + std::array address{16, 2, 0, 0, 127, 0, 0, 1}; + const int listener = sceNetSocket("closed-stream-listener", 2, 1, 6); + Require(listener >= 0); + Require(sceNetBind_nid_postfix(listener, address.data(), address.size()) == 0 && sceNetListen(listener, 1) == 0); + std::uint32_t size = address.size(); + Require(sceNetGetsockname(listener, address.data(), &size) == 0); + const int client = sceNetSocket("closed-stream-client", 2, 1, 6); + Require(client >= 0 && sceNetConnect(client, address.data(), address.size()) == 0); + const int accepted = sceNetAccept(listener, nullptr, nullptr); + Require(accepted >= 0); + const int epoll = sceNetEpollCreate("epoll-closed-stream", 0); + Require(epoll >= 0); + NetEpollEvent registration{}; + registration.events = 1; + registration.ident = static_cast(accepted); + Require(sceNetEpollControl(epoll, 1, accepted, ®istration) == 0); + Require(sceNetSocketClose(client) == 0); + NetEpollEvent ready{}; + Require(sceNetEpollWait(epoll, &ready, 1, 1000000) == 1 && ready.events == 1); + char byte = 0; + Require(sceNetRecv(accepted, &byte, 1, 0) == 0); + registration.events = 3; + Require(sceNetEpollControl(epoll, 2, accepted, ®istration) == 0); + Require(sceNetEpollWait(epoll, &ready, 1, 0) == 1 && ready.events == 3); + Require(sceNetShutdown(accepted, 1) == 0); + registration.events = 1; + Require(sceNetEpollControl(epoll, 2, accepted, ®istration) == 0); + Require(sceNetEpollWait(epoll, &ready, 1, 0) == 1 && ready.events == (1 | 16)); + Require(sceNetEpollDestroy(epoll) == 0); + Require(sceNetSocketClose(accepted) == 0 && sceNetSocketClose(listener) == 0); +} + static bool ipv6Unavailable = false; static bool HostIpv6LoopbackUnavailable() { @@ -654,6 +687,7 @@ int main() { CheckAddressText(28, "::1"); CheckAddressText(28, "1234:5678:9abc:def0:1234:5678:9abc:def0"); CheckEpollIdent(); + CheckEpollClosedStream(); CheckLinkLocalAllNodesIpv6(); CheckUnboundSocketName(); CheckUnspecifiedIpv6(); From 52d6cb1d2340b110b613ed85c0565898661e7841 Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:04:45 -0700 Subject: [PATCH 0008/1059] fix(libkernel): resolve original .prx names to relinked .guest.prx in lstat #4688 made stat, sceKernelStat, read-only open and sceKernelCheckReachability find X.guest.prx when a title asks for X, the name the relinker replaced. lstat still answered ENOENT for the same path, so a title that stats a plugin with lstat saw it missing while stat and dlopen found it. lstat now resolves the name the same way; an existing X, including a dangling symlink, is still reported as is. --- core/libs/prx/libkernel/File/src/Stdio.cpp | 1 + core/libs/tests/GuestFilesystem.cpp | 2 ++ 2 files changed, 3 insertions(+) diff --git a/core/libs/prx/libkernel/File/src/Stdio.cpp b/core/libs/prx/libkernel/File/src/Stdio.cpp index d9ceb8756b..28d8addf6f 100644 --- a/core/libs/prx/libkernel/File/src/Stdio.cpp +++ b/core/libs/prx/libkernel/File/src/Stdio.cpp @@ -787,6 +787,7 @@ int APS5_VABI lstat_nid_postfix(const char* path, FileStat* sb) { if (const int error = PathError(path)) return PosixFailure(error); std::filesystem::path native; if (const int error = ResolvePathError_nid_no_patch(path, native)) return PosixFailure(error); + native = File::PathOrRelinkedModule(native); if (!File::FillLinkStat(native, sb)) return PosixResult(SceErrorFromErrno(errno)); return 0; } diff --git a/core/libs/tests/GuestFilesystem.cpp b/core/libs/tests/GuestFilesystem.cpp index 987e09890d..26880c11c2 100644 --- a/core/libs/tests/GuestFilesystem.cpp +++ b/core/libs/tests/GuestFilesystem.cpp @@ -183,11 +183,13 @@ static void TestRelinkedModuleNames() { const auto relinked = original + ".guest.prx"; FileStat status{}; Require(sceKernelStat(original.c_str(), &status) == static_cast(0x80020002u)); + Require(lstat_nid_postfix(original.c_str(), &status) == -1 && *__error_nid_postfix() == 2); Require(sceKernelCheckReachability(original.c_str()) == static_cast(0x80020002u)); Require(sceKernelOpen(original.c_str(), 0, 0) == static_cast(0x80020002u)); { std::ofstream file(relinked); file << "relinked"; } Require(sceKernelStat(original.c_str(), &status) == 0 && status.st_size == 8); Require(stat_nid_postfix(original.c_str(), &status) == 0 && status.st_size == 8); + Require(lstat_nid_postfix(original.c_str(), &status) == 0 && status.st_size == 8); Require(sceKernelCheckReachability(original.c_str()) == 0); const int opened = sceKernelOpen(original.c_str(), 0, 0); Require(opened >= 0 && sceKernelClose(opened) == 0); From 4d35b48dafc7432c3136c53d02aacefddde646ea Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:08:21 -0700 Subject: [PATCH 0009/1059] fix(libkernel): stop Winsock failing an unconnected datagram receive after an unreachable port Winsock reports an ICMP port-unreachable reply as WSAECONNRESET on the next receive of any datagram socket, so on Windows a guest UDP socket that sent to a port nobody listens on failed its next recvfrom with ECONNRESET. FreeBSD's udp_common_ctlinput passes the reply on only to a socket connected to the sender; an unconnected one keeps receiving. socket now turns SIO_UDP_CONNRESET off on datagram sockets on Windows, as #5295 does for sceNetSocket. --- core/libs/prx/libkernel/Socket/src/Udp.cpp | 8 ++++++ core/libs/tests/GuestUdp.cpp | 29 ++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/core/libs/prx/libkernel/Socket/src/Udp.cpp b/core/libs/prx/libkernel/Socket/src/Udp.cpp index 7a0304ce10..f648525a9e 100644 --- a/core/libs/prx/libkernel/Socket/src/Udp.cpp +++ b/core/libs/prx/libkernel/Socket/src/Udp.cpp @@ -2,6 +2,7 @@ #define WIN32_LEAN_AND_MEAN #include #include +#include #include #include #else @@ -770,6 +771,13 @@ int APS5_VABI socket_nid_postfix(int family, int type, int protocol) { const int ipv6Only = 1; if (family == 28 && ::setsockopt(native, IPPROTO_IPV6, IPV6_V6ONLY, reinterpret_cast(&ipv6Only), sizeof(ipv6Only))) return Fail(NativeError()); +#ifdef _WIN32 + BOOL reportUnreachable = FALSE; + DWORD returned = 0; + if (nativeType == SOCK_DGRAM && WSAIoctl(native, SIO_UDP_CONNRESET, &reportUnreachable, sizeof(reportUnreachable), nullptr, 0, + &returned, nullptr, nullptr) != 0) + return Fail(NativeError()); +#endif try { auto socket = std::make_shared(native, family, plainType); guard.value = Invalid; diff --git a/core/libs/tests/GuestUdp.cpp b/core/libs/tests/GuestUdp.cpp index c1b4db8b80..6a51c10908 100644 --- a/core/libs/tests/GuestUdp.cpp +++ b/core/libs/tests/GuestUdp.cpp @@ -47,6 +47,34 @@ std::int64_t APS5_VABI sceKernelWritev(int, const Iovec*, int); } static void Require(bool value) { if (!value) std::abort(); } +static void CheckUnreachablePortIgnored() { + const int closing = socket_nid_postfix(2, 2, 0); + Require(closing >= 0); + std::array unreachable{16, 2, 0, 0, 127, 0, 0, 1}; + Require(bind_nid_postfix(closing, unreachable.data(), unreachable.size()) == 0); + std::uint32_t size = unreachable.size(); + Require(getsockname_nid_postfix(closing, unreachable.data(), &size) == 0 && size == unreachable.size()); + const int receiver = socket_nid_postfix(2, 2 | 0x20000000, 0); + const int sender = socket_nid_postfix(2, 2, 0); + Require(receiver >= 0 && sender >= 0); + std::array address{16, 2, 0, 0, 127, 0, 0, 1}; + Require(bind_nid_postfix(receiver, address.data(), address.size()) == 0); + size = address.size(); + Require(getsockname_nid_postfix(receiver, address.data(), &size) == 0); + Require(close_nid_postfix(closing) == 0); + Require(sendto_nid_postfix(receiver, "x", 1, 0, unreachable.data(), unreachable.size()) == 1); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + char received[4]{}; + for (int attempt = 0; attempt < 3; ++attempt) { + Require(recvfrom_nid_postfix(receiver, received, sizeof(received), 0, nullptr, nullptr) == -1 && *__error_nid_postfix() == 35); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + Require(sendto_nid_postfix(sender, "ok", 2, 0, address.data(), address.size()) == 2); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + Require(recvfrom_nid_postfix(receiver, received, sizeof(received), 0, nullptr, nullptr) == 2 && received[0] == 'o'); + Require(close_nid_postfix(sender) == 0 && close_nid_postfix(receiver) == 0); +} + static void CheckAddressLengths(int family) { const std::uint32_t addressSize = family == 2 ? 16 : 28; const int receiver = socket_nid_postfix(family, 2, 0); @@ -276,4 +304,5 @@ int main() { Require(closeOnExec >= 0 && fcntl_nid_postfix(closeOnExec, 3) == 2); Require(close_nid_postfix(closeOnExec) == 0); Require(socket_nid_postfix(2, 0x20000000, 0) == -1 && *__error_nid_postfix() == 43); + CheckUnreachablePortIgnored(); } From f4cfb75479eb3f9f97161e1fb0d2e37ef76f0642 Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:08:26 -0700 Subject: [PATCH 0010/1059] docs(libkernel): record that datagram sockets ignore ICMP port-unreachable on Windows With SIO_UDP_CONNRESET off, a datagram socket connected to a port nobody listens on no longer fails its next send or receive at all on Windows, where FreeBSD fails it with ECONNREFUSED. --- docs/dev/debt/libkernel.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/dev/debt/libkernel.md b/docs/dev/debt/libkernel.md index 637ab5eb36..f89845a54b 100644 --- a/docs/dev/debt/libkernel.md +++ b/docs/dev/debt/libkernel.md @@ -138,6 +138,7 @@ - [hw.model](../../../core/libs/prx/libkernel/System/src/SystemConfiguration.cpp) (libkernel) reports the host CPU's name, built as FreeBSD 11 builds `hw.model`: the CPUID brand string (leaves 0x80000002 to 0x80000004) without leading spaces ([identcpu.c](https://github.com/freebsd/freebsd-src/blob/release/11.0.0/sys/x86/x86/identcpu.c#L683-L691)). Trailing padding stays, as it does there, so an AMD host's name ends in spaces. The name a PS5 reports is unknown. A write returns -1 with `EPERM`. A host without an x86 CPUID brand string, such as an Apple Silicon Mac, throws. PPSA99203 only prints it (`[console] hw.model: %s`) - [getrlimit](../../../core/libs/prx/libkernel/System/src/Process.cpp) (libkernel) - `RLIMIT_CPU`, `RLIMIT_FSIZE`, `RLIMIT_CORE`, `RLIMIT_SBSIZE`, `RLIMIT_VMEM`/`AS`, `RLIMIT_NPTS`, `RLIMIT_SWAP`, `RLIMIT_KQUEUES` and `RLIMIT_UMTXP` report infinity as in FreeBSD's process-0 defaults, and `RLIMIT_DATA` mirrors the host limit. `RLIMIT_STACK`, `RLIMIT_RSS`, `RLIMIT_MEMLOCK`, `RLIMIT_NPROC` and `RLIMIT_NOFILE` throw because no console values are established. - [setrlimit](../../../core/libs/prx/libkernel/System/src/Process.cpp) (libkernel) validates the resource and input in FreeBSD order, maps negative limits to infinity, and lowers a soft limit above the hard limit. A request succeeds only when its normalized values match `getrlimit`; changing limits throws because guest limit state is not stored. +- [socket](../../../core/libs/prx/libkernel/Socket/src/Udp.cpp) (libkernel) turns off Winsock's report of an ICMP port-unreachable reply on datagram sockets (`SIO_UDP_CONNRESET`). Winsock fails the next receive on any datagram socket with `WSAECONNRESET`, where FreeBSD's [`udp_common_ctlinput`](https://github.com/freebsd/freebsd-src/blob/d106002967eff712894ab8b5337bbbff106ce5e0/sys/netinet/udp_usrreq.c#L774-L798) reports the reply only to a socket connected to the sender. So on Windows a datagram socket connected to a port nobody listens on does not fail its next send or receive with `ECONNREFUSED` either - [execvp](../../../core/libs/prx/libkernel/System/src/Process.cpp) (libkernel) throws: replacing the process image is not supported. PPSA85300 imports it and fails to load without it; its `fork` and `vfork` fail with `ENOSYS` ([libc_missing.c](https://github.com/ZiZc3/VLC/blob/f3253020a64368153233ede29183014c0b6c8979/ps5/compat/libc_missing.c#L290-L292)), so the child process that would call it is never created. - [socketpair](../../../core/libs/prx/libkernel/Socket/src/Udp.cpp) (libkernel) - the pair is the host's `AF_UNIX` pair on Linux and, on Windows, a loopback TCP connection for `SOCK_STREAM` or two connected UDP sockets for `SOCK_DGRAM`; `SOCK_SEQPACKET` throws on Windows. A datagram sent to a full receiver gets `ENOBUFS` on the console, waits or gets `EAGAIN` on Linux and is dropped on Windows, and on Windows a datagram `recv` after the peer closed reports `ECONNRESET` instead of the console's `ENOTCONN`, and a stream `send` after the peer closed reports `ECONNABORTED` instead of the console's `EPIPE`. `read` and `write` on a pair end go to the host like on every guest socket. `getpeername` on a pair end succeeds after the peer closed, where the console returns `ENOTCONN`, and `connect`, `bind` and `sendto` with an address on a pair end report an address-family error instead of the console's `EISCONN`. On Windows `poll` on a stream end reports `POLLHUP` without `POLLIN` after the peer's `shutdown(SHUT_WR)` or `close`, where the console reports `POLLIN`, with `POLLHUP` only after `close` - [UDP recv/recvfrom](../../../core/libs/prx/libkernel/Socket/src/Udp.cpp) and [sceNetRecv/sceNetRecvfrom](../../../core/libs/prx/libSceNet/Export.cpp) translate Windows WSAEMSGSIZE into the copied byte count for datagram sockets, including MSG_PEEK, and retain the sender address. This follows FreeBSD 11.4 recv(2) and Winsock's documented truncated-datagram behavior; console behavior and existing title impact are unverified. From ef4590484296d7b76fa827ad4a4b24561a946b7a Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:26:47 -0700 Subject: [PATCH 0011/1059] fix(libkernel): unblock only the signals a handler added when it returns The blocked mask is still process-wide, so restoring the whole mask saved before the handler undid any change another thread made while it ran. --- .../libs/prx/libkernel/System/src/Signals.cpp | 4 ++-- core/libs/tests/GuestSignalHandlerMask.cpp | 22 +++++++++++++++++++ docs/dev/debt/libkernel.md | 2 +- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/core/libs/prx/libkernel/System/src/Signals.cpp b/core/libs/prx/libkernel/System/src/Signals.cpp index bcfa89969e..b20877c19e 100644 --- a/core/libs/prx/libkernel/System/src/Signals.cpp +++ b/core/libs/prx/libkernel/System/src/Signals.cpp @@ -153,9 +153,9 @@ void RunHandler(int guest, GuestHandler handler) { std::uint32_t mask = handlerMasks[guest].load(); if ((flags & SaNodefer) == 0 && guest <= 32) mask |= SignalBit(guest); if ((flags & SaResethand) != 0) ResetToDefault(guest); - const std::uint32_t saved = blockedMask.fetch_or(mask); + const std::uint32_t added = mask & ~blockedMask.fetch_or(mask); handler(guest); - blockedMask.store(saved); + blockedMask.fetch_and(~added); } void Dispatch(int native) { int guest = 0; diff --git a/core/libs/tests/GuestSignalHandlerMask.cpp b/core/libs/tests/GuestSignalHandlerMask.cpp index 5ef9ecfd2a..332bbbbb21 100644 --- a/core/libs/tests/GuestSignalHandlerMask.cpp +++ b/core/libs/tests/GuestSignalHandlerMask.cpp @@ -1,6 +1,7 @@ #include "prx/libc/include/general/VabiMacros.hpp" #include #include +#include extern "C" { int APS5_VABI sigaction_nid_postfix(int, const void*, void*); @@ -25,6 +26,7 @@ static constexpr int SaRestart = 0x2; static constexpr int SaResethand = 0x4; static constexpr int SaNodefer = 0x10; static constexpr int SigBlock = 1; +static constexpr int SigUnblock = 2; static std::uint32_t Bit(int signal) { return 1u << (signal - 1); } @@ -52,6 +54,14 @@ static void APS5_VABI Count(int) { ++calls; } +static void APS5_VABI BlockFromOtherThread(int) { + ++calls; + std::thread([] { + const GuestSignalSet set{{Bit(SigTerm), 0, 0, 0}}; + Require(sigprocmask_nid_postfix(SigBlock, &set, nullptr) == 0); + }).join(); +} + static void Install(int signal, void (APS5_VABI* handler)(int), int flags, std::uint32_t mask) { GuestSigaction action{reinterpret_cast(handler), flags, {{mask, 0, 0, 0}}}; Require(sigaction_nid_postfix(signal, &action, nullptr) == 0); @@ -89,10 +99,22 @@ static void CheckResethand() { Require((current.flags & SaResethand) != 0); } +static void CheckOtherThreadKept() { + calls = 0; + Install(SigUsr1, BlockFromOtherThread, SaRestart, 0); + Require(raise_nid_postfix(SigUsr1) == 0); + Require(calls == 1); + Require(CurrentMask() == Bit(SigTerm)); + const GuestSignalSet set{{Bit(SigTerm), 0, 0, 0}}; + Require(sigprocmask_nid_postfix(SigUnblock, &set, nullptr) == 0); + Require(CurrentMask() == 0); +} + int main() { CheckDeferred(SigUsr2); CheckDeferred(SigTerm); CheckNodefer(); CheckResethand(); + CheckOtherThreadKept(); return 0; } diff --git a/docs/dev/debt/libkernel.md b/docs/dev/debt/libkernel.md index 44c450c7b3..576d1c5a67 100644 --- a/docs/dev/debt/libkernel.md +++ b/docs/dev/debt/libkernel.md @@ -153,7 +153,7 @@ - [sigtimedwait](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) waits on a process-wide pending set instead of throwing: a signal raised while blocked stays pending and is taken by `sigwait`, `sigwaitinfo` or `sigtimedwait`, delivered to the handler when `_sigprocmask` unblocks it, or delivered by `pause`. The set covers all 128 signals and the lowest number that is pending and in the set is selected (FreeBSD specifies the lowest number only among real-time signals). Selection, `EAGAIN` on timeout, `EINVAL` for a `tv_nsec` outside 0 to 1000000000 while nothing is pending and `NULL` timeout blocking indefinitely follow FreeBSD's [sigtimedwait(2)](https://man.freebsd.org/cgi/man.cgi?query=sigtimedwait). `sigwait` returns 0 with the number in its output, as POSIX specifies, and `sigwaitinfo` and `sigtimedwait` return the number, as FreeBSD does. The guest `siginfo_t` follows FreeBSD 11's `sys/sys/siginfo.h`: 80 bytes, `si_signo` at 0, `si_code` at 8, `si_pid` at 12, `si_uid` at 16, `si_addr` at 24, `si_value` at 32 and `si_band` at 40; only `si_signo`, `si_code` (`SI_USER`) and `si_pid` (the guest pid) are written, `si_uid` stays 0 because no uid is modelled, and the console's layout is not verified. PPSA85300 imports `sigtimedwait` and fails to load without it; its only call site is VLC's `vlc_writev` ([filesystem.c](https://github.com/videolan/vlc/blob/6de05adcbaf2e8b85fe86aad4169393098628119/src/posix/filesystem.c#L243-L278)), which polls with a zero timeout to drain a `SIGPIPE` only after `writev` fails with `EPIPE`. The waits are not cancellation points and an unblocked caught signal does not interrupt them with `EINTR`, where FreeBSD's `sigwaitinfo` and `sigtimedwait` may return it. `SIGPIPE` has no host equivalent and is never raised by a host write here (see `read, write and vector I/O`), so VLC's drain finds nothing - [pause, sigwait, sigwaitinfo](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) take the pending set of `sigtimedwait` instead of throwing. `pause` waits for a pending signal that is not blocked and whose disposition can be delivered, runs its handler on the calling thread and returns -1 with `EINTR`; an ignored signal is discarded and the wait continues, and it never returns for a signal whose handler cannot run. A signal whose registered handler has `SA_SIGINFO` still throws, and one whose disposition is the default action and that has no host equivalent (every signal except 2, 4, 6, 8, 11 and 15) is neither delivered nor discarded: it stays pending until a wait takes it, where FreeBSD runs the default action, which terminates the process for most of them. PPSA68091, PPSA99169, PPSA99420 and PPSA97358 import them and fail to load without them. PPSA68091 calls `pause` at one site (0x8F7266), in a loop that waits while a global holds another thread's `errno` address. PPSA99169 only stores the address of `pause` in a table of symbol addresses (0x30DB1). PPSA99420 calls `pause` at 0x3BD80F6 and 0x3C9E220, `sigwait` at 0x3C9E4C7 and `sigwaitinfo` at 0x3C9E588. PPSA97358 calls `sigwait` from QEMU's `sigwait_compat` thread ([compatfd.c](https://github.com/ZiZc3/XPSemu/blob/8eec07b4e31f66dce8e0b05c1ca1281af303e933/util/compatfd.c#L28-L53)), which `qemu_signal_init` ([main-loop.c](https://github.com/ZiZc3/XPSemu/blob/8eec07b4e31f66dce8e0b05c1ca1281af303e933/util/main-loop.c#L110-L147)) starts for `SIGIO`, `SIGALRM` and `SIGBUS` when the main loop is created, so it reaches the wait at start-up, and from the vCPU thread of the dummy accelerator ([dummy-cpus.c](https://github.com/ZiZc3/XPSemu/blob/8eec07b4e31f66dce8e0b05c1ca1281af303e933/accel/dummy-cpus.c#L48-L56)). None of the four waits is a cancellation point - [getsid](../../../core/libs/prx/libkernel/System/src/Process.cpp) (libkernel) returns the calling process ID for pid 0 and the calling process, and `ESRCH` for other process IDs, assuming the single guest process is its own session leader. PPSA74126 imports it and fails to load without it -- [sigaction](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) records the handler, flags and mask of every signal from 1 to 128 and reports them back, following FreeBSD's `sigaction` (`EINVAL` for other signals, and for a handler on `SIGKILL` or `SIGSTOP`); `signal` records its handler with `SA_RESTART`, as FreeBSD's libc does. Only handlers without `SA_SIGINFO` on the signals `signal` already delivers (`SIGINT`, `SIGILL`, `SIGABRT`, `SIGFPE`, `SIGSEGV`, `SIGTERM`) run, the same way `signal` handlers do. While a handler runs, its own signal (unless `SA_NODEFER`) and the first 32 signals of its mask are added to the process-wide mask, which is restored when it returns, and `SA_RESETHAND` resets the disposition to the default before it runs, as FreeBSD's `postsig_done` does; a signal raised meanwhile stays pending and is delivered when the handler returns to `raise`, but one left pending by a handler that a host signal ran asynchronously waits for the next `raise` or `_sigprocmask`. `SA_ONSTACK` is not applied. `SA_SIGINFO` handlers are recorded but never called, since that needs FreeBSD's `siginfo_t` and `ucontext_t`, and neither are the handlers of other signals (`SIGBUS`, `SIGHUP`, ...), whether they are set by `sigaction` or by `signal`, which records them too (every number from 1 to 128, with `SA_RESTART` and an empty mask replacing what `sigaction` stored; `SIGKILL` and `SIGSTOP` accept only the default handler in both calls, as in FreeBSD's `kern_sigaction`). `raise` and `kill` accept every number from 1 to 128 now: a blocked one stays pending on the set of `sigtimedwait`, an unblocked one whose handler is registered runs it on the calling thread, and an unblocked one that has no host equivalent and no handler still fails with `EINVAL`, where FreeBSD runs its default action. `raise` of an `SA_SIGINFO` handler still throws instead of terminating +- [sigaction](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) records the handler, flags and mask of every signal from 1 to 128 and reports them back, following FreeBSD's `sigaction` (`EINVAL` for other signals, and for a handler on `SIGKILL` or `SIGSTOP`); `signal` records its handler with `SA_RESTART`, as FreeBSD's libc does. Only handlers without `SA_SIGINFO` on the signals `signal` already delivers (`SIGINT`, `SIGILL`, `SIGABRT`, `SIGFPE`, `SIGSEGV`, `SIGTERM`) run, the same way `signal` handlers do. While a handler runs, its own signal (unless `SA_NODEFER`) and the first 32 signals of its mask are added to the process-wide mask, and `SA_RESETHAND` resets the disposition to the default before it runs, as FreeBSD's `postsig_done` does. When the handler returns, only the signals it added are unblocked again, so a mask change another thread made meanwhile is kept, but so is one the handler made itself, which FreeBSD's `sigreturn` would undo; a signal raised meanwhile stays pending and is delivered when the handler returns to `raise`, but one left pending by a handler that a host signal ran asynchronously waits for the next `raise` or `_sigprocmask`. `SA_ONSTACK` is not applied. `SA_SIGINFO` handlers are recorded but never called, since that needs FreeBSD's `siginfo_t` and `ucontext_t`, and neither are the handlers of other signals (`SIGBUS`, `SIGHUP`, ...), whether they are set by `sigaction` or by `signal`, which records them too (every number from 1 to 128, with `SA_RESTART` and an empty mask replacing what `sigaction` stored; `SIGKILL` and `SIGSTOP` accept only the default handler in both calls, as in FreeBSD's `kern_sigaction`). `raise` and `kill` accept every number from 1 to 128 now: a blocked one stays pending on the set of `sigtimedwait`, an unblocked one whose handler is registered runs it on the calling thread, and an unblocked one that has no host equivalent and no handler still fails with `EINVAL`, where FreeBSD runs its default action. `raise` of an `SA_SIGINFO` handler still throws instead of terminating - [kill, killpg](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) follow FreeBSD's `kern_kill` and `killpg1` for the only guest process: `EINVAL` for signals outside 0 to 128 and for negative `killpg` process groups, signal 0 only checks that the target exists, and its own pid, 0 and minus its own pid (the guest is taken to lead its own process group) signal it through `raise`, so they share `raise`'s limits above. Any other pid, and -1, which skips the caller, report `ESRCH`, so `EPERM` is never reported - [sceKernelFcntl](../../../core/libs/prx/libkernel/File/src/Open.cpp) (libkernel) runs the POSIX `fcntl` below and returns its guest errno as `SCE_KERNEL_ERROR_*`, the form the other `sceKernel` file exports use. The return values and error codes are unverified on a console - [fcntl](../../../core/libs/prx/libkernel/File/src/DescriptorControl.cpp) (libkernel) follows FreeBSD 11.4's [`kern_fcntl`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/kern_descrip.c#L479-L799) and [`kern_dup`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/kern_descrip.c#L812) on file, directory, pipe and character device descriptors; a socket keeps the libSceNet path (`F_GETFL`, `F_SETFL` with `O_NONBLOCK`, `EINVAL` for every other command, `F_GETFD` and `F_SETFD` included). The close-on-exec flag and the status flags live in a guest table, the status flags shared by `dup`, `dup2` and `F_DUPFD` as in an open file description; a descriptor the table has not seen, such as 0 to 2, starts from the host's flags, since the console's are not known. `F_GETFL` takes the access mode from the host (the granted access of the handle on Windows), so pipe ends report `O_RDONLY` and `O_WRONLY` where FreeBSD's bidirectional pipes report `O_RDWR`. `F_DUPFD` and `F_DUPFD_CLOEXEC` return the lowest free host descriptor at or above the start, so the bound is the host table (`EMFILE` on Windows, `EINVAL` past `RLIMIT_NOFILE` on Linux), not the console's unknown `RLIMIT_NOFILE` and `kern.maxfilesperproc`. `O_NONBLOCK` switches a host pipe (`PIPE_NOWAIT` on Windows, where an empty read and a full write then report `EAGAIN`) and is only recorded on files, directories and the random device, as their reads never block; on a Windows console or other character device it throws. Changing `O_APPEND` on a Windows file, changing `O_FSYNC` on a file, `O_ASYNC` on a pipe or character device (`SIGIO` is not delivered), `F_GETOWN`, `F_SETOWN` and `F_SETLK_REMOTE` throw; `O_DIRECT`, `F_RDAHEAD` and `F_READAHEAD` are recorded as the hints they are. Record locks (`F_GETLK`, `F_SETLK`, `F_SETLKW` and their forms without `l_sysid`) use the host's POSIX locks on Linux, where `F_GETLK` with `F_UNLCK` throws. On Windows they follow [`kern_fcntl`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/kern_descrip.c#L607-L752) and [`lf_advlockasync`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/kern_lockf.c#L433-L487) for the one guest process, which owns every lock: `EOVERFLOW` for an `SEEK_CUR` start past `OFF_MAX`, then `EBADF` for `F_RDLCK` without read access or `F_WRLCK` without write access and `EINVAL` for another type, then `EINVAL` and `EOVERFLOW` for the range; as [`lf_blocks`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/kern_lockf.c#L866) never lets locks of one owner conflict, whichever descriptor or thread asks, `F_SETLK` and `F_SETLKW` then succeed without waiting and `F_GETLK` reports `F_UNLCK`, so no lock list is kept and merging, splitting and the release on close have nothing to change. Nothing is held on the host, since Windows byte-range locks are mandatory and would fail the guest's own reads and writes through other descriptors, so other host processes neither see these locks nor are seen; a lock from the end of a directory throws. The return values and error codes are unverified on a console From 624815b2004c8d86a20ccd06ad52acf1baab1184 Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:45:47 -0700 Subject: [PATCH 0012/1059] fix(libc): save and restore the signal mask in setjmp and longjmp FreeBSD's setjmp saves the signal mask at 72 in the jump buffer and its longjmp restores it, unlike _setjmp and _longjmp. Ours kept only the registers, so a longjmp back from code that changed the mask, such as a signal handler, left the mask as it was. libc can't call libkernel, so libkernel registers its sigprocmask with libc when it loads. --- core/libs/prx/libc/include/JumpBuffer.hpp | 18 ++++++ core/libs/prx/libc/src/JumpBuffer.cpp | 59 +++++++++++++++++-- .../libs/prx/libkernel/System/src/Signals.cpp | 10 ++++ core/libs/tests/GuestSignalSets.cpp | 27 +++++++++ docs/dev/debt/libc.md | 1 + 5 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 core/libs/prx/libc/include/JumpBuffer.hpp diff --git a/core/libs/prx/libc/include/JumpBuffer.hpp b/core/libs/prx/libc/include/JumpBuffer.hpp new file mode 100644 index 0000000000..07e272b61e --- /dev/null +++ b/core/libs/prx/libc/include/JumpBuffer.hpp @@ -0,0 +1,18 @@ +#ifndef CORE_LIBS_PRX_LIBC_INCLUDE_JUMPBUFFER_HPP +#define CORE_LIBS_PRX_LIBC_INCLUDE_JUMPBUFFER_HPP + +#include "prx/libc/include/general/VabiMacros.hpp" + +namespace JumpBuffer { + +using Sigprocmask = int (APS5_VABI*)(int how, const void* set, void* previousSet); + +extern "C" { + +void GuestSigprocmaskRegister_nid_no_patch(Sigprocmask sigprocmask); + +} + +} + +#endif diff --git a/core/libs/prx/libc/src/JumpBuffer.cpp b/core/libs/prx/libc/src/JumpBuffer.cpp index 77dca484e4..551a11c089 100644 --- a/core/libs/prx/libc/src/JumpBuffer.cpp +++ b/core/libs/prx/libc/src/JumpBuffer.cpp @@ -1,12 +1,62 @@ +#include "prx/libc/include/JumpBuffer.hpp" #include "prx/libc/include/General.hpp" #include "prx/libc/include/general/AsmMacros.hpp" +#include +#include +#include +#include + +namespace JumpBuffer { +namespace { + +constexpr std::size_t MaskOffset = 72; +constexpr std::size_t MaskSavedOffset = 88; +constexpr int SigBlock = 1; +constexpr int SigSetmask = 3; + +std::atomic guestSigprocmask{nullptr}; + +} + +extern "C" { + +void GuestSigprocmaskRegister_nid_no_patch(Sigprocmask sigprocmask) { + guestSigprocmask.store(sigprocmask); +} + +void APS5_VABI JumpBufferSaveMask_nid_no_patch(unsigned char* buffer) { + const auto sigprocmask = guestSigprocmask.load(); + const std::int32_t saved = sigprocmask != nullptr && sigprocmask(SigBlock, nullptr, buffer + MaskOffset) == 0; + std::memcpy(buffer + MaskSavedOffset, &saved, sizeof(saved)); +} + +void APS5_VABI JumpBufferRestoreMask_nid_no_patch(const unsigned char* buffer) { + std::int32_t saved = 0; + std::memcpy(&saved, buffer + MaskSavedOffset, sizeof(saved)); + const auto sigprocmask = guestSigprocmask.load(); + if (saved != 0 && sigprocmask != nullptr) sigprocmask(SigSetmask, buffer + MaskOffset, nullptr); +} + +} + +} // setjmp/longjmp must capture the guest's own frame, so they are written directly in assembly with // the guest (System V) calling convention. The saved state fits the guest's 96-byte jmp_buf: -// return address, rbx, rsp, rbp, r12-r15, MXCSR and the x87 control word. +// return address, rbx, rsp, rbp, r12-r15, MXCSR, the x87 control word and, as FreeBSD's do, the +// signal mask at 72 with the flag sigsetjmp keeps at 88, once libkernel has registered sigprocmask. + +#ifdef _WIN32 +#define APS5_JUMP_ASM_CALL(name) " call " name "\n" +#else +#define APS5_JUMP_ASM_CALL(name) " call " name "@PLT\n" +#endif asm(".text\n" APS5_ASM_FUNCTION("setjmp_nid_postfix") + " push %rdi\n" + APS5_JUMP_ASM_CALL("JumpBufferSaveMask_nid_no_patch") + " pop %rdi\n" " mov (%rsp), %rax\n" " mov %rax, 0(%rdi)\n" " mov %rbx, 8(%rdi)\n" @@ -22,15 +72,16 @@ asm(".text\n" " xor %eax, %eax\n" " ret\n" APS5_ASM_FUNCTION("longjmp_nid_postfix") -#ifdef _WIN32 " mov %rdi, %rbx\n" " mov %esi, %r12d\n" - " mov 16(%rdi), %rdi\n" " and $-16, %rsp\n" + APS5_JUMP_ASM_CALL("JumpBufferRestoreMask_nid_no_patch") +#ifdef _WIN32 + " mov 16(%rbx), %rdi\n" " call GuestStackSwitch_nid_no_patch\n" +#endif " mov %rbx, %rdi\n" " mov %r12d, %esi\n" -#endif " mov %esi, %eax\n" " test %eax, %eax\n" " jnz 1f\n" diff --git a/core/libs/prx/libkernel/System/src/Signals.cpp b/core/libs/prx/libkernel/System/src/Signals.cpp index b20877c19e..46d2427e43 100644 --- a/core/libs/prx/libkernel/System/src/Signals.cpp +++ b/core/libs/prx/libkernel/System/src/Signals.cpp @@ -1,5 +1,6 @@ #include "SceTypes.hpp" #include "prx/libc/include/General.hpp" +#include "prx/libc/include/JumpBuffer.hpp" #include "prx/libkernel/Pthread/Posix/Common.hpp" #include "prx/libkernel/Pthread/include/Pthread.hpp" #include "prx/libc/include/general/VabiMacros.hpp" @@ -630,3 +631,12 @@ int APS5_VABI _is_signal_return_nid_postfix(std::uint64_t programCounter) { } } + +namespace { + +[[maybe_unused]] const bool RegisteredForJumps = [] { + JumpBuffer::GuestSigprocmaskRegister_nid_no_patch(sigprocmask_nid_postfix); + return true; +}(); + +} diff --git a/core/libs/tests/GuestSignalSets.cpp b/core/libs/tests/GuestSignalSets.cpp index 798c736518..eee6e75af7 100644 --- a/core/libs/tests/GuestSignalSets.cpp +++ b/core/libs/tests/GuestSignalSets.cpp @@ -20,6 +20,8 @@ int* APS5_VABI __error_nid_postfix(); int APS5_VABI _sigprocmask_nid_postfix(int, const GuestSignalSet*, GuestSignalSet*); [[gnu::returns_twice]] int APS5_VABI sigsetjmp_nid_postfix(std::uint64_t* buffer, int saveMask); [[noreturn]] void APS5_VABI siglongjmp_nid_postfix(std::uint64_t* buffer, int value); +[[gnu::returns_twice]] int APS5_VABI setjmp_nid_postfix(std::uint64_t* buffer); +[[noreturn]] void APS5_VABI longjmp_nid_postfix(std::uint64_t* buffer, int value); int APS5_VABI JumpKeepingCalleeSaved(std::uint64_t* buffer); } @@ -164,9 +166,34 @@ static void CheckSigsetjmp() { SetBlockedMask(0); } +static int LongjumpBack(int value, std::uint32_t maskAtSet, std::uint32_t maskAtJump) { + std::uint64_t buffer[12]{}; + volatile int jumped = 0; + SetBlockedMask(maskAtSet); + const int result = setjmp_nid_postfix(buffer); + if (jumped == 0) { + Require(result == 0); + Require(BufferWord(buffer, 72) == maskAtSet); + Require(BufferWord(buffer, 88) == 1); + jumped = 1; + SetBlockedMask(maskAtJump); + longjmp_nid_postfix(buffer, value); + } + return result; +} + +static void CheckLongjmp() { + Require(LongjumpBack(0, 0x5u, 0x30u) == 1); + Require(BlockedMask() == 0x5u); + Require(LongjumpBack(7, 0x80000009u, 0u) == 7); + Require(BlockedMask() == 0x80000009u); + SetBlockedMask(0); +} + int main() { CheckSigsetjmp(); CheckSiglongjmp(); + CheckLongjmp(); static constexpr int Untouched = 5; GuestSignalSet set{{0x12345678u, 0x9abcdef0u, 0xffffffffu, 1u}}; *__error_nid_postfix() = Untouched; diff --git a/docs/dev/debt/libc.md b/docs/dev/debt/libc.md index 124436d2ad..050e2a66bb 100644 --- a/docs/dev/debt/libc.md +++ b/docs/dev/debt/libc.md @@ -5,6 +5,7 @@ - [_Mtx_trylock](../../../core/libs/prx/libc/src/Threads.cpp) (libc, NID `k6pGNMwJB08`) - returns busy for a non-recursive mutex already owned by the caller, assumed to match `_Mtx_lock`'s ownership handling; exact console behavior is unverified. - [nearbyintf](../../../core/libs/prx/libc/src/Math.cpp) (libc, NID `c+4r-T-tEIc`) uses the host rounding mode, not the console's; console rounding mode state remains unverified. - [_Thrd_sleep](../../../core/libs/prx/libc/src/Threads.cpp) (libc, NID `jfRI3snge3o`) follows the Dinkumware threads library shipped with [Visual C++ 2013](https://github.com/adityaramesh/concurrency_runtime/tree/master/VS2013_SP2/crt/src/thr) (`xtime.c`) and C11 Annex K, without console verification. It reads a 16-byte xtime (64-bit seconds and nanoseconds) on the guest `CLOCK_REALTIME`, normalizes the nanoseconds and sleeps through `nanosleep`, retrying on `EINTR`, so it is a cancellation point; a null xtime or one whose normalization overflows throws. The console's `_Thrd_sleep` (disassembled from a title's `libc.prx`) also takes an optional `xtime*` of remaining time and returns `int`; here the sleep is never cut short, so it returns 0 and leaves the remaining time untouched. +- [setjmp, longjmp](../../../core/libs/prx/libc/src/JumpBuffer.cpp) (libc) save the signal mask at 72 in the jump buffer and restore it, as FreeBSD 11.4's [`setjmp` and `longjmp`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/lib/libc/amd64/gen/setjmp.S#L51-L85) do, which is unverified on a console. They go through the `sigprocmask` libkernel registers when it loads and mark the buffer at 88 as `sigsetjmp` does, so a buffer saved before then is jumped to without changing the mask - [_Mtx_init_with_name](../../../core/libs/prx/libc/src/Threads.cpp) (libc, NID `tgioGpKtmbE`) forwards to `_Mtx_init` and ignores the mutex name parameter. - [_Cnd_timedwait](../../../core/libs/prx/libc/src/Threads.cpp) (libc, NID `McaImWKXong`) follows the console's `_Cnd_timedwait` (disassembled from a title's `libc.prx`), where xtime is an absolute deadline converted against the current time: it returns `_Thrd_timedout` (2) on expiration and `_Thrd_success` (0) on notification. The console returns 4 for other internal pthread errors while this implementation throws. - [Guest arena](../../../core/libs/prx/libc/src/GuestArena.cpp) (libc) - the application map area is assumed to end at 0xFC_0000_0000, as `SCE_KERNEL_APP_MAP_AREA_END_ADDR` does on the PS4. On Windows, host allocations made in the arena before libc loads stay, and a fixed guest mapping over one throws From 9f584de29b42f5a1ad557c157d5413461566f6a7 Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:56:46 -0700 Subject: [PATCH 0013/1059] test(libkernel): leave a signal handler with longjmp and raise it again A handler that leaves with longjmp never returns to unblock its signal, so longjmp has to bring back the mask setjmp saved before the signal. --- core/libs/tests/GuestSignalHandlerMask.cpp | 23 ++++++++++++++++++++++ docs/dev/debt/libkernel.md | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/core/libs/tests/GuestSignalHandlerMask.cpp b/core/libs/tests/GuestSignalHandlerMask.cpp index 332bbbbb21..d118ab5fea 100644 --- a/core/libs/tests/GuestSignalHandlerMask.cpp +++ b/core/libs/tests/GuestSignalHandlerMask.cpp @@ -7,6 +7,8 @@ extern "C" { int APS5_VABI sigaction_nid_postfix(int, const void*, void*); int APS5_VABI sigprocmask_nid_postfix(int, const void*, void*); int APS5_VABI raise_nid_postfix(int); +[[gnu::returns_twice]] int APS5_VABI setjmp_nid_postfix(std::uint64_t* buffer); +[[noreturn]] void APS5_VABI longjmp_nid_postfix(std::uint64_t* buffer, int value); } struct GuestSignalSet { @@ -54,6 +56,13 @@ static void APS5_VABI Count(int) { ++calls; } +static std::uint64_t handlerExit[12]; + +static void APS5_VABI LeaveWithLongjmp(int) { + ++calls; + longjmp_nid_postfix(handlerExit, 1); +} + static void APS5_VABI BlockFromOtherThread(int) { ++calls; std::thread([] { @@ -110,11 +119,25 @@ static void CheckOtherThreadKept() { Require(CurrentMask() == 0); } +static void CheckLeftWithLongjmp() { + calls = 0; + Install(SigUsr1, LeaveWithLongjmp, SaRestart, Bit(SigUsr2)); + for (int round = 1; round <= 2; ++round) { + if (setjmp_nid_postfix(handlerExit) == 0) { + raise_nid_postfix(SigUsr1); + std::abort(); + } + Require(calls == round); + Require(CurrentMask() == 0); + } +} + int main() { CheckDeferred(SigUsr2); CheckDeferred(SigTerm); CheckNodefer(); CheckResethand(); CheckOtherThreadKept(); + CheckLeftWithLongjmp(); return 0; } diff --git a/docs/dev/debt/libkernel.md b/docs/dev/debt/libkernel.md index 576d1c5a67..feec6807e1 100644 --- a/docs/dev/debt/libkernel.md +++ b/docs/dev/debt/libkernel.md @@ -153,7 +153,7 @@ - [sigtimedwait](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) waits on a process-wide pending set instead of throwing: a signal raised while blocked stays pending and is taken by `sigwait`, `sigwaitinfo` or `sigtimedwait`, delivered to the handler when `_sigprocmask` unblocks it, or delivered by `pause`. The set covers all 128 signals and the lowest number that is pending and in the set is selected (FreeBSD specifies the lowest number only among real-time signals). Selection, `EAGAIN` on timeout, `EINVAL` for a `tv_nsec` outside 0 to 1000000000 while nothing is pending and `NULL` timeout blocking indefinitely follow FreeBSD's [sigtimedwait(2)](https://man.freebsd.org/cgi/man.cgi?query=sigtimedwait). `sigwait` returns 0 with the number in its output, as POSIX specifies, and `sigwaitinfo` and `sigtimedwait` return the number, as FreeBSD does. The guest `siginfo_t` follows FreeBSD 11's `sys/sys/siginfo.h`: 80 bytes, `si_signo` at 0, `si_code` at 8, `si_pid` at 12, `si_uid` at 16, `si_addr` at 24, `si_value` at 32 and `si_band` at 40; only `si_signo`, `si_code` (`SI_USER`) and `si_pid` (the guest pid) are written, `si_uid` stays 0 because no uid is modelled, and the console's layout is not verified. PPSA85300 imports `sigtimedwait` and fails to load without it; its only call site is VLC's `vlc_writev` ([filesystem.c](https://github.com/videolan/vlc/blob/6de05adcbaf2e8b85fe86aad4169393098628119/src/posix/filesystem.c#L243-L278)), which polls with a zero timeout to drain a `SIGPIPE` only after `writev` fails with `EPIPE`. The waits are not cancellation points and an unblocked caught signal does not interrupt them with `EINTR`, where FreeBSD's `sigwaitinfo` and `sigtimedwait` may return it. `SIGPIPE` has no host equivalent and is never raised by a host write here (see `read, write and vector I/O`), so VLC's drain finds nothing - [pause, sigwait, sigwaitinfo](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) take the pending set of `sigtimedwait` instead of throwing. `pause` waits for a pending signal that is not blocked and whose disposition can be delivered, runs its handler on the calling thread and returns -1 with `EINTR`; an ignored signal is discarded and the wait continues, and it never returns for a signal whose handler cannot run. A signal whose registered handler has `SA_SIGINFO` still throws, and one whose disposition is the default action and that has no host equivalent (every signal except 2, 4, 6, 8, 11 and 15) is neither delivered nor discarded: it stays pending until a wait takes it, where FreeBSD runs the default action, which terminates the process for most of them. PPSA68091, PPSA99169, PPSA99420 and PPSA97358 import them and fail to load without them. PPSA68091 calls `pause` at one site (0x8F7266), in a loop that waits while a global holds another thread's `errno` address. PPSA99169 only stores the address of `pause` in a table of symbol addresses (0x30DB1). PPSA99420 calls `pause` at 0x3BD80F6 and 0x3C9E220, `sigwait` at 0x3C9E4C7 and `sigwaitinfo` at 0x3C9E588. PPSA97358 calls `sigwait` from QEMU's `sigwait_compat` thread ([compatfd.c](https://github.com/ZiZc3/XPSemu/blob/8eec07b4e31f66dce8e0b05c1ca1281af303e933/util/compatfd.c#L28-L53)), which `qemu_signal_init` ([main-loop.c](https://github.com/ZiZc3/XPSemu/blob/8eec07b4e31f66dce8e0b05c1ca1281af303e933/util/main-loop.c#L110-L147)) starts for `SIGIO`, `SIGALRM` and `SIGBUS` when the main loop is created, so it reaches the wait at start-up, and from the vCPU thread of the dummy accelerator ([dummy-cpus.c](https://github.com/ZiZc3/XPSemu/blob/8eec07b4e31f66dce8e0b05c1ca1281af303e933/accel/dummy-cpus.c#L48-L56)). None of the four waits is a cancellation point - [getsid](../../../core/libs/prx/libkernel/System/src/Process.cpp) (libkernel) returns the calling process ID for pid 0 and the calling process, and `ESRCH` for other process IDs, assuming the single guest process is its own session leader. PPSA74126 imports it and fails to load without it -- [sigaction](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) records the handler, flags and mask of every signal from 1 to 128 and reports them back, following FreeBSD's `sigaction` (`EINVAL` for other signals, and for a handler on `SIGKILL` or `SIGSTOP`); `signal` records its handler with `SA_RESTART`, as FreeBSD's libc does. Only handlers without `SA_SIGINFO` on the signals `signal` already delivers (`SIGINT`, `SIGILL`, `SIGABRT`, `SIGFPE`, `SIGSEGV`, `SIGTERM`) run, the same way `signal` handlers do. While a handler runs, its own signal (unless `SA_NODEFER`) and the first 32 signals of its mask are added to the process-wide mask, and `SA_RESETHAND` resets the disposition to the default before it runs, as FreeBSD's `postsig_done` does. When the handler returns, only the signals it added are unblocked again, so a mask change another thread made meanwhile is kept, but so is one the handler made itself, which FreeBSD's `sigreturn` would undo; a signal raised meanwhile stays pending and is delivered when the handler returns to `raise`, but one left pending by a handler that a host signal ran asynchronously waits for the next `raise` or `_sigprocmask`. `SA_ONSTACK` is not applied. `SA_SIGINFO` handlers are recorded but never called, since that needs FreeBSD's `siginfo_t` and `ucontext_t`, and neither are the handlers of other signals (`SIGBUS`, `SIGHUP`, ...), whether they are set by `sigaction` or by `signal`, which records them too (every number from 1 to 128, with `SA_RESTART` and an empty mask replacing what `sigaction` stored; `SIGKILL` and `SIGSTOP` accept only the default handler in both calls, as in FreeBSD's `kern_sigaction`). `raise` and `kill` accept every number from 1 to 128 now: a blocked one stays pending on the set of `sigtimedwait`, an unblocked one whose handler is registered runs it on the calling thread, and an unblocked one that has no host equivalent and no handler still fails with `EINVAL`, where FreeBSD runs its default action. `raise` of an `SA_SIGINFO` handler still throws instead of terminating +- [sigaction](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) records the handler, flags and mask of every signal from 1 to 128 and reports them back, following FreeBSD's `sigaction` (`EINVAL` for other signals, and for a handler on `SIGKILL` or `SIGSTOP`); `signal` records its handler with `SA_RESTART`, as FreeBSD's libc does. Only handlers without `SA_SIGINFO` on the signals `signal` already delivers (`SIGINT`, `SIGILL`, `SIGABRT`, `SIGFPE`, `SIGSEGV`, `SIGTERM`) run, the same way `signal` handlers do. While a handler runs, its own signal (unless `SA_NODEFER`) and the first 32 signals of its mask are added to the process-wide mask, and `SA_RESETHAND` resets the disposition to the default before it runs, as FreeBSD's `postsig_done` does. When the handler returns, only the signals it added are unblocked again, so a mask change another thread made meanwhile is kept, but so is one the handler made itself, which FreeBSD's `sigreturn` would undo. A handler left with `longjmp`, or with `siglongjmp` to a buffer that saved the mask, gets back the mask that buffer saved, as on FreeBSD; a signal raised meanwhile stays pending and is delivered when the handler returns to `raise`, but one left pending by a handler that a host signal ran asynchronously waits for the next `raise` or `_sigprocmask`. `SA_ONSTACK` is not applied. `SA_SIGINFO` handlers are recorded but never called, since that needs FreeBSD's `siginfo_t` and `ucontext_t`, and neither are the handlers of other signals (`SIGBUS`, `SIGHUP`, ...), whether they are set by `sigaction` or by `signal`, which records them too (every number from 1 to 128, with `SA_RESTART` and an empty mask replacing what `sigaction` stored; `SIGKILL` and `SIGSTOP` accept only the default handler in both calls, as in FreeBSD's `kern_sigaction`). `raise` and `kill` accept every number from 1 to 128 now: a blocked one stays pending on the set of `sigtimedwait`, an unblocked one whose handler is registered runs it on the calling thread, and an unblocked one that has no host equivalent and no handler still fails with `EINVAL`, where FreeBSD runs its default action. `raise` of an `SA_SIGINFO` handler still throws instead of terminating - [kill, killpg](../../../core/libs/prx/libkernel/System/src/Signals.cpp) (libkernel) follow FreeBSD's `kern_kill` and `killpg1` for the only guest process: `EINVAL` for signals outside 0 to 128 and for negative `killpg` process groups, signal 0 only checks that the target exists, and its own pid, 0 and minus its own pid (the guest is taken to lead its own process group) signal it through `raise`, so they share `raise`'s limits above. Any other pid, and -1, which skips the caller, report `ESRCH`, so `EPERM` is never reported - [sceKernelFcntl](../../../core/libs/prx/libkernel/File/src/Open.cpp) (libkernel) runs the POSIX `fcntl` below and returns its guest errno as `SCE_KERNEL_ERROR_*`, the form the other `sceKernel` file exports use. The return values and error codes are unverified on a console - [fcntl](../../../core/libs/prx/libkernel/File/src/DescriptorControl.cpp) (libkernel) follows FreeBSD 11.4's [`kern_fcntl`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/kern_descrip.c#L479-L799) and [`kern_dup`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/kern_descrip.c#L812) on file, directory, pipe and character device descriptors; a socket keeps the libSceNet path (`F_GETFL`, `F_SETFL` with `O_NONBLOCK`, `EINVAL` for every other command, `F_GETFD` and `F_SETFD` included). The close-on-exec flag and the status flags live in a guest table, the status flags shared by `dup`, `dup2` and `F_DUPFD` as in an open file description; a descriptor the table has not seen, such as 0 to 2, starts from the host's flags, since the console's are not known. `F_GETFL` takes the access mode from the host (the granted access of the handle on Windows), so pipe ends report `O_RDONLY` and `O_WRONLY` where FreeBSD's bidirectional pipes report `O_RDWR`. `F_DUPFD` and `F_DUPFD_CLOEXEC` return the lowest free host descriptor at or above the start, so the bound is the host table (`EMFILE` on Windows, `EINVAL` past `RLIMIT_NOFILE` on Linux), not the console's unknown `RLIMIT_NOFILE` and `kern.maxfilesperproc`. `O_NONBLOCK` switches a host pipe (`PIPE_NOWAIT` on Windows, where an empty read and a full write then report `EAGAIN`) and is only recorded on files, directories and the random device, as their reads never block; on a Windows console or other character device it throws. Changing `O_APPEND` on a Windows file, changing `O_FSYNC` on a file, `O_ASYNC` on a pipe or character device (`SIGIO` is not delivered), `F_GETOWN`, `F_SETOWN` and `F_SETLK_REMOTE` throw; `O_DIRECT`, `F_RDAHEAD` and `F_READAHEAD` are recorded as the hints they are. Record locks (`F_GETLK`, `F_SETLK`, `F_SETLKW` and their forms without `l_sysid`) use the host's POSIX locks on Linux, where `F_GETLK` with `F_UNLCK` throws. On Windows they follow [`kern_fcntl`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/kern_descrip.c#L607-L752) and [`lf_advlockasync`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/kern_lockf.c#L433-L487) for the one guest process, which owns every lock: `EOVERFLOW` for an `SEEK_CUR` start past `OFF_MAX`, then `EBADF` for `F_RDLCK` without read access or `F_WRLCK` without write access and `EINVAL` for another type, then `EINVAL` and `EOVERFLOW` for the range; as [`lf_blocks`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/kern_lockf.c#L866) never lets locks of one owner conflict, whichever descriptor or thread asks, `F_SETLK` and `F_SETLKW` then succeed without waiting and `F_GETLK` reports `F_UNLCK`, so no lock list is kept and merging, splitting and the release on close have nothing to change. Nothing is held on the host, since Windows byte-range locks are mandatory and would fail the guest's own reads and writes through other descriptors, so other host processes neither see these locks nor are seen; a lock from the end of a directory throws. The return values and error codes are unverified on a console From 93df5c42a1b3ddd9362d7fc8541f811eac730248 Mon Sep 17 00:00:00 2001 From: LeandroLP23 Date: Thu, 15 Oct 2026 12:43:58 +0200 Subject: [PATCH 0014/1059] fix(agc): run the unwatch hook when a watched import leaves the write watch An import that should stay watched still drops its range from the watch when the range was only partly watched or its import window cannot be resolved. That fallback skipped the unwatch hook, so a storage image with results pending in the range lost the watch without its guest bytes saved, and its next store threw "storing storage image results pending over memory outside the write watch without the guest bytes saved before them is not implemented". Since #6060 settled private imports on Windows take this path, and ProsperoPuzzles (PPSA99006) stopped 5 s in (#6301). The fallback now releases the tracker lock, runs the hook over the page range, and unwatches it. Co-Authored-By: Claude Opus 5.5 --- .../Execution/src/GuestMemory.cpp | 7 ++++++- .../libSceAgcDriver/tests/WriteTracking.cpp | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp b/core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp index 8850453b34..26e7dff7d2 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp +++ b/core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp @@ -1345,7 +1345,12 @@ bool ImportWatched(std::uint64_t address, std::size_t bytes, const std::function return true; } const WindowCopy window{first, saved, slots}; - if (!before || !walkWrites(tracker, first, stop, StampKind::Cpu, true, &window)) unwatchLocked(tracker, first, static_cast(stop - first)); + if (!before || !walkWrites(tracker, first, stop, StampKind::Cpu, true, &window)) { + lock.unlock(); + beforeUnwatch(first, static_cast(stop - first)); + lock.lock(); + unwatchLocked(tracker, first, static_cast(stop - first)); + } return true; } diff --git a/core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp b/core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp index 86da33486d..ee6c53b388 100644 --- a/core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp @@ -570,6 +570,26 @@ void CheckImportWindow() { Require(!UnchangedSince(base, Block, after), "a store after the import window was missed"); } +std::uint64_t hookedBegin = 0; +std::uint64_t hookedEnd = 0; + +void CheckImportWindowOverUnwatchedPart() { + void* memory = AllocateWatched(3 * Block); + const auto base = reinterpret_cast(memory); + std::memset(memory, 0x11, 3 * Block); + static_cast(CollectWritesUncached(base, 3 * Block)); + Unwatch(base + Block, Block); + SetUnwatchHook([](std::uint64_t address, std::size_t bytes) { + hookedBegin = address; + hookedEnd = address + bytes; + }); + const bool imported = ImportWatched(base, 3 * Block, [] { return true; }); + SetUnwatchHook(nullptr); + Require(imported, "an import over a partly unwatched range failed"); + Require(!Watched(base, Block) && !Watched(base + 2 * Block, Block), "an import over a partly unwatched range left its watched blocks watched"); + Require(hookedBegin <= base && hookedEnd >= base + 3 * Block, "an import that left the write watch did not run the unwatch hook over its range"); +} + void CheckImportWindowOtherThread() { void* memory = AllocateWatched(2 * Block); const auto base = reinterpret_cast(memory); @@ -848,6 +868,7 @@ int main() { CheckImportWindow(); CheckImportWindowZeroPages(); CheckImportWindowOtherThread(); + CheckImportWindowOverUnwatchedPart(); CheckUnwatchedImport(); CheckCommittedRanges(); } catch (const std::exception& error) { From 0205c3672fed5551239ec4c5138688533966d4c2 Mon Sep 17 00:00:00 2001 From: AlxcBTW <187763517+AlxcBTW@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:29:02 +0200 Subject: [PATCH 0015/1059] test(agc): store results pending when an import over a partly unwatched range drops it A storage image has results pending over a block whose neighbour block already left the write watch. An import of both blocks that should stay watched cannot vouch for its pages, so the range leaves the watch, and the image's next store has to find the guest bytes the unwatch hook saved. agc_storage_unwalked_import (Linux) runs that flow through HostImportFor. The host import of the first creation is refused through the import budget so that the import under test is the first one over the block. staleGenerationTests runs the same flow for an import that does not stay watched; it now exists for both. --- core/libs/prx/libSceAgcDriver/CMakeLists.txt | 4 ++ .../prx/libSceAgcDriver/tests/Recorder.cpp | 47 ++++++++++++++----- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/CMakeLists.txt b/core/libs/prx/libSceAgcDriver/CMakeLists.txt index a6de94a52f..81879eb4d4 100644 --- a/core/libs/prx/libSceAgcDriver/CMakeLists.txt +++ b/core/libs/prx/libSceAgcDriver/CMakeLists.txt @@ -1180,6 +1180,10 @@ if(BUILD_TESTING) set_tests_properties(agc_storage_kept_unit_settle PROPERTIES ENVIRONMENT_MODIFICATION "PATH=path_list_prepend:$") endif() + if(NOT WIN32) + add_test(NAME agc_storage_unwalked_import COMMAND agc_driver_recorder_tests --unwalked-import-only) + set_tests_properties(agc_storage_unwalked_import PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 30) + endif() add_test(NAME agc_storage_baseline_settled COMMAND agc_driver_recorder_tests --settled-baseline-only) add_test(NAME agc_storage_integer_clear COMMAND agc_driver_recorder_tests --integer-clear-only) set_tests_properties(agc_storage_integer_clear PROPERTIES SKIP_RETURN_CODE 77) diff --git a/core/libs/prx/libSceAgcDriver/tests/Recorder.cpp b/core/libs/prx/libSceAgcDriver/tests/Recorder.cpp index 7c4bd1b701..9135f56974 100644 --- a/core/libs/prx/libSceAgcDriver/tests/Recorder.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/Recorder.cpp @@ -4444,41 +4444,51 @@ void importWatchTests(const Device& device) { #endif } -void staleGenerationTests(const Device& device, Recorder& recorder) { +template +bool staleGenerationTests(const Device& device, Recorder& recorder) { const auto& base = device.GetContext(); #ifdef _WIN32 static_cast(recorder); static_cast(base); std::cout << "a range leaving the watch: Linux write watch only\n"; + return false; #else if (base.hostImportAlignment == 0) { std::cout << "host imports unavailable: a range leaving the watch not tested\n"; - return; + return false; } constexpr std::uint32_t side = 256; constexpr std::size_t surfaceBytes = side * side * 4; constexpr std::size_t keyCount = surfaceBytes / 256; - constexpr std::size_t bytes = surfaceBytes + 65536; + constexpr std::size_t lead = StaysWatched ? 65536 : 0; + constexpr std::size_t bytes = lead + surfaceBytes + 65536; void* block = AllocateWatched(bytes, 65536); if (block == nullptr) { std::cout << "no write watching: a range leaving the watch not tested\n"; - return; + return false; } struct Release { void* block; ~Release() { ReleaseWatched(block, bytes); } } release{block}; const auto decided = PrepareImportWatch(base); - SetImportWatch(base, ImportWatch::Unwatch); + SetImportWatch(base, StaysWatched ? ImportWatch::Watch : ImportWatch::Unwatch); struct Restore { const Context& context; ImportWatch decided; ~Restore() { SetImportWatch(context, decided); } } restore{base, decided}; - auto* texels = static_cast(block); + struct Budget { + bool set; + ~Budget() { + if (set) SetHostImportBudget(0); + } + } budget{StaysWatched}; + auto* texels = static_cast(block) + lead; std::memset(texels, 0x55, surfaceBytes); std::memset(texels + surfaceBytes, 0x00, keyCount); - const auto address = reinterpret_cast(block); + const auto blockAddress = reinterpret_cast(block); + const auto address = blockAddress + lead; { GuestAllocations::Mutation mutation; mutation.Add(block, bytes, true, true, true); @@ -4494,7 +4504,7 @@ void staleGenerationTests(const Device& device, Recorder& recorder) { } HostImportFor(context, address, bytes); } - } unregister{base, block, address}; + } unregister{base, block, blockAddress}; TextureDetiler detiler(base); auto context = base; context.detiler = &detiler; @@ -4512,7 +4522,8 @@ void staleGenerationTests(const Device& device, Recorder& recorder) { resource.dstSelW = 7; resource.dccAddress = address + surfaceBytes; { - Require(AgcDriver::GuestMemory::Watched(address, bytes), "(s) the block is not watched before the storage image takes its generation"); + Require(AgcDriver::GuestMemory::Watched(blockAddress, bytes), "(s) the block is not watched before the storage image takes its generation"); + if constexpr (StaysWatched) SetHostImportBudget(1); auto image = StorageTexture::Create(context, detiler, resource, 0); const VkImageSubresourceRange range{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; const auto commands = recorder.Commands(); @@ -4523,13 +4534,20 @@ void staleGenerationTests(const Device& device, Recorder& recorder) { context.Function("vkCmdClearColorImage")(commands, image->Image(), VK_IMAGE_LAYOUT_GENERAL, &red, 1, &range); RecordMemoryBarrier(context, commands, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT | VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT); image->MarkDirty(); - const auto* import = HostImportFor(base, address, bytes); + if constexpr (StaysWatched) { + SetHostImportBudget(0); + Require(!image->GuestSnapshotValid(), "(s) the pending results had saved guest bytes while their memory was watched"); + AgcDriver::GuestMemory::Unwatch(blockAddress, lead); + Require(!AgcDriver::GuestMemory::Watched(blockAddress, lead) && AgcDriver::GuestMemory::Watched(address, bytes - lead), "(s) the block before the surface did not leave the watch alone"); + } + const auto* import = HostImportFor(base, blockAddress, bytes); if (import == nullptr) { std::cout << "host import of the stale generation block refused: a range leaving the watch not tested\n"; recorder.Sync(); - return; + return false; } - Require(!AgcDriver::GuestMemory::Watched(address, bytes), "(s) the imported range stayed watched"); + Require(!AgcDriver::GuestMemory::Watched(blockAddress, bytes), "(s) the imported range stayed watched"); + if constexpr (StaysWatched) Require(image->GuestSnapshotValid(), "(s) the results pending when a walk gave up the watch saved no guest bytes"); image->Refresh(); Buffer readback(context, surfaceBytes, VK_BUFFER_USAGE_TRANSFER_DST_BIT); const auto copyCommands = recorder.Commands(); @@ -4553,6 +4571,7 @@ void staleGenerationTests(const Device& device, Recorder& recorder) { for (std::size_t i = 0; i < surfaceBytes; ++i) Require(texels[i] == expected[i % 4], "(s) results pending when their memory left the watch did not reach it"); } recorder.Sync(); + return true; #endif } @@ -8801,6 +8820,7 @@ int main(int argc, char** argv) { settledBaselineTests(device, recorder); return 0; } + if (argc == 2 && std::string_view(argv[1]) == "--unwalked-import-only") return staleGenerationTests(device, recorder) ? 0 : 77; if (argc == 2 && std::string_view(argv[1]) == "--completion-labels-only") { completionCountTests(device, recorder); afterRecordedWorkTests(device, recorder); @@ -8997,7 +9017,8 @@ int main(int argc, char** argv) { keptUnitSettleTests(device, recorder); settledBaselineTests(device, recorder); importWatchTests(device); - staleGenerationTests(device, recorder); + staleGenerationTests(device, recorder); + staleGenerationTests(device, recorder); importWindowTests(device, recorder); dataWordPositionsTests(); dataRefreshTests(device, recorder); From c20686e11410a33fe490a9e9d5446945ddabcd90 Mon Sep 17 00:00:00 2001 From: AlxcBTW <187763517+AlxcBTW@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:29:04 +0200 Subject: [PATCH 0016/1059] fix(agc): run the unwatch hook before the import when the page walk already fails An import that should stay watched runs the unwatch hook only when its range leaves the write watch, after the import. When the range is partly outside the watch or the walk before the import fails, the range leaves the watch whatever the import does, so the hook can run first, as it does for the import that does not stay watched. After the import, the walk that tells an import's own page marks from CPU writes (the window compare) is skipped in this case, so the hook collects those marks as CPU writes. A driver that marks the pages of an import written (the NVIDIA driver on Windows does, see Pages under an import window) makes the hook keep the blocks they cover for the CPU unless their bytes are proven unchanged, and those blocks do not receive the GPU results. A failure of the walk after the import still runs the hook after it. The check of #6308 also requires the hook to have run before the import. --- .../prx/libSceAgcDriver/Execution/src/GuestMemory.cpp | 11 ++++++++--- core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp | 7 ++++++- docs/dev/debt/libSceAgcDriver.md | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp b/core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp index 8d7f33e036..265951de04 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp +++ b/core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp @@ -1409,6 +1409,13 @@ bool ImportWatched(std::uint64_t address, std::size_t bytes, const std::function } } const bool before = covered && walkWrites(tracker, first, stop, StampKind::Cpu, true); + const auto saveBytes = [&] { + lock.unlock(); + beforeUnwatch(first, static_cast(stop - first)); + lock.lock(); + }; + const bool savedEarly = staysWatched && !before; + if (savedEarly) saveBytes(); bool imported = false; { #ifdef __APPLE__ @@ -1424,9 +1431,7 @@ bool ImportWatched(std::uint64_t address, std::size_t bytes, const std::function } const WindowCopy window{first, saved, slots}; if (!before || !walkWrites(tracker, first, stop, StampKind::Cpu, true, &window)) { - lock.unlock(); - beforeUnwatch(first, static_cast(stop - first)); - lock.lock(); + if (!savedEarly) saveBytes(); unwatchLocked(tracker, first, static_cast(stop - first)); } return true; diff --git a/core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp b/core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp index 63491a8619..f7607574bc 100644 --- a/core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp @@ -793,11 +793,16 @@ void CheckImportWindowOverUnwatchedPart() { hookedBegin = address; hookedEnd = address + bytes; }); - const bool imported = ImportWatched(base, 3 * Block, [] { return true; }); + bool hookedBeforeImport = false; + const bool imported = ImportWatched(base, 3 * Block, [&] { + hookedBeforeImport = hookedEnd != 0; + return true; + }); SetUnwatchHook(nullptr); Require(imported, "an import over a partly unwatched range failed"); Require(!Watched(base, Block) && !Watched(base + 2 * Block, Block), "an import over a partly unwatched range left its watched blocks watched"); Require(hookedBegin <= base && hookedEnd >= base + 3 * Block, "an import that left the write watch did not run the unwatch hook over its range"); + Require(hookedBeforeImport, "an import over a partly unwatched range ran the unwatch hook after the import"); } void CheckImportWindowOtherThread() { diff --git a/docs/dev/debt/libSceAgcDriver.md b/docs/dev/debt/libSceAgcDriver.md index 01fbd5d3e5..e340ce1f71 100644 --- a/docs/dev/debt/libSceAgcDriver.md +++ b/docs/dev/debt/libSceAgcDriver.md @@ -79,9 +79,9 @@ - On a device without VK_EXT_robustness2's `nullDescriptor`, such as MoltenVK, a null image descriptor in a typed heap is a [placeholder image](../../../core/libs/prx/libSceAgcDriver/Graphics/src/PlaceholderImages.cpp) of that heap's type, 1×1 and cleared to zero, which every null descriptor of the heap shares. Reads give zeros, as `nullDescriptor` allows, but stores and atomics to it are kept, so a later read of a null storage image can return them, and a depth comparison compares against depth 0. A heap whose placeholder the device cannot create, for example 1D or 3D depth, 64-bit atomics without 64-bit image atomics, or multisampled storage without `shaderStorageImageMultisample`, throws when a null descriptor reaches it. - [Draw input snapshots](../../../core/libs/prx/libSceAgcDriver/Graphics/src/ShaderResources.cpp) (libSceAgcDriver): `PrepareDrawBindings` caps read-only input buffer snapshots at 64 MiB (overridable via `APS5_DRAW_SNAPSHOT_MAX_MIB`). Larger inputs (streaming heaps, bindless pools) or draws exceeding the cumulative budget skip the per-draw snapshot and bind in place, avoiding multi-gigabyte CPU copy stalls (such as Ratchet & Clank: Rift Apart PPSA01474 stalling at frame ~380 copying 36 GB every 5 s). If the title rewrites those large ranges on the CPU before GPU completion, the in-place binding observes the modification, as verified by `drawSnapshotCapTests`. - [Pending storage-image results](../../../core/libs/prx/libSceAgcDriver/Graphics/src/Texture.cpp) (libSceAgcDriver) are stored when something reads their memory, not when the GPU work that wrote them completes. A title that waits for that work, frees the image and reuses its memory can have the new data overwritten by the late store (#1970). A title's own CPU reads are not seen either, so a title that reads the results after waiting for a label reads stale memory. `APS5_STORE_AT_FLIP=1` stores every pending result at each flip and `APS5_STORE_AT_LABEL=1` before each label the GPU writes; both store every pending image, not only the ones the waited-for work wrote, and neither is on by default. -- [Pending storage-image results over memory outside the write watch](../../../core/libs/prx/libSceAgcDriver/Graphics/src/Texture.cpp) (libSceAgcDriver) leave to the CPU the 64 KiB blocks whose bytes differ from the ones saved at the upload, the fill, the image copy, the previous store or the moment their range left the watch (`Unwatch`, or a host import that does not stay watched), and the blocks the watch saw the CPU write before that moment. A store made on the GPU saves its bytes through a host-visible copy that becomes the saved bytes when its batch completes; a store or a refresh that needs them earlier waits for that batch. A unit without a write generation saves the guest bytes when results land in it outside the watch. One whose range leaves the watch while its results are pending, and ranges that leave the watch through a failed collect, have no saved bytes; storing such results throws. - Windows host-pointer imports of the guest mapping itself are compared instead of write-watched, except writable imports of private `MEM_WRITE_WATCH` memory when the import probe keeps the write watch and a settle queue exists: those of 1 MiB or more stay watched, and smaller ones return to the watch once their settle has completed (see Pages under an import window): a driver can mark imported pages dirty after the synchronous import window. Shared memory imported through a separate writable alias retains protection-based tracking on the guest views; driver access to the import alias does not disarm those views. [GuestMemory](../../../core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp) excludes every overlapping 64 KiB tracker block while its backing is retained. A full block replaced by fresh private backing regains tracking and gets a new generation; protection changes, partial replacements and shared remappings do not restore it. Excluded blocks keep their write-watch marks as a hint only (`UntrackedHint`): a storage image or a sampled texture snapshot over them compares its bytes again after a mark in its range, a driver store, a mapping change or a recorded GPU write instead of at every collect epoch or lookup, and a mark the driver makes without a CPU store costs one comparison. This preserves CPU edits through byte comparisons but limits snapshot reuse on excluded blocks to draws of one collect epoch and disables unit shadows for them; restoring tracking on import retirement without replacing the backing remains unimplemented. - [Pages under an import window](../../../core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp) (libSceAgcDriver) whose write protection was resolved while a host import that leaves its range watched was made are compared with the bytes they held before the import and count as stored over only when those differ. A CPU store of the bytes already there within the window cannot be told apart from an import that resolves the write protection without storing, as the NVIDIA driver (610.57.04) does for every imported page, so such a store does not win over pending results. Where host-pointer and dma-buf imports differ in whether they stay watched, a failed host-pointer import and the dma-buf import after it are two windows, and the pages the failed one resolved count as stored over. On Windows, the NVIDIA driver (617.42) marks every page of a host import of private `MEM_WRITE_WATCH` memory written, asynchronously, until a GPU use of the import completes; a 4-byte read of it on the settle queue ends that even while main-queue work that used the import first is still running, and an earlier import of the same pages whose first use runs after that read marks none of them (`agc_import_settle_first_use`, `agc_import_settle_earlier_import`). Such an import stays watched only after a 4-byte read of it on the settle queue has completed inside the window. Each one waits for that read, which keeps its window open for one round trip on the settle queue, so a same-value CPU store from another thread in that time counts as unchanged too. An import made where no settle queue exists is compared instead. An import of under 1 MiB is not settled inside its window, because the settle blocks the importing thread and a title that imports a 64 KiB buffer around every submission lost about 19 % of its frame rate to that wait (Dripl1x, #6060). It is compared until its read, submitted in a batch by a later import lookup under `GpuMutex` while the imports match the registry, has completed; a later lookup then returns its whole blocks to the write watch, each stamped as a CPU store, so whatever was derived from them while they were compared reads as changed once. Its blocks stay compared while storage results are pending over them, and those of an import retired before that stay compared. +- [Pending storage-image results over memory outside the write watch](../../../core/libs/prx/libSceAgcDriver/Graphics/src/Texture.cpp) (libSceAgcDriver) leave to the CPU the 64 KiB blocks whose bytes differ from the ones saved at the upload, the fill, the image copy, the previous store or the moment their range left the watch (`Unwatch`, or a host import that does not stay watched or whose page walk fails), and the blocks the watch saw the CPU write before that moment. A store made on the GPU saves its bytes through a host-visible copy that becomes the saved bytes when its batch completes; a store or a refresh that needs them earlier waits for that batch. A unit without a write generation saves the guest bytes when results land in it outside the watch. One whose range leaves the watch while its results are pending, and ranges that leave the watch through the failed collect of a driver store or, on Linux, because their direct memory is mapped a second time, have no saved bytes; storing such results throws. - [Settled dma-buf imports](../../../core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp) (libSceAgcDriver): on Linux a dma-buf import of 1 MiB or more is read by a 4-byte copy on the settle queue right after it is made (`settleImport`), so the kernel maps its pages for the GPU there and not in the first submission that uses it (about 2 s for a 3 GiB import on RADV). The import's buffer is shared with the settle queue's family. An import made at map time settles on the guest thread that maps the range, inside its map call, so that thread waits while the kernel maps the pages: 0.47 to 2.2 s for the 3 GiB range of Hades (PPSA03355) and 0.49 to 4.3 s for the 4.5 GiB range of ASTRO BOT (PPSA21567) on RADV. Both titles map these ranges at start-up; a title that maps large ranges during gameplay stalls its mapping thread for as long. An import made from a submission settles under the GPU mutex, so its frame still pays for it; a smaller import, and every import on a device with no settle queue (one without a copy-capable queue besides the main one), pays in its first submission. A range imported through a host pointer, as lavapipe imports shared memory, is not settled - [Sparse dma-buf imports](../../../core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp) (libSceAgcDriver): on Linux a shared-backed range larger than one 64 MiB udmabuf buffer is imported as one sparse buffer bound to the 64 MiB buffers, on a queue of its own when the device has a second family that binds sparse memory. The path is decided before anything is imported, and a failure after that throws. A range takes the single udmabuf import instead, which udmabuf's `size_limit_mb` refuses above 64 MiB by default so the range is copied, when its offset in the backing file is not a multiple of the buffer's sparse block size (64 KiB on RADV), when the sparse buffer does not accept every memory type a chunk import can have, when the backing file ends inside its last chunk, or when it is imported at map time on a device with no second family (the bind then needs the GPU mutex); the NVIDIA proprietary driver imports no dma-buf memory - [Unnormalized S# coordinates](../../../core/libs/prx/libSceAgcDriver/Graphics/src/GuestSamplerResource.cpp) (libSceAgcDriver) - `FORCE_UNNORMALIZED` uses a Vulkan `unnormalizedCoordinates` sampler, so the S# LOD range, LOD bias and mip filter are not applied, which is exact only on a one-level view. From aaf19708ff31c4d28dea3bfaf0cc04dfbb62e110 Mon Sep 17 00:00:00 2001 From: AlxcBTW <187763517+AlxcBTW@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:47:31 +0200 Subject: [PATCH 0017/1059] test(agc): cover the unwatch hook on each exit that drops a range from the write watch CheckUnwatchHooks runs Unwatch, an import that does not stay watched, an import whose page walk vouches for its pages and, on Linux, an import whose range loses a block during the import. The hook has to run once, over the dropped range, while the range is still watched: before the import for the first two, after it for the last, and not at all for the vouched import. --- .../libSceAgcDriver/tests/WriteTracking.cpp | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp b/core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp index f7607574bc..7f8fc018ee 100644 --- a/core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp @@ -938,6 +938,80 @@ void CheckUnwatchedImport() { #endif } +struct HookCall { + std::uint64_t address; + std::size_t bytes; + bool watched; +}; + +std::vector hookCalls; +std::uint64_t hookProbe = 0; +std::size_t hookProbeBytes = 0; + +void RecordHook(std::uint64_t address, std::size_t bytes) { + hookCalls.push_back({address, bytes, Watched(hookProbe, hookProbeBytes)}); +} + +struct RecordedHook { + RecordedHook() { + hookCalls.clear(); + SetUnwatchHook(&RecordHook); + } + ~RecordedHook() { SetUnwatchHook(nullptr); } + RecordedHook(const RecordedHook&) = delete; + RecordedHook& operator=(const RecordedHook&) = delete; +}; + +void RequireHookOver(std::uint64_t address, std::size_t bytes, const std::string& path) { + Require(hookCalls.size() == 1, path + " did not run the unwatch hook once"); + const auto& call = hookCalls.front(); + Require(call.address <= address && address + bytes <= call.address + call.bytes, path + " gave the unwatch hook a range that misses its own"); + Require(call.watched, path + " ran the unwatch hook after its range left the write watch"); +} + +void CheckUnwatchHooks() { + void* memory = AllocateWatched(8 * Block); + const auto base = reinterpret_cast(memory); + std::memset(memory, 0x11, 8 * Block); + Require(CollectWritesUncached(base, 8 * Block) != 0, "the hooked block is not collected"); + const RecordedHook recorded; + hookProbe = base; + hookProbeBytes = Block; + Unwatch(base, Block); + RequireHookOver(base, Block, "Unwatch"); + Require(!Watched(base, Block), "Unwatch left its range watched"); + + hookCalls.clear(); + hookProbe = base + Block; + bool hookedBeforeImport = false; + Require(ImportWatched(base + Block, Block, [&] { + hookedBeforeImport = hookCalls.size() == 1; + return true; + }, false), "an import that does not stay watched failed"); + RequireHookOver(base + Block, Block, "an import that does not stay watched"); + Require(hookedBeforeImport, "an import that does not stay watched ran the unwatch hook after the import"); + Require(!Watched(base + Block, Block), "an import that does not stay watched left its range watched"); + + hookCalls.clear(); + Require(ImportWatched(base + 2 * Block, Block, [] { return true; }), "an import that stays watched failed"); + Require(hookCalls.empty() && Watched(base + 2 * Block, Block), "an import whose page walk vouched for its pages ran the unwatch hook or gave up its range"); + +#ifndef _WIN32 + hookCalls.clear(); + const auto late = base + 4 * Block; + hookProbe = late; + bool hookedBeforeLoss = false; + Require(ImportWatched(late, 3 * Block, [&] { + GuestWriteWatch::GuestWriteWatchUnregister_nid_postfix(reinterpret_cast(late + 2 * Block), Block); + hookedBeforeLoss = !hookCalls.empty(); + return true; + }), "an import whose range lost a block during the import failed"); + RequireHookOver(late, 3 * Block, "an import whose page walk failed after the import"); + Require(!hookedBeforeLoss, "an import whose page walk failed after the import ran the unwatch hook before it"); + Require(!Watched(late, Block), "an import whose page walk failed after the import left its range watched"); +#endif +} + class PartlyCommitted { public: static constexpr std::size_t Bytes = 4 * Block; @@ -1111,6 +1185,7 @@ int main() { CheckImportWindowOverUnwatchedPart(); CheckStoreAcrossImports(); CheckUnwatchedImport(); + CheckUnwatchHooks(); CheckCommittedRanges(); } catch (const std::exception& error) { std::cerr << "write tracking test failed: " << error.what() << "\n"; From d5886fce88378e350a895cc0720f60bc9938269d Mon Sep 17 00:00:00 2001 From: javierUDP Date: Tue, 20 Oct 2026 02:22:14 -0300 Subject: [PATCH 0018/1059] test(relinker): execute the in-place Rosetta replacements RDPID, RDSEED and CLWB are checked through their replacement bytes but never run, although all three raise an illegal instruction on a Rosetta host, which is what the replacements exist for. The x86-64 execution section now calls the RDPID stub and the RDSEED and CLWB replacements and checks the value, the carry flag and the registers they leave. --- .../codegen/tests/Amd64OnlyConverterTests.cpp | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/core/relinker/codegen/tests/Amd64OnlyConverterTests.cpp b/core/relinker/codegen/tests/Amd64OnlyConverterTests.cpp index 8d4f08930c..9615dc1ae3 100644 --- a/core/relinker/codegen/tests/Amd64OnlyConverterTests.cpp +++ b/core/relinker/codegen/tests/Amd64OnlyConverterTests.cpp @@ -1903,6 +1903,91 @@ void mcommitExecution() { } munmap(code, 4096); } + +void rdpidExecution() { + const auto matcher = Codegen::MakeAmd64OnlyInstructionMatcher(Codegen::Amd64OnlyTarget::Rosetta); + const Bytes site = {0xF3, 0x0F, 0xC7, 0xF8}; + const auto match = matcher->Match(site.data(), site.size()); + require(match && match->Lowering == Codegen::Amd64OnlyLowering::Trampoline, "RDPID 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()); + std::uint64_t processor = ~std::uint64_t{0}; + std::uint8_t carry = 0; + asm volatile( + "sub $128, %%rsp\n\t" + "stc\n\t" + "call *%[code]\n\t" + "setc %[carry]\n\t" + "add $128, %%rsp\n\t" + : "=&a"(processor), [carry] "=&q"(carry) + : [code] "r"(code) + : "memory", "cc"); + munmap(code, 4096); + require(processor == 0, "RDPID stub left a value other than processor 0 in RAX"); + require(carry == 1, "RDPID stub changed the flags"); +} + +void rdseedExecution() { + const auto matcher = Codegen::MakeAmd64OnlyInstructionMatcher(Codegen::Amd64OnlyTarget::Rosetta); + const Bytes site = {0x0F, 0xC7, 0xF8}; + const auto match = matcher->Match(site.data(), site.size()); + require(match && match->Lowering == Codegen::Amd64OnlyLowering::InPlace, "RDSEED replacement was not produced"); + require(match->ReplacementBytes.size() == site.size(), "RDSEED replacement changed the instruction length"); + auto body = match->ReplacementBytes; + body.push_back(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 replacement"); + std::memcpy(code, body.data(), body.size()); + bool produced = false; + for (unsigned attempt = 0; attempt < 16 && !produced; ++attempt) { + std::uint64_t value = 0; + std::uint8_t carry = 0; + asm volatile( + "sub $128, %%rsp\n\t" + "call *%[code]\n\t" + "setc %[carry]\n\t" + "add $128, %%rsp\n\t" + : "=&a"(value), [carry] "=&q"(carry) + : [code] "r"(code) + : "memory", "cc"); + produced = carry == 1; + } + munmap(code, 4096); + require(produced, "RDSEED replacement never reported a random value"); +} + +void clwbExecution() { + const auto matcher = Codegen::MakeAmd64OnlyInstructionMatcher(Codegen::Amd64OnlyTarget::Rosetta); + const Bytes site = {0x66, 0x0F, 0xAE, 0x30}; + const auto match = matcher->Match(site.data(), site.size()); + require(match && match->Lowering == Codegen::Amd64OnlyLowering::InPlace, "CLWB replacement was not produced"); + require(match->ReplacementBytes.size() == site.size(), "CLWB replacement changed the instruction length"); + auto body = match->ReplacementBytes; + body.push_back(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 replacement"); + std::memcpy(code, body.data(), body.size()); + std::uint8_t line[64] = {}; + std::uint64_t address = reinterpret_cast(line); + std::uint64_t kept = 0x5A5A5A5A5A5A5A5Aull; + std::uint8_t carry = 0; + asm volatile( + "sub $128, %%rsp\n\t" + "stc\n\t" + "call *%[code]\n\t" + "setc %[carry]\n\t" + "add $128, %%rsp\n\t" + : "+a"(address), "+b"(kept), [carry] "=&q"(carry) + : [code] "r"(code) + : "memory", "cc"); + munmap(code, 4096); + require(address == reinterpret_cast(line), "CLWB replacement changed its address register"); + require(kept == 0x5A5A5A5A5A5A5A5Aull, "CLWB replacement changed an unrelated register"); + require(carry == 1, "CLWB replacement changed the flags"); +} #else void reciprocalExecution() {} void registerFormExecution() {} @@ -1947,6 +2032,9 @@ void rdtscpExecution() { } #else void rdtscpExecution() {} +void rdpidExecution() {} +void rdseedExecution() {} +void clwbExecution() {} #endif void scannerZeroTail() { @@ -1993,6 +2081,9 @@ int main() { rosettaLegacyScalarCompares(); converterRosettaLegacyScalarCompare(); rdtscpExecution(); + rdpidExecution(); + rdseedExecution(); + clwbExecution(); wideStoreSplit(); converterWideStoreWindows(); converterJumpTableTargets(); From 5272ecd1afbe06fc7a03779e31ffba2ac58aef71 Mon Sep 17 00:00:00 2001 From: Amr Yasser <154547867+AMRYB@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:54:57 +0300 Subject: [PATCH 0019/1059] feat(input): add browser-based input mapping configurator --- docs/user/INPUT_MAPPING.md | 26 + tools/input-mapper/app.js | 446 ++++++++ tools/input-mapper/capture.js | 30 + tools/input-mapper/dropdown.js | 167 +++ tools/input-mapper/i18n.js | 1263 ++++++++++++++++++++++ tools/input-mapper/index.html | 78 ++ tools/input-mapper/keys.js | 230 ++++ tools/input-mapper/mapping.js | 34 + tools/input-mapper/package.json | 15 + tools/input-mapper/serve.js | 30 + tools/input-mapper/styles.css | 203 ++++ tools/input-mapper/tests/i18n.test.js | 39 + tools/input-mapper/tests/mapping.test.js | 132 +++ tools/input-mapper/validation.js | 93 ++ 14 files changed, 2786 insertions(+) create mode 100644 tools/input-mapper/app.js create mode 100644 tools/input-mapper/capture.js create mode 100644 tools/input-mapper/dropdown.js create mode 100644 tools/input-mapper/i18n.js create mode 100644 tools/input-mapper/index.html create mode 100644 tools/input-mapper/keys.js create mode 100644 tools/input-mapper/mapping.js create mode 100644 tools/input-mapper/package.json create mode 100644 tools/input-mapper/serve.js create mode 100644 tools/input-mapper/styles.css create mode 100644 tools/input-mapper/tests/i18n.test.js create mode 100644 tools/input-mapper/tests/mapping.test.js create mode 100644 tools/input-mapper/validation.js diff --git a/docs/user/INPUT_MAPPING.md b/docs/user/INPUT_MAPPING.md index 7ab80c3d4f..9a051c8898 100644 --- a/docs/user/INPUT_MAPPING.md +++ b/docs/user/INPUT_MAPPING.md @@ -130,3 +130,29 @@ MouseCurve = 1.3 Earlier versions sampled the mouse every 33 ms and tilted the stick at least halfway for any movement. `MouseDeadzoneOffset = 0.5`, `MouseSmoothingMs = 0` and `MouseDecayMs = 33` come close to that behavior. An invalid line reports the file and line number and stops input initialization. If the configured file does not exist or cannot be read, AnyPS5 reports an error. With no `anyps5-input.ini` and no `ANYPS5_INPUT_CONFIG`, the built-in mapping is used. + + + +## Web configurator + +The [input mapper](../../tools/input-mapper/index.html) edits keyboard, mouse and wheel overrides in the browser. It supports alternate bindings, physical-key capture, search, INI import, preview, copy and download. Invalid imports preserve the current configuration. Invalid bindings block export; duplicate and shared inputs, including retained built-in bindings, produce warnings. Removing every binding for an action restores its built-in inputs. + +From the repository root, with Node.js 22 or later: + +```sh +npm --prefix tools/input-mapper start +``` + +Open `http://127.0.0.1:4173`. Stop the server with Ctrl+C. No package installation is required. Set `PORT` to use another port. Run the tests with: + +```sh +npm --prefix tools/input-mapper test +``` + +Controls, Configuration and Help are separate views; switching views preserves bindings. Search covers all control groups. The group selector supports arrow keys, Home and End. Input and language menus support search, arrow keys and Enter; Escape cancels selection. Menus open beside their fields on larger screens and as bottom sheets on phones. The interface uses system fonts and respects reduced-motion preferences. + +The translation button selects one of 22 interface and FAQ languages. First visits use English; a saved language choice takes priority. Egyptian Arabic appears first in the language list. Arabic, Urdu and Persian use right-to-left layouts. Only the language preference is persisted; copy or download input changes before reloading. + +Keyboard capture converts physical browser key codes to SDL scancode names. Escape cancels capture; select Escape manually to assign it. Composition, ambiguous keys and some browser or system shortcuts require manual selection. Names containing `#` or `;` cannot be exported because those characters start INI comments. The key catalogue follows SDL commit `4b69833bc54abf3dd3288d4aa7afbba527775e5b`. The source-contract tests compare actions, restrictions and defaults with the current native input code. + +For static hosting, serve `index.html`, `styles.css`, `app.js`, `mapping.js`, `keys.js`, `capture.js`, `validation.js`, `i18n.js` and `dropdown.js` from one directory. For the existing GitHub Pages deployment, these files can be copied into `out/input-mapper/` after the Progress workflow generates `out`. The progress dashboard remains at `out/index.html`; publication and a dashboard link are left for maintainer review. diff --git a/tools/input-mapper/app.js b/tools/input-mapper/app.js new file mode 100644 index 0000000000..4703578fe0 --- /dev/null +++ b/tools/input-mapper/app.js @@ -0,0 +1,446 @@ +import {actions, defaults, groups, mouseValues, typesForAction, wheelValues} from './mapping.js'; +import {exportableKeys} from './keys.js'; +import {captureKey} from './capture.js'; +import {parseIni, serialize, validate} from './validation.js'; +import {diagnostic, initialLocale, locales, translate} from './i18n.js'; +import {createDropdown} from './dropdown.js'; + +const byId = id => document.getElementById(id); +let entries = []; +let savedConfiguration = '[]'; +let captureTarget; +let captureButton; +let selectedGroup = groups[0].name; +let locale = initialLocale(); +let languageControl; +const bindingControls = new WeakMap(); +let activeView = ['controls', 'configuration', 'help'].includes(location.hash.slice(1)) ? location.hash.slice(1) : 'controls'; +let currentStatus; +let importReport; +const dialog = byId('capture-dialog'); +try { + const savedLocale = localStorage.getItem('anyps5-controls-language'); + locale = initialLocale(savedLocale); +} catch {} +const t = (key, values) => translate(locale, key, values); +const groupLabel = name => t(`group${groups.findIndex(group => group.name === name)}`); + +function setStatus(key, values = {}) { + currentStatus = {key, values}; + byId('status').textContent = t(key, values); +} + +function showView(view, focus = true, updateHash = true) { + activeView = view; + const controls = view === 'controls'; + byId('categories').hidden = !controls; + byId('toolbar').hidden = !controls; + byId('mappings').hidden = !controls; + byId('configuration').hidden = view !== 'configuration'; + byId('help').hidden = view !== 'help'; + byId('validation').hidden = view === 'help'; + byId('status').hidden = view === 'help'; + byId('import-report').hidden = view === 'help' || !importReport; + byId('view-intro').textContent = t(controls ? 'intro' : view === 'help' ? 'helpIntro' : 'configIntro'); + if (controls) filterActions(); + else byId('group-title').textContent = t(view); + for (const link of document.querySelectorAll('[data-view]')) { + const current = link.dataset.view === view; + link.classList.toggle('current', current); + if (current) link.setAttribute('aria-current', 'page'); + else link.removeAttribute('aria-current'); + } + if (updateHash && location.hash !== `#${view}`) history.pushState(null, '', `#${view}`); + if (focus) { + byId('content').focus({preventScroll: true}); + window.scrollTo({top: 0, behavior: 'auto'}); + } +} + +function renderHelp() { + const opened = [...byId('help').querySelectorAll('details')].map(item => item.open); + byId('help').replaceChildren(); + for (let index = 0; index < 4; index++) { + const question = element('details', undefined, 'faq'); + question.open = opened[index] || false; + question.append(element('summary', t(`faq${index}Question`)), element('p', t(`faq${index}Answer`))); + byId('help').append(question); + } +} + +function renderImportReport() { + if (!importReport) return; + const report = byId('import-report'); + report.replaceChildren(element('h2', t(importReport.errors.length ? 'importRejected' : 'importRead'))); + const messages = [...importReport.errors, ...importReport.warnings]; + if (messages.length) { + const list = element('ul'); + for (const item of messages) list.append(element('li', t('line', {line: item.line, message: diagnostic(locale, item.message)}))); + report.append(list); + } + if (importReport.errors.length) report.append(element('p', t('preserved'), 'error')); + report.hidden = false; +} + +function applyLocale() { + document.documentElement.lang = locale; + document.documentElement.dir = locales.find(item => item.code === locale).direction; + for (const node of document.querySelectorAll('[data-i18n]')) node.textContent = t(node.dataset.i18n); + for (const node of document.querySelectorAll('[data-i18n-label]')) node.setAttribute('aria-label', t(node.dataset.i18nLabel)); + for (const node of document.querySelectorAll('[data-i18n-placeholder]')) node.placeholder = t(node.dataset.i18nPlaceholder); + languageControl.label = t('language'); + languageControl.direction = document.documentElement.dir; + languageControl.text = dropdownText(); + languageControl.setValue(locale); + byId('settings').title = t('language'); + byId('categories').replaceChildren(); + renderCategories(); + renderHelp(); + render(); + renderImportReport(); + if (currentStatus) setStatus(currentStatus.key, currentStatus.values); + showView(activeView, false, false); + updateClock(); +} + +function element(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +function isDirty() { + return JSON.stringify(entries) !== savedConfiguration; +} + +async function confirmReplacement() { + if (!isDirty()) return true; + const confirmation = byId('replace-dialog'); + confirmation.returnValue = 'cancel'; + return new Promise(resolve => { + confirmation.addEventListener('close', () => resolve(confirmation.returnValue === 'discard'), {once: true}); + confirmation.showModal(); + }); +} + +function valueChoices(type) { + return type === 'KEY' ? exportableKeys : type === 'MOUSE' ? mouseValues : type === 'WHEEL' ? wheelValues : []; +} + +function dropdownText() { + return {search: t('searchOptions'), close: t('close'), empty: t('noOptions')}; +} + +function selectControl(label, choices, value) { + return createDropdown({label, choices: [{value: '', label: t('select')}, ...choices], value, placeholder: t('select'), text: dropdownText()}); +} + +function button(text, label, click) { + const control = element('button', text); + control.type = 'button'; + control.setAttribute('aria-label', label); + control.addEventListener('click', click); + return control; +} + +function renderCategories() { + const icons = ['face-icon', 'shoulder-icon', 'direction-icon', 'stick-icon', 'stick-icon', 'touch-icon']; + const symbols = ['△ ○ × □', 'L2 R2', '', 'L', 'R', '']; + groups.forEach((group, index) => { + const category = button('', groupLabel(group.name), () => { + selectedGroup = group.name; + byId('search').value = ''; + filterActions(); + }); + category.className = 'category'; + category.dataset.group = group.name; + const icon = element('span', undefined, 'category-icon'); + icon.setAttribute('aria-hidden', 'true'); + const mark = element('span', undefined, icons[index]); + if (index === 0) { + for (const symbol of symbols[index].split(' ')) mark.append(element('span', symbol)); + } else mark.textContent = symbols[index]; + icon.append(mark); + category.append(icon, element('span', t(`group${index}`), 'category-label')); + category.addEventListener('keydown', event => { + const direction = document.documentElement.dir === 'rtl' ? -1 : 1; + const offset = event.key === 'ArrowRight' ? direction : event.key === 'ArrowLeft' ? -direction : 0; + if (!offset && event.key !== 'Home' && event.key !== 'End') return; + event.preventDefault(); + const next = event.key === 'Home' ? 0 : event.key === 'End' ? groups.length - 1 : (index + offset + groups.length) % groups.length; + const target = byId('categories').children[next]; + target.click(); + target.focus(); + target.scrollIntoView({block: 'nearest', inline: 'nearest'}); + }); + byId('categories').append(category); + }); +} + +function renderBinding(entry, position) { + const row = element('div', undefined, 'binding'); + const label = t('bindingLabel', {action: entry.action, number: position + 1}); + const type = selectControl(t('typeLabel', {binding: label}), typesForAction(entry.action), entry.type); + const value = selectControl(t('valueLabel', {binding: label}), valueChoices(entry.type), entry.value); + type.element.dir = 'ltr'; + value.element.dir = 'ltr'; + const capture = button(t('capture'), t('captureLabel', {binding: label}), () => { + captureTarget = entry; + captureButton = capture; + byId('capture-title').textContent = t('captureTitle', {action: entry.action}); + byId('capture-status').textContent = ''; + dialog.showModal(); + }); + capture.disabled = entry.type !== 'KEY'; + const remove = button(t('remove'), t('removeLabel', {binding: label}), () => { + entries = entries.filter(binding => binding !== entry); + render(); + const remaining = document.querySelector(`[data-action="${entry.action}"] .dropdown-trigger`); + (remaining || document.querySelector(`[data-action="${entry.action}"] .add-binding`)).focus(); + }); + type.select.addEventListener('change', () => { + entry.type = type.select.value; + entry.value = ''; + value.setChoices([{value: '', label: t('select')}, ...valueChoices(entry.type)]); + value.setValue(''); + capture.disabled = entry.type !== 'KEY'; + updateOutput(); + }); + value.select.addEventListener('change', () => { + entry.value = value.select.value; + updateOutput(); + }); + row.append(type.element, value.element, capture, remove); + bindingControls.set(entry, value); + return row; +} + +function render() { + const container = byId('mappings'); + container.replaceChildren(); + for (const group of groups) { + const section = element('section', undefined, 'group'); + section.dataset.group = group.name; + section.append(element('h2', groupLabel(group.name))); + for (const action of group.actions) { + const article = element('div', undefined, 'action'); + article.dataset.action = action; + const heading = element('div', undefined, 'action-heading'); + const add = button(t('add'), t('addLabel', {action}), () => { + entries.push({action, type: 'KEY', value: ''}); + render(); + const rows = document.querySelectorAll(`[data-action="${action}"] .binding`); + rows[rows.length - 1].querySelectorAll('.dropdown-trigger')[1].focus(); + }); + add.className = 'add-binding'; + const title = element('div', undefined, 'action-title'); + const symbols = {Cross: '×', Circle: '○', Triangle: '△', Square: '□', Up: '↑', Right: '→', Down: '↓', Left: '←'}; + if (symbols[action]) { + const symbol = element('span', symbols[action], 'action-symbol'); + symbol.setAttribute('aria-hidden', 'true'); + title.append(symbol); + } + const actionName = element('h3', action); + actionName.dir = 'ltr'; + title.append(actionName); + heading.append(title, add); + article.append(heading); + const configured = entries.filter(entry => entry.action === action); + article.append(element('p', configured.length ? t('custom') : t('builtin', {inputs: defaults[action].join(', ') || t('noDefault')}), 'hint')); + configured.forEach((entry, position) => article.append(renderBinding(entry, position))); + section.append(article); + } + container.append(section); + } + filterActions(); + updateOutput(); +} + +function filterActions() { + const search = byId('search').value.trim().toLowerCase(); + if (activeView === 'controls') byId('group-title').textContent = search ? t('search') : groupLabel(selectedGroup); + for (const category of byId('categories').children) { + category.setAttribute('aria-pressed', String(!search && category.dataset.group === selectedGroup)); + } + let visible = 0; + for (const section of document.querySelectorAll('.group')) { + let matches = 0; + for (const action of section.querySelectorAll('.action')) { + action.hidden = search ? !`${section.dataset.group} ${groupLabel(section.dataset.group)} ${action.dataset.action}`.toLowerCase().includes(search) : section.dataset.group !== selectedGroup; + if (!action.hidden) matches++; + } + section.hidden = matches === 0; + visible += matches; + } + let empty = byId('no-results'); + if (!empty) { + empty = element('p', t('noResults'), 'hint'); + empty.id = 'no-results'; + byId('mappings').append(empty); + } + empty.hidden = visible !== 0; +} + +function updateOutput() { + const result = validate(entries); + const valid = result.errors.length === 0; + byId('preview').value = valid ? serialize(entries) : ''; + byId('count').textContent = `${new Set(result.bindings.map(binding => binding.action)).size} / ${actions.length}`; + byId('download').disabled = !valid; + byId('copy').disabled = !valid; + const report = byId('validation'); + report.replaceChildren(); + for (const [name, messages] of [['error', result.errors], ['warning', result.warnings]]) { + if (!messages.length) continue; + const list = element('ul', undefined, name); + for (const item of messages) list.append(element('li', diagnostic(locale, item.message))); + report.append(list); + } + const rows = [...document.querySelectorAll('.binding')]; + const renderedEntries = groups.flatMap(group => group.actions.flatMap(action => entries.filter(entry => entry.action === action))); + rows.forEach((row, index) => { + const invalid = result.errors.some(error => entries[error.index] === renderedEntries[index]); + for (const control of row.querySelectorAll('.dropdown-trigger')) control.setAttribute('aria-invalid', String(invalid)); + }); +} + +function cancelCapture(message) { + captureTarget = undefined; + dialog.close(); + captureButton?.focus(); + if (message) setStatus(message); +} + +byId('cancel-capture').addEventListener('click', () => cancelCapture('captureCancelled')); +dialog.addEventListener('cancel', event => { + event.preventDefault(); + cancelCapture('captureCancelled'); +}); +window.addEventListener('blur', () => { + if (captureTarget) cancelCapture('captureBlur'); +}); +window.addEventListener('keydown', event => { + if (!captureTarget) return; + event.preventDefault(); + event.stopImmediatePropagation(); + if (event.code === 'Escape' || event.key === 'Escape') { + cancelCapture('captureCancelled'); + return; + } + if (event.repeat) return; + const result = captureKey(event); + if (result.error) { + byId('capture-status').textContent = diagnostic(locale, result.error); + return; + } + const target = captureTarget; + target.value = result.value; + cancelCapture(); + bindingControls.get(target)?.setValue(result.value); + updateOutput(); + setStatus('captured', {value: result.value, action: target.action}); +}, true); + +byId('search').addEventListener('input', filterActions); +byId('reset').addEventListener('click', async () => { + if (!await confirmReplacement()) return; + entries = []; + savedConfiguration = '[]'; + importReport = undefined; + byId('import-report').hidden = true; + render(); + setStatus('resetDone'); +}); +byId('import').addEventListener('click', () => byId('file').click()); +byId('file').addEventListener('change', async event => { + const file = event.target.files[0]; + if (!file) return; + event.target.value = ''; + try { + if (file.size > 1024 * 1024) throw new Error(t('fileTooLarge')); + const text = new TextDecoder('utf-8', {fatal: true}).decode(await file.arrayBuffer()); + const result = parseIni(text); + const report = byId('import-report'); + importReport = result; + renderImportReport(); + if (result.errors.length) { + report.focus(); + return; + } + if (!await confirmReplacement()) return; + entries = result.entries; + savedConfiguration = JSON.stringify(entries); + render(); + setStatus('imported', {name: file.name}); + } catch (error) { + setStatus('importFailed', {message: error instanceof TypeError ? t('invalidUtf8') : error.message}); + } +}); +byId('download').addEventListener('click', () => { + const content = serialize(entries); + const url = URL.createObjectURL(new Blob([content], {type: 'text/plain;charset=utf-8'})); + const link = element('a'); + link.href = url; + link.download = 'anyps5-input.ini'; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + savedConfiguration = JSON.stringify(entries); + setStatus('downloaded'); +}); +byId('copy').addEventListener('click', async () => { + try { + const content = serialize(entries); + const copiedConfiguration = JSON.stringify(entries); + await navigator.clipboard.writeText(content); + savedConfiguration = copiedConfiguration; + setStatus('copied'); + } catch { + byId('preview').focus(); + byId('preview').select(); + setStatus('clipboardFailed'); + } +}); +window.addEventListener('beforeunload', event => { + if (!isDirty()) return; + event.preventDefault(); + event.returnValue = ''; +}); +byId('focus-search').addEventListener('click', () => { + showView('controls'); + byId('search').focus(); +}); +for (const link of document.querySelectorAll('[data-view]')) { + link.addEventListener('click', event => { + event.preventDefault(); + showView(link.dataset.view); + }); +} +byId('back-controls').addEventListener('click', () => showView('controls')); +function restoreView() { + const view = location.hash.slice(1); + if (['controls', 'configuration', 'help'].includes(view)) showView(view, false, false); + else if (!view) showView('controls', false, false); +} +window.addEventListener('popstate', restoreView); +window.addEventListener('hashchange', restoreView); +languageControl = createDropdown({ + label: t('language'), choices: locales.map(item => ({value: item.code, label: item.name, lang: item.code, direction: item.direction})), + value: locale, placeholder: t('language'), text: dropdownText(), trigger: byId('settings'), direction: locales.find(item => item.code === locale).direction +}); +byId('language-control').append(languageControl.element); +languageControl.select.addEventListener('change', event => { + locale = event.target.value; + try { localStorage.setItem('anyps5-controls-language', locale); } catch {} + applyLocale(); +}); +function updateClock() { + const now = new Date(); + byId('clock').textContent = new Intl.DateTimeFormat(locale, {hour: '2-digit', minute: '2-digit'}).format(now); + byId('clock').dateTime = now.toISOString(); +} +updateClock(); +setInterval(updateClock, 60000); +applyLocale(); diff --git a/tools/input-mapper/capture.js b/tools/input-mapper/capture.js new file mode 100644 index 0000000000..05bbce0c36 --- /dev/null +++ b/tools/input-mapper/capture.js @@ -0,0 +1,30 @@ +import {exportableKeys} from './keys.js'; + +const codes = { + Enter: 'Return', Escape: 'Escape', Space: 'Space', Backspace: 'Backspace', Tab: 'Tab', + Minus: '-', Equal: '=', BracketLeft: '[', BracketRight: ']', Backslash: '\\', + Semicolon: ';', Quote: "'", Backquote: '`', Comma: ',', Period: '.', Slash: '/', + CapsLock: 'CapsLock', PrintScreen: 'PrintScreen', ScrollLock: 'ScrollLock', Pause: 'Pause', + Insert: 'Insert', Home: 'Home', PageUp: 'PageUp', Delete: 'Delete', End: 'End', PageDown: 'PageDown', + ArrowRight: 'Right', ArrowLeft: 'Left', ArrowDown: 'Down', ArrowUp: 'Up', NumLock: 'Numlock', + NumpadDivide: 'Keypad /', NumpadMultiply: 'Keypad *', NumpadSubtract: 'Keypad -', + NumpadAdd: 'Keypad +', NumpadEnter: 'Keypad Enter', NumpadDecimal: 'Keypad .', + NumpadEqual: 'Keypad =', NumpadComma: 'Keypad ,', ContextMenu: 'Application', + ControlLeft: 'Left Ctrl', ControlRight: 'Right Ctrl', ShiftLeft: 'Left Shift', ShiftRight: 'Right Shift', + AltLeft: 'Left Alt', AltRight: 'Right Alt', MetaLeft: 'Left GUI', MetaRight: 'Right GUI' +}; + +export function captureKey(event) { + if (event.isComposing || event.key === 'Dead' || event.key === 'Process') { + return {error: 'Composition and dead keys are ambiguous. Select an SDL key from the list.'}; + } + let value = codes[event.code]; + if (/^Key[A-Z]$/u.test(event.code)) value = event.code.slice(3); + else if (/^Digit[0-9]$/u.test(event.code)) value = event.code.slice(5); + else if (/^Numpad[0-9]$/u.test(event.code)) value = `Keypad ${event.code.slice(6)}`; + else if (/^F(?:[1-9]|1[0-9]|2[0-4])$/u.test(event.code)) value = event.code; + if (!value || !exportableKeys.includes(value)) { + return {error: 'This physical key cannot be captured in the current INI format. Select an SDL key from the list.'}; + } + return {value}; +} diff --git a/tools/input-mapper/dropdown.js b/tools/input-mapper/dropdown.js new file mode 100644 index 0000000000..63a962e2ea --- /dev/null +++ b/tools/input-mapper/dropdown.js @@ -0,0 +1,167 @@ +const dialog = document.getElementById('picker-dialog'); +const title = document.getElementById('picker-title'); +const search = document.getElementById('picker-search'); +const list = document.getElementById('picker-options'); +const empty = document.getElementById('picker-empty'); +const close = document.getElementById('picker-close'); +let active; +let visible = []; +let focused = 0; + +function renderOptions() { + const query = search.value.trim().toLocaleLowerCase(); + visible = active.choices.filter(item => `${item.label} ${item.value}`.toLocaleLowerCase().includes(query)); + list.replaceChildren(); + const exactMatch = query ? visible.findIndex(item => item.label.toLocaleLowerCase() === query || item.value.toLocaleLowerCase() === query) : -1; + focused = Math.max(0, exactMatch >= 0 ? exactMatch : visible.findIndex(item => item.value === active.select.value)); + visible.forEach((item, index) => { + const option = document.createElement('button'); + option.type = 'button'; + option.className = 'picker-option'; + option.setAttribute('role', 'option'); + option.setAttribute('aria-selected', String(item.value === active.select.value)); + option.tabIndex = index === focused ? 0 : -1; + option.textContent = item.label; + if (item.lang) option.lang = item.lang; + option.dir = item.direction || active.direction; + option.addEventListener('click', () => choose(item)); + option.addEventListener('focus', () => { focused = index; }); + list.append(option); + }); + empty.hidden = visible.length !== 0; + if (dialog.open) list.children[focused]?.scrollIntoView({block: 'nearest'}); +} + +function choose(item) { + const current = active; + current.setValue(item.value); + dialog.close(); + current.select.dispatchEvent(new Event('change', {bubbles: true})); +} + +function focusOption(index) { + if (!visible.length) return; + focused = (index + visible.length) % visible.length; + [...list.children].forEach((option, position) => { option.tabIndex = position === focused ? 0 : -1; }); + list.children[focused].focus({preventScroll: true}); + list.children[focused].scrollIntoView({block: 'nearest'}); +} + +function positionPicker() { + if (!dialog.open || !active) return; + if (matchMedia('(max-width: 600px)').matches) { + for (const name of ['top', 'left', 'width']) dialog.style.removeProperty(name); + return; + } + const rect = active.trigger.getBoundingClientRect(); + const width = Math.min(Math.max(rect.width, 320), innerWidth - 32); + dialog.style.width = `${width}px`; + const height = dialog.getBoundingClientRect().height; + const preferred = rect.bottom + 10; + const top = preferred + height <= innerHeight - 16 ? preferred : Math.max(16, rect.top - height - 10); + const left = active.direction === 'rtl' ? rect.right - width : rect.left; + dialog.style.top = `${Math.min(top, Math.max(16, innerHeight - height - 16))}px`; + dialog.style.left = `${Math.max(16, Math.min(left, innerWidth - width - 16))}px`; +} + +function openPicker(control) { + active = control; + title.textContent = control.label; + list.setAttribute('aria-label', control.label); + search.value = ''; + search.placeholder = control.text.search; + search.setAttribute('aria-label', control.text.search); + close.textContent = control.text.close; + empty.textContent = control.text.empty; + dialog.style.setProperty('--picker-height', `${Math.min(480, 136 + Math.min(control.choices.length, 8) * 46)}px`); + renderOptions(); + control.trigger.setAttribute('aria-expanded', 'true'); + dialog.showModal(); + positionPicker(); + list.children[focused]?.scrollIntoView({block: 'nearest'}); + search.focus(); +} + +search.addEventListener('input', renderOptions); +dialog.addEventListener('keydown', event => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + const inSearch = event.target === search; + focusOption(inSearch ? focused : focused + (event.key === 'ArrowDown' ? 1 : -1)); + } else if (event.target !== search && (event.key === 'Home' || event.key === 'End')) { + event.preventDefault(); + focusOption(event.key === 'Home' ? 0 : visible.length - 1); + } else if (event.target === search && event.key === 'Enter' && visible.length) { + event.preventDefault(); + choose(visible[focused]); + } +}); +close.addEventListener('click', () => dialog.close()); +dialog.addEventListener('click', event => { + if (event.target !== dialog) return; + const rect = dialog.getBoundingClientRect(); + if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) dialog.close(); +}); +dialog.addEventListener('close', () => { + const current = active; + active = undefined; + current?.trigger.setAttribute('aria-expanded', 'false'); + if (current?.trigger.isConnected) current.trigger.focus({preventScroll: true}); +}); +window.addEventListener('resize', positionPicker); + +export function createDropdown({label, choices, value, placeholder, text, trigger, direction = 'ltr'}) { + const wrapper = document.createElement('div'); + wrapper.className = 'dropdown'; + const select = document.createElement('select'); + select.hidden = true; + const providedTrigger = Boolean(trigger); + const control = trigger || document.createElement('button'); + control.type = 'button'; + if (!providedTrigger) { + control.className = 'dropdown-trigger'; + control.setAttribute('role', 'combobox'); + } + control.setAttribute('aria-label', label); + control.setAttribute('aria-haspopup', 'dialog'); + control.setAttribute('aria-controls', 'picker-dialog'); + control.setAttribute('aria-expanded', 'false'); + const selected = document.createElement('span'); + selected.className = 'dropdown-value'; + if (!providedTrigger) control.append(selected); + const api = { + element: wrapper, select, trigger: control, label, direction, text, choices: [], + setValue(next) { + select.value = next; + const choice = api.choices.find(item => item.value === select.value); + if (!providedTrigger) selected.textContent = choice?.label || placeholder; + if (!providedTrigger) control.title = choice?.label || placeholder; + }, + setChoices(next) { + const previous = select.value; + api.choices = next.map(item => typeof item === 'string' ? {value: item, label: item} : item); + select.replaceChildren(); + for (const item of api.choices) { + const option = document.createElement('option'); + option.value = item.value; + option.textContent = item.label; + select.append(option); + } + api.setValue(previous); + }, + focus() { control.focus(); } + }; + api.setChoices(choices); + api.setValue(value); + select.addEventListener('change', () => api.setValue(select.value)); + control.addEventListener('click', () => openPicker(api)); + control.addEventListener('keydown', event => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + openPicker(api); + } + }); + wrapper.append(select); + if (!providedTrigger) wrapper.append(control); + return api; +} diff --git a/tools/input-mapper/i18n.js b/tools/input-mapper/i18n.js new file mode 100644 index 0000000000..3f6b3f0d86 --- /dev/null +++ b/tools/input-mapper/i18n.js @@ -0,0 +1,1263 @@ +export const locales = [ + { + "code": "ar-EG", + "name": "العربية (المصري)", + "direction": "rtl" + }, + { + "code": "en", + "name": "English", + "direction": "ltr" + }, + { + "code": "es", + "name": "Español", + "direction": "ltr" + }, + { + "code": "fr", + "name": "Français", + "direction": "ltr" + }, + { + "code": "de", + "name": "Deutsch", + "direction": "ltr" + }, + { + "code": "it", + "name": "Italiano", + "direction": "ltr" + }, + { + "code": "pt", + "name": "Português", + "direction": "ltr" + }, + { + "code": "ru", + "name": "Русский", + "direction": "ltr" + }, + { + "code": "tr", + "name": "Türkçe", + "direction": "ltr" + }, + { + "code": "zh-Hans", + "name": "简体中文", + "direction": "ltr" + }, + { + "code": "ja", + "name": "日本語", + "direction": "ltr" + }, + { + "code": "ko", + "name": "한국어", + "direction": "ltr" + }, + { + "code": "hi", + "name": "हिन्दी", + "direction": "ltr" + }, + { + "code": "bn", + "name": "বাংলা", + "direction": "ltr" + }, + { + "code": "ur", + "name": "اردو", + "direction": "rtl" + }, + { + "code": "fa", + "name": "فارسی", + "direction": "rtl" + }, + { + "code": "id", + "name": "Bahasa Indonesia", + "direction": "ltr" + }, + { + "code": "vi", + "name": "Tiếng Việt", + "direction": "ltr" + }, + { + "code": "th", + "name": "ไทย", + "direction": "ltr" + }, + { + "code": "sw", + "name": "Kiswahili", + "direction": "ltr" + }, + { + "code": "nl", + "name": "Nederlands", + "direction": "ltr" + }, + { + "code": "pl", + "name": "Polski", + "direction": "ltr" + } +]; + +export const messages = { + "en": { + "controls": "Controls", + "configuration": "Configuration", + "help": "Help", + "settings": "Settings", + "language": "Language", + "close": "Close", + "eyebrow": "Accessories / Custom controls", + "intro": "Choose a control, then assign your keyboard or mouse inputs.", + "search": "Search controls", + "import": "Import INI", + "reset": "Reset", + "group0": "Face buttons", + "group1": "Shoulders", + "group2": "D-pad", + "group3": "Left stick", + "group4": "Right stick", + "group5": "Touch & utility", + "select": "Select…", + "add": "+ Add binding", + "capture": "Capture", + "remove": "Remove", + "builtin": "Built-in: {inputs}", + "custom": "Custom inputs replace built-in inputs.", + "noResults": "No controls match your search.", + "download": "Download INI", + "copy": "Copy configuration", + "previewEmpty": "Add a binding to begin.", + "backControls": "Back to controls", + "switchGroup": "Switch group", + "moveFocus": "Move focus", + "helpIntro": "A few answers to help you get started.", + "configIntro": "Review your inputs, then copy or download your file.", + "captureTitle": "Press a key for {action}", + "cancelCapture": "Cancel capture", + "keepEditing": "Keep editing", + "discard": "Discard changes", + "faq0Question": "How do I assign a key?", + "faq0Answer": "Choose a group, select Add binding, then Capture or choose a key from the list. Multiple bindings are alternatives.", + "faq1Question": "Where does my INI file go?", + "faq1Answer": "Download anyps5-input.ini and put it next to the generated game executable. For another location, set ANYPS5_INPUT_CONFIG to the file path.", + "faq2Question": "Why does a key not get captured?", + "faq2Answer": "Browser or system shortcuts may intercept it. Choose it manually from the list. Escape cancels capture; bind Escape using the list.", + "faq3Question": "Are my edits saved automatically?", + "faq3Answer": "Input edits stay in memory: copy or download before reloading. Only your language choice is saved in this browser. Unconfigured actions keep their defaults.", + "skip": "Skip to content", + "mainNav": "Main navigation", + "groups": "Control groups", + "find": "Find an action", + "clock": "Local time", + "output": "Configuration output", + "captureHelp": "Capture uses physical key positions. Escape cancels capture; choose Escape from the list to assign it. Some browser and system shortcuts require manual selection.", + "replaceTitle": "Discard unsaved changes?", + "replaceHelp": "Copy or download your configuration first if you want to keep it.", + "captureCancelled": "Capture cancelled.", + "captureBlur": "Capture cancelled because the window lost focus.", + "captured": "Captured {value} for {action}.", + "resetDone": "Configuration reset.", + "importRejected": "Import rejected", + "importRead": "Import read successfully", + "line": "Line {line}: {message}", + "preserved": "Your current configuration has been preserved.", + "imported": "Imported {name}.", + "importFailed": "Import failed: {message}", + "fileTooLarge": "INI file must be smaller than 1 MiB.", + "invalidUtf8": "The file must be valid UTF-8 text.", + "downloaded": "Configuration download requested.", + "copied": "Configuration copied.", + "clipboardFailed": "Clipboard unavailable. Copy the selected preview with Ctrl+C.", + "noScript": "Enable JavaScript to use this configurator.", + "noDefault": "no keyboard or mouse input", + "bindingLabel": "{action} binding {number}", + "typeLabel": "{binding} input type", + "valueLabel": "{binding} input value", + "addLabel": "Add binding to {action}", + "captureLabel": "Capture key for {binding}", + "removeLabel": "Remove {binding}", + "searchOptions": "Search options", + "noOptions": "No matching options." + }, + "ar-EG": { + "controls": "التحكم", + "configuration": "ملف الإعدادات", + "help": "المساعدة", + "settings": "الإعدادات", + "language": "اللغة", + "close": "قفل", + "eyebrow": "الملحقات / تخصيص التحكم", + "intro": "اختار زرار، وبعدين حدد له زرار من الكيبورد أو الماوس.", + "search": "دور على زرار", + "import": "استيراد INI", + "reset": "إعادة ضبط", + "group0": "أزرار التحكم", + "group1": "الأزرار العلوية", + "group2": "الاتجاهات", + "group3": "الانالوج الشمال", + "group4": "الانالوج اليمين", + "group5": "اللمس وأدوات تانية", + "select": "اختار…", + "add": "+ ضيف زرار", + "capture": "تسجيل زرار", + "remove": "إزالة", + "builtin": "الافتراضي: {inputs}", + "custom": "الزرار اللي اخترته بيستبدل التحكم الافتراضي.", + "noResults": "مفيش أزرار مطابقة للبحث.", + "download": "تنزيل INI", + "copy": "نسخ الإعدادات", + "previewEmpty": "ضيف زرار عشان تبدأ.", + "backControls": "ارجع للتحكم", + "switchGroup": "بدّل المجموعة", + "moveFocus": "انقل التركيز", + "helpIntro": "إجابات سريعة تساعدك تبدأ.", + "configIntro": "راجع اختياراتك، وبعدين انسخ الملف أو نزّله.", + "captureTitle": "اضغط زرار لـ {action}", + "cancelCapture": "إلغاء التسجيل", + "keepEditing": "كمّل التعديل", + "discard": "تجاهل التعديلات", + "faq0Question": "أربط زرار إزاي؟", + "faq0Answer": "اختار المجموعة واضغط ضيف زرار. بعدها اضغط تسجيل زرار أو اختاره من القائمة. تقدر تضيف أكتر من زرار لنفس الحركة.", + "faq1Question": "أحط ملف INI فين؟", + "faq1Answer": "نزّل anyps5-input.ini وحطه جنب ملف تشغيل اللعبة اللي اتولّد. لو عايزه في مكان تاني، حط مساره في ANYPS5_INPUT_CONFIG.", + "faq2Question": "ليه فيه زرار مش بيتسجل؟", + "faq2Answer": "ممكن المتصفح أو النظام يكون بيستخدمه كاختصار. اختاره من القائمة يدويًا. Escape بيلغي التسجيل؛ لو عايز تربطه اختاره من القائمة.", + "faq3Question": "تعديلاتي بتتحفظ لوحدها؟", + "faq3Answer": "التعديلات بتفضل في الذاكرة بس: انسخها أو نزّلها قبل تحديث الصفحة. اللغة بس هي اللي بتتحفظ في المتصفح. الأزرار اللي ماعدّلتهاش بتفضل على الافتراضي.", + "skip": "روح للمحتوى", + "mainNav": "التنقل الرئيسي", + "groups": "مجموعات التحكم", + "find": "دور على زرار", + "clock": "الوقت الحالي", + "output": "ملف الإعدادات", + "captureHelp": "التسجيل بيستخدم مكان الزرار على الكيبورد. Escape بيلغي التسجيل؛ عشان تربطه اختاره من القائمة. بعض اختصارات المتصفح والنظام لازم تختارها يدويًا.", + "replaceTitle": "تتجاهل التعديلات اللي لسه ماحفظتهاش؟", + "replaceHelp": "انسخ الإعدادات أو نزّلها الأول لو عايز تحتفظ بيها.", + "captureCancelled": "تم إلغاء تسجيل الزرار.", + "captureBlur": "التسجيل اتلغى عشان خرجت من النافذة.", + "captured": "تم تسجيل {value} لـ {action}.", + "resetDone": "الإعدادات رجعت للافتراضي.", + "importRejected": "الملف مااتقبلش", + "importRead": "الملف اتقري بنجاح", + "line": "سطر {line}: {message}", + "preserved": "إعداداتك الحالية لسه زي ما هي.", + "imported": "تم استيراد {name}.", + "importFailed": "الاستيراد ماكملش: {message}", + "fileTooLarge": "ملف INI لازم يكون أصغر من 1 MiB.", + "invalidUtf8": "الملف لازم يكون نص بترميز UTF-8 سليم.", + "downloaded": "تم طلب تنزيل الإعدادات.", + "copied": "الإعدادات اتنسخت.", + "clipboardFailed": "النسخ مش متاح. انسخ النص المحدد بـ Ctrl+C.", + "noScript": "فعّل JavaScript عشان تستخدم الصفحة.", + "noDefault": "مفيش زرار كيبورد أو ماوس افتراضي", + "bindingLabel": "{action}، الربط {number}", + "typeLabel": "نوع الإدخال لـ {binding}", + "valueLabel": "الزرار لـ {binding}", + "addLabel": "ضيف زرار لـ {action}", + "captureLabel": "سجّل زرار لـ {binding}", + "removeLabel": "شيل {binding}", + "searchOptions": "دور في القائمة", + "noOptions": "مفيش اختيار مطابق للبحث." + }, + "es": { + "controls": "Controles", + "configuration": "Configuración", + "help": "Ayuda", + "settings": "Ajustes", + "language": "Idioma", + "close": "Cerrar", + "eyebrow": "Accesorios / Controles personalizados", + "intro": "Elige un control y asigna entradas del teclado o ratón.", + "search": "Buscar controles", + "import": "Importar INI", + "reset": "Restablecer", + "group0": "Botones frontales", + "group1": "Botones superiores", + "group2": "Cruceta", + "group3": "Stick izquierdo", + "group4": "Stick derecho", + "group5": "Táctil y utilidades", + "select": "Seleccionar…", + "add": "+ Añadir entrada", + "capture": "Capturar", + "remove": "Eliminar", + "builtin": "Predeterminado: {inputs}", + "custom": "Las entradas personalizadas sustituyen las predeterminadas.", + "noResults": "No se encontraron controles.", + "download": "Descargar INI", + "copy": "Copiar configuración", + "previewEmpty": "Añade una entrada para empezar.", + "backControls": "Volver a controles", + "switchGroup": "Cambiar grupo", + "moveFocus": "Mover el foco", + "helpIntro": "Respuestas rápidas para empezar.", + "configIntro": "Revisa las entradas y copia o descarga el archivo.", + "captureTitle": "Pulsa una tecla para {action}", + "cancelCapture": "Cancelar captura", + "keepEditing": "Seguir editando", + "discard": "Descartar cambios", + "faq0Question": "¿Cómo asigno una tecla?", + "faq0Answer": "Elige un grupo y Añadir entrada. Usa Capturar o la lista. Puedes asignar varias entradas a una acción.", + "faq1Question": "¿Dónde guardo el archivo INI?", + "faq1Answer": "Pon anyps5-input.ini junto al ejecutable generado del juego. Para otra ruta, usa ANYPS5_INPUT_CONFIG.", + "faq2Question": "¿Por qué no se captura una tecla?", + "faq2Answer": "El navegador o sistema puede interceptarla. Elígela en la lista. Escape cancela la captura; asígnala desde la lista.", + "faq3Question": "¿Se guardan mis cambios solos?", + "faq3Answer": "Los cambios están en memoria: cópialos o descárgalos antes de recargar. Solo se guarda el idioma. Las acciones no configuradas conservan sus valores predeterminados.", + "searchOptions": "Buscar opciones", + "noOptions": "No hay opciones coincidentes." + }, + "fr": { + "controls": "Commandes", + "configuration": "Configuration", + "help": "Aide", + "settings": "Paramètres", + "language": "Langue", + "close": "Fermer", + "eyebrow": "Accessoires / Commandes personnalisées", + "intro": "Choisissez une commande, puis une touche ou un bouton de souris.", + "search": "Rechercher une commande", + "import": "Importer INI", + "reset": "Réinitialiser", + "group0": "Boutons principaux", + "group1": "Boutons supérieurs", + "group2": "Croix directionnelle", + "group3": "Stick gauche", + "group4": "Stick droit", + "group5": "Tactile et utilitaires", + "select": "Choisir…", + "add": "+ Ajouter une entrée", + "capture": "Capturer", + "remove": "Supprimer", + "builtin": "Par défaut : {inputs}", + "custom": "Les entrées personnalisées remplacent celles par défaut.", + "noResults": "Aucune commande trouvée.", + "download": "Télécharger INI", + "copy": "Copier la configuration", + "previewEmpty": "Ajoutez une entrée pour commencer.", + "backControls": "Retour aux commandes", + "switchGroup": "Changer de groupe", + "moveFocus": "Déplacer le focus", + "helpIntro": "Quelques réponses pour bien commencer.", + "configIntro": "Vérifiez les entrées, puis copiez ou téléchargez le fichier.", + "captureTitle": "Appuyez sur une touche pour {action}", + "cancelCapture": "Annuler la capture", + "keepEditing": "Continuer la modification", + "discard": "Ignorer les modifications", + "faq0Question": "Comment attribuer une touche ?", + "faq0Answer": "Choisissez un groupe et Ajouter une entrée. Utilisez Capturer ou la liste. Plusieurs entrées peuvent déclencher la même action.", + "faq1Question": "Où placer le fichier INI ?", + "faq1Answer": "Placez anyps5-input.ini près de l’exécutable généré du jeu. Pour un autre chemin, utilisez ANYPS5_INPUT_CONFIG.", + "faq2Question": "Pourquoi une touche n’est-elle pas capturée ?", + "faq2Answer": "Un raccourci du navigateur ou du système peut l’intercepter. Choisissez-la dans la liste. Escape annule la capture ; attribuez-la via la liste.", + "faq3Question": "Mes modifications sont-elles enregistrées ?", + "faq3Answer": "Les modifications restent en mémoire : copiez-les ou téléchargez-les avant de recharger. Seule la langue est mémorisée. Les actions non configurées gardent leurs valeurs par défaut.", + "searchOptions": "Rechercher une option", + "noOptions": "Aucune option trouvée." + }, + "de": { + "controls": "Steuerung", + "configuration": "Konfiguration", + "help": "Hilfe", + "settings": "Einstellungen", + "language": "Sprache", + "close": "Schließen", + "eyebrow": "Zubehör / Eigene Steuerung", + "intro": "Wähle eine Steuerung und weise Tastatur- oder Mauseingaben zu.", + "search": "Steuerung suchen", + "import": "INI importieren", + "reset": "Zurücksetzen", + "group0": "Aktionstasten", + "group1": "Schultertasten", + "group2": "Steuerkreuz", + "group3": "Linker Stick", + "group4": "Rechter Stick", + "group5": "Touch und Werkzeuge", + "select": "Auswählen…", + "add": "+ Eingabe hinzufügen", + "capture": "Erfassen", + "remove": "Entfernen", + "builtin": "Standard: {inputs}", + "custom": "Eigene Eingaben ersetzen die Standardeingaben.", + "noResults": "Keine passende Steuerung gefunden.", + "download": "INI herunterladen", + "copy": "Konfiguration kopieren", + "previewEmpty": "Füge eine Eingabe hinzu, um zu beginnen.", + "backControls": "Zurück zur Steuerung", + "switchGroup": "Gruppe wechseln", + "moveFocus": "Fokus verschieben", + "helpIntro": "Kurze Antworten für den Einstieg.", + "configIntro": "Prüfe die Eingaben und kopiere oder lade die Datei herunter.", + "captureTitle": "Drücke eine Taste für {action}", + "cancelCapture": "Erfassung abbrechen", + "keepEditing": "Weiter bearbeiten", + "discard": "Änderungen verwerfen", + "faq0Question": "Wie weise ich eine Taste zu?", + "faq0Answer": "Wähle eine Gruppe und Eingabe hinzufügen. Nutze Erfassen oder die Liste. Mehrere Eingaben können dieselbe Aktion auslösen.", + "faq1Question": "Wohin gehört die INI-Datei?", + "faq1Answer": "Lege anyps5-input.ini neben die erzeugte Spielprogrammdatei. Für einen anderen Pfad nutze ANYPS5_INPUT_CONFIG.", + "faq2Question": "Warum wird eine Taste nicht erfasst?", + "faq2Answer": "Browser- oder Systemkürzel können sie abfangen. Wähle sie in der Liste. Escape bricht die Erfassung ab; weise Escape über die Liste zu.", + "faq3Question": "Werden Änderungen automatisch gespeichert?", + "faq3Answer": "Änderungen bleiben im Speicher: vor dem Neuladen kopieren oder herunterladen. Nur die Sprache wird gespeichert. Nicht konfigurierte Aktionen behalten ihre Standardwerte.", + "searchOptions": "Optionen suchen", + "noOptions": "Keine passenden Optionen." + }, + "it": { + "controls": "Controlli", + "configuration": "Configurazione", + "help": "Aiuto", + "settings": "Impostazioni", + "language": "Lingua", + "close": "Chiudi", + "eyebrow": "Accessori / Controlli personalizzati", + "intro": "Scegli un controllo e assegna un tasto o un pulsante del mouse.", + "search": "Cerca controlli", + "import": "Importa INI", + "reset": "Reimposta", + "group0": "Pulsanti principali", + "group1": "Pulsanti superiori", + "group2": "Croce direzionale", + "group3": "Stick sinistro", + "group4": "Stick destro", + "group5": "Touch e utilità", + "select": "Seleziona…", + "add": "+ Aggiungi input", + "capture": "Acquisisci", + "remove": "Rimuovi", + "builtin": "Predefinito: {inputs}", + "custom": "Gli input personalizzati sostituiscono quelli predefiniti.", + "noResults": "Nessun controllo trovato.", + "download": "Scarica INI", + "copy": "Copia configurazione", + "previewEmpty": "Aggiungi un input per iniziare.", + "backControls": "Torna ai controlli", + "switchGroup": "Cambia gruppo", + "moveFocus": "Sposta il focus", + "helpIntro": "Risposte rapide per iniziare.", + "configIntro": "Controlla gli input e copia o scarica il file.", + "captureTitle": "Premi un tasto per {action}", + "cancelCapture": "Annulla acquisizione", + "keepEditing": "Continua a modificare", + "discard": "Scarta modifiche", + "faq0Question": "Come assegno un tasto?", + "faq0Answer": "Scegli un gruppo e Aggiungi input. Usa Acquisisci o l’elenco. Puoi assegnare più input alla stessa azione.", + "faq1Question": "Dove metto il file INI?", + "faq1Answer": "Metti anyps5-input.ini accanto all’eseguibile generato del gioco. Per un altro percorso, usa ANYPS5_INPUT_CONFIG.", + "faq2Question": "Perché un tasto non viene acquisito?", + "faq2Answer": "Il browser o il sistema potrebbe intercettarlo. Sceglilo dall’elenco. Escape annulla l’acquisizione; assegnalo dall’elenco.", + "faq3Question": "Le modifiche si salvano automaticamente?", + "faq3Answer": "Le modifiche restano in memoria: copiale o scaricale prima di ricaricare. Si salva solo la lingua. Le azioni non configurate mantengono i valori predefiniti.", + "searchOptions": "Cerca opzioni", + "noOptions": "Nessuna opzione trovata." + }, + "pt": { + "controls": "Controles", + "configuration": "Configuração", + "help": "Ajuda", + "settings": "Configurações", + "language": "Idioma", + "close": "Fechar", + "eyebrow": "Acessórios / Controles personalizados", + "intro": "Escolha um controle e atribua entradas do teclado ou mouse.", + "search": "Buscar controles", + "import": "Importar INI", + "reset": "Redefinir", + "group0": "Botões principais", + "group1": "Botões superiores", + "group2": "Direcional", + "group3": "Analógico esquerdo", + "group4": "Analógico direito", + "group5": "Toque e utilidades", + "select": "Selecionar…", + "add": "+ Adicionar entrada", + "capture": "Capturar", + "remove": "Remover", + "builtin": "Padrão: {inputs}", + "custom": "As entradas personalizadas substituem as entradas padrão.", + "noResults": "Nenhum controle encontrado.", + "download": "Baixar INI", + "copy": "Copiar configuração", + "previewEmpty": "Adicione uma entrada para começar.", + "backControls": "Voltar aos controles", + "switchGroup": "Trocar grupo", + "moveFocus": "Mover o foco", + "helpIntro": "Respostas rápidas para começar.", + "configIntro": "Confira as entradas e copie ou baixe o arquivo.", + "captureTitle": "Pressione uma tecla para {action}", + "cancelCapture": "Cancelar captura", + "keepEditing": "Continuar editando", + "discard": "Descartar alterações", + "faq0Question": "Como atribuo uma tecla?", + "faq0Answer": "Escolha um grupo e Adicionar entrada. Use Capturar ou a lista. Uma ação pode ter várias entradas alternativas.", + "faq1Question": "Onde coloco o arquivo INI?", + "faq1Answer": "Coloque anyps5-input.ini junto do executável gerado do jogo. Para outro caminho, use ANYPS5_INPUT_CONFIG.", + "faq2Question": "Por que uma tecla não é capturada?", + "faq2Answer": "O navegador ou sistema pode interceptá-la. Escolha na lista. Escape cancela a captura; atribua Escape pela lista.", + "faq3Question": "As alterações são salvas sozinhas?", + "faq3Answer": "As alterações ficam na memória: copie ou baixe antes de recarregar. Só o idioma é salvo. Ações não configuradas mantêm os padrões.", + "searchOptions": "Buscar opções", + "noOptions": "Nenhuma opção encontrada." + }, + "ru": { + "controls": "Управление", + "configuration": "Конфигурация", + "help": "Помощь", + "settings": "Настройки", + "language": "Язык", + "close": "Закрыть", + "eyebrow": "Аксессуары / Настройка управления", + "intro": "Выберите действие и назначьте клавишу или кнопку мыши.", + "search": "Поиск действий", + "import": "Импорт INI", + "reset": "Сбросить", + "group0": "Основные кнопки", + "group1": "Верхние кнопки", + "group2": "Крестовина", + "group3": "Левый стик", + "group4": "Правый стик", + "group5": "Сенсор и инструменты", + "select": "Выбрать…", + "add": "+ Добавить ввод", + "capture": "Записать", + "remove": "Удалить", + "builtin": "По умолчанию: {inputs}", + "custom": "Новые назначения заменяют стандартные.", + "noResults": "Действия не найдены.", + "download": "Скачать INI", + "copy": "Копировать конфигурацию", + "previewEmpty": "Добавьте назначение, чтобы начать.", + "backControls": "Назад к управлению", + "switchGroup": "Сменить группу", + "moveFocus": "Переместить фокус", + "helpIntro": "Краткие ответы для начала работы.", + "configIntro": "Проверьте назначения и скопируйте или скачайте файл.", + "captureTitle": "Нажмите клавишу для {action}", + "cancelCapture": "Отменить запись", + "keepEditing": "Продолжить изменение", + "discard": "Отменить изменения", + "faq0Question": "Как назначить клавишу?", + "faq0Answer": "Выберите группу и Добавить ввод. Используйте Записать или список. Одно действие может иметь несколько назначений.", + "faq1Question": "Куда поместить INI-файл?", + "faq1Answer": "Поместите anyps5-input.ini рядом с созданным исполняемым файлом игры. Для другого пути используйте ANYPS5_INPUT_CONFIG.", + "faq2Question": "Почему клавиша не записывается?", + "faq2Answer": "Браузер или система может перехватывать её. Выберите клавишу в списке. Escape отменяет запись; назначайте Escape через список.", + "faq3Question": "Изменения сохраняются автоматически?", + "faq3Answer": "Изменения хранятся в памяти: копируйте или скачивайте их перед перезагрузкой. Сохраняется только язык. Ненастроенные действия используют значения по умолчанию.", + "searchOptions": "Поиск вариантов", + "noOptions": "Совпадений нет." + }, + "tr": { + "controls": "Kontroller", + "configuration": "Yapılandırma", + "help": "Yardım", + "settings": "Ayarlar", + "language": "Dil", + "close": "Kapat", + "eyebrow": "Aksesuarlar / Özel kontroller", + "intro": "Bir kontrol seçip klavye veya fare girdisi atayın.", + "search": "Kontrol ara", + "import": "INI içe aktar", + "reset": "Sıfırla", + "group0": "Ana düğmeler", + "group1": "Üst düğmeler", + "group2": "Yön tuşları", + "group3": "Sol çubuk", + "group4": "Sağ çubuk", + "group5": "Dokunma ve araçlar", + "select": "Seç…", + "add": "+ Girdi ekle", + "capture": "Yakala", + "remove": "Kaldır", + "builtin": "Varsayılan: {inputs}", + "custom": "Özel girdiler varsayılanların yerini alır.", + "noResults": "Kontrol bulunamadı.", + "download": "INI indir", + "copy": "Yapılandırmayı kopyala", + "previewEmpty": "Başlamak için bir girdi ekleyin.", + "backControls": "Kontrollere dön", + "switchGroup": "Grubu değiştir", + "moveFocus": "Odağı taşı", + "helpIntro": "Başlamanıza yardımcı olacak kısa yanıtlar.", + "configIntro": "Girdileri kontrol edip dosyayı kopyalayın veya indirin.", + "captureTitle": "{action} için bir tuşa basın", + "cancelCapture": "Yakalamayı iptal et", + "keepEditing": "Düzenlemeye devam et", + "discard": "Değişiklikleri sil", + "faq0Question": "Nasıl tuş atarım?", + "faq0Answer": "Bir grup seçip Girdi ekle düğmesine basın. Yakala veya listeyi kullanın. Bir eyleme birden fazla girdi atanabilir.", + "faq1Question": "INI dosyasını nereye koyarım?", + "faq1Answer": "anyps5-input.ini dosyasını oluşturulan oyun yürütülebilir dosyasının yanına koyun. Başka yol için ANYPS5_INPUT_CONFIG kullanın.", + "faq2Question": "Neden bir tuş yakalanmıyor?", + "faq2Answer": "Tarayıcı veya sistem kısayolu engelleyebilir. Listeden seçin. Escape yakalamayı iptal eder; Escape tuşunu listeden atayın.", + "faq3Question": "Değişiklikler otomatik kaydedilir mi?", + "faq3Answer": "Değişiklikler bellekte kalır: yenilemeden önce kopyalayın veya indirin. Yalnızca dil kaydedilir. Ayarlanmayan eylemler varsayılanlarını korur.", + "searchOptions": "Seçenek ara", + "noOptions": "Eşleşen seçenek yok." + }, + "zh-Hans": { + "controls": "控制", + "configuration": "配置", + "help": "帮助", + "settings": "设置", + "language": "语言", + "close": "关闭", + "eyebrow": "配件 / 自定义控制", + "intro": "选择一个控制,然后分配键盘或鼠标输入。", + "search": "搜索控制", + "import": "导入 INI", + "reset": "重置", + "group0": "主要按钮", + "group1": "肩部按钮", + "group2": "方向键", + "group3": "左摇杆", + "group4": "右摇杆", + "group5": "触摸和工具", + "select": "选择…", + "add": "+ 添加输入", + "capture": "录入", + "remove": "移除", + "builtin": "默认:{inputs}", + "custom": "自定义输入会替换默认输入。", + "noResults": "没有找到匹配的控制。", + "download": "下载 INI", + "copy": "复制配置", + "previewEmpty": "添加输入以开始。", + "backControls": "返回控制", + "switchGroup": "切换分组", + "moveFocus": "移动焦点", + "helpIntro": "帮助您入门的简短解答。", + "configIntro": "检查输入,然后复制或下载文件。", + "captureTitle": "为 {action} 按下一个键", + "cancelCapture": "取消录入", + "keepEditing": "继续编辑", + "discard": "放弃更改", + "faq0Question": "如何分配按键?", + "faq0Answer": "选择分组和添加输入,然后使用录入或列表。同一操作可以使用多个输入。", + "faq1Question": "INI 文件放在哪里?", + "faq1Answer": "将 anyps5-input.ini 放在生成的游戏可执行文件旁。其他位置可使用 ANYPS5_INPUT_CONFIG 指定路径。", + "faq2Question": "为什么无法录入某个键?", + "faq2Answer": "浏览器或系统快捷键可能会拦截它。请从列表选择。Escape 会取消录入;要分配 Escape,请使用列表。", + "faq3Question": "修改会自动保存吗?", + "faq3Answer": "输入修改只保留在内存中,刷新前请复制或下载。浏览器只保存语言选择。未配置的操作保留默认输入。", + "searchOptions": "搜索选项", + "noOptions": "没有匹配选项。" + }, + "ja": { + "controls": "操作", + "configuration": "構成", + "help": "ヘルプ", + "settings": "設定", + "language": "言語", + "close": "閉じる", + "eyebrow": "アクセサリー / 操作のカスタマイズ", + "intro": "操作を選び、キーボードやマウスの入力を割り当てます。", + "search": "操作を検索", + "import": "INI を読み込む", + "reset": "リセット", + "group0": "メインボタン", + "group1": "ショルダーボタン", + "group2": "方向キー", + "group3": "左スティック", + "group4": "右スティック", + "group5": "タッチとツール", + "select": "選択…", + "add": "+ 入力を追加", + "capture": "記録", + "remove": "削除", + "builtin": "初期設定:{inputs}", + "custom": "カスタム入力は初期設定を置き換えます。", + "noResults": "一致する操作がありません。", + "download": "INI をダウンロード", + "copy": "構成をコピー", + "previewEmpty": "入力を追加して開始します。", + "backControls": "操作に戻る", + "switchGroup": "グループを切り替え", + "moveFocus": "フォーカスを移動", + "helpIntro": "使い始めるための簡単な説明です。", + "configIntro": "入力を確認してファイルをコピーまたはダウンロードします。", + "captureTitle": "{action} のキーを押してください", + "cancelCapture": "記録をキャンセル", + "keepEditing": "編集を続ける", + "discard": "変更を破棄", + "faq0Question": "キーを割り当てるには?", + "faq0Answer": "グループと入力を追加を選び、記録または一覧を使います。同じ操作に複数の入力を割り当てられます。", + "faq1Question": "INI ファイルはどこに置きますか?", + "faq1Answer": "anyps5-input.ini を生成されたゲーム実行ファイルの隣に置きます。別の場所には ANYPS5_INPUT_CONFIG でパスを指定します。", + "faq2Question": "キーが記録されないのはなぜですか?", + "faq2Answer": "ブラウザーやシステムのショートカットが優先される場合があります。一覧から選んでください。Escape は記録をキャンセルするため、一覧から割り当てます。", + "faq3Question": "変更は自動保存されますか?", + "faq3Answer": "入力の変更はメモリ内だけです。再読み込み前にコピーかダウンロードをしてください。言語のみ保存されます。未設定の操作は初期設定を使います。", + "searchOptions": "選択肢を検索", + "noOptions": "一致する選択肢がありません。" + }, + "ko": { + "controls": "컨트롤", + "configuration": "구성", + "help": "도움말", + "settings": "설정", + "language": "언어", + "close": "닫기", + "eyebrow": "액세서리 / 컨트롤 맞춤 설정", + "intro": "컨트롤을 선택하고 키보드 또는 마우스 입력을 지정하세요.", + "search": "컨트롤 검색", + "import": "INI 가져오기", + "reset": "초기화", + "group0": "주요 버튼", + "group1": "상단 버튼", + "group2": "방향키", + "group3": "왼쪽 스틱", + "group4": "오른쪽 스틱", + "group5": "터치 및 도구", + "select": "선택…", + "add": "+ 입력 추가", + "capture": "기록", + "remove": "제거", + "builtin": "기본값: {inputs}", + "custom": "사용자 입력이 기본 입력을 대체합니다.", + "noResults": "일치하는 컨트롤이 없습니다.", + "download": "INI 다운로드", + "copy": "구성 복사", + "previewEmpty": "입력을 추가하여 시작하세요.", + "backControls": "컨트롤로 돌아가기", + "switchGroup": "그룹 전환", + "moveFocus": "포커스 이동", + "helpIntro": "시작을 돕는 간단한 답변입니다.", + "configIntro": "입력을 확인하고 파일을 복사하거나 다운로드하세요.", + "captureTitle": "{action}에 사용할 키를 누르세요", + "cancelCapture": "기록 취소", + "keepEditing": "계속 편집", + "discard": "변경 사항 삭제", + "faq0Question": "키는 어떻게 지정하나요?", + "faq0Answer": "그룹과 입력 추가를 선택한 뒤 기록 또는 목록을 사용하세요. 같은 동작에 여러 입력을 지정할 수 있습니다.", + "faq1Question": "INI 파일은 어디에 두나요?", + "faq1Answer": "생성된 게임 실행 파일 옆에 anyps5-input.ini를 두세요. 다른 경로는 ANYPS5_INPUT_CONFIG로 지정하세요.", + "faq2Question": "키가 기록되지 않는 이유는 무엇인가요?", + "faq2Answer": "브라우저나 시스템 단축키가 가로챌 수 있습니다. 목록에서 선택하세요. Escape는 기록을 취소하므로 목록에서 지정하세요.", + "faq3Question": "변경 사항이 자동 저장되나요?", + "faq3Answer": "입력 변경은 메모리에만 있습니다. 새로고침 전에 복사하거나 다운로드하세요. 언어 선택만 저장됩니다. 설정하지 않은 동작은 기본값을 유지합니다.", + "searchOptions": "옵션 검색", + "noOptions": "일치하는 옵션이 없습니다." + }, + "hi": { + "controls": "नियंत्रण", + "configuration": "कॉन्फ़िगरेशन", + "help": "मदद", + "settings": "सेटिंग्स", + "language": "भाषा", + "close": "बंद करें", + "eyebrow": "सहायक उपकरण / कस्टम नियंत्रण", + "intro": "नियंत्रण चुनें और कीबोर्ड या माउस इनपुट जोड़ें।", + "search": "नियंत्रण खोजें", + "import": "INI आयात करें", + "reset": "रीसेट", + "group0": "मुख्य बटन", + "group1": "ऊपरी बटन", + "group2": "दिशा बटन", + "group3": "बायाँ स्टिक", + "group4": "दायाँ स्टिक", + "group5": "टच और उपकरण", + "select": "चुनें…", + "add": "+ इनपुट जोड़ें", + "capture": "रिकॉर्ड करें", + "remove": "हटाएँ", + "builtin": "डिफ़ॉल्ट: {inputs}", + "custom": "कस्टम इनपुट डिफ़ॉल्ट इनपुट की जगह लेते हैं।", + "noResults": "कोई नियंत्रण नहीं मिला।", + "download": "INI डाउनलोड करें", + "copy": "कॉन्फ़िगरेशन कॉपी करें", + "previewEmpty": "शुरू करने के लिए इनपुट जोड़ें।", + "backControls": "नियंत्रण पर वापस जाएँ", + "switchGroup": "समूह बदलें", + "moveFocus": "फ़ोकस बदलें", + "helpIntro": "शुरू करने के लिए कुछ आसान जवाब।", + "configIntro": "इनपुट जाँचें और फ़ाइल कॉपी या डाउनलोड करें।", + "captureTitle": "{action} के लिए कोई कुंजी दबाएँ", + "cancelCapture": "रिकॉर्डिंग रद्द करें", + "keepEditing": "संपादन जारी रखें", + "discard": "बदलाव हटाएँ", + "faq0Question": "कुंजी कैसे जोड़ूँ?", + "faq0Answer": "समूह और इनपुट जोड़ें चुनें, फिर रिकॉर्ड करें या सूची का उपयोग करें। एक काम के लिए कई इनपुट जोड़ सकते हैं।", + "faq1Question": "INI फ़ाइल कहाँ रखें?", + "faq1Answer": "anyps5-input.ini को बनाए गए गेम प्रोग्राम के पास रखें। दूसरे स्थान के लिए ANYPS5_INPUT_CONFIG में पथ दें।", + "faq2Question": "कुंजी रिकॉर्ड क्यों नहीं होती?", + "faq2Answer": "ब्राउज़र या सिस्टम का शॉर्टकट उसे रोक सकता है। सूची से चुनें। Escape रिकॉर्डिंग रद्द करता है; इसे सूची से जोड़ें।", + "faq3Question": "क्या बदलाव अपने आप सेव होते हैं?", + "faq3Answer": "बदलाव केवल मेमोरी में रहते हैं: रीलोड से पहले कॉपी या डाउनलोड करें। केवल भाषा सेव होती है। बिना बदले काम अपने डिफ़ॉल्ट रखते हैं।", + "searchOptions": "विकल्प खोजें", + "noOptions": "कोई विकल्प नहीं मिला।" + }, + "bn": { + "controls": "নিয়ন্ত্রণ", + "configuration": "কনফিগারেশন", + "help": "সাহায্য", + "settings": "সেটিংস", + "language": "ভাষা", + "close": "বন্ধ করুন", + "eyebrow": "আনুষঙ্গিক / নিজস্ব নিয়ন্ত্রণ", + "intro": "একটি নিয়ন্ত্রণ বেছে কিবোর্ড বা মাউস ইনপুট দিন।", + "search": "নিয়ন্ত্রণ খুঁজুন", + "import": "INI আমদানি", + "reset": "রিসেট", + "group0": "প্রধান বোতাম", + "group1": "ওপরের বোতাম", + "group2": "দিক বোতাম", + "group3": "বাম স্টিক", + "group4": "ডান স্টিক", + "group5": "টাচ ও সরঞ্জাম", + "select": "বাছুন…", + "add": "+ ইনপুট যোগ", + "capture": "রেকর্ড", + "remove": "সরান", + "builtin": "ডিফল্ট: {inputs}", + "custom": "নিজস্ব ইনপুট ডিফল্ট ইনপুটের বদলে কাজ করে।", + "noResults": "কোনো নিয়ন্ত্রণ পাওয়া যায়নি।", + "download": "INI ডাউনলোড", + "copy": "কনফিগারেশন কপি", + "previewEmpty": "শুরু করতে একটি ইনপুট যোগ করুন।", + "backControls": "নিয়ন্ত্রণে ফিরে যান", + "switchGroup": "গ্রুপ বদলান", + "moveFocus": "ফোকাস সরান", + "helpIntro": "শুরু করার জন্য কিছু সহজ উত্তর।", + "configIntro": "ইনপুট দেখে ফাইল কপি বা ডাউনলোড করুন।", + "captureTitle": "{action}-এর জন্য একটি কী চাপুন", + "cancelCapture": "রেকর্ড বাতিল", + "keepEditing": "সম্পাদনা চালান", + "discard": "পরিবর্তন বাদ দিন", + "faq0Question": "কী কীভাবে যোগ করব?", + "faq0Answer": "গ্রুপ ও ইনপুট যোগ বেছে রেকর্ড বা তালিকা ব্যবহার করুন। এক কাজের জন্য একাধিক ইনপুট দিতে পারেন।", + "faq1Question": "INI ফাইল কোথায় রাখব?", + "faq1Answer": "তৈরি হওয়া গেম চালানোর ফাইলের পাশে anyps5-input.ini রাখুন। অন্য পথের জন্য ANYPS5_INPUT_CONFIG ব্যবহার করুন।", + "faq2Question": "কোনো কী রেকর্ড হচ্ছে না কেন?", + "faq2Answer": "ব্রাউজার বা সিস্টেম শর্টকাট সেটি আটকাতে পারে। তালিকা থেকে বাছুন। Escape রেকর্ড বাতিল করে; তালিকা থেকে যোগ করুন।", + "faq3Question": "পরিবর্তন কি নিজে থেকে সেভ হয়?", + "faq3Answer": "পরিবর্তন শুধু মেমোরিতে থাকে: রিলোডের আগে কপি বা ডাউনলোড করুন। শুধু ভাষা সেভ হয়। না বদলানো কাজগুলো ডিফল্ট রাখে।", + "searchOptions": "বিকল্প খুঁজুন", + "noOptions": "কোনো বিকল্প মেলেনি।" + }, + "ur": { + "controls": "کنٹرول", + "configuration": "ترتیب", + "help": "مدد", + "settings": "سیٹنگز", + "language": "زبان", + "close": "بند کریں", + "eyebrow": "لوازمات / حسب ضرورت کنٹرول", + "intro": "کنٹرول منتخب کرکے کی بورڈ یا ماؤس ان پٹ مقرر کریں۔", + "search": "کنٹرول تلاش کریں", + "import": "INI درآمد کریں", + "reset": "ری سیٹ", + "group0": "بنیادی بٹن", + "group1": "اوپری بٹن", + "group2": "سمت کے بٹن", + "group3": "بائیں اسٹک", + "group4": "دائیں اسٹک", + "group5": "ٹچ اور اوزار", + "select": "منتخب کریں…", + "add": "+ ان پٹ شامل کریں", + "capture": "ریکارڈ کریں", + "remove": "ہٹائیں", + "builtin": "پہلے سے طے شدہ: {inputs}", + "custom": "حسب ضرورت ان پٹ پہلے سے طے شدہ ان پٹ کی جگہ لیتے ہیں۔", + "noResults": "کوئی کنٹرول نہیں ملا۔", + "download": "INI ڈاؤن لوڈ کریں", + "copy": "ترتیب کاپی کریں", + "previewEmpty": "شروع کرنے کے لیے ان پٹ شامل کریں۔", + "backControls": "کنٹرول پر واپس جائیں", + "switchGroup": "گروپ بدلیں", + "moveFocus": "فوکس بدلیں", + "helpIntro": "شروع کرنے کے لیے مختصر جوابات۔", + "configIntro": "ان پٹ دیکھ کر فائل کاپی یا ڈاؤن لوڈ کریں۔", + "captureTitle": "{action} کے لیے کوئی کلید دبائیں", + "cancelCapture": "ریکارڈنگ منسوخ کریں", + "keepEditing": "ترمیم جاری رکھیں", + "discard": "تبدیلیاں چھوڑ دیں", + "faq0Question": "کلید کیسے مقرر کروں؟", + "faq0Answer": "گروپ اور ان پٹ شامل کریں منتخب کریں، پھر ریکارڈ یا فہرست استعمال کریں۔ ایک عمل کے لیے کئی ان پٹ رکھ سکتے ہیں۔", + "faq1Question": "INI فائل کہاں رکھوں؟", + "faq1Answer": "anyps5-input.ini کو بنائی گئی گیم کی قابل اجرا فائل کے ساتھ رکھیں۔ دوسرے راستے کے لیے ANYPS5_INPUT_CONFIG استعمال کریں۔", + "faq2Question": "کلید ریکارڈ کیوں نہیں ہوتی؟", + "faq2Answer": "براؤزر یا نظام کا شارٹ کٹ روک سکتا ہے۔ فہرست سے منتخب کریں۔ Escape ریکارڈنگ منسوخ کرتا ہے؛ اسے فہرست سے مقرر کریں۔", + "faq3Question": "کیا تبدیلیاں خود محفوظ ہوتی ہیں؟", + "faq3Answer": "تبدیلیاں صرف میموری میں رہتی ہیں: ری لوڈ سے پہلے کاپی یا ڈاؤن لوڈ کریں۔ صرف زبان محفوظ ہوتی ہے۔ غیر تبدیل شدہ اعمال اپنی طے شدہ ترتیب رکھتے ہیں۔", + "searchOptions": "اختیارات تلاش کریں", + "noOptions": "کوئی اختیار نہیں ملا۔" + }, + "fa": { + "controls": "کنترل‌ها", + "configuration": "پیکربندی", + "help": "راهنما", + "settings": "تنظیمات", + "language": "زبان", + "close": "بستن", + "eyebrow": "لوازم جانبی / کنترل سفارشی", + "intro": "یک کنترل را انتخاب کنید و ورودی صفحه‌کلید یا ماوس بدهید.", + "search": "جستجوی کنترل", + "import": "وارد کردن INI", + "reset": "بازنشانی", + "group0": "دکمه‌های اصلی", + "group1": "دکمه‌های بالایی", + "group2": "دکمه‌های جهت", + "group3": "دسته چپ", + "group4": "دسته راست", + "group5": "لمس و ابزارها", + "select": "انتخاب…", + "add": "+ افزودن ورودی", + "capture": "ثبت", + "remove": "حذف", + "builtin": "پیش‌فرض: {inputs}", + "custom": "ورودی سفارشی جای ورودی پیش‌فرض را می‌گیرد.", + "noResults": "کنترلی پیدا نشد.", + "download": "دانلود INI", + "copy": "کپی پیکربندی", + "previewEmpty": "برای شروع یک ورودی اضافه کنید.", + "backControls": "بازگشت به کنترل‌ها", + "switchGroup": "تغییر گروه", + "moveFocus": "جابجایی تمرکز", + "helpIntro": "پاسخ‌های کوتاه برای شروع.", + "configIntro": "ورودی‌ها را بررسی و فایل را کپی یا دانلود کنید.", + "captureTitle": "برای {action} کلیدی فشار دهید", + "cancelCapture": "لغو ثبت", + "keepEditing": "ادامه ویرایش", + "discard": "کنار گذاشتن تغییرات", + "faq0Question": "چطور کلید تعیین کنم؟", + "faq0Answer": "گروه و افزودن ورودی را انتخاب کنید، سپس ثبت یا فهرست را به کار ببرید. یک عمل می‌تواند چند ورودی داشته باشد.", + "faq1Question": "فایل INI را کجا بگذارم؟", + "faq1Answer": "anyps5-input.ini را کنار فایل اجرایی ساخته‌شده بازی قرار دهید. برای مسیر دیگر از ANYPS5_INPUT_CONFIG استفاده کنید.", + "faq2Question": "چرا کلید ثبت نمی‌شود؟", + "faq2Answer": "میانبر مرورگر یا سیستم ممکن است آن را بگیرد. از فهرست انتخاب کنید. Escape ثبت را لغو می‌کند؛ آن را از فهرست تعیین کنید.", + "faq3Question": "آیا تغییرات خودکار ذخیره می‌شوند؟", + "faq3Answer": "تغییرات فقط در حافظه‌اند: قبل از بارگذاری دوباره کپی یا دانلود کنید. تنها زبان ذخیره می‌شود. اعمال تنظیم‌نشده پیش‌فرض خود را حفظ می‌کنند.", + "searchOptions": "جستجوی گزینه‌ها", + "noOptions": "گزینه‌ای پیدا نشد." + }, + "id": { + "controls": "Kontrol", + "configuration": "Konfigurasi", + "help": "Bantuan", + "settings": "Pengaturan", + "language": "Bahasa", + "close": "Tutup", + "eyebrow": "Aksesori / Kontrol khusus", + "intro": "Pilih kontrol, lalu tetapkan input keyboard atau mouse.", + "search": "Cari kontrol", + "import": "Impor INI", + "reset": "Atur ulang", + "group0": "Tombol utama", + "group1": "Tombol atas", + "group2": "Tombol arah", + "group3": "Stik kiri", + "group4": "Stik kanan", + "group5": "Sentuh dan alat", + "select": "Pilih…", + "add": "+ Tambah input", + "capture": "Rekam", + "remove": "Hapus", + "builtin": "Bawaan: {inputs}", + "custom": "Input khusus menggantikan input bawaan.", + "noResults": "Kontrol tidak ditemukan.", + "download": "Unduh INI", + "copy": "Salin konfigurasi", + "previewEmpty": "Tambahkan input untuk memulai.", + "backControls": "Kembali ke kontrol", + "switchGroup": "Ganti grup", + "moveFocus": "Pindahkan fokus", + "helpIntro": "Jawaban singkat untuk memulai.", + "configIntro": "Periksa input, lalu salin atau unduh berkas.", + "captureTitle": "Tekan tombol untuk {action}", + "cancelCapture": "Batalkan rekaman", + "keepEditing": "Lanjutkan mengedit", + "discard": "Buang perubahan", + "faq0Question": "Bagaimana menetapkan tombol?", + "faq0Answer": "Pilih grup dan Tambah input, lalu Rekam atau gunakan daftar. Satu aksi dapat memiliki beberapa input.", + "faq1Question": "Di mana berkas INI diletakkan?", + "faq1Answer": "Letakkan anyps5-input.ini di samping berkas program game yang dihasilkan. Untuk lokasi lain, gunakan ANYPS5_INPUT_CONFIG.", + "faq2Question": "Mengapa tombol tidak direkam?", + "faq2Answer": "Pintasan browser atau sistem mungkin menangkapnya. Pilih dari daftar. Escape membatalkan rekaman; tetapkan Escape melalui daftar.", + "faq3Question": "Apakah perubahan disimpan otomatis?", + "faq3Answer": "Perubahan hanya di memori: salin atau unduh sebelum memuat ulang. Hanya bahasa yang disimpan. Aksi yang tidak diatur tetap memakai bawaan.", + "searchOptions": "Cari opsi", + "noOptions": "Tidak ada opsi yang cocok." + }, + "vi": { + "controls": "Điều khiển", + "configuration": "Cấu hình", + "help": "Trợ giúp", + "settings": "Cài đặt", + "language": "Ngôn ngữ", + "close": "Đóng", + "eyebrow": "Phụ kiện / Điều khiển tùy chỉnh", + "intro": "Chọn điều khiển rồi gán phím hoặc nút chuột.", + "search": "Tìm điều khiển", + "import": "Nhập INI", + "reset": "Đặt lại", + "group0": "Nút chính", + "group1": "Nút phía trên", + "group2": "Phím hướng", + "group3": "Cần trái", + "group4": "Cần phải", + "group5": "Cảm ứng và công cụ", + "select": "Chọn…", + "add": "+ Thêm đầu vào", + "capture": "Ghi phím", + "remove": "Xóa", + "builtin": "Mặc định: {inputs}", + "custom": "Đầu vào tùy chỉnh thay thế đầu vào mặc định.", + "noResults": "Không tìm thấy điều khiển.", + "download": "Tải INI", + "copy": "Sao chép cấu hình", + "previewEmpty": "Thêm đầu vào để bắt đầu.", + "backControls": "Trở về điều khiển", + "switchGroup": "Đổi nhóm", + "moveFocus": "Di chuyển tiêu điểm", + "helpIntro": "Câu trả lời nhanh để bắt đầu.", + "configIntro": "Kiểm tra đầu vào rồi sao chép hoặc tải tệp.", + "captureTitle": "Nhấn phím cho {action}", + "cancelCapture": "Hủy ghi phím", + "keepEditing": "Tiếp tục chỉnh sửa", + "discard": "Bỏ thay đổi", + "faq0Question": "Làm sao gán phím?", + "faq0Answer": "Chọn nhóm và Thêm đầu vào, rồi dùng Ghi phím hoặc danh sách. Một hành động có thể có nhiều đầu vào.", + "faq1Question": "Đặt tệp INI ở đâu?", + "faq1Answer": "Đặt anyps5-input.ini cạnh tệp chạy trò chơi được tạo. Dùng ANYPS5_INPUT_CONFIG cho đường dẫn khác.", + "faq2Question": "Tại sao không ghi được phím?", + "faq2Answer": "Trình duyệt hoặc hệ thống có thể chặn phím tắt. Chọn từ danh sách. Escape hủy ghi phím; hãy gán Escape từ danh sách.", + "faq3Question": "Thay đổi có tự lưu không?", + "faq3Answer": "Thay đổi chỉ ở trong bộ nhớ: sao chép hoặc tải xuống trước khi tải lại. Chỉ ngôn ngữ được lưu. Hành động chưa cấu hình giữ mặc định.", + "searchOptions": "Tìm tùy chọn", + "noOptions": "Không có tùy chọn phù hợp." + }, + "th": { + "controls": "การควบคุม", + "configuration": "การกำหนดค่า", + "help": "ช่วยเหลือ", + "settings": "การตั้งค่า", + "language": "ภาษา", + "close": "ปิด", + "eyebrow": "อุปกรณ์เสริม / ปรับแต่งการควบคุม", + "intro": "เลือกการควบคุม แล้วกำหนดปุ่มแป้นพิมพ์หรือเมาส์", + "search": "ค้นหาการควบคุม", + "import": "นำเข้า INI", + "reset": "รีเซ็ต", + "group0": "ปุ่มหลัก", + "group1": "ปุ่มด้านบน", + "group2": "ปุ่มทิศทาง", + "group3": "สติ๊กซ้าย", + "group4": "สติ๊กขวา", + "group5": "สัมผัสและเครื่องมือ", + "select": "เลือก…", + "add": "+ เพิ่มอินพุต", + "capture": "บันทึกปุ่ม", + "remove": "ลบ", + "builtin": "ค่าเริ่มต้น: {inputs}", + "custom": "อินพุตที่กำหนดเองจะแทนที่ค่าเริ่มต้น", + "noResults": "ไม่พบการควบคุม", + "download": "ดาวน์โหลด INI", + "copy": "คัดลอกการกำหนดค่า", + "previewEmpty": "เพิ่มอินพุตเพื่อเริ่มต้น", + "backControls": "กลับไปการควบคุม", + "switchGroup": "เปลี่ยนกลุ่ม", + "moveFocus": "ย้ายโฟกัส", + "helpIntro": "คำตอบสั้นๆ เพื่อช่วยเริ่มต้น", + "configIntro": "ตรวจสอบอินพุต แล้วคัดลอกหรือดาวน์โหลดไฟล์", + "captureTitle": "กดปุ่มสำหรับ {action}", + "cancelCapture": "ยกเลิกการบันทึก", + "keepEditing": "แก้ไขต่อ", + "discard": "ทิ้งการเปลี่ยนแปลง", + "faq0Question": "กำหนดปุ่มอย่างไร?", + "faq0Answer": "เลือกกลุ่มและเพิ่มอินพุต แล้วใช้บันทึกปุ่มหรือรายการ หนึ่งการกระทำมีได้หลายอินพุต", + "faq1Question": "วางไฟล์ INI ที่ไหน?", + "faq1Answer": "วาง anyps5-input.ini ข้างไฟล์โปรแกรมเกมที่สร้างขึ้น ใช้ ANYPS5_INPUT_CONFIG สำหรับตำแหน่งอื่น", + "faq2Question": "ทำไมบางปุ่มจึงบันทึกไม่ได้?", + "faq2Answer": "ทางลัดของเบราว์เซอร์หรือระบบอาจดักปุ่มไว้ เลือกจากรายการแทน Escape ยกเลิกการบันทึก จึงต้องกำหนดจากรายการ", + "faq3Question": "บันทึกการเปลี่ยนแปลงอัตโนมัติไหม?", + "faq3Answer": "การเปลี่ยนแปลงอยู่ในหน่วยความจำเท่านั้น คัดลอกหรือดาวน์โหลดก่อนโหลดใหม่ บันทึกเฉพาะภาษา การกระทำที่ยังไม่ตั้งค่าจะใช้ค่าเริ่มต้น", + "searchOptions": "ค้นหาตัวเลือก", + "noOptions": "ไม่พบตัวเลือกที่ตรงกัน" + }, + "sw": { + "controls": "Vidhibiti", + "configuration": "Usanidi", + "help": "Msaada", + "settings": "Mipangilio", + "language": "Lugha", + "close": "Funga", + "eyebrow": "Vifaa / Vidhibiti maalum", + "intro": "Chagua kidhibiti kisha weka ingizo la kibodi au kipanya.", + "search": "Tafuta vidhibiti", + "import": "Ingiza INI", + "reset": "Weka upya", + "group0": "Vibonye vikuu", + "group1": "Vibonye vya juu", + "group2": "Vibonye vya mwelekeo", + "group3": "Kijiti cha kushoto", + "group4": "Kijiti cha kulia", + "group5": "Mguso na zana", + "select": "Chagua…", + "add": "+ Ongeza ingizo", + "capture": "Rekodi", + "remove": "Ondoa", + "builtin": "Chaguo-msingi: {inputs}", + "custom": "Ingizo maalum linachukua nafasi ya ingizo la kawaida.", + "noResults": "Hakuna vidhibiti vilivyopatikana.", + "download": "Pakua INI", + "copy": "Nakili usanidi", + "previewEmpty": "Ongeza ingizo ili kuanza.", + "backControls": "Rudi kwenye vidhibiti", + "switchGroup": "Badili kundi", + "moveFocus": "Hamisha fokasi", + "helpIntro": "Majibu mafupi ya kukusaidia kuanza.", + "configIntro": "Kagua ingizo kisha nakili au pakua faili.", + "captureTitle": "Bonyeza kitufe kwa {action}", + "cancelCapture": "Ghairi kurekodi", + "keepEditing": "Endelea kuhariri", + "discard": "Tupa mabadiliko", + "faq0Question": "Ninawekaje kitufe?", + "faq0Answer": "Chagua kundi na Ongeza ingizo, kisha Rekodi au tumia orodha. Kitendo kimoja kinaweza kuwa na ingizo nyingi.", + "faq1Question": "Ninaweka wapi faili la INI?", + "faq1Answer": "Weka anyps5-input.ini karibu na faili la programu ya mchezo lililozalishwa. Tumia ANYPS5_INPUT_CONFIG kwa njia nyingine.", + "faq2Question": "Kwa nini kitufe hakirekodiwi?", + "faq2Answer": "Njia ya mkato ya kivinjari au mfumo inaweza kukizuia. Chagua kwenye orodha. Escape hughairi kurekodi; kiweke kupitia orodha.", + "faq3Question": "Mabadiliko huhifadhiwa kiotomatiki?", + "faq3Answer": "Mabadiliko hubaki kwenye kumbukumbu: nakili au pakua kabla ya kupakia upya. Lugha pekee huhifadhiwa. Vitendo visivyosanidiwa hutumia chaguo-msingi.", + "searchOptions": "Tafuta chaguo", + "noOptions": "Hakuna chaguo linalolingana." + }, + "nl": { + "controls": "Bediening", + "configuration": "Configuratie", + "help": "Hulp", + "settings": "Instellingen", + "language": "Taal", + "close": "Sluiten", + "eyebrow": "Accessoires / Eigen bediening", + "intro": "Kies een actie en wijs toetsenbord- of muisinvoer toe.", + "search": "Bediening zoeken", + "import": "INI importeren", + "reset": "Herstellen", + "group0": "Actieknoppen", + "group1": "Schouderknoppen", + "group2": "Richtingsknoppen", + "group3": "Linker stick", + "group4": "Rechter stick", + "group5": "Aanraakvlak en hulpmiddelen", + "select": "Selecteren…", + "add": "+ Invoer toevoegen", + "capture": "Vastleggen", + "remove": "Verwijderen", + "builtin": "Standaard: {inputs}", + "custom": "Eigen invoer vervangt de standaardinvoer.", + "noResults": "Geen passende bediening gevonden.", + "download": "INI downloaden", + "copy": "Configuratie kopiëren", + "previewEmpty": "Voeg invoer toe om te beginnen.", + "backControls": "Terug naar bediening", + "switchGroup": "Groep wisselen", + "moveFocus": "Focus verplaatsen", + "helpIntro": "Korte antwoorden om te beginnen.", + "configIntro": "Controleer de invoer en kopieer of download het bestand.", + "captureTitle": "Druk op een toets voor {action}", + "cancelCapture": "Vastleggen annuleren", + "keepEditing": "Verder bewerken", + "discard": "Wijzigingen weggooien", + "faq0Question": "Hoe wijs ik een toets toe?", + "faq0Answer": "Kies een groep en Invoer toevoegen, dan Vastleggen of de lijst. Eén actie kan meerdere invoeren hebben.", + "faq1Question": "Waar plaats ik het INI-bestand?", + "faq1Answer": "Zet anyps5-input.ini naast het gegenereerde spelprogramma. Gebruik ANYPS5_INPUT_CONFIG voor een ander pad.", + "faq2Question": "Waarom wordt een toets niet vastgelegd?", + "faq2Answer": "Een browser- of systeemsneltoets kan hem onderscheppen. Kies uit de lijst. Escape annuleert vastleggen; wijs Escape via de lijst toe.", + "faq3Question": "Worden wijzigingen automatisch opgeslagen?", + "faq3Answer": "Wijzigingen staan alleen in het geheugen: kopieer of download vóór herladen. Alleen de taal wordt opgeslagen. Ongewijzigde acties houden hun standaardwaarden.", + "searchOptions": "Opties zoeken", + "noOptions": "Geen passende opties." + }, + "pl": { + "controls": "Sterowanie", + "configuration": "Konfiguracja", + "help": "Pomoc", + "settings": "Ustawienia", + "language": "Język", + "close": "Zamknij", + "eyebrow": "Akcesoria / Własne sterowanie", + "intro": "Wybierz akcję i przypisz klawisz lub przycisk myszy.", + "search": "Szukaj sterowania", + "import": "Importuj INI", + "reset": "Resetuj", + "group0": "Główne przyciski", + "group1": "Górne przyciski", + "group2": "Przyciski kierunkowe", + "group3": "Lewy drążek", + "group4": "Prawy drążek", + "group5": "Dotyk i narzędzia", + "select": "Wybierz…", + "add": "+ Dodaj wejście", + "capture": "Zarejestruj", + "remove": "Usuń", + "builtin": "Domyślne: {inputs}", + "custom": "Własne wejścia zastępują domyślne.", + "noResults": "Nie znaleziono pasującej akcji.", + "download": "Pobierz INI", + "copy": "Kopiuj konfigurację", + "previewEmpty": "Dodaj wejście, aby zacząć.", + "backControls": "Wróć do sterowania", + "switchGroup": "Zmień grupę", + "moveFocus": "Przenieś fokus", + "helpIntro": "Krótkie odpowiedzi na początek.", + "configIntro": "Sprawdź wejścia i skopiuj lub pobierz plik.", + "captureTitle": "Naciśnij klawisz dla {action}", + "cancelCapture": "Anuluj rejestrację", + "keepEditing": "Edytuj dalej", + "discard": "Odrzuć zmiany", + "faq0Question": "Jak przypisać klawisz?", + "faq0Answer": "Wybierz grupę i Dodaj wejście, potem Zarejestruj lub listę. Jedna akcja może mieć kilka wejść.", + "faq1Question": "Gdzie umieścić plik INI?", + "faq1Answer": "Umieść anyps5-input.ini obok wygenerowanego pliku wykonywalnego gry. Inny adres podaj w ANYPS5_INPUT_CONFIG.", + "faq2Question": "Dlaczego klawisz nie jest rejestrowany?", + "faq2Answer": "Skrót przeglądarki lub systemu może go przechwycić. Wybierz z listy. Escape anuluje rejestrację; przypisz go z listy.", + "faq3Question": "Czy zmiany zapisują się automatycznie?", + "faq3Answer": "Zmiany są tylko w pamięci: skopiuj lub pobierz przed odświeżeniem. Zapisywany jest tylko język. Nieustawione akcje zachowują wartości domyślne.", + "searchOptions": "Szukaj opcji", + "noOptions": "Brak pasujących opcji." + } +}; + +export function translate(locale, key, values = {}) { + const text = messages[locale]?.[key] ?? messages.en[key] ?? key; + return text.replace(/\{(\w+)\}/gu, (match, name) => String(values[name] ?? match)); +} + +export function diagnostic(locale, message) { + if (locale !== 'ar-EG') return message; + return message + .replace('Configuration must be a list of bindings.', 'الإعدادات لازم تكون قائمة روابط أزرار.') + .replace('Unknown or empty action.', 'الحركة مش معروفة أو فاضية.') + .replace('Input type must be KEY, MOUSE or WHEEL.', 'نوع الإدخال لازم يكون KEY أو MOUSE أو WHEEL.') + .replace(/(\w+) cannot use (\w+)\./gu, '$1 مينفعش يستخدم $2.') + .replace(/Unknown or empty (\w+) value\./gu, 'قيمة $1 مش معروفة أو فاضية.') + .replace('This SDL key name cannot be represented in the current INI comment syntax.', 'اسم الزرار ده مينفعش يتحفظ بالصيغة الحالية لملف INI.') + .replace(/duplicate (.+) will be omitted\./gu, 'الربط $1 متكرر وهيتحفظ مرة واحدة.') + .replace(' controls ', ' بيشغّل ') + .replaceAll(' (built-in)', ' (افتراضي)') + .replaceAll(' and ', ' و ') + .replace('Expected Action = TYPE:VALUE.', 'الصيغة المطلوبة: Action = TYPE:VALUE.') + .replace('Duplicate binding ignored.', 'الربط المتكرر تم تجاهله.') + .replace('This physical key cannot be captured in the current INI format. Select an SDL key from the list.', 'الزرار ده مينفعش يتسجل بالصيغة الحالية لملف INI. اختار زرار SDL من القائمة.') + .replace('Composition and dead keys are ambiguous. Select an SDL key from the list.', 'زرار تركيب الحروف مش واضح في التسجيل. اختار زرار SDL من القائمة.'); +} + +export function initialLocale(savedLocale) { + return locales.some(item => item.code === savedLocale) ? savedLocale : 'en'; +} diff --git a/tools/input-mapper/index.html b/tools/input-mapper/index.html new file mode 100644 index 0000000000..ee5a61c712 --- /dev/null +++ b/tools/input-mapper/index.html @@ -0,0 +1,78 @@ + + + + + + + AnyPS5 · Input mapper + + + + + + +
+ AnyPS5 + +
+ + + +
+
+
+ +
+

Accessories / Custom controls

+

Face buttons

+

Choose a control, then assign your keyboard or mouse inputs.

+
+
+ +
+ + + +
+
+
+ + +
+

+ +
+
© Amr Yasser←→ Switch groupTab Move focus
+ +

Press a key

+

Capture uses physical key positions. Escape cancels capture; choose Escape from the list to assign it. Some browser and system shortcuts require manual selection.

+

+ +
+ +

Discard unsaved changes?

+

Copy or download your configuration first if you want to keep it.

+
+ + +
+
+ + +

+ +
+ +
+ + + diff --git a/tools/input-mapper/keys.js b/tools/input-mapper/keys.js new file mode 100644 index 0000000000..5c44d49036 --- /dev/null +++ b/tools/input-mapper/keys.js @@ -0,0 +1,230 @@ +export const keyNames = [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "0", + "Return", + "Escape", + "Backspace", + "Tab", + "Space", + "-", + "=", + "[", + "]", + "\\", + "#", + ";", + "'", + "`", + ",", + ".", + "/", + "CapsLock", + "F1", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "F10", + "F11", + "F12", + "PrintScreen", + "ScrollLock", + "Pause", + "Insert", + "Home", + "PageUp", + "Delete", + "End", + "PageDown", + "Right", + "Left", + "Down", + "Up", + "Numlock", + "Keypad /", + "Keypad *", + "Keypad -", + "Keypad +", + "Keypad Enter", + "Keypad 1", + "Keypad 2", + "Keypad 3", + "Keypad 4", + "Keypad 5", + "Keypad 6", + "Keypad 7", + "Keypad 8", + "Keypad 9", + "Keypad 0", + "Keypad .", + "Application", + "Power", + "Keypad =", + "F13", + "F14", + "F15", + "F16", + "F17", + "F18", + "F19", + "F20", + "F21", + "F22", + "F23", + "F24", + "Execute", + "Help", + "Menu", + "Select", + "Stop", + "Again", + "Undo", + "Cut", + "Copy", + "Paste", + "Find", + "Mute", + "VolumeUp", + "VolumeDown", + "Keypad ,", + "Keypad = (AS400)", + "AltErase", + "SysReq", + "Cancel", + "Clear", + "Prior", + "Separator", + "Out", + "Oper", + "Clear / Again", + "CrSel", + "ExSel", + "Keypad 00", + "Keypad 000", + "ThousandsSeparator", + "DecimalSeparator", + "CurrencyUnit", + "CurrencySubUnit", + "Keypad (", + "Keypad )", + "Keypad {", + "Keypad }", + "Keypad Tab", + "Keypad Backspace", + "Keypad A", + "Keypad B", + "Keypad C", + "Keypad D", + "Keypad E", + "Keypad F", + "Keypad XOR", + "Keypad ^", + "Keypad %", + "Keypad <", + "Keypad >", + "Keypad &", + "Keypad &&", + "Keypad |", + "Keypad ||", + "Keypad :", + "Keypad #", + "Keypad Space", + "Keypad @", + "Keypad !", + "Keypad MemStore", + "Keypad MemRecall", + "Keypad MemClear", + "Keypad MemAdd", + "Keypad MemSubtract", + "Keypad MemMultiply", + "Keypad MemDivide", + "Keypad +/-", + "Keypad Clear", + "Keypad ClearEntry", + "Keypad Binary", + "Keypad Octal", + "Keypad Decimal", + "Keypad Hexadecimal", + "Left Ctrl", + "Left Shift", + "Left Alt", + "Left GUI", + "Right Ctrl", + "Right Shift", + "Right Alt", + "Right GUI", + "ModeSwitch", + "AudioNext", + "AudioPrev", + "AudioStop", + "AudioPlay", + "AudioMute", + "MediaSelect", + "WWW", + "Mail", + "Calculator", + "Computer", + "AC Search", + "AC Home", + "AC Back", + "AC Forward", + "AC Stop", + "AC Refresh", + "AC Bookmarks", + "BrightnessDown", + "BrightnessUp", + "DisplaySwitch", + "KBDIllumToggle", + "KBDIllumDown", + "KBDIllumUp", + "Eject", + "Sleep", + "App1", + "App2", + "AudioRewind", + "AudioFastForward", + "SoftLeft", + "SoftRight", + "Call", + "EndCall" +]; + +export const exportableKeys = keyNames.filter(name => !/[#;]/u.test(name)); diff --git a/tools/input-mapper/mapping.js b/tools/input-mapper/mapping.js new file mode 100644 index 0000000000..9d29df0382 --- /dev/null +++ b/tools/input-mapper/mapping.js @@ -0,0 +1,34 @@ +export const groups = [ + {name: 'Face buttons', actions: ['Cross', 'Circle', 'Triangle', 'Square']}, + {name: 'Shoulders, triggers and stick clicks', actions: ['L1', 'R1', 'L2', 'R2', 'L3', 'R3']}, + {name: 'Directional controls', actions: ['Up', 'Right', 'Down', 'Left']}, + {name: 'Left analog stick', actions: ['LeftStickLeft', 'LeftStickRight', 'LeftStickUp', 'LeftStickDown']}, + {name: 'Right analog stick', actions: ['RightStickLeft', 'RightStickRight', 'RightStickUp', 'RightStickDown']}, + {name: 'Touch and utility', actions: ['Options', 'TouchLeft', 'TouchRight', 'ToggleMouse', 'ToggleFullscreen']} +]; + +export const actions = groups.flatMap(group => group.actions); +export const padButtons = new Set(['Cross', 'Circle', 'Triangle', 'Square', 'L1', 'R1', 'L2', 'R2', 'L3', 'R3', 'Options', 'Up', 'Right', 'Down', 'Left']); +export const mouseValues = ['Left', 'Middle', 'Right', 'X1', 'X2']; +export const wheelValues = ['Up', 'Down']; + +export const defaults = { + Cross: ['KEY:Return', 'KEY:Space'], Circle: ['KEY:C'], Triangle: ['KEY:I'], Square: ['MOUSE:Left'], + L1: ['KEY:Q'], R1: ['KEY:E', 'KEY:Left Alt', 'KEY:Right Alt'], L2: [], R2: ['MOUSE:Right'], + L3: ['KEY:Left Shift', 'KEY:Right Shift'], R3: ['KEY:Left Ctrl', 'KEY:Right Ctrl'], Options: ['KEY:Escape'], + Up: ['KEY:Up', 'WHEEL:Up'], Right: ['KEY:Right'], Down: ['KEY:Down', 'WHEEL:Down'], Left: ['KEY:Left'], + LeftStickLeft: ['KEY:A'], LeftStickRight: ['KEY:D'], LeftStickUp: ['KEY:W'], LeftStickDown: ['KEY:S'], + RightStickLeft: ['KEY:F'], RightStickRight: ['KEY:H'], RightStickUp: ['KEY:T'], RightStickDown: ['KEY:G'], + TouchLeft: ['KEY:Backspace'], TouchRight: ['KEY:Tab'], ToggleMouse: ['MOUSE:Middle'], ToggleFullscreen: ['KEY:F11'] +}; + +export function typesForAction(action) { + if (!actions.includes(action)) return []; + if (action === 'ToggleFullscreen') return ['KEY']; + return padButtons.has(action) ? ['KEY', 'MOUSE', 'WHEEL'] : ['KEY', 'MOUSE']; +} + +export function canonicalValue(value, choices) { + if (typeof value !== 'string') return undefined; + return choices.find(choice => choice.toUpperCase() === value.trim().toUpperCase()); +} diff --git a/tools/input-mapper/package.json b/tools/input-mapper/package.json new file mode 100644 index 0000000000..064ea01892 --- /dev/null +++ b/tools/input-mapper/package.json @@ -0,0 +1,15 @@ +{ + "name": "anyps5-input-mapper", + "version": "1.0.0", + "private": true, + "description": "Browser-based keyboard and mouse input configurator for AnyPS5", + "type": "module", + "license": "GPL-2.0", + "engines": { + "node": ">=22" + }, + "scripts": { + "start": "node serve.js", + "test": "node --test tests/*.test.js" + } +} diff --git a/tools/input-mapper/serve.js b/tools/input-mapper/serve.js new file mode 100644 index 0000000000..ccdb20e26c --- /dev/null +++ b/tools/input-mapper/serve.js @@ -0,0 +1,30 @@ +import {createServer} from 'node:http'; +import {readFile} from 'node:fs/promises'; + +const files = new Set(['index.html', 'styles.css', 'app.js', 'mapping.js', 'keys.js', 'capture.js', 'validation.js', 'i18n.js', 'dropdown.js']); +const mime = {html: 'text/html', css: 'text/css', js: 'text/javascript'}; +const port = Number(process.env.PORT || 4173); +const server = createServer(async (request, response) => { + const path = new URL(request.url, 'http://127.0.0.1').pathname; + const file = path === '/' ? 'index.html' : path.slice(1); + if (!['GET', 'HEAD'].includes(request.method)) { + response.writeHead(405, {Allow: 'GET, HEAD'}).end(); + return; + } + if (!files.has(file)) { + response.writeHead(404).end('Not found'); + return; + } + try { + const content = await readFile(new URL(file, import.meta.url)); + response.writeHead(200, {'Content-Type': `${mime[file.split('.').pop()]}; charset=utf-8`, 'Cache-Control': 'no-store'}); + response.end(request.method === 'HEAD' ? undefined : content); + } catch { + response.writeHead(500).end('Cannot read static file'); + } +}); +server.on('error', error => { + console.error(error.message); + process.exitCode = 1; +}); +server.listen(port, '127.0.0.1', () => console.log(`Input mapper: http://127.0.0.1:${port}`)); diff --git a/tools/input-mapper/styles.css b/tools/input-mapper/styles.css new file mode 100644 index 0000000000..1be7b42096 --- /dev/null +++ b/tools/input-mapper/styles.css @@ -0,0 +1,203 @@ +:root { color-scheme: dark; font-family: "Segoe UI", Helvetica, Arial, sans-serif; font-weight: 400; color: #f4f5f7; background: #101116; --muted: #c1c4ce; --line: #ffffff1c; --ease: cubic-bezier(.2,.75,.25,1); } +* { box-sizing: border-box; } +* { scrollbar-width: thin; scrollbar-color: #dbe0ea70 transparent; } +::-webkit-scrollbar { width: 4px; height: 4px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #dbe0ea70; border-radius: 20px; } +::-webkit-scrollbar-thumb:hover { background: #eef1f8c0; } +::-webkit-scrollbar-button { display: none; width: 0; height: 0; } +::-webkit-scrollbar-corner { background: transparent; } +@supports selector(::-webkit-scrollbar) { * { scrollbar-width: auto; scrollbar-color: auto; } } +[hidden] { display: none !important; } +html { scroll-behavior: smooth; scroll-padding-top: 30px; } +body { margin: 0; min-height: 100vh; isolation: isolate; } +a { color: inherit; } +button, input, select, textarea { font: inherit; } +button, input, select { min-height: 42px; border: 1px solid #ffffff24; border-radius: 4px; color: inherit; background: #ffffff09; padding: 9px 14px; transition: color .2s, background .2s, border-color .2s, box-shadow .25s, transform .25s var(--ease); } +button { cursor: pointer; font-weight: 400; } +button:hover:not(:disabled) { background: #ffffff16; border-color: #ffffff70; } +button:active:not(:disabled) { transform: scale(.97); } +button:disabled { cursor: not-allowed; opacity: .42; } +:focus-visible { outline: none; box-shadow: inset 0 -2px 0 #7d9ac9; } +button:focus-visible:not(:disabled) { background: #31415b80; } +input:focus-visible, textarea:focus-visible { border-color: #788da9; } +select option { color: #f4f5f7; background: #22232a; } +.ambient { position: fixed; inset: 0; z-index: -1; overflow: hidden; pointer-events: none; background: radial-gradient(ellipse at 80% 100%, #16385278, transparent 65%), radial-gradient(ellipse at 20% 18%, #4c484b50, transparent 60%), linear-gradient(140deg, #18191e, #101116 50%, #111820); } +.ambient-glow { position: absolute; width: 110%; height: 75%; top: -45%; left: -20%; border-radius: 50%; background: radial-gradient(ellipse, #c9b99a33, #b3bfd614 38%, transparent 68%); filter: blur(36px); animation: atmosphere 24s ease-in-out infinite alternate; } +.particles { position: absolute; inset: -12%; opacity: .7; background-image: radial-gradient(circle at 12% 29%, #e3d5b5d0 0 1px, transparent 2px), radial-gradient(circle at 32% 68%, #e0d5b9a0 0 1px, transparent 3px), radial-gradient(circle at 68% 23%, #e8d8b6b0 0 1px, transparent 3px), radial-gradient(circle at 88% 59%, #f4ead2a0 0 1px, transparent 2px), radial-gradient(circle at 54% 48%, #a2bfda90 0 1px, transparent 2px), radial-gradient(circle at 76% 78%, #ebd9ada0 0 1px, transparent 3px); background-size: 410px 370px, 590px 530px, 670px 490px, 710px 630px, 830px 730px, 970px 810px; animation: particles 38s linear infinite alternate; } +.particles.distant { opacity: .25; filter: blur(1px); animation-duration: 58s; animation-direction: alternate-reverse; } +header { max-width: 1640px; margin: auto; display: flex; align-items: center; gap: 42px; padding: 34px 5.5%; } +.brand { font-size: 20px; text-decoration: none; font-weight: 300; letter-spacing: -.5px; } +.system-nav { display: flex; gap: 32px; } +.system-nav a { text-decoration: none; font-size: 23px; font-weight: 300; color: #b8bac1; transition: color .2s; } +.system-nav .current, .system-nav a:hover { color: white; } +.system-tools { display: flex; align-items: center; gap: 24px; margin-inline-start: auto; } +.icon-button { display: grid; place-items: center; width: 38px; min-height: 38px; border: 0; border-radius: 50%; padding: 0; background: transparent; text-decoration: none; font-size: 26px; } +.search-icon { width: 15px; height: 15px; border: 2px solid currentColor; border-radius: 50%; position: relative; } +.search-icon::after { content: ''; position: absolute; height: 7px; width: 2px; background: currentColor; right: -4px; bottom: -5px; transform: rotate(-45deg); } +.translation-icon { display: flex; align-items: center; gap: 2px; direction: ltr; font-size: 18px; line-height: 1; } +.translation-icon span { display: grid; place-items: center; width: 21px; height: 23px; border: 1px solid #aab7cb70; border-radius: 4px; } +.translation-icon span:last-child { margin-left: -7px; margin-top: 11px; background: #252a34; } +#clock { font-size: 22px; font-weight: 300; min-width: 58px; font-variant-numeric: tabular-nums; white-space: nowrap; } +main { max-width: 1440px; min-height: calc(100vh - 210px); margin: auto; padding: 6px 5% 35px; } +main:focus { outline: none; box-shadow: none; } +#categories { display: flex; align-items: flex-start; gap: 20px; padding: 16px 8px 22px; margin-inline-start: -8px; min-height: 155px; overflow-x: auto; } +.category { background: transparent; border: 0; padding: 0; width: 108px; flex: 0 0 108px; text-align: center; } +.category:hover:not(:disabled), .category:focus-visible:not(:disabled) { background: transparent; color: white; } +.category:focus-visible { outline: none; box-shadow: none; } +.category-icon { display: grid; place-items: center; height: 92px; width: 92px; margin: 0 auto 13px; border-radius: 16px; border: 1px solid #ffffff24; background: linear-gradient(145deg, #565860a0, #272b33c0); color: #eeeeef; box-shadow: 0 8px 20px #0002; transition: transform .3s var(--ease), box-shadow .3s, background .3s; } +.category[aria-pressed="true"] .category-icon, .category:focus-visible .category-icon { transform: scale(1.06); background: linear-gradient(145deg, #6a778db0, #354359c0); border-color: #91a4c366; box-shadow: 0 12px 30px #0004; } +.category:hover .category-icon { transform: translateY(-3px); } +.category[aria-pressed="true"]:hover .category-icon { transform: scale(1.06) translateY(-2px); } +.category-label { display: block; font-size: 12px; color: #babdc6; line-height: 1.4; } +.category[aria-pressed="true"] .category-label { color: white; } +.face-icon { position: relative; width: 60px; height: 60px; font-size: 24px; line-height: 1; font-weight: 300; } +.face-icon span { position: absolute; display: grid; place-items: center; width: 24px; height: 24px; } +.face-icon span:nth-child(1) { top: 0; left: 18px; } +.face-icon span:nth-child(2) { right: 0; top: 18px; } +.face-icon span:nth-child(3) { bottom: 0; left: 18px; } +.face-icon span:nth-child(4) { left: 0; top: 18px; } +.shoulder-icon { border: 2px solid currentColor; border-radius: 6px 6px 13px 13px; padding: 7px 10px; font-size: 17px; } +.direction-icon { position: relative; width: 42px; height: 42px; background: currentColor; clip-path: polygon(34% 0,66% 0,66% 34%,100% 34%,100% 66%,66% 66%,66% 100%,34% 100%,34% 66%,0 66%,0 34%,34% 34%); } +.stick-icon { display: grid; place-items: center; border: 2px solid currentColor; border-radius: 50%; width: 43px; height: 43px; font-size: 18px; box-shadow: 0 4px 0 1px #ffffff45; } +.touch-icon { width: 50px; height: 33px; border: 2px solid currentColor; border-radius: 7px; background: radial-gradient(circle, #ffffff60 1px, transparent 1.5px) 0 0 / 7px 7px; } +.intro { margin: 8px 0 26px; animation: enter .6s var(--ease) both; } +.eyebrow { color: #b9bbc2; font-size: 13px; margin: 0 0 13px; } +h1 { font-size: clamp(34px, 4vw, 48px); font-weight: 300; letter-spacing: -.9px; line-height: 1.15; margin: 0; } +.intro > p:last-child { color: var(--muted); font-size: 15px; margin: 13px 0 0; } +.toolbar, .tools, .output-heading { display: flex; gap: 12px; align-items: center; } +.toolbar { justify-content: space-between; margin-bottom: 22px; } +.search { width: min(350px, 55%); } +.search input { width: 100%; border-radius: 30px; padding: 10px 20px; background: #ffffff08; } +input::placeholder { color: #b6b9c4; } +.tools button { border-radius: 30px; padding: 9px 22px; } +.group, .configuration-panel, .faq { background: #181a22b0; border: 1px solid var(--line); border-radius: 6px; backdrop-filter: blur(20px); box-shadow: 0 18px 45px #0002; } +.group { margin-bottom: 20px; animation: enter .35s var(--ease); } +.group h2 { font-size: 12px; font-weight: 400; color: #bdc2ce; margin: 0; padding: 18px 24px; border-bottom: 1px solid var(--line); } +.action { padding: 19px 24px; border-bottom: 1px solid #ffffff10; transition: background .25s; } +.action:last-child { border-bottom: 0; } +.action:hover { background: #ffffff06; } +.action:focus-within { background: #ffffff0c; } +.action-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.action-title { display: flex; align-items: center; gap: 15px; } +.action-symbol { display: grid; place-items: center; width: 30px; height: 30px; font-size: 29px; line-height: 1; font-weight: 300; color: #eaedf5; } +.action h3 { margin: 0; font-size: 18px; font-weight: 300; } +.action-heading button { border: 0; min-height: 34px; font-size: 12px; border-radius: 30px; padding: 7px 14px; background: #ffffff0a; } +.hint { font-size: 12px; color: var(--muted); line-height: 1.65; overflow-wrap: anywhere; } +.action > .hint { margin: 7px 0 0; margin-inline-start: 45px; } +.binding { display: grid; grid-template-columns: 110px minmax(0, 1fr) auto auto; gap: 10px; margin-top: 17px; animation: enter .25s var(--ease); } +.dropdown { min-width: 0; } +.dropdown-trigger { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 10px 13px; font-size: 13px; text-align: start; border-radius: 9px; background: #0e131e66; } +.dropdown-trigger::after { content: ''; width: 6px; height: 6px; flex-shrink: 0; border-right: 1.5px solid #a7b2c5; border-bottom: 1.5px solid #a7b2c5; transform: rotate(45deg); margin-inline-start: auto; margin-top: -4px; } +.dropdown-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dropdown-trigger[aria-expanded="true"] { background: #27344999; border-color: #677c9a; } +.binding button { font-size: 12px; padding: 8px 11px; } +[aria-invalid="true"] { border-color: #ff9a9a; } +.configuration-panel { padding: 32px; max-width: 960px; margin: auto; animation: enter .35s var(--ease); } +.output-heading { justify-content: space-between; } +.output-heading h2 { font-size: 22px; font-weight: 300; margin: 0; } +.tag { font-size: 11px; color: #b6bcc9; white-space: nowrap; } +.configuration-panel label { display: block; margin: 20px 0 10px; color: #d0d3db; font-size: 12px; direction: ltr; } +textarea { width: 100%; height: 340px; resize: vertical; padding: 18px; border: 1px solid #ffffff18; border-radius: 4px; background: #090b1290; color: #edf0f8; font: 14px/1.9 Consolas, ui-monospace, monospace; text-align: left; } +.export-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 20px; } +.export-actions button { border-radius: 30px; } +.primary { background: #f0f1f5; color: #171920; border-color: #f0f1f5; } +.primary:hover:not(:disabled), .primary:focus-visible:not(:disabled) { background: #d8e0ee; color: #171920; box-shadow: none; } +.error { color: #ffb5b5; } +.warning { color: #e8cf9b; } +#validation { font-size: 12px; line-height: 1.6; } +#validation ul { padding-inline-start: 18px; } +#count { direction: ltr; unicode-bidi: isolate; } +#help { max-width: 960px; margin: auto; } +.faq { margin-bottom: 14px; } +.faq summary { cursor: pointer; padding: 22px 26px; font-size: 18px; font-weight: 300; } +.faq[open] summary { border-bottom: 1px solid var(--line); } +.faq p { padding: 0 26px 12px; color: var(--muted); line-height: 1.9; } +#picker-dialog { position: fixed; inset: auto; margin: 0; padding: 18px; border: 1px solid #70809c45; border-radius: 16px; background: linear-gradient(145deg, #242a36fa, #131a27fc); max-width: calc(100% - 32px); height: min(var(--picker-height, 480px), calc(100dvh - 32px)); max-height: calc(100dvh - 32px); box-shadow: 0 24px 80px #0008; } +#picker-dialog[open] { display: grid; grid-template-rows: auto auto minmax(0, 1fr); gap: 14px; } +#picker-dialog::backdrop { background: #080b1540; backdrop-filter: blur(3px); } +.picker-heading { display: flex; gap: 12px; align-items: center; justify-content: space-between; min-width: 0; } +.picker-heading h2 { font-size: 17px; line-height: 1.5; overflow-wrap: anywhere; } +.picker-heading button { min-height: 36px; margin: 0; padding: 6px 12px; border: 0; background: #ffffff08; font-size: 12px; flex-shrink: 0; } +#picker-search { width: 100%; min-width: 0; border-radius: 10px; padding: 11px 14px; font-size: 16px; } +#picker-options { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 2px 3px; } +.picker-option { display: block; width: 100%; min-height: 44px; margin: 0 0 3px; border: 0; border-radius: 8px; padding: 12px 14px; background: transparent; text-align: start; font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; } +.picker-option[aria-selected="true"] { background: #3547629c; color: #e4edff; } +.picker-option:focus-visible { background: #3b506fa6; box-shadow: none; } +#picker-empty { position: absolute; top: 160px; inset-inline: 18px; text-align: center; } +html[dir="rtl"] h1 { letter-spacing: 0; } +html[dir="rtl"] .intro > p:last-child { line-height: 1.9; } +html[dir="rtl"] .brand, footer > span:first-child { direction: ltr; unicode-bidi: isolate; } +#status { color: #dae3f6; font-size: 13px; line-height: 1.6; } +#import-report { margin-top: 22px; padding: 24px; border: 1px solid var(--line); background: #171b25c0; border-radius: 6px; overflow-wrap: anywhere; } +#import-report h2 { font-size: 23px; font-weight: 300; } +dialog { width: min(520px, calc(100% - 32px)); max-height: calc(100dvh - 32px); overflow-y: auto; color: inherit; background: linear-gradient(140deg, #30323bef, #181d28f5); border: 1px solid #ffffff3c; border-radius: 6px; padding: 32px; box-shadow: 0 20px 100px #0008; } +dialog[open] { animation: dialog-enter .3s var(--ease); } +dialog h2 { font-size: 28px; font-weight: 300; margin: 0; } +dialog p { line-height: 1.7; font-size: 14px; color: var(--muted); } +dialog button { border-radius: 30px; margin-top: 12px; } +dialog form { display: flex; gap: 12px; flex-wrap: wrap; } +dialog::backdrop { background: #080b15a0; backdrop-filter: blur(12px); } +footer { display: flex; justify-content: space-between; align-items: center; gap: 18px; padding: 20px 5.5% 30px; max-width: 1640px; margin: auto; color: #b1b6c2; font-size: 12px; } +.navigation-hint { display: flex; gap: 7px; align-items: center; } +kbd { border: 1px solid #ffffff50; border-radius: 4px; padding: 1px 5px; font: inherit; color: #e3e5eb; } +.footer-divider { width: 1px; height: 12px; background: #ffffff30; margin: 0 8px; } +.skip-link { position: fixed; top: -80px; left: 16px; padding: 12px; z-index: 2; background: #242836; } +.skip-link:focus { top: 12px; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } +@keyframes atmosphere { to { transform: translate(15%, 20%) scale(1.15); opacity: .7; } } +@keyframes particles { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-35px, -65px, 0); } } +@keyframes enter { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } +@keyframes dialog-enter { from { opacity: 0; transform: translateY(16px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } } +@media (max-width: 1100px) { header { gap: 26px; } #categories { gap: 14px; } .category { width: 96px; flex-basis: 96px; } .category-icon { width: 82px; height: 82px; } } +@media (max-width: 760px) { + header { display: grid; grid-template-columns: 1fr auto; gap: 18px 12px; padding: 22px 5% 14px; } + .brand { font-size: 18px; } + .system-tools { margin: 0; gap: 12px; } + .system-nav { grid-column: 1 / -1; grid-row: 2; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 3px; background: #ffffff04; border-radius: 12px; padding: 4px; } + .system-nav a { min-width: 0; padding: 10px 6px; font-size: clamp(12px, 3.7vw, 16px); text-align: center; border-radius: 8px; overflow-wrap: anywhere; } + .system-nav .current { background: #3b4b6233; } + #clock { font-size: 15px; } + main { min-height: calc(100vh - 225px); padding-inline: 5%; } + #categories { gap: 10px; padding: 12px 8px 16px; min-height: 119px; scroll-snap-type: x proximity; } + .category { flex-basis: 80px; width: 80px; scroll-snap-align: center; } + .category-icon { width: 66px; height: 66px; border-radius: 13px; margin-bottom: 10px; } + .category-label { font-size: 10px; min-height: 28px; } + .face-icon { transform: scale(.8); } + .intro { margin: 9px 0 22px; } + h1 { font-size: clamp(28px, 7.5vw, 36px); } + .intro > p:last-child { font-size: 13px; line-height: 1.7; } + .eyebrow { font-size: 11px; margin-bottom: 10px; } + .toolbar { flex-wrap: wrap; gap: 10px; } + .search, .tools { width: 100%; } + .search input { font-size: 16px; min-height: 44px; } + .tools button { flex: 1; min-height: 44px; padding: 10px 14px; font-size: 13px; } + .group h2 { padding: 15px 16px; font-size: 11px; } + .action { padding: 17px 16px; } + .action-heading { flex-wrap: wrap; gap: 10px; } + .action-title { gap: 10px; min-width: 0; flex: 1 1 140px; } + .action h3 { font-size: 16px; overflow-wrap: anywhere; } + .action-heading button { min-height: 38px; max-width: 100%; } + .action-symbol { width: 25px; flex-shrink: 0; } + .action > .hint { margin-inline-start: 0; font-size: 11px; } + .binding { grid-template-columns: 104px minmax(0, 1fr); gap: 10px; margin-top: 14px; } + .binding > button { min-height: 44px; border-radius: 9px; } + .dropdown-trigger { font-size: 14px; } + .configuration-panel { padding: 18px; } + textarea { height: clamp(220px, 40dvh, 340px); font-size: 13px; } + .export-actions { grid-template-columns: 1fr; gap: 10px; } + .export-actions button { min-height: 44px; } + .faq summary { padding: 18px; font-size: 16px; } + .faq p { padding-inline: 18px; font-size: 14px; } + dialog { padding: 22px; border-radius: 16px; } + dialog h2 { font-size: 23px; } + footer { justify-content: center; padding-block: 22px; } + .navigation-hint { display: none; } +} +@media (max-width: 600px) { + #picker-dialog { inset-inline: 8px; top: auto; bottom: 8px; width: calc(100% - 16px); max-width: calc(100% - 16px); height: min(var(--picker-height, 480px), 76dvh); max-height: calc(100dvh - 16px); padding: 18px 16px calc(16px + env(safe-area-inset-bottom)); border-radius: 22px; } + #picker-dialog::backdrop { background: #05081080; backdrop-filter: blur(7px); } + .picker-option { min-height: 46px; font-size: 15px; } +} +@media (max-width: 360px) { #clock { display: none; } .system-tools { gap: 16px; } .binding { grid-template-columns: 94px minmax(0, 1fr); } .action-title { flex-basis: 120px; } } +@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } *, *::before, *::after { animation: none !important; transition: none !important; } } diff --git a/tools/input-mapper/tests/i18n.test.js b/tools/input-mapper/tests/i18n.test.js new file mode 100644 index 0000000000..65d98f0140 --- /dev/null +++ b/tools/input-mapper/tests/i18n.test.js @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {diagnostic, initialLocale, locales, messages, translate} from '../i18n.js'; +import {parseIni, serialize} from '../validation.js'; + +test('every selectable language supplies the main interface and every FAQ', () => { + assert.equal(locales[0].code, 'ar-EG'); + assert.equal(new Set(locales.map(item => item.code)).size, locales.length); + const required = ['controls', 'configuration', 'help', 'settings', 'language', 'close', 'intro', 'search', 'import', 'reset', 'select', 'add', 'capture', 'remove', 'download', 'copy', 'backControls', 'helpIntro', 'configIntro']; + for (let index = 0; index < 6; index++) required.push(`group${index}`); + for (let index = 0; index < 4; index++) required.push(`faq${index}Question`, `faq${index}Answer`); + for (const locale of locales) { + assert.doesNotThrow(() => new Intl.DateTimeFormat(locale.code)); + assert.equal(locale.direction, ['ar-EG', 'ur', 'fa'].includes(locale.code) ? 'rtl' : 'ltr'); + for (const key of required) assert.ok(messages[locale.code][key], `${locale.code}: ${key}`); + } +}); + +test('first visits use English while valid saved language preferences are retained', () => { + for (const saved of [undefined, null, '', 'unsupported']) assert.equal(initialLocale(saved), 'en'); + for (const locale of locales) assert.equal(initialLocale(locale.code), locale.code); +}); + +test('language substitution preserves canonical inputs and exported INI bytes', () => { + const source = 'Cross = KEY:F\nCross = KEY:Space\nToggleMouse = MOUSE:X1\n'; + const entries = parseIni(source).entries; + for (const locale of locales) { + assert.ok(translate(locale.code, 'builtin', {inputs: 'KEY:F, KEY:Space'}).includes('KEY:F, KEY:Space')); + assert.equal(serialize(entries), source); + } + assert.equal(translate('ar-EG', 'captured', {value: 'F', action: 'Cross'}), 'تم تسجيل F لـ Cross.'); + assert.equal(translate('unsupported', 'help'), 'Help'); +}); + +test('Egyptian diagnostics retain action tokens and unknown technical details', () => { + assert.equal(diagnostic('ar-EG', 'Cross: Unknown or empty KEY value.'), 'Cross: قيمة KEY مش معروفة أو فاضية.'); + assert.equal(diagnostic('ar-EG', 'Unexpected diagnostic'), 'Unexpected diagnostic'); + assert.equal(diagnostic('fr', 'Cross: Unknown or empty KEY value.'), 'Cross: Unknown or empty KEY value.'); +}); diff --git a/tools/input-mapper/tests/mapping.test.js b/tools/input-mapper/tests/mapping.test.js new file mode 100644 index 0000000000..6554c52a9e --- /dev/null +++ b/tools/input-mapper/tests/mapping.test.js @@ -0,0 +1,132 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {actions, defaults, mouseValues, padButtons, typesForAction} from '../mapping.js'; +import {exportableKeys, keyNames} from '../keys.js'; +import {captureKey} from '../capture.js'; +import {parseIni, serialize, validate} from '../validation.js'; + +const binding = (action, type = 'KEY', value = 'F') => ({action, type, value}); + +test('action names and pad button categories agree with the current native parser', () => { + const source = readFileSync(new URL('../../../core/libs/prx/libSceVideoOut/src/InputMapping.cpp', import.meta.url), 'utf8'); + const definitions = [...source.matchAll(/InputAction\{"(\w+)", Pad::InputControl::(\w+),/gu)]; + assert.deepEqual([...actions].sort(), definitions.map(match => match[1]).sort()); + assert.deepEqual([...padButtons].sort(), definitions.filter(match => match[2] === 'Button').map(match => match[1]).sort()); + assert.match(source, /SDL_GetScancodeFromName\(keyName\.c_str\(\)\)/u); + assert.match(source, /if \(action\.control == Pad::InputControl::ToggleFullscreen\)/u); + assert.match(source, /if \(action\.control != Pad::InputControl::Button\)/u); +}); + +test('built-in conflict catalogue agrees with the current native bindings', () => { + const source = readFileSync(new URL('../../../core/libs/prx/libScePad/include/InputMapping.hpp', import.meta.url), 'utf8'); + const special = {RETURN: 'Return', SPACE: 'Space', ESCAPE: 'Escape', BACKSPACE: 'Backspace', TAB: 'Tab', UP: 'Up', RIGHT: 'Right', DOWN: 'Down', LEFT: 'Left', LALT: 'Left Alt', RALT: 'Right Alt', LSHIFT: 'Left Shift', RSHIFT: 'Right Shift', LCTRL: 'Left Ctrl', RCTRL: 'Right Ctrl'}; + const native = Object.fromEntries(actions.map(action => [action, []])); + for (const match of source.matchAll(/InputBinding\{SDL_SCANCODE_(\w+), MouseButton::(\w+), InputControl::(\w+)(?:, PadButton::(\w+))?(?:, (-?\d+))?\}/gu)) { + const [, key, mouse, control, button, wheel] = match; + const action = control === 'Button' ? button : control; + const value = key !== 'UNKNOWN' ? `KEY:${special[key] || key}` : mouse !== 'None' ? `MOUSE:${mouse}` : `WHEEL:${wheel === '1' ? 'Up' : 'Down'}`; + native[action].push(value); + } + assert.deepEqual(native, defaults); +}); + +test('every action serializes with a supported key', () => { + const input = actions.map(action => binding(action)); + assert.equal(serialize(input), actions.map(action => `${action} = KEY:F\n`).join('')); +}); + +test('multiple inputs are alternatives and omitted actions stay omitted', () => { + assert.equal(serialize([binding('Cross'), binding('Cross', 'KEY', 'Space')]), 'Cross = KEY:F\nCross = KEY:Space\n'); + assert.equal(serialize([]), ''); + assert.equal(serialize([binding('R2', 'MOUSE', 'Right')]), 'R2 = MOUSE:Right\n'); +}); + +test('case and whitespace normalize; duplicate bindings warn and serialize once', () => { + const input = [binding(' cross ', ' key ', ' f '), binding('Cross')]; + assert.equal(serialize(input), 'Cross = KEY:F\n'); + assert.equal(validate(input).warnings.filter(warning => warning.message.includes('duplicate')).length, 1); +}); + +test('invalid and incomplete bindings block export', () => { + for (const input of [binding('Unknown'), binding('Cross', 'GAMEPAD'), binding('Cross', 'KEY', 'Enter'), binding('Cross', 'KEY', 'KeyF'), binding('Cross', 'KEY', ''), binding('Cross', 'KEY', 'F\nR2 = MOUSE:Left'), null, {}]) { + assert.ok(validate([input]).errors.length); + assert.throws(() => serialize([input])); + } + assert.ok(validate({}).errors.length); +}); + +test('all exportable SDL names validate and unsafe comment names are rejected', () => { + for (const key of exportableKeys) assert.equal(validate([binding('Cross', 'KEY', key)]).errors.length, 0, key); + for (const key of [';', '#', 'Keypad #']) assert.ok(validate([binding('Cross', 'KEY', key)]).errors.length, key); + assert.equal(new Set(keyNames).size, keyNames.length); +}); + +test('mouse values and action restrictions match the current INI format', () => { + for (const action of actions) { + for (const value of mouseValues) { + assert.equal(validate([binding(action, 'MOUSE', value)]).errors.length === 0, action !== 'ToggleFullscreen'); + } + } + assert.ok(validate([binding('Cross', 'MOUSE', 'Button1')]).errors.length); +}); + +test('wheel directions only map to pad buttons', () => { + for (const action of actions) { + for (const value of ['Up', 'Down']) { + assert.equal(validate([binding(action, 'WHEEL', value)]).errors.length === 0, padButtons.has(action)); + } + assert.deepEqual(typesForAction(action).includes('WHEEL'), padButtons.has(action)); + } + assert.ok(validate([binding('Cross', 'WHEEL', 'Left')]).errors.length); +}); + +test('conflicts warn without blocking, including omitted built-in actions', () => { + const input = [binding('Cross'), binding('Square')]; + const result = validate(input); + assert.equal(result.errors.length, 0); + assert.ok(result.warnings.some(warning => warning.message.includes('Cross and Square and RightStickLeft (built-in)'))); + assert.doesNotThrow(() => serialize(input)); + const overridden = validate([binding('Cross'), binding('RightStickLeft', 'KEY', 'J')]); + assert.equal(overridden.warnings.length, 0); +}); + +test('serialization is deterministic in action order and preserves alternative order', () => { + const input = [binding('ToggleMouse', 'MOUSE', 'X1'), binding('Cross', 'KEY', 'Space'), binding('Cross')]; + assert.equal(serialize(input), 'Cross = KEY:Space\nCross = KEY:F\nToggleMouse = MOUSE:X1\n'); + assert.deepEqual(input, [binding('ToggleMouse', 'MOUSE', 'X1'), binding('Cross', 'KEY', 'Space'), binding('Cross')]); +}); + +test('import supports BOM, CRLF, inline comments, extra equals and colons in key names', () => { + const result = parseIni('\uFEFF# config\r\n cross = key : space ; note\r\nCross=KEY:Space\r\nR2=mouse:right\r\nL1=KEY:=\r\nL2=KEY:Keypad :\r\n'); + assert.deepEqual(result.errors, []); + assert.equal(result.warnings.length, 1); + assert.deepEqual(result.entries, [binding('Cross', 'KEY', 'Space'), binding('R2', 'MOUSE', 'Right'), binding('L1', 'KEY', '='), binding('L2', 'KEY', 'Keypad :')]); +}); + +test('import rejects sections, unsupported extensions and malformed lines with line numbers', () => { + const result = parseIni('[Bindings]\nCross=NONE\nCross=KEY:4\nToggleFullscreen=MOUSE:Left\nLeftStickUp=WHEEL:Up\nCross=KEY:;\nCross=KEY:KeyF\nCross=KEY:\n'); + assert.deepEqual(result.errors.map(error => error.line), [1, 2, 4, 5, 6, 7, 8]); + assert.deepEqual(result.entries, [binding('Cross', 'KEY', '4')]); + assert.throws(() => parseIni(null), TypeError); +}); + +test('round trip is stable for every safe key and supported mouse/wheel source', () => { + const input = [...exportableKeys.map(value => binding('Cross', 'KEY', value)), ...mouseValues.map(value => binding('Square', 'MOUSE', value)), binding('R2', 'WHEEL', 'Down')]; + const text = serialize(input); + const imported = parseIni(text); + assert.equal(imported.errors.length, 0); + assert.equal(serialize(imported.entries), text); +}); + +test('capture maps physical positions and distinguishes modifiers and keypad', () => { + const cases = {KeyA: 'A', Digit1: '1', Enter: 'Return', NumpadEnter: 'Keypad Enter', Numpad1: 'Keypad 1', ControlLeft: 'Left Ctrl', AltRight: 'Right Alt', ArrowUp: 'Up', F24: 'F24', Equal: '=', Backslash: '\\'}; + for (const [code, value] of Object.entries(cases)) assert.deepEqual(captureKey({code, key: 'ignored'}), {value}); + assert.equal(captureKey({code: 'KeyQ', key: 'a'}).value, 'Q'); +}); + +test('capture rejects missing identifiers, composition and unsafe punctuation', () => { + for (const event of [{code: 'Unidentified'}, {key: 'a'}, {code: 'IntlBackslash'}, {code: 'Semicolon'}, {code: 'KeyA', isComposing: true}, {code: 'Quote', key: 'Dead'}, {code: 'KeyA', key: 'Process'}]) { + assert.ok(captureKey(event).error); + } +}); diff --git a/tools/input-mapper/validation.js b/tools/input-mapper/validation.js new file mode 100644 index 0000000000..ce4c7a8bb9 --- /dev/null +++ b/tools/input-mapper/validation.js @@ -0,0 +1,93 @@ +import {actions, canonicalValue, defaults, mouseValues, typesForAction, wheelValues} from './mapping.js'; +import {keyNames} from './keys.js'; + +export function validate(entries) { + const errors = []; + const warnings = []; + const bindings = []; + const seen = new Set(); + if (!Array.isArray(entries)) return {errors: [{message: 'Configuration must be a list of bindings.'}], warnings, bindings}; + entries.forEach((entry, index) => { + const action = canonicalValue(entry?.action, actions); + const type = canonicalValue(entry?.type, ['KEY', 'MOUSE', 'WHEEL']); + let message; + if (!action) message = 'Unknown or empty action.'; + else if (!type) message = 'Input type must be KEY, MOUSE or WHEEL.'; + else if (!typesForAction(action).includes(type)) message = `${action} cannot use ${type}.`; + const choices = type === 'KEY' ? keyNames : type === 'MOUSE' ? mouseValues : wheelValues; + const value = canonicalValue(entry?.value, choices); + if (!message && !value) message = `Unknown or empty ${type} value.`; + if (!message && /[#;\r\n]/u.test(value)) message = 'This SDL key name cannot be represented in the current INI comment syntax.'; + if (message) { + errors.push({index, message: `${entry?.action || 'Binding'}: ${message}`}); + return; + } + const binding = {action, type, value}; + const identity = `${action}:${type}:${value}`; + if (seen.has(identity)) warnings.push({index, message: `${action}: duplicate ${type}:${value} will be omitted.`}); + else bindings.push(binding); + seen.add(identity); + }); + const configured = new Set(bindings.map(binding => binding.action)); + const sources = new Map(); + const effective = bindings.map(binding => ({action: binding.action, source: `${binding.type}:${binding.value}`, builtin: false})); + for (const action of actions) { + if (!configured.has(action)) { + for (const source of defaults[action]) effective.push({action, source, builtin: true}); + } + } + for (const binding of effective) { + const owners = sources.get(binding.source) || []; + owners.push(binding); + sources.set(binding.source, owners); + } + for (const [source, owners] of sources) { + if (owners.length > 1 && owners.some(owner => !owner.builtin)) { + warnings.push({message: `${source} controls ${owners.map(owner => `${owner.action}${owner.builtin ? ' (built-in)' : ''}`).join(' and ')}.`}); + } + } + return {errors, warnings, bindings}; +} + +export function serialize(entries) { + const result = validate(entries); + if (result.errors.length) throw new Error(result.errors.map(error => error.message).join('\n')); + const lines = []; + for (const action of actions) { + for (const binding of result.bindings.filter(binding => binding.action === action)) { + lines.push(`${action} = ${binding.type}:${binding.value}`); + } + } + return lines.length ? `${lines.join('\n')}\n` : ''; +} + +export function parseIni(text) { + if (typeof text !== 'string') throw new TypeError('INI content must be text.'); + const entries = []; + const errors = []; + const warnings = []; + const identities = new Set(); + text.replace(/^\uFEFF/u, '').split('\n').forEach((line, index) => { + const content = line.split(/[#;]/u, 1)[0].trim(); + if (!content) return; + const separator = content.indexOf('='); + const source = content.slice(separator + 1); + const colon = source.indexOf(':'); + if (separator === -1 || colon === -1) { + errors.push({line: index + 1, message: 'Expected Action = TYPE:VALUE.'}); + return; + } + const entry = {action: content.slice(0, separator), type: source.slice(0, colon), value: source.slice(colon + 1)}; + const result = validate([entry]); + if (result.errors.length) { + errors.push(...result.errors.map(error => ({line: index + 1, message: error.message}))); + return; + } + const binding = result.bindings[0]; + const identity = JSON.stringify(binding); + if (identities.has(identity)) warnings.push({line: index + 1, message: 'Duplicate binding ignored.'}); + else entries.push(binding); + identities.add(identity); + }); + return {entries, errors, warnings}; +} From 8644428aa70946fe6e2da67b6ebe7a759a8b0c85 Mon Sep 17 00:00:00 2001 From: Amr Yasser <154547867+AMRYB@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:35:50 +0300 Subject: [PATCH 0020/1059] ci(input): run mapper tests and clarify hosting documentation --- .github/workflows/input-mapper.yml | 24 ++++++++++++++++++++++++ docs/user/INPUT_MAPPING.md | 4 +--- 2 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/input-mapper.yml diff --git a/.github/workflows/input-mapper.yml b/.github/workflows/input-mapper.yml new file mode 100644 index 0000000000..73783169c6 --- /dev/null +++ b/.github/workflows/input-mapper.yml @@ -0,0 +1,24 @@ +name: Input mapper + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + input-mapper: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: '22' + package-manager-cache: false + - run: npm --prefix tools/input-mapper test diff --git a/docs/user/INPUT_MAPPING.md b/docs/user/INPUT_MAPPING.md index 9a051c8898..5e974ab0e6 100644 --- a/docs/user/INPUT_MAPPING.md +++ b/docs/user/INPUT_MAPPING.md @@ -131,8 +131,6 @@ Earlier versions sampled the mouse every 33 ms and tilted the stick at least hal An invalid line reports the file and line number and stops input initialization. If the configured file does not exist or cannot be read, AnyPS5 reports an error. With no `anyps5-input.ini` and no `ANYPS5_INPUT_CONFIG`, the built-in mapping is used. - - ## Web configurator The [input mapper](../../tools/input-mapper/index.html) edits keyboard, mouse and wheel overrides in the browser. It supports alternate bindings, physical-key capture, search, INI import, preview, copy and download. Invalid imports preserve the current configuration. Invalid bindings block export; duplicate and shared inputs, including retained built-in bindings, produce warnings. Removing every binding for an action restores its built-in inputs. @@ -155,4 +153,4 @@ The translation button selects one of 22 interface and FAQ languages. First visi Keyboard capture converts physical browser key codes to SDL scancode names. Escape cancels capture; select Escape manually to assign it. Composition, ambiguous keys and some browser or system shortcuts require manual selection. Names containing `#` or `;` cannot be exported because those characters start INI comments. The key catalogue follows SDL commit `4b69833bc54abf3dd3288d4aa7afbba527775e5b`. The source-contract tests compare actions, restrictions and defaults with the current native input code. -For static hosting, serve `index.html`, `styles.css`, `app.js`, `mapping.js`, `keys.js`, `capture.js`, `validation.js`, `i18n.js` and `dropdown.js` from one directory. For the existing GitHub Pages deployment, these files can be copied into `out/input-mapper/` after the Progress workflow generates `out`. The progress dashboard remains at `out/index.html`; publication and a dashboard link are left for maintainer review. +For static hosting, serve `index.html`, `styles.css`, `app.js`, `mapping.js`, `keys.js`, `capture.js`, `validation.js`, `i18n.js` and `dropdown.js` from one directory. The configurator is not published on the project's GitHub Pages site yet. From c75d7bfdedb8118e04d84ca5436d1ec958ae446c Mon Sep 17 00:00:00 2001 From: Amr Yasser <154547867+AMRYB@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:01:45 +0300 Subject: [PATCH 0021/1059] fix(input): sync web mapper with current native actions --- .github/workflows/input-mapper.yml | 5 +++++ tools/input-mapper/index.html | 2 +- tools/input-mapper/mapping.js | 9 ++++---- tools/input-mapper/tests/mapping.test.js | 26 ++++++++++++++++++++++-- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/.github/workflows/input-mapper.yml b/.github/workflows/input-mapper.yml index 73783169c6..55120c5b54 100644 --- a/.github/workflows/input-mapper.yml +++ b/.github/workflows/input-mapper.yml @@ -5,6 +5,11 @@ on: branches: [main] pull_request: branches: [main] + paths: + - '.github/workflows/input-mapper.yml' + - 'tools/input-mapper/**' + - 'core/libs/prx/libScePad/include/**' + - 'core/libs/prx/libSceVideoOut/src/InputMapping.cpp' permissions: contents: read diff --git a/tools/input-mapper/index.html b/tools/input-mapper/index.html index ee5a61c712..3b340b95e6 100644 --- a/tools/input-mapper/index.html +++ b/tools/input-mapper/index.html @@ -37,7 +37,7 @@