diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index b83e691d..fd4911ec 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -524,13 +524,16 @@ struct FakeGame final : Game { std::optional flight_input(std::string_view) override { return FlightInput{1, 0, 0.5f, 3, 4, true}; } std::optional pad = PadInput{0.25f, 0, 0, 0, 0, 1, 0x1000, false}; std::optional gamepad() override { return pad; } - std::optional joystick() override { + std::optional 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 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)) diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index 3d0e1649..e5c19397 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -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(static_cast(wanted))) + luaL_argerror(L, 1, "a controller's number, as reskate.input.joysticks() lists it"); + device = static_cast(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(stick->axes.size()), 0); + for (int i = 0; i < static_cast(stick->axes.size()); ++i) { lua_pushnumber(L, stick->axes[static_cast(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(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}}); diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index dc9f1994..656cb7fb 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -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 axes{}; + std::array 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 flight_input(std::string_view /*plugin*/) { return std::nullopt; } virtual std::optional gamepad() { return std::nullopt; } - virtual std::optional 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 joystick(int /*device*/ = -1) { return std::nullopt; } + virtual std::vector 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. diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index 743aa033..eeaf4219 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -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 joystick() override { + std::optional 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 joysticks() override { + std::vector 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) { diff --git a/Extension/Plugins/plugin_joystick.cpp b/Extension/Plugins/plugin_joystick.cpp new file mode 100644 index 00000000..1120e1af --- /dev/null +++ b/Extension/Plugins/plugin_joystick.cpp @@ -0,0 +1,192 @@ +#include "plugin_joystick.h" +#ifndef DIRECTINPUT_VERSION +#define DIRECTINPUT_VERSION 0x0800 +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#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 latest; // under mutex: what the thread read last +std::atomic asked_at{}; +std::once_flag started; + +std::string narrow(const wchar_t* text) { + std::string out; + for (; *text; ++text) out += *text < 128 ? static_cast(*text) : '?'; + return out; +} +std::string lowered(std::string text) { + for (auto& ch : text) if (ch >= 'A' && ch <= 'Z') ch = static_cast(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 input; + bool seen{}; +}; + +BOOL CALLBACK found_one(const DIDEVICEINSTANCEW* found, void* list) { + static_cast*>(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 open(IDirectInput8W& input, const GUID& instance) { + ComPtr 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(value) / static_cast(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& devices) { + std::vector 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 input; + if (FAILED(DirectInput8Create(GetModuleHandleW(nullptr), DIRECTINPUT_VERSION, IID_IDirectInput8W, + reinterpret_cast(input.GetAddressOf()), nullptr))) + return; + std::vector 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 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 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& 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 list() { return snapshot(); } + +bool game_in_foreground() { + DWORD pid{}; + GetWindowThreadProcessId(GetForegroundWindow(), &pid); + return pid == GetCurrentProcessId(); +} +} diff --git a/Extension/Plugins/plugin_joystick.h b/Extension/Plugins/plugin_joystick.h index f6d43f41..3b9af2ed 100644 --- a/Extension/Plugins/plugin_joystick.h +++ b/Extension/Plugins/plugin_joystick.h @@ -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 +// 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 -#include #include #include -#ifdef _WIN32 -#include -#include -#pragma comment(lib, "winmm.lib") +#include 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 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(value) - static_cast(lo)) / (static_cast(hi) - static_cast(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(*text) : '?'; - return out; -} -inline std::string lowered(std::string s) { - for (auto& ch : s) if (ch >= 'A' && ch <= 'Z') ch = static_cast(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(id); - out.valid = true; - return true; -} -} +inline constexpr std::size_t axis_count = 8; +struct Sample { + bool valid = false; + std::array 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 cached{-1}; - static std::atomic next_scan{0}; - Sample sample; - int id = cached.load(); - if (id >= 0 && detail::read_device(static_cast(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(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(i); - if (detail::lowered(candidate.name).find("radio") != std::string::npos) { cached.store(static_cast(i)); return candidate; } - } - if (first >= 0) { detail::read_device(static_cast(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 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& 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 diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 437b04b0..91baf437 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -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