[HID] file-pad: a controller driven by a text file, for scripted RE
Some checks failed
Orchestrator / Commit Message Validation (push) Has been skipped
Orchestrator / Lint (push) Failing after 2m24s
Orchestrator / Windows (x86-64) (push) Has been skipped
Orchestrator / Linux (x86-64) (push) Has been skipped
Orchestrator / Create Release (push) Has been skipped

The scripted-input tool this project uses for reverse engineering created its
pad through /dev/uinput. Input devices are NOT namespaced by the kernel, so a
uinput device created inside a container registers with the HOST's input stack:
every trigger hold and button press is delivered to whatever on the host reads
gamepads, not just to the emulator. That was noticed the hard way, and it made
every runtime experiment -- booting, menu navigation, unit harvesting, flight
measurement -- unusable from inside the box.

This driver takes the kernel out of the loop. Pad state lives in an ordinary
text file only the container can see; GetState re-reads it when it changes.
Nothing is registered with the host and no X server is involved. A bonus for RE:
analogue values are exact rather than whatever a virtual stick quantises to.

    press=A,START     buttons by name, comma separated
    buttons=0x1010    or the raw XINPUT mask
    lt=0 rt=255       triggers, 0..255
    lx=0 ly=0         thumbs, -32768..32767

Absent keys are neutral, so `press=A` alone is a valid file, and a missing or
empty file means no input -- the safe default if it is deleted mid-run.

Selected with --hid=file, path from --pad_file (default /tmp/xenia_pad.txt).
Deliberately NOT part of "any": this pad has to be asked for. Header-only, so it
adds no build target and no cost to anyone not using it.
This commit is contained in:
2026-08-13 20:12:02 +00:00
parent 31366e5cac
commit d15c8cfab6
2 changed files with 262 additions and 1 deletions

View File

@@ -64,6 +64,12 @@
#include "xenia/hid/winkey/winkey_hid.h"
#include "xenia/hid/xinput/xinput_hid.h"
#endif // XE_PLATFORM_WIN32
// RE aid: a controller driven by a text file instead of a kernel input device.
// A uinput pad created inside a container registers with the HOST's input stack
// (input devices are not namespaced), so scripted input leaks to the desktop.
// This one is visible only to whoever can read the file. Header-only on purpose:
// it adds no build target.
#include "xenia/hid/file/file_input_driver.h"
#if XE_PLATFORM_WIN32
#define APU_OPTIONS "[any, nop, sdl, xaudio2]"
@@ -72,7 +78,7 @@
#elif XE_PLATFORM_LINUX
#define APU_OPTIONS "[any, alsa, nop, sdl]"
#define GPU_OPTIONS "[any, vulkan, null]"
#define HID_OPTIONS "[any, nop, sdl]"
#define HID_OPTIONS "[any, file, nop, sdl]"
#else
#define APU_OPTIONS "[any, nop, sdl]"
#define GPU_OPTIONS "[any, vulkan, null]"
@@ -82,6 +88,11 @@
DEFINE_string(apu, "any", "Audio system. Use: " APU_OPTIONS, "APU");
DEFINE_string(gpu, "any", "Graphics system. Use: " GPU_OPTIONS, "GPU");
DEFINE_string(hid, "any", "Input system. Use: " HID_OPTIONS, "HID");
DEFINE_string(pad_file, "/tmp/xenia_pad.txt",
"Controller state file read by `--hid=file`: key=value pairs such "
"as `press=A,START lt=0 rt=255 lx=0 ly=0`. Absent keys are "
"neutral, a missing file means no input.",
"HID");
DEFINE_path(
storage_root, "",
@@ -446,6 +457,10 @@ std::vector<std::unique_ptr<hid::InputDriver>> EmulatorApp::CreateInputDrivers(
if (cvars::hid.compare("nop") == 0) {
drivers.emplace_back(
xe::hid::nop::Create(window, EmulatorWindow::kZOrderHidInput));
} else if (cvars::hid.compare("file") == 0) {
// Explicit, never part of "any": this pad must be asked for.
drivers.emplace_back(
xe::hid::filepad::Create(window, EmulatorWindow::kZOrderHidInput));
} else {
Factory<hid::InputDriver, ui::Window*, size_t> factory;
#if XE_PLATFORM_WIN32

View File

@@ -0,0 +1,246 @@
/**
******************************************************************************
* Xenia : Xbox 360 Emulator Research Project *
******************************************************************************
* RE aid: a controller whose state comes from a FILE, not from a device.
******************************************************************************
*/
#ifndef XENIA_HID_FILE_FILE_INPUT_DRIVER_H_
#define XENIA_HID_FILE_FILE_INPUT_DRIVER_H_
// Why this exists
// ---------------
// The scripted-input tool used for reverse engineering created its pad through
// `/dev/uinput`. Input devices are NOT namespaced by the kernel, so a uinput
// device created inside a container is registered with the HOST's input stack:
// every trigger hold and button press is delivered to whatever on the host reads
// gamepads, not only to the emulator. That is a real leak, and it was noticed the
// hard way.
//
// This driver takes the kernel out of the loop entirely. The pad state lives in
// an ordinary text file that only this container can see, and `GetState` reads it
// (re-parsing only when the file changes). Nothing is registered with the host,
// no X server is involved either, and — a bonus for RE — the analogue values are
// exact rather than whatever a virtual stick quantises to.
//
// File format: one or more whitespace/newline separated `key=value` pairs.
//
// press=A,START buttons by name (see kButtonNames), comma separated
// buttons=0x1010 or the raw XINPUT mask, if you prefer
// lt=0 rt=255 triggers, 0..255
// lx=0 ly=0 left thumb, -32768..32767
// rx=0 ry=0 right thumb, -32768..32767
//
// Anything absent is neutral, so `press=A` alone is a valid file. An empty or
// missing file means "no input" — which is also the safe default if the file is
// deleted mid-run.
//
// Path: `--pad_file=<path>`, default `/tmp/xenia_pad.txt`. Only user 0 is
// connected; other slots report no device, as a single-pad console would.
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <memory>
#include <string>
#include <sys/stat.h>
#include "xenia/base/cvar.h"
#include "xenia/base/logging.h"
#include "xenia/hid/input_driver.h"
DECLARE_string(pad_file);
namespace xe {
namespace hid {
namespace filepad {
struct NamedButton {
const char* name;
uint16_t mask;
};
static constexpr NamedButton kButtonNames[] = {
{"UP", X_INPUT_GAMEPAD_DPAD_UP},
{"DOWN", X_INPUT_GAMEPAD_DPAD_DOWN},
{"LEFT", X_INPUT_GAMEPAD_DPAD_LEFT},
{"RIGHT", X_INPUT_GAMEPAD_DPAD_RIGHT},
{"START", X_INPUT_GAMEPAD_START},
{"BACK", X_INPUT_GAMEPAD_BACK},
{"LS", X_INPUT_GAMEPAD_LEFT_THUMB},
{"RS", X_INPUT_GAMEPAD_RIGHT_THUMB},
{"LB", X_INPUT_GAMEPAD_LEFT_SHOULDER},
{"RB", X_INPUT_GAMEPAD_RIGHT_SHOULDER},
{"A", X_INPUT_GAMEPAD_A},
{"B", X_INPUT_GAMEPAD_B},
{"X", X_INPUT_GAMEPAD_X},
{"Y", X_INPUT_GAMEPAD_Y},
};
class FileInputDriver final : public InputDriver {
public:
FileInputDriver(xe::ui::Window* window, size_t window_z_order)
: InputDriver(window, window_z_order) {}
~FileInputDriver() override = default;
X_STATUS Setup() override {
XELOGI("[file-pad] reading controller state from {}", cvars::pad_file);
return X_STATUS_SUCCESS;
}
X_RESULT GetCapabilities(uint32_t user_index, uint32_t flags,
X_INPUT_CAPABILITIES* out_caps) override {
if (user_index != 0) {
return X_ERROR_DEVICE_NOT_CONNECTED;
}
std::memset(out_caps, 0, sizeof(*out_caps));
out_caps->type = 0x01; // XINPUT_DEVTYPE_GAMEPAD
out_caps->sub_type = 0x01; // XINPUT_DEVSUBTYPE_GAMEPAD
out_caps->flags = 0;
out_caps->gamepad.buttons = 0xFFFF;
out_caps->gamepad.left_trigger = 0xFF;
out_caps->gamepad.right_trigger = 0xFF;
out_caps->gamepad.thumb_lx = static_cast<int16_t>(0xFFFFu);
out_caps->gamepad.thumb_ly = static_cast<int16_t>(0xFFFFu);
out_caps->gamepad.thumb_rx = static_cast<int16_t>(0xFFFFu);
out_caps->gamepad.thumb_ry = static_cast<int16_t>(0xFFFFu);
return X_ERROR_SUCCESS;
}
X_RESULT GetState(uint32_t user_index, X_INPUT_STATE* out_state) override {
if (user_index != 0) {
return X_ERROR_DEVICE_NOT_CONNECTED;
}
Refresh();
std::memset(out_state, 0, sizeof(*out_state));
out_state->packet_number = packet_;
out_state->gamepad.buttons = buttons_;
out_state->gamepad.left_trigger = lt_;
out_state->gamepad.right_trigger = rt_;
out_state->gamepad.thumb_lx = lx_;
out_state->gamepad.thumb_ly = ly_;
out_state->gamepad.thumb_rx = rx_;
out_state->gamepad.thumb_ry = ry_;
return X_ERROR_SUCCESS;
}
X_RESULT SetState(uint32_t user_index, X_INPUT_VIBRATION* vibration) override {
return user_index == 0 ? X_ERROR_SUCCESS : X_ERROR_DEVICE_NOT_CONNECTED;
}
X_RESULT GetKeystroke(uint32_t user_index, uint32_t flags,
X_INPUT_KEYSTROKE* out_keystroke) override {
return X_ERROR_EMPTY;
}
InputType GetInputType() const override { return InputType::Controller; }
private:
// Re-parse only when the file actually changed: `GetState` is polled every
// frame and a stat is far cheaper than a read+parse.
void Refresh() {
struct stat st;
if (::stat(cvars::pad_file.c_str(), &st) != 0) {
Neutral();
return;
}
if (st.st_mtime == mtime_ && st.st_size == size_) {
return;
}
mtime_ = st.st_mtime;
size_ = st.st_size;
std::FILE* f = std::fopen(cvars::pad_file.c_str(), "rb");
if (!f) {
Neutral();
return;
}
char buf[512] = {0};
size_t n = std::fread(buf, 1, sizeof(buf) - 1, f);
std::fclose(f);
buf[n] = '\0';
Parse(buf);
++packet_;
}
void Neutral() {
buttons_ = 0;
lt_ = rt_ = 0;
lx_ = ly_ = rx_ = ry_ = 0;
}
void Parse(const char* text) {
Neutral();
std::string s(text);
size_t pos = 0;
while (pos < s.size()) {
size_t end = s.find_first_of(" \t\r\n", pos);
if (end == std::string::npos) {
end = s.size();
}
std::string tok = s.substr(pos, end - pos);
pos = end + 1;
size_t eq = tok.find('=');
if (eq == std::string::npos) {
continue;
}
std::string key = tok.substr(0, eq), val = tok.substr(eq + 1);
if (key == "press") {
size_t p = 0;
while (p < val.size()) {
size_t c = val.find(',', p);
if (c == std::string::npos) {
c = val.size();
}
std::string name = val.substr(p, c - p);
p = c + 1;
for (const auto& b : kButtonNames) {
if (name == b.name) {
buttons_ |= b.mask;
break;
}
}
}
} else if (key == "buttons") {
buttons_ |= static_cast<uint16_t>(std::strtoul(val.c_str(), nullptr, 0));
} else if (key == "lt") {
lt_ = Clamp8(std::strtol(val.c_str(), nullptr, 0));
} else if (key == "rt") {
rt_ = Clamp8(std::strtol(val.c_str(), nullptr, 0));
} else if (key == "lx") {
lx_ = Clamp16(std::strtol(val.c_str(), nullptr, 0));
} else if (key == "ly") {
ly_ = Clamp16(std::strtol(val.c_str(), nullptr, 0));
} else if (key == "rx") {
rx_ = Clamp16(std::strtol(val.c_str(), nullptr, 0));
} else if (key == "ry") {
ry_ = Clamp16(std::strtol(val.c_str(), nullptr, 0));
}
}
}
static uint8_t Clamp8(long v) {
return static_cast<uint8_t>(v < 0 ? 0 : (v > 255 ? 255 : v));
}
static int16_t Clamp16(long v) {
return static_cast<int16_t>(v < -32768 ? -32768 : (v > 32767 ? 32767 : v));
}
uint16_t buttons_ = 0;
uint8_t lt_ = 0, rt_ = 0;
int16_t lx_ = 0, ly_ = 0, rx_ = 0, ry_ = 0;
uint32_t packet_ = 1;
time_t mtime_ = 0;
off_t size_ = -1;
};
inline std::unique_ptr<InputDriver> Create(xe::ui::Window* window,
size_t window_z_order) {
return std::make_unique<FileInputDriver>(window, window_z_order);
}
} // namespace filepad
} // namespace hid
} // namespace xe
#endif // XENIA_HID_FILE_FILE_INPUT_DRIVER_H_