From 3a35841aad5ad27f08ade356bf9a3dfc03ed06b9 Mon Sep 17 00:00:00 2001 From: TibixDev Date: Wed, 7 Oct 2026 09:34:34 +0300 Subject: [PATCH] feat(pad): accept timed controller pulses from a local command file Add an optional input helper for test runs without desktop input injection. Preserve physical input and release injected buttons automatically. Validation: guest_pad tests and Python helper CLI smoke checks passed. Written with AI assistance. --- .../libScePad/include/InputCommandFile.hpp | 59 +++++++++++++++ core/libs/prx/libScePad/src/PadState.cpp | 30 ++++++-- core/libs/tests/GuestPad.cpp | 75 +++++++++++++++++++ docs/user/USAGE.md | 13 ++++ tools/pad_input.py | 38 ++++++++++ 5 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 core/libs/prx/libScePad/include/InputCommandFile.hpp create mode 100644 tools/pad_input.py diff --git a/core/libs/prx/libScePad/include/InputCommandFile.hpp b/core/libs/prx/libScePad/include/InputCommandFile.hpp new file mode 100644 index 000000000..8915531f4 --- /dev/null +++ b/core/libs/prx/libScePad/include/InputCommandFile.hpp @@ -0,0 +1,59 @@ +#ifndef CORE_LIBS_PRX_LIBSCEPAD_INPUTCOMMANDFILE_HPP +#define CORE_LIBS_PRX_LIBSCEPAD_INPUTCOMMANDFILE_HPP + +#include +#include +#include +#include + +namespace Pad { +class InputCommandFile { +public: + explicit InputCommandFile(const char* path) : path(path != nullptr ? path : "") { + if (!this->path.empty()) lastSequence = read().sequence; + } + + std::uint32_t Buttons(std::uint64_t now) { + if (path.empty()) return 0; + if (now >= nextPoll) { + nextPoll = now + 100000; + const auto command = read(); + if (command.sequence > lastSequence) { + lastSequence = command.sequence; + buttons = command.buttons; + until = now + command.durationMs * 1000ull; + } + } + return now < until ? buttons : 0; + } + +private: + struct Command { + std::uint64_t sequence = 0; + std::uint32_t buttons = 0; + std::uint32_t durationMs = 0; + }; + + Command read() const { + std::ifstream file(path); + char line[128]{}; + if (!file.getline(line, sizeof(line))) return {}; + const std::string text(line); + if (text.find('-') != std::string::npos) return {}; + std::istringstream fields(text); + Command command; + std::string extra; + if (!(fields >> command.sequence >> command.buttons >> command.durationMs) || fields >> extra) return {}; + if (command.sequence == 0 || (command.buttons & ~0x10fffeu) != 0 || command.durationMs == 0 || command.durationMs > 5000) return {}; + return command; + } + + std::string path; + std::uint64_t lastSequence = 0; + std::uint64_t nextPoll = 0; + std::uint64_t until = 0; + std::uint32_t buttons = 0; +}; +} + +#endif diff --git a/core/libs/prx/libScePad/src/PadState.cpp b/core/libs/prx/libScePad/src/PadState.cpp index 1bd213078..1f40fc26a 100644 --- a/core/libs/prx/libScePad/src/PadState.cpp +++ b/core/libs/prx/libScePad/src/PadState.cpp @@ -1,9 +1,11 @@ #include "prx/libScePad/include/PadState.hpp" #include "prx/libScePad/include/Pad.hpp" +#include "prx/libScePad/include/InputCommandFile.hpp" #include "prx/libkernel/Time/include/Time.hpp" #include #include #include +#include #include #include #include @@ -107,15 +109,15 @@ void Pad::Initialize() { } namespace { -PadData BuildData(const PadInputState& state, std::uint64_t changeTime, std::uint64_t now) { +PadData BuildData(const PadInputState& state, std::uint64_t changeTime, std::uint64_t now, std::uint32_t injected) { PadData data{}; - data.buttons = state.buttons; + data.buttons = state.buttons | injected; data.left_stick_x = state.sticks[0]; data.left_stick_y = state.sticks[1]; data.right_stick_x = state.sticks[2]; data.right_stick_y = state.sticks[3]; - data.analog_buttons_l2 = std::max(state.analogButtonsL2, (state.buttons & 0x100) != 0 ? 255 : 0); - data.analog_buttons_r2 = std::max(state.analogButtonsR2, (state.buttons & 0x200) != 0 ? 255 : 0); + data.analog_buttons_l2 = std::max(state.analogButtonsL2, (data.buttons & 0x100) != 0 ? 255 : 0); + data.analog_buttons_r2 = std::max(state.analogButtonsR2, (data.buttons & 0x200) != 0 ? 255 : 0); const bool live = state.hasMotion && output.motionEnabled; const std::array rest{0.0f, 9.80665f, 0.0f}; @@ -169,13 +171,26 @@ PadData BuildData(const PadInputState& state, std::uint64_t changeTime, std::uin data.touch_data_touch_num = touchNum; return data; } + +std::uint32_t InjectedButtons(std::uint64_t now) { + static Pad::InputCommandFile commands(std::getenv("APS5_PAD_INPUT_FILE")); + static std::uint32_t lastInjected = 0; + const auto injected = commands.Buttons(now); + if (injected != lastInjected) { + lastInjected = injected; + timestamp = now; + } + return injected; +} } PadData Pad::ReadState() { std::lock_guard lock(stateMutex); if (failure) std::rethrow_exception(failure); if (!initialized) throw std::runtime_error("Pad: read before initialization"); - return BuildData(state, timestamp, sceKernelGetProcessTime()); + const std::uint64_t now = sceKernelGetProcessTime(); + const std::uint32_t injected = InjectedButtons(now); + return BuildData(state, timestamp, now, injected); } int Pad::Read(PadData* data, int num) { @@ -184,14 +199,15 @@ int Pad::Read(PadData* data, int num) { if (failure) std::rethrow_exception(failure); if (!initialized) throw std::runtime_error("Pad: read before initialization"); const std::uint64_t now = sceKernelGetProcessTime(); + const std::uint32_t injected = InjectedButtons(now); if (queue.empty()) { - data[0] = BuildData(state, timestamp, now); + data[0] = BuildData(state, timestamp, now, injected); return 1; } int count = 0; while (count < num && !queue.empty()) { const QueuedInput& entry = queue.front(); - data[count++] = BuildData(entry.input, entry.timestamp, now); + data[count++] = BuildData(entry.input, entry.timestamp, now, injected); queue.pop_front(); } return count; diff --git a/core/libs/tests/GuestPad.cpp b/core/libs/tests/GuestPad.cpp index bf874be0a..992f0f7b9 100644 --- a/core/libs/tests/GuestPad.cpp +++ b/core/libs/tests/GuestPad.cpp @@ -1,9 +1,12 @@ #include "prx/libScePad/include/Pad.hpp" #include "prx/libScePad/include/PadInputTypes.hpp" #include "prx/libScePad/include/PadState.hpp" +#include "prx/libScePad/include/InputCommandFile.hpp" #include "prx/libc/include/general/VabiMacros.hpp" #include #include +#include +#include #include extern "C" { @@ -25,6 +28,41 @@ int APS5_VABI scePadIsRemoteController(int, bool*); static void Require(bool value) { if (!value) std::abort(); } +static void CheckInputCommands() { + const auto path = std::filesystem::temp_directory_path() / ("anyps5-pad-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + struct Cleanup { + std::filesystem::path path; + ~Cleanup() { std::error_code error; std::filesystem::remove(path, error); } + } cleanup{path}; + const auto write = [&](const char* command) { std::ofstream(path) << command << '\n'; }; + write("1 16384 200"); + Pad::InputCommandFile commands(path.string().c_str()); + Require(commands.Buttons(1000) == 0); + write("2 16384 200"); + Require(commands.Buttons(101000) == 0x4000); + Require(commands.Buttons(300999) == 0x4000); + Require(commands.Buttons(301000) == 0); + Require(commands.Buttons(401000) == 0); + write("3 8192 5001"); + Require(commands.Buttons(501000) == 0); + write("-3 8192 300"); + Require(commands.Buttons(601000) == 0); + write("3 1 300"); + Require(commands.Buttons(701000) == 0); + write("3 8192 300 trailing"); + Require(commands.Buttons(801000) == 0); + write("3 8192 300"); + Require(commands.Buttons(901000) == 0x2000); + write("4 0 1"); + Require(commands.Buttons(1001000) == 0); + write("2 16384 200"); + Require(commands.Buttons(1101000) == 0); + std::filesystem::remove(path); + Require(commands.Buttons(1201000) == 0); + Pad::InputCommandFile disabled(nullptr); + Require(disabled.Buttons(0) == 0); +} + static float SettleOrientationW() { float w = 1.0f; for (int i = 0; i < 10; ++i) { @@ -96,7 +134,43 @@ static void CheckRemoteController(int handle) { Require(!remote); } +static void CheckInjectedState(const std::filesystem::path& path) { + PadInputState physical; + physical.buttons = 0x2000; + physical.analogButtonsR2 = 73; + physical.sticks[0] = 42; + PadPublishInput_nid_postfix(physical); + const auto before = Pad::ReadState(); + std::ofstream(path) << "1 16640 300\n"; + PadData pressed{}; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + do { + pressed = Pad::ReadState(); + if ((pressed.buttons & 0x4000) != 0) break; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } while (std::chrono::steady_clock::now() < deadline); + Require(pressed.buttons == (0x2000 | 0x4000 | 0x100)); + Require(pressed.analog_buttons_l2 == 255 && pressed.analog_buttons_r2 == 73); + Require(pressed.left_stick_x == 42 && pressed.timestamp > before.timestamp); + std::this_thread::sleep_for(std::chrono::milliseconds(310)); + const auto released = Pad::ReadState(); + Require(released.buttons == 0x2000 && released.analog_buttons_l2 == 0); + Require(released.timestamp > pressed.timestamp); + PadPublishInput_nid_postfix(PadInputState{}); +} + int main() { + CheckInputCommands(); + const auto path = std::filesystem::temp_directory_path() / ("anyps5-pad-state-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + struct Cleanup { + std::filesystem::path path; + ~Cleanup() { std::error_code error; std::filesystem::remove(path, error); } + } cleanup{path}; +#ifdef _WIN32 + Require(_putenv_s("APS5_PAD_INPUT_FILE", path.string().c_str()) == 0); +#else + Require(setenv("APS5_PAD_INPUT_FILE", path.string().c_str(), 1) == 0); +#endif constexpr int noHandle = static_cast(0x80920008); constexpr int user = 0x10000000; @@ -118,6 +192,7 @@ int main() { CheckReadQueue(handle); CheckReadStateHandle(handle); CheckRemoteController(handle); + CheckInjectedState(path); Require(scePadSetVibrationMode(handle, 1) == 0); Require(scePadSetVibrationMode(handle, 2) == 0); Require(scePadSetVibrationMode(handle, 3) == PAD_ERROR_INVALID_ARG); diff --git a/docs/user/USAGE.md b/docs/user/USAGE.md index d1bc65edd..2aa4daa4a 100644 --- a/docs/user/USAGE.md +++ b/docs/user/USAGE.md @@ -103,6 +103,19 @@ Windows PowerShell: .\app.exe ``` +### Controller input for test runs + +Set `APS5_PAD_INPUT_FILE` to an absolute path before launching the application to enable controller pulses from a local command file. Once the application is reading controller input, send a button with the helper from the repository root: + +```sh +APS5_PAD_INPUT_FILE=/tmp/anyps5-pad ./app.elf +python3 tools/pad_input.py /tmp/anyps5-pad cross +python3 tools/pad_input.py /tmp/anyps5-pad down --hold-ms 500 +python3 tools/pad_input.py /tmp/anyps5-pad release +``` + +Run the helper in a second terminal. Commands are polled at most every 100 ms during controller reads and add to physical controller input. A press lasts 300 ms by default, starting when the application reads it, and releases automatically; `--hold-ms` accepts 1–5000 ms. Wait for the application to observe a release before sending another press. The file holds only the latest command; rapid commands can replace one another. Commands left over when the application first reads the controller are ignored. Without the environment variable, file input is disabled. + ### System fonts Games that open the console's system font sets (`sceFontOpenFontSet`) need font files in an `anyps5-fonts/` directory beside the output executable; set `ANYPS5_SYSTEM_FONTS` to use another directory. Files dumped from the console are used under their own names (`SST-Roman.otf`, `SST-Bold.otf`, `SSTJpPro-Regular.otf`, ...). Without them, these openly licensed substitutes are used when present: `NotoSans-{Light,Regular,Medium,Bold}.ttf` and `NotoSans-{LightItalic,Italic,MediumItalic,BoldItalic}.ttf` (Latin and Vietnamese), `NotoSansMono-{Light,Regular,Medium,Bold}.ttf` (typewriter), `NotoSansThai-{Light,Regular,Medium,Bold}.ttf` (Thai) and `NotoSansCJK-{Light,Regular,Medium,Bold}.ttc` (Japanese and Chinese). Without either, opening a system font set fails and the game shows no text in those fonts. diff --git a/tools/pad_input.py b/tools/pad_input.py new file mode 100644 index 000000000..8e1bed63d --- /dev/null +++ b/tools/pad_input.py @@ -0,0 +1,38 @@ +import argparse +import os +from pathlib import Path +import tempfile +import time + + +BUTTONS = { + "l3": 0x2, "r3": 0x4, "options": 0x8, + "up": 0x10, "right": 0x20, "down": 0x40, "left": 0x80, + "l2": 0x100, "r2": 0x200, "l1": 0x400, "r1": 0x800, + "triangle": 0x1000, "circle": 0x2000, "cross": 0x4000, + "square": 0x8000, "touchpad": 0x100000, "release": 0, +} + + +def main(): + parser = argparse.ArgumentParser(description="Queue a controller pulse for APS5_PAD_INPUT_FILE.") + parser.add_argument("path", type=Path) + parser.add_argument("button", choices=BUTTONS) + parser.add_argument("--hold-ms", type=int, default=300) + args = parser.parse_args() + if not 1 <= args.hold_ms <= 5000: + parser.error("--hold-ms must be between 1 and 5000") + temporary = None + try: + with tempfile.NamedTemporaryFile(mode="w", dir=args.path.parent, delete=False) as output: + temporary = Path(output.name) + output.write(f"{time.monotonic_ns()} {BUTTONS[args.button]} {args.hold_ms}\n") + os.replace(temporary, args.path) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + print(f"Queued {args.button} for {args.hold_ms} ms in {args.path}") + + +if __name__ == "__main__": + main()