Plugins: joysticks are read on a thread of their own, with all 8 radio channels, and a plugin picks which one

reskate.input.joystick() scanned Windows' 16 winmm slots inside the plugin's call whenever no
controller was cached; on some PCs empty slots made one scan take over 50 ms, and the FPV drone
was stopped for its tick running long after switching to a radio with none plugged in. A thread
now finds the controllers (every 2 s) and reads them (250 a second) through DirectInput, only
while a plugin has asked in the last 2 s, and a call copies its last reading.

DirectInput gives the eight axes a radio sends, where winmm gave six: axes are now X, Y, Z, Rx,
Ry, Rz and the two sliders. A plugin that saved axis numbers should let players set them again.

reskate.input.joysticks() lists every controller plugged in ({device, name}), and
reskate.input.joystick(device) reads that one, so a player with a gamepad and a radio picks which
flies; with no argument it still reads the first, a radio first. The list and the device field
follow neekgate's plugin-joystick-pick branch.
This commit is contained in:
AyeZeeBB committed 2026-10-27 16:44:45 -05:00
1 parent be7a72d816
commit 6bad0e95d2
7 files changed
+292 -98

No files matched your search

+16 -3
View File
@@ -524,13 +524,16 @@ struct FakeGame final : Game {
std::optional<FlightInput> flight_input(std::string_view) override { return FlightInput{1, 0, 0.5f, 3, 4, true}; }
std::optional<PadInput> pad = PadInput{0.25f, 0, 0, 0, 0, 1, 0x1000, false};
std::optional<PadInput> gamepad() override { return pad; }
std::optional<StickInput> joystick() override {
std::optional<StickInput> joystick(int device) override {
if (device > 1) return std::nullopt;
StickInput stick;
stick.axes = {0, -1, 0.5f, 0, 0, 0};
stick.axes = {0, -1, 0.5f, 0, 0, 0, 0.25f, -0.75f};
stick.buttons = 0x4;
stick.name = "Radio";
stick.name = device == 1 ? "Pad" : "Radio";
stick.device = device == 1 ? 1 : 0;
return stick;
}
std::vector<StickDevice> joysticks() override { return {{0, "Radio"}, {1, "Pad"}}; }
std::string capture_input(std::string_view plugin, bool on) override {
calls.push_back(std::format("capture {} {}", plugin, on ? "on" : "off"));
return {};
@@ -1347,6 +1350,10 @@ void a_permission_must_be_declared() {
assert(not ok and err:find("needs the \"board\" permission"), err)
ok, err = pcall(reskate.board.parameters, "deck")
assert(not ok and err:find("needs the \"board\" permission"), err)
ok, err = pcall(reskate.input.joysticks)
assert(not ok and err:find("needs the \"input\" permission"), err)
ok, err = pcall(reskate.input.joystick, 1)
assert(not ok and err:find("needs the \"input\" permission"), err)
reskate.console.print("never")
)")});
stopped(host, "bare", "raised an error");
@@ -3058,6 +3065,12 @@ void the_wider_set_reaches_the_game() {
assert(pad.buttons.a and not pad.buttons.b and pad.left_x == 0.25 and pad.right_trigger == 1, "the gamepad")
local stick = reskate.input.joystick()
assert(stick.axes[2] == -1 and stick.buttons[3] and not stick.buttons[1] and stick.name == "Radio", "the radio")
assert(#stick.axes == 8 and stick.axes[7] == 0.25 and stick.axes[8] == -0.75, "a radio's channels 7 and 8")
assert(stick.device == 0, "the radio's number")
local list = reskate.input.joysticks()
assert(#list == 2 and list[2].device == 1 and list[2].name == "Pad", "every controller")
assert(reskate.input.joystick(1).name == "Pad" and reskate.input.joystick(5) == nil, "a chosen controller")
assert(not pcall(reskate.input.joystick, -1) and not pcall(reskate.input.joystick, 1.5), "a controller's number")
assert(reskate.input.capture(true))
reskate.camera.release()
assert(reskate.skater.freeze(true))
+33 -5
View File
@@ -2700,17 +2700,26 @@ struct WorldApi {
lua_setfield(L, -2, "playstation");
return 1;
}
// joystick(): the first controller (a radio first); joystick(device): that one, a number
// joysticks() lists.
static int input_joystick(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "input", "reskate.input.joystick");
const auto stick = game(plugin).joystick();
int device = -1;
if (!lua_isnoneornil(L, 1)) {
const double wanted = luaL_checknumber(L, 1);
if (!(wanted >= 0 && wanted < 16) || wanted != static_cast<double>(static_cast<int>(wanted)))
luaL_argerror(L, 1, "a controller's number, as reskate.input.joysticks() lists it");
device = static_cast<int>(wanted);
}
const auto stick = game(plugin).joystick(device);
if (!stick) {
lua_pushnil(L);
return 1;
}
lua_createtable(L, 0, 3);
lua_createtable(L, 6, 0);
for (int i = 0; i < 6; ++i) {
lua_createtable(L, 0, 4);
lua_createtable(L, static_cast<int>(stick->axes.size()), 0);
for (int i = 0; i < static_cast<int>(stick->axes.size()); ++i) {
lua_pushnumber(L, stick->axes[static_cast<std::size_t>(i)]);
lua_rawseti(L, -2, i + 1);
}
@@ -2723,6 +2732,25 @@ struct WorldApi {
lua_setfield(L, -2, "buttons");
lua_pushlstring(L, stick->name.data(), stick->name.size());
lua_setfield(L, -2, "name");
lua_pushinteger(L, stick->device);
lua_setfield(L, -2, "device");
return 1;
}
// joysticks(): {{device = n, name = "..."}, ...}, every controller plugged in.
static int input_joysticks(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "input", "reskate.input.joysticks");
const auto found = game(plugin).joysticks();
lua_createtable(L, static_cast<int>(found.size()), 0);
int n = 0;
for (const auto& stick : found) {
lua_createtable(L, 0, 2);
lua_pushinteger(L, stick.device);
lua_setfield(L, -2, "device");
lua_pushlstring(L, stick.name.data(), stick.name.size());
lua_setfield(L, -2, "name");
lua_rawseti(L, -2, ++n);
}
return 1;
}
static int input_capture(lua_State* L) {
@@ -2999,7 +3027,7 @@ void WorldApi::install(Plugin& plugin, lua_State* L) {
{"game", game_current}, {"game_next", game_next}});
add_functions(L, "progression", {{"get", progress}});
add_functions(L, "settings", {{"game", setting}, {"names", setting_names}, {"set", setting_set}});
add_functions(L, "input", {{"bind", bind}, {"flight", input_flight}, {"gamepad", input_gamepad}, {"joystick", input_joystick}, {"capture", input_capture},
add_functions(L, "input", {{"bind", bind}, {"flight", input_flight}, {"gamepad", input_gamepad}, {"joystick", input_joystick}, {"joysticks", input_joysticks}, {"capture", input_capture},
{"hide", input_hide}, {"cursor", input_cursor}, {"text", input_text}});
add_functions(L, "room", {{"open", room_open}, {"leave", room_leave}, {"state", room_state}});
add_functions(L, "ui", {{"screen", screen}, {"open", open}, {"close", close}});
+12 -2
View File
@@ -74,10 +74,17 @@ struct PadInput {
std::uint32_t buttons{};
bool playstation{};
};
// A joystick or radio's eight axes (X, Y, Z, Rx, Ry, Rz, the two sliders) and 32 buttons.
struct StickInput {
std::array<float, 6> axes{};
std::array<float, 8> axes{};
std::uint32_t buttons{};
std::string name;
int device = -1; // its number, as joysticks() lists it
};
// A controller plugged in: its number (for joystick(device)) and name.
struct StickDevice {
int device{};
std::string name;
};
// A sound a plugin plays (reskate.audio): its wave, or the motor synth when it has none.
struct SoundStart {
@@ -932,7 +939,10 @@ public:
// and only while the plugin's camera is shown.
virtual std::optional<FlightInput> flight_input(std::string_view /*plugin*/) { return std::nullopt; }
virtual std::optional<PadInput> gamepad() { return std::nullopt; }
virtual std::optional<StickInput> joystick() { return std::nullopt; }
// The first controller (a radio first), or with device >= 0 that one (StickDevice::device).
// Neither ever waits on Windows: a thread of ReSkate's reads the controllers.
virtual std::optional<StickInput> joystick(int /*device*/ = -1) { return std::nullopt; }
virtual std::vector<StickDevice> joysticks() { return {}; }
virtual std::string capture_input(std::string_view /*plugin*/, bool) { return unavailable; }
// A named action on a key or pad combo (controller_bindings.h's encoding); presses come back
// from take_presses as (plugin, action), only while the game itself has the input.
+10 -4
View File
@@ -912,12 +912,18 @@ public:
if (!pad.valid) return std::nullopt;
return PadInput{pad.left_x, pad.left_y, pad.right_x, pad.right_y, pad.left_trigger, pad.right_trigger, pad.buttons, pad.playstation};
}
std::optional<StickInput> joystick() override {
std::optional<StickInput> joystick(int device) override {
if (!joystick::game_in_foreground()) return std::nullopt;
const auto sample = joystick::poll();
const auto sample = joystick::poll(device);
if (!sample.valid) return std::nullopt;
overlay::note_joystick(sample.buttons, sample.axes);
return StickInput{sample.axes, sample.buttons, sample.name};
// (Binds take a joystick's first six axes.)
overlay::note_joystick(sample.buttons, {sample.axes[0], sample.axes[1], sample.axes[2], sample.axes[3], sample.axes[4], sample.axes[5]});
return StickInput{sample.axes, sample.buttons, sample.name, sample.device};
}
std::vector<StickDevice> joysticks() override {
std::vector<StickDevice> out;
for (const auto& sample : joystick::list()) out.push_back({sample.device, sample.name});
return out;
}
std::string capture_input(std::string_view plugin, bool on) override {
if (!on) {
+192
View File
@@ -0,0 +1,192 @@
#include "plugin_joystick.h"
#ifndef DIRECTINPUT_VERSION
#define DIRECTINPUT_VERSION 0x0800
#endif
#include <Windows.h>
#include <dinput.h>
#include <wrl/client.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <mutex>
#include <thread>
#pragma comment(lib, "dinput8.lib")
#pragma comment(lib, "dxguid.lib")
namespace dingosdk::plugins::joystick {
namespace {
using Microsoft::WRL::ComPtr;
constexpr LONG range = 1000; // each axis reads -range..range
constexpr auto read_every = std::chrono::milliseconds(4); // 250 a second while asked
constexpr ULONGLONG look_every_ms = 2000; // the device list, while asked
constexpr ULONGLONG asked_for_ms = 2000; // reads go on this long after a call
std::mutex mutex;
std::vector<Sample> latest; // under mutex: what the thread read last
std::atomic<ULONGLONG> asked_at{};
std::once_flag started;
std::string narrow(const wchar_t* text) {
std::string out;
for (; *text; ++text) out += *text < 128 ? static_cast<char>(*text) : '?';
return out;
}
std::string lowered(std::string text) {
for (auto& ch : text) if (ch >= 'A' && ch <= 'Z') ch = static_cast<char>(ch + 'a' - 'A');
return text;
}
// One controller the thread has open. Its number stays while it is plugged in.
struct Device {
GUID instance{};
int number{};
std::string name;
ComPtr<IDirectInputDevice8W> input;
bool seen{};
};
BOOL CALLBACK found_one(const DIDEVICEINSTANCEW* found, void* list) {
static_cast<std::vector<DIDEVICEINSTANCEW>*>(list)->push_back(*found);
return DIENUM_CONTINUE;
}
// Opens a controller for reading: the standard joystick layout, every axis -range..range, no dead
// zone. Left at the default cooperative level (background, not exclusive), so the game and other
// programs read it as before.
ComPtr<IDirectInputDevice8W> open(IDirectInput8W& input, const GUID& instance) {
ComPtr<IDirectInputDevice8W> device;
if (FAILED(input.CreateDevice(instance, device.GetAddressOf(), nullptr))) return nullptr;
if (FAILED(device->SetDataFormat(&c_dfDIJoystick2))) return nullptr;
DIPROPRANGE axes{};
axes.diph = {sizeof(axes), sizeof(axes.diph), 0, DIPH_DEVICE};
axes.lMin = -range;
axes.lMax = range;
device->SetProperty(DIPROP_RANGE, &axes.diph);
DIPROPDWORD dead{};
dead.diph = {sizeof(dead), sizeof(dead.diph), 0, DIPH_DEVICE};
device->SetProperty(DIPROP_DEADZONE, &dead.diph);
device->Acquire();
return device;
}
bool read(Device& device, Sample& out) {
DIJOYSTATE2 state{};
device.input->Poll();
auto result = device.input->GetDeviceState(sizeof(state), &state);
if (result == DIERR_INPUTLOST || result == DIERR_NOTACQUIRED) {
device.input->Acquire();
device.input->Poll();
result = device.input->GetDeviceState(sizeof(state), &state);
}
if (FAILED(result)) return false;
const auto axis = [](LONG value) { return std::clamp(static_cast<float>(value) / static_cast<float>(range), -1.0f, 1.0f); };
out.axes = {axis(state.lX), axis(state.lY), axis(state.lZ), axis(state.lRx), axis(state.lRy), axis(state.lRz),
axis(state.rglSlider[0]), axis(state.rglSlider[1])};
out.buttons = 0;
for (int i = 0; i < 32; ++i)
if (state.rgbButtons[i] & 0x80) out.buttons |= 1u << i;
out.name = device.name;
out.device = device.number;
out.valid = true;
return true;
}
// Looks at the device list: new controllers open with the lowest free number, gone ones close.
void look(IDirectInput8W& input, std::vector<Device>& devices) {
std::vector<DIDEVICEINSTANCEW> found;
if (FAILED(input.EnumDevices(DI8DEVCLASS_GAMECTRL, found_one, &found, DIEDFL_ATTACHEDONLY))) return;
for (auto& device : devices) device.seen = false;
for (const auto& instance : found) {
const auto known = std::find_if(devices.begin(), devices.end(),
[&](const Device& device) { return IsEqualGUID(device.instance, instance.guidInstance); });
if (known != devices.end()) {
known->seen = true;
continue;
}
if (devices.size() >= 16) continue;
auto opened = open(input, instance.guidInstance);
if (!opened) continue;
int number = 0;
while (std::any_of(devices.begin(), devices.end(), [&](const Device& device) { return device.number == number; })) ++number;
devices.push_back({instance.guidInstance, number, narrow(instance.tszProductName), std::move(opened), true});
}
std::erase_if(devices, [](const Device& device) { return !device.seen; });
std::sort(devices.begin(), devices.end(), [](const Device& a, const Device& b) { return a.number < b.number; });
}
void run() {
// The overlay keeps captured controllers from the game's own reads; these are ReSkate's, for
// the thread's whole life.
const InputAccess access;
ComPtr<IDirectInput8W> input;
if (FAILED(DirectInput8Create(GetModuleHandleW(nullptr), DIRECTINPUT_VERSION, IID_IDirectInput8W,
reinterpret_cast<void**>(input.GetAddressOf()), nullptr)))
return;
std::vector<Device> devices;
ULONGLONG looked_at{};
for (;;) {
const auto now = GetTickCount64();
if (now - asked_at.load() > asked_for_ms) {
// Nobody is asking: nothing is read, and what was read is forgotten.
{
std::lock_guard lock(mutex);
latest.clear();
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
continue;
}
if (!looked_at || now - looked_at >= look_every_ms) {
look(*input.Get(), devices);
looked_at = now;
}
std::vector<Sample> read_now;
for (auto& device : devices) {
Sample sample;
if (read(device, sample)) read_now.push_back(std::move(sample));
}
{
std::lock_guard lock(mutex);
latest = std::move(read_now);
}
std::this_thread::sleep_for(read_every);
}
}
std::vector<Sample> snapshot() {
asked_at.store(GetTickCount64());
std::call_once(started, [] {
try {
std::thread(run).detach();
} catch (...) {}
});
std::lock_guard lock(mutex);
return latest;
}
} // namespace
int preferred(const std::vector<Sample>& found) {
for (const auto& sample : found) {
const auto name = lowered(sample.name);
if (name.find("radio") != std::string::npos || name.find("edgetx") != std::string::npos ||
name.find("opentx") != std::string::npos)
return sample.device;
}
return found.empty() ? -1 : found.front().device;
}
Sample poll(int device) {
const auto found = snapshot();
const int wanted = device < 0 ? preferred(found) : device;
for (const auto& sample : found)
if (sample.device == wanted) return sample;
return {};
}
std::vector<Sample> list() { return snapshot(); }
bool game_in_foreground() {
DWORD pid{};
GetWindowThreadProcessId(GetForegroundWindow(), &pid);
return pid == GetCurrentProcessId();
}
}
+28 -84
View File
@@ -1,31 +1,25 @@
#pragma once
// Joysticks and radio controllers for plugins (reskate.input.joystick): anything that shows up in
// Windows' "Set up USB game controllers" (a radio in its USB Joystick mode, a flight stick). Axis
// numbers 1..6 are winmm's X, Y, Z, R, U, V, each -1..1. Windows only (winmm).
#include <algorithm>
// Joysticks and radio controllers for plugins (reskate.input.joystick, reskate.input.joysticks):
// anything Windows lists as a game controller (a radio in its USB Joystick mode, a flight stick, a
// gamepad). Read through DirectInput, which gives all eight axes a radio sends (winmm gave six):
// axes 1..8 are X, Y, Z, Rx, Ry, Rz and the two sliders, each -1..1, 0 for one the controller
// does not have. Windows only.
//
// A thread of its own finds the controllers and reads them: looking at the device list can take
// far longer than a plugin's tick may (empty winmm slots took over 50 ms on some PCs, and a plugin
// was stopped for it), so a call only copies what the thread read last. It reads only while a
// plugin has asked in the last two seconds.
#include <array>
#include <atomic>
#include <cstdint>
#include <string>
#ifdef _WIN32
#include <Windows.h>
#include <mmsystem.h>
#pragma comment(lib, "winmm.lib")
#include <vector>
namespace dingosdk::overlay { // UI/Overlay/overlay.h: reads that see the controller while the game is kept off it
void begin_input_access() noexcept;
void end_input_access() noexcept;
}
namespace dingosdk::plugins::joystick {
struct Sample {
bool valid = false;
std::array<float, 6> axes{};
std::uint32_t buttons = 0; // bit n-1 set = button n pressed
std::string name;
int device = -1;
};
// While it lives, reads see the controllers even while a plugin keeps the game off them.
// While it lives, this thread's reads see the controllers even while a plugin keeps the game off them.
struct InputAccess {
InputAccess() noexcept { overlay::begin_input_access(); }
~InputAccess() { overlay::end_input_access(); }
@@ -33,73 +27,23 @@ struct InputAccess {
InputAccess& operator=(const InputAccess&) = delete;
};
namespace detail {
inline float normalize_axis(std::uint32_t value, std::uint32_t lo, std::uint32_t hi) {
if (hi <= lo) return 0.0f;
const float t = (static_cast<float>(value) - static_cast<float>(lo)) / (static_cast<float>(hi) - static_cast<float>(lo));
return std::clamp(t * 2.0f - 1.0f, -1.0f, 1.0f);
}
inline std::string narrow(const wchar_t* text) {
std::string out;
for (; *text; ++text) out += *text < 128 ? static_cast<char>(*text) : '?';
return out;
}
inline std::string lowered(std::string s) {
for (auto& ch : s) if (ch >= 'A' && ch <= 'Z') ch = static_cast<char>(ch + 'a' - 'A');
return s;
}
inline bool read_device(UINT id, Sample& out) {
JOYCAPSW caps{};
if (joyGetDevCapsW(id, &caps, sizeof(caps)) != JOYERR_NOERROR) return false;
JOYINFOEX info{};
info.dwSize = sizeof(info);
info.dwFlags = JOY_RETURNX | JOY_RETURNY | JOY_RETURNZ | JOY_RETURNR | JOY_RETURNU | JOY_RETURNV | JOY_RETURNBUTTONS;
if (joyGetPosEx(id, &info) != JOYERR_NOERROR) return false;
const UINT n = caps.wNumAxes;
out.axes = {};
if (n > 0) out.axes[0] = normalize_axis(info.dwXpos, caps.wXmin, caps.wXmax);
if (n > 1) out.axes[1] = normalize_axis(info.dwYpos, caps.wYmin, caps.wYmax);
if (n > 2) out.axes[2] = normalize_axis(info.dwZpos, caps.wZmin, caps.wZmax);
if (n > 3) out.axes[3] = normalize_axis(info.dwRpos, caps.wRmin, caps.wRmax);
if (n > 4) out.axes[4] = normalize_axis(info.dwUpos, caps.wUmin, caps.wUmax);
if (n > 5) out.axes[5] = normalize_axis(info.dwVpos, caps.wVmin, caps.wVmax);
out.buttons = info.dwButtons;
out.name = narrow(caps.szPname);
out.device = static_cast<int>(id);
out.valid = true;
return true;
}
}
inline constexpr std::size_t axis_count = 8;
struct Sample {
bool valid = false;
std::array<float, axis_count> axes{};
std::uint32_t buttons = 0; // bit n-1 set = button n pressed
std::string name;
int device = -1; // its number while it stays plugged in (list())
};
// Reads the first controller, preferring one named a radio. Any thread; rescans at most once a second.
inline Sample poll() {
InputAccess access;
static std::atomic<int> cached{-1};
static std::atomic<ULONGLONG> next_scan{0};
Sample sample;
int id = cached.load();
if (id >= 0 && detail::read_device(static_cast<UINT>(id), sample)) return sample;
cached.store(-1);
const auto now = GetTickCount64();
if (now < next_scan.load()) return sample;
next_scan.store(now + 1000);
const UINT count = std::min<UINT>(joyGetNumDevs(), 16);
int first = -1;
for (UINT i = 0; i < count; ++i) {
Sample candidate;
if (!detail::read_device(i, candidate)) continue;
if (first < 0) first = static_cast<int>(i);
if (detail::lowered(candidate.name).find("radio") != std::string::npos) { cached.store(static_cast<int>(i)); return candidate; }
}
if (first >= 0) { detail::read_device(static_cast<UINT>(first), sample); cached.store(first); }
return sample;
}
// The controller `device` (a number list() gives), or with -1 the first one, preferring one named
// a radio. Not valid when there is none. Any thread; never waits on Windows.
Sample poll(int device = -1);
// Every controller plugged in, lowest number first. Any thread; never waits on Windows.
std::vector<Sample> list();
// The first of `found` a plugin reads when it names none: one named a radio, else the lowest number.
int preferred(const std::vector<Sample>& found);
// True while this game's window has focus, so a controller does not drive it from the background.
inline bool game_in_foreground() {
DWORD pid{};
GetWindowThreadProcessId(GetForegroundWindow(), &pid);
return pid == GetCurrentProcessId();
bool game_in_foreground();
}
}
#endif
+1
View File
@@ -140,6 +140,7 @@ add_library(dingosdk_runtime SHARED
Extension/Plugins/plugin_http.cpp
Extension/Plugins/plugin_game_runtime.cpp
Extension/Rendering/plugin_effects.cpp
Extension/Plugins/plugin_joystick.cpp
Extension/Skater/skater_animation.cpp
Extension/Plugins/plugin_sound.cpp
Extension/Skater/skater_events.cpp