From 636cc9e585a42f6d23be667e751f4ae2e653c8a4 Mon Sep 17 00:00:00 2001 From: AlxcBTW <187763517+AlxcBTW@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:51:58 +0200 Subject: [PATCH 1/3] feat(libScePad): read per-title input profiles beside the shared input file --- .../prx/libScePad/include/InputMapping.hpp | 7 +- .../prx/libSceVideoOut/src/InputMapping.cpp | 73 +++++++++++-------- core/libs/prx/libSceVideoOut/src/PadInput.cpp | 12 ++- .../prx/libSceVideoOut/tests/InputMapping.cpp | 55 ++++++++++++++ docs/user/INPUT_MAPPING.md | 11 +++ 5 files changed, 124 insertions(+), 34 deletions(-) diff --git a/core/libs/prx/libScePad/include/InputMapping.hpp b/core/libs/prx/libScePad/include/InputMapping.hpp index a8914303d5..b0707112bb 100644 --- a/core/libs/prx/libScePad/include/InputMapping.hpp +++ b/core/libs/prx/libScePad/include/InputMapping.hpp @@ -2,7 +2,10 @@ #define CORE_LIBS_PRX_LIBSCEPAD_INPUTMAPPING_HPP #include +#include #include +#include +#include #include #include "SDL_scancode.h" #include "SDL_mouse.h" @@ -15,6 +18,7 @@ inline constexpr int WheelPressDurationMs = 80; inline constexpr double DefaultMouseSensitivity = 1.0; inline constexpr double MaxMouseSensitivity = 10.0; inline constexpr int MaxStickDeadzonePercent = 90; +inline constexpr std::size_t MaxTitleIdLength = 32; struct InputSettings { int leftStickDeadzonePercent = 0; @@ -69,7 +73,8 @@ inline constexpr std::array InputMapping{ InputBinding{SDL_SCANCODE_UNKNOWN, MouseButton::None, InputControl::Button, PadButton::Down, -1} }; -InputConfiguration LoadInputMapping(); +InputConfiguration LoadInputMapping(std::string_view titleId = {}); +std::string InputProfileName(std::string_view titleId); std::array StickWithDeadzone(std::int16_t x, std::int16_t y, int deadzonePercent); } diff --git a/core/libs/prx/libSceVideoOut/src/InputMapping.cpp b/core/libs/prx/libSceVideoOut/src/InputMapping.cpp index 334984d1f7..2ca246396a 100644 --- a/core/libs/prx/libSceVideoOut/src/InputMapping.cpp +++ b/core/libs/prx/libSceVideoOut/src/InputMapping.cpp @@ -243,37 +243,10 @@ std::filesystem::path defaultConfigPath() { return "anyps5-input.ini"; } -} - -std::array Pad::StickWithDeadzone(std::int16_t x, std::int16_t y, int deadzonePercent) { - if (deadzonePercent < 0 || deadzonePercent > MaxStickDeadzonePercent) throw std::invalid_argument("Pad: stick deadzone out of range"); - if (deadzonePercent == 0) return {stickByte(x), stickByte(y)}; - const double normalizedX = std::max(x / 32767.0, -1.0); - const double normalizedY = std::max(y / 32767.0, -1.0); - const double magnitude = std::hypot(normalizedX, normalizedY); - const double deadzone = deadzonePercent / 100.0; - if (magnitude <= deadzone) return {stickByte(0), stickByte(0)}; - const double scale = (magnitude - deadzone) / (1.0 - deadzone) / magnitude; - const auto axis = [scale](double value) { - return stickByte(static_cast(std::lround(std::clamp(value * scale, -1.0, 1.0) * 32767.0))); - }; - return {axis(normalizedX), axis(normalizedY)}; -} - -Pad::InputConfiguration Pad::LoadInputMapping() { - InputConfiguration configuration{{InputMapping.begin(), InputMapping.end()}, {}}; - auto& bindings = configuration.bindings; - const auto configured = configuredPath(); - const bool explicitPath = !configured.empty(); - const std::filesystem::path path = explicitPath ? configured : defaultConfigPath(); +void loadInputFile(Pad::InputConfiguration& configuration, const std::filesystem::path& path) { std::ifstream file(path); - if (!file) { - if (explicitPath || std::filesystem::exists(path)) { - throw std::runtime_error("Pad: cannot read input mapping '" + pathToUtf8(path) + "'"); - } - return configuration; - } - + if (!file) throw std::runtime_error("Pad: cannot read input mapping '" + pathToUtf8(path) + "'"); + auto& bindings = configuration.bindings; std::unordered_set overriddenActions; std::array configuredSettings{}; std::string line; @@ -308,9 +281,9 @@ Pad::InputConfiguration Pad::LoadInputMapping() { auto binding = parseBinding(*action, source); const auto normalizedAction = upper(action->name); if (overriddenActions.insert(normalizedAction).second) { - std::erase_if(bindings, [action](const InputBinding& existing) { return matchesAction(existing, *action); }); + std::erase_if(bindings, [action](const Pad::InputBinding& existing) { return matchesAction(existing, *action); }); } - const bool duplicate = std::any_of(bindings.begin(), bindings.end(), [&binding](const InputBinding& existing) { + const bool duplicate = std::any_of(bindings.begin(), bindings.end(), [&binding](const Pad::InputBinding& existing) { return existing.key == binding.key && existing.mouseButton == binding.mouseButton && existing.control == binding.control && existing.button == binding.button && existing.wheelDirection == binding.wheelDirection; @@ -321,6 +294,42 @@ Pad::InputConfiguration Pad::LoadInputMapping() { } } if (file.bad()) throw std::runtime_error("Pad: cannot read input mapping '" + pathToUtf8(path) + "'"); +} +} + +std::array Pad::StickWithDeadzone(std::int16_t x, std::int16_t y, int deadzonePercent) { + if (deadzonePercent < 0 || deadzonePercent > MaxStickDeadzonePercent) throw std::invalid_argument("Pad: stick deadzone out of range"); + if (deadzonePercent == 0) return {stickByte(x), stickByte(y)}; + const double normalizedX = std::max(x / 32767.0, -1.0); + const double normalizedY = std::max(y / 32767.0, -1.0); + const double magnitude = std::hypot(normalizedX, normalizedY); + const double deadzone = deadzonePercent / 100.0; + if (magnitude <= deadzone) return {stickByte(0), stickByte(0)}; + const double scale = (magnitude - deadzone) / (1.0 - deadzone) / magnitude; + const auto axis = [scale](double value) { + return stickByte(static_cast(std::lround(std::clamp(value * scale, -1.0, 1.0) * 32767.0))); + }; + return {axis(normalizedX), axis(normalizedY)}; +} + +std::string Pad::InputProfileName(std::string_view titleId) { + const bool valid = !titleId.empty() && titleId.size() <= MaxTitleIdLength && std::all_of(titleId.begin(), titleId.end(), [](char character) { + return std::isalnum(static_cast(character)) != 0 || character == '_' || character == '-'; + }); + return valid ? "anyps5-input." + std::string(titleId) + ".ini" : std::string(); +} + +Pad::InputConfiguration Pad::LoadInputMapping(std::string_view titleId) { + InputConfiguration configuration{{InputMapping.begin(), InputMapping.end()}, {}}; + const auto configured = configuredPath(); + const bool explicitPath = !configured.empty(); + const std::filesystem::path path = explicitPath ? configured : defaultConfigPath(); + if (explicitPath || std::filesystem::exists(path)) loadInputFile(configuration, path); + const auto profile = InputProfileName(titleId); + if (!profile.empty()) { + const auto profilePath = path.parent_path() / profile; + if (std::filesystem::exists(profilePath)) loadInputFile(configuration, profilePath); + } return configuration; } diff --git a/core/libs/prx/libSceVideoOut/src/PadInput.cpp b/core/libs/prx/libSceVideoOut/src/PadInput.cpp index 25ccd5af4a..04287a9bce 100644 --- a/core/libs/prx/libSceVideoOut/src/PadInput.cpp +++ b/core/libs/prx/libSceVideoOut/src/PadInput.cpp @@ -15,8 +15,18 @@ #include "prx/libScePad/include/PadInputTypes.hpp" #include "prx/libc/include/General.hpp" #include "prx/libSceIme/include/ImeInput.hpp" +#include "prx/libkernel/AppMetadata/include/AppMetadata.hpp" -PadInput::PadInput() : PadInput(Pad::LoadInputMapping()) { +namespace { + +std::string appTitleId() { + const auto titleId = GetAppTitleId_nid_postfix(); + return std::string(titleId.value, std::find(titleId.value, titleId.value + sizeof(titleId.value), '\0')); +} + +} + +PadInput::PadInput() : PadInput(Pad::LoadInputMapping(appTitleId())) { } PadInput::PadInput(Pad::InputConfiguration configuration) : keyboardMouse(std::move(configuration)) { diff --git a/core/libs/prx/libSceVideoOut/tests/InputMapping.cpp b/core/libs/prx/libSceVideoOut/tests/InputMapping.cpp index 1fe0f4b30b..0ad1c4c0d2 100644 --- a/core/libs/prx/libSceVideoOut/tests/InputMapping.cpp +++ b/core/libs/prx/libSceVideoOut/tests/InputMapping.cpp @@ -147,6 +147,59 @@ void nonAsciiPaths() { require(message.find(utf8Name) != std::string::npos, "a missing file names the path in UTF-8"); } +void writeProfile(const std::string& titleId, const std::string& text) { + std::ofstream file(root / Pad::InputProfileName(titleId), std::ios::binary | std::ios::trunc); + file << text; + require(file.good(), "write the input profile"); +} + +bool crossOnKey(const Pad::InputConfiguration& configuration, SDL_Scancode key) { + std::size_t crossBindings = 0; + bool onKey = false; + for (const auto& binding : configuration.bindings) { + if (binding.control != Pad::InputControl::Button || binding.button != Pad::PadButton::Cross || binding.key == SDL_SCANCODE_UNKNOWN) continue; + ++crossBindings; + onKey = onKey || binding.key == key; + } + return crossBindings == 1 && onKey; +} + +void profileNames() { + require(Pad::InputProfileName("PPSA16064") == "anyps5-input.PPSA16064.ini", "a title ID names its profile"); + require(Pad::InputProfileName("CUSA_01-x") == "anyps5-input.CUSA_01-x.ini", "underscores and dashes are allowed"); + require(Pad::InputProfileName("").empty(), "an empty title ID has no profile"); + require(Pad::InputProfileName("../PPSA16064").empty(), "a title ID with a path separator has no profile"); + require(Pad::InputProfileName("PPSA16064.ini").empty(), "a title ID with a dot has no profile"); + require(Pad::InputProfileName(std::string(Pad::MaxTitleIdLength + 1, 'A')).empty(), "a title ID over the length limit has no profile"); +} + +void profileOverridesShared() { + useConfig("MouseSensitivity = 2\nCross = KEY:F\n"); + writeProfile("PPSA00001", "MouseSensitivity = 4\nMouseCurve = 2\nCross = KEY:G\n"); + const auto profiled = Pad::LoadInputMapping("PPSA00001"); + require(profiled.settings.mouseSensitivity == 4.0 && profiled.settings.mouseCurve == 2.0, "a profile overrides and adds settings"); + require(crossOnKey(profiled, SDL_SCANCODE_G), "a profile replaces the bindings of an action it lists"); + const auto other = Pad::LoadInputMapping("PPSA00002"); + require(other.settings.mouseSensitivity == 2.0 && other.settings.mouseCurve == 1.0 && crossOnKey(other, SDL_SCANCODE_F), "another title keeps the shared file"); + const auto none = Pad::LoadInputMapping(); + require(none.settings.mouseSensitivity == 2.0 && crossOnKey(none, SDL_SCANCODE_F), "no title ID keeps the shared file"); + writeProfile("PPSA00001", "MouseCurve = 3\n"); + const auto partial = Pad::LoadInputMapping("PPSA00001"); + require(partial.settings.mouseSensitivity == 2.0 && partial.settings.mouseCurve == 3.0 && crossOnKey(partial, SDL_SCANCODE_F), "a profile keeps what it does not list"); + writeProfile("PPSA00001", "MouseSensitivity = 4\n"); + require(Pad::LoadInputMapping("PPSA00001").settings.mouseSensitivity == 4.0, "a profile may set a setting the shared file also sets"); + writeProfile("PPSA00001", "MouseCurve = 9\n"); + std::string message; + try { + Pad::LoadInputMapping("PPSA00001"); + } catch (const std::runtime_error& error) { + message = error.what(); + } + require(message.find("anyps5-input.PPSA00001.ini:1") != std::string::npos, "an invalid profile names its file and line"); + require(Pad::LoadInputMapping("PPSA00002").settings.mouseCurve == 1.0, "an invalid profile does not affect other titles"); + std::filesystem::remove(root / Pad::InputProfileName("PPSA00001")); +} + void deadzoneOffMatchesRawSticks() { for (const std::int16_t axis : {std::int16_t{-32768}, std::int16_t{-20000}, std::int16_t{-1}, std::int16_t{0}, std::int16_t{1}, std::int16_t{1234}, std::int16_t{32767}}) { const auto stick = Pad::StickWithDeadzone(axis, axis, 0); @@ -183,6 +236,8 @@ int main() try { mouseLookDefault(); mouseLookParse(); mouseLookReject(); + profileNames(); + profileOverridesShared(); nonAsciiPaths(); deadzoneOffMatchesRawSticks(); deadzoneShapesSticks(); diff --git a/docs/user/INPUT_MAPPING.md b/docs/user/INPUT_MAPPING.md index 882ce0a000..3b49199c3f 100644 --- a/docs/user/INPUT_MAPPING.md +++ b/docs/user/INPUT_MAPPING.md @@ -76,6 +76,17 @@ RightStickDeadzone = 8 MouseSensitivity = 2.5 ``` +## Per-game profiles + +A file named `anyps5-input..ini` beside `anyps5-input.ini` (or beside the file `ANYPS5_INPUT_CONFIG` names) is read after it when the running title has that ID, for example `anyps5-input.PPSA16064.ini`. The profile has the same format and overrides only what it lists: an action it names replaces the bindings from the shared file, a setting it names replaces the shared value, and everything else stays. Other titles don't read it. A title ID may contain letters, digits, `_` and `-`. An invalid line in a profile reports the profile's file and line number. + +For example, a profile that gives one title a lower mouse sensitivity and Cross on F: + +```ini +MouseSensitivity = 0.6 +Cross = KEY:F +``` + ## Mouse as the right stick `ToggleMouse` (the middle mouse button by default) captures the mouse and drives the right stick from how fast the mouse moves. Capture uses raw relative input, so the pointer speed and acceleration set in the operating system don't apply. Press it again to release the mouse. Capture also ends, and every held key is let go, when the window loses focus. From 6e6d28c5887571a6174e08a2fa6e1b840eac072b Mon Sep 17 00:00:00 2001 From: Dean Galvin Date: Fri, 30 Oct 2026 00:22:09 +1300 Subject: [PATCH 2/3] docs(input): use a fictional title ID in profile examples --- core/libs/prx/libSceVideoOut/tests/InputMapping.cpp | 6 +++--- docs/user/INPUT_MAPPING.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/libs/prx/libSceVideoOut/tests/InputMapping.cpp b/core/libs/prx/libSceVideoOut/tests/InputMapping.cpp index 1a91562760..1c97f13996 100644 --- a/core/libs/prx/libSceVideoOut/tests/InputMapping.cpp +++ b/core/libs/prx/libSceVideoOut/tests/InputMapping.cpp @@ -430,11 +430,11 @@ bool crossOnKey(const Pad::InputConfiguration& configuration, SDL_Scancode key) } void profileNames() { - require(Pad::InputProfileName("PPSA16064") == "anyps5-input.PPSA16064.ini", "a title ID names its profile"); + require(Pad::InputProfileName("PPSA00001") == "anyps5-input.PPSA00001.ini", "a title ID names its profile"); require(Pad::InputProfileName("CUSA_01-x") == "anyps5-input.CUSA_01-x.ini", "underscores and dashes are allowed"); require(Pad::InputProfileName("").empty(), "an empty title ID has no profile"); - require(Pad::InputProfileName("../PPSA16064").empty(), "a title ID with a path separator has no profile"); - require(Pad::InputProfileName("PPSA16064.ini").empty(), "a title ID with a dot has no profile"); + require(Pad::InputProfileName("../PPSA00001").empty(), "a title ID with a path separator has no profile"); + require(Pad::InputProfileName("PPSA00001.ini").empty(), "a title ID with a dot has no profile"); require(Pad::InputProfileName(std::string(Pad::MaxTitleIdLength + 1, 'A')).empty(), "a title ID over the length limit has no profile"); } diff --git a/docs/user/INPUT_MAPPING.md b/docs/user/INPUT_MAPPING.md index 7d2d420ac8..79b7103e55 100644 --- a/docs/user/INPUT_MAPPING.md +++ b/docs/user/INPUT_MAPPING.md @@ -133,7 +133,7 @@ While the `Shake` input is held (X by default), the controller reports its accel ## Per-game profiles -A file named `anyps5-input..ini` beside `anyps5-input.ini` (or beside the file `ANYPS5_INPUT_CONFIG` names) is read after it when the running title has that ID, for example `anyps5-input.PPSA16064.ini`. The profile has the same format and overrides only what it lists: an action it names replaces the bindings from the shared file, a setting it names replaces the shared value, and everything else stays. Other titles don't read it. A title ID may contain letters, digits, `_` and `-`. An invalid line in a profile reports the profile's file and line number. +A file named `anyps5-input..ini` beside `anyps5-input.ini` (or beside the file `ANYPS5_INPUT_CONFIG` names) is read after it when the running title has that ID, for example `anyps5-input.PPSA00001.ini`. The profile has the same format and overrides only what it lists: an action it names replaces the bindings from the shared file, a setting it names replaces the shared value, and everything else stays. Other titles don't read it. A title ID may contain letters, digits, `_` and `-`. An invalid line in a profile reports the profile's file and line number. For example, a profile that gives one title a lower mouse sensitivity and Cross on F: From 9528d7dc9e2f79ef612e16c7d1a3c7c56f4e4447 Mon Sep 17 00:00:00 2001 From: Dean Galvin Date: Mon, 2 Nov 2026 07:35:18 +1300 Subject: [PATCH 3/3] fix(libSceVideoOut): skip the per-title input profile when no title is mounted PadInput() asked for the title ID while constructing, and GetAppTitleId_nid_postfix throws when there is no param.json or param.sfo, so pad tests and tools that mount no title failed. Return no title ID when neither file exists and let any other metadata error propagate. --- core/libs/prx/libSceVideoOut/src/PadInput.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/libs/prx/libSceVideoOut/src/PadInput.cpp b/core/libs/prx/libSceVideoOut/src/PadInput.cpp index 1319f8c1b8..1a435ffb32 100644 --- a/core/libs/prx/libSceVideoOut/src/PadInput.cpp +++ b/core/libs/prx/libSceVideoOut/src/PadInput.cpp @@ -3,7 +3,7 @@ #include #include #include -#include +#include #include #include @@ -30,6 +30,7 @@ namespace { std::string appTitleId() { + if (!std::filesystem::exists(ResolvePath_nid_no_patch("/app0/sce_sys/param.json")) && !std::filesystem::exists(ResolvePath_nid_no_patch("/app0/sce_sys/param.sfo"))) return {}; const auto titleId = GetAppTitleId_nid_postfix(); return std::string(titleId.value, std::find(titleId.value, titleId.value + sizeof(titleId.value), '\0')); }