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.
This commit is contained in:
TibixDev 2026-10-07 09:34:34 +03:00 committed by Adrià Franch
parent 1c75f4d228
commit 3a35841aad
5 changed files with 208 additions and 7 deletions

View File

@ -0,0 +1,59 @@
#ifndef CORE_LIBS_PRX_LIBSCEPAD_INPUTCOMMANDFILE_HPP
#define CORE_LIBS_PRX_LIBSCEPAD_INPUTCOMMANDFILE_HPP
#include <cstdint>
#include <fstream>
#include <sstream>
#include <string>
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

View File

@ -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 <algorithm>
#include <cmath>
#include <cstring>
#include <cstdlib>
#include <deque>
#include <mutex>
#include <stdexcept>
@ -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<std::uint8_t>(state.analogButtonsL2, (state.buttons & 0x100) != 0 ? 255 : 0);
data.analog_buttons_r2 = std::max<std::uint8_t>(state.analogButtonsR2, (state.buttons & 0x200) != 0 ? 255 : 0);
data.analog_buttons_l2 = std::max<std::uint8_t>(state.analogButtonsL2, (data.buttons & 0x100) != 0 ? 255 : 0);
data.analog_buttons_r2 = std::max<std::uint8_t>(state.analogButtonsR2, (data.buttons & 0x200) != 0 ? 255 : 0);
const bool live = state.hasMotion && output.motionEnabled;
const std::array<float, 3> 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;

View File

@ -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 <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <thread>
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<int>(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);

View File

@ -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.

38
tools/pad_input.py Normal file
View File

@ -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()