//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND #include #include #include #include #include #include #include #include #include #endif #ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 #include "Crafter.Graphics-GamepadWin32.h" #endif module Crafter.Graphics; import std; using namespace Crafter; // ──────────────────────────────────────────────────────────────────── // Linux backend (libudev + libevdev) // ──────────────────────────────────────────────────────────────────── #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND namespace { // Per-device runtime state. The public `Gamepad::Device` carries the // identity + polled state + events; this struct holds the libevdev / // fd / mapping tables we need to drain events from. struct LinuxBackend { libevdev* evdev = nullptr; int fd = -1; // Per-axis calibration (libevdev's min/max so we can normalize). // Indexed by Gamepad::Axis. -1 in `code` means "this axis isn't // exposed by this device". struct AxisInfo { int code = -1; // kernel ABS_* code, or -1 int min = 0; int max = 0; float flat = 0.0f; // deadzone hint (raw units) bool invertY = false; // negate output (stick Y by convention) }; AxisInfo axisInfo[(std::size_t)Crafter::Gamepad::Axis::Max]; // FF effect id from EVIOCSFF, -1 if no rumble support / not active. int ffEffectId = -1; std::chrono::steady_clock::time_point rumbleExpiry{}; }; std::unordered_map> g_backends; udev* g_udev = nullptr; udev_monitor* g_monitor = nullptr; bool g_initialized = false; std::uint32_t SyspathToId(const char* syspath) { // FNV-1a 32-bit, stable across runs for the same physical device. std::uint32_t h = 0x811C9DC5u; for (const char* p = syspath; *p; ++p) { h ^= static_cast(*p); h *= 0x01000193u; } // Reserve 0 as "unmapped". return h == 0 ? 1 : h; } // BTN_* → Gamepad::Button. -1 = not a button we care about. int ButtonFromKernel(int code) { using B = Crafter::Gamepad::Button; switch (code) { case BTN_SOUTH: return (int)B::South; case BTN_EAST: return (int)B::East; case BTN_NORTH: return (int)B::North; case BTN_WEST: return (int)B::West; case BTN_TL: return (int)B::LeftBumper; case BTN_TR: return (int)B::RightBumper; case BTN_TL2: return (int)B::LeftTrigger; case BTN_TR2: return (int)B::RightTrigger; case BTN_SELECT: return (int)B::Select; case BTN_START: return (int)B::Start; case BTN_MODE: return (int)B::Home; case BTN_THUMBL: return (int)B::LeftStickClick; case BTN_THUMBR: return (int)B::RightStickClick; case BTN_DPAD_UP: return (int)B::DPadUp; case BTN_DPAD_DOWN: return (int)B::DPadDown; case BTN_DPAD_LEFT: return (int)B::DPadLeft; case BTN_DPAD_RIGHT: return (int)B::DPadRight; default: return -1; } } // Populate `bp->axisInfo` from libevdev. Sticks use ABS_X/Y/RX/RY; // triggers prefer ABS_Z/RZ but fall back to ABS_BRAKE/GAS. void DiscoverAxes(LinuxBackend* bp) { using A = Crafter::Gamepad::Axis; auto setup = [&](A which, int code, bool invertY = false) { if (!libevdev_has_event_code(bp->evdev, EV_ABS, code)) return; auto* info = libevdev_get_abs_info(bp->evdev, code); if (!info) return; auto& slot = bp->axisInfo[(std::size_t)which]; slot.code = code; slot.min = info->minimum; slot.max = info->maximum; slot.flat = (float)info->flat; slot.invertY = invertY; }; setup(A::LeftStickX, ABS_X); setup(A::LeftStickY, ABS_Y, /*invertY=*/true); setup(A::RightStickX, ABS_RX); setup(A::RightStickY, ABS_RY, /*invertY=*/true); // Trigger axis: Z/RZ on Xbox, BRAKE/GAS on some others. if (libevdev_has_event_code(bp->evdev, EV_ABS, ABS_Z)) { setup(A::LeftTrigger, ABS_Z); } else if (libevdev_has_event_code(bp->evdev, EV_ABS, ABS_BRAKE)) { setup(A::LeftTrigger, ABS_BRAKE); } if (libevdev_has_event_code(bp->evdev, EV_ABS, ABS_RZ)) { setup(A::RightTrigger, ABS_RZ); } else if (libevdev_has_event_code(bp->evdev, EV_ABS, ABS_GAS)) { setup(A::RightTrigger, ABS_GAS); } } // Normalize a raw axis value to -1..1 (sticks) or 0..1 (triggers). float NormalizeAxis(const LinuxBackend::AxisInfo& info, int raw, bool isTrigger) { if (info.code < 0 || info.max == info.min) return 0.0f; float v; if (isTrigger) { v = float(raw - info.min) / float(info.max - info.min); if (v < 0.0f) v = 0.0f; if (v > 1.0f) v = 1.0f; } else { float mid = 0.5f * (info.max + info.min); float halfR = 0.5f * (info.max - info.min); v = (raw - mid) / halfR; if (v < -1.0f) v = -1.0f; if (v > 1.0f) v = 1.0f; if (info.invertY) v = -v; } return v; } bool IsGamepadDevice(libevdev* evdev) { // A real gamepad reports BTN_GAMEPAD (a kernel alias for BTN_SOUTH // when the device is classified as a gamepad). This filters out // keyboards/mice/touchpads/tablets that share the evdev interface. return libevdev_has_event_type(evdev, EV_KEY) && libevdev_has_event_code(evdev, EV_KEY, BTN_GAMEPAD); } Crafter::Gamepad::Device* OpenDevice(const char* devnode, const char* syspath, const char* name) { int fd = ::open(devnode, O_RDWR | O_NONBLOCK); if (fd < 0) { // Read-only is fine if we just can't rumble. fd = ::open(devnode, O_RDONLY | O_NONBLOCK); if (fd < 0) return nullptr; } libevdev* evdev = nullptr; if (libevdev_new_from_fd(fd, &evdev) < 0) { ::close(fd); return nullptr; } if (!IsGamepadDevice(evdev)) { libevdev_free(evdev); ::close(fd); return nullptr; } auto dev = std::make_unique(); dev->id = SyspathToId(syspath); dev->name = name ? name : libevdev_get_name(evdev); auto bp = std::make_unique(); bp->evdev = evdev; bp->fd = fd; DiscoverAxes(bp.get()); auto* raw = dev.get(); g_backends[raw] = std::move(bp); Crafter::Gamepad::connected.push_back(std::move(dev)); return raw; } void CloseDevice(Crafter::Gamepad::Device* dev) { auto it = g_backends.find(dev); if (it != g_backends.end()) { if (it->second->evdev) libevdev_free(it->second->evdev); if (it->second->fd >= 0) ::close(it->second->fd); g_backends.erase(it); } auto& v = Crafter::Gamepad::connected; for (auto vi = v.begin(); vi != v.end(); ++vi) { if (vi->get() == dev) { v.erase(vi); break; } } } void EnumerateExisting() { udev_enumerate* en = udev_enumerate_new(g_udev); if (!en) return; udev_enumerate_add_match_subsystem(en, "input"); udev_enumerate_scan_devices(en); udev_list_entry* list = udev_enumerate_get_list_entry(en); udev_list_entry* entry; udev_list_entry_foreach(entry, list) { const char* syspath = udev_list_entry_get_name(entry); udev_device* d = udev_device_new_from_syspath(g_udev, syspath); if (!d) continue; const char* devnode = udev_device_get_devnode(d); const char* sysname = udev_device_get_sysname(d); if (devnode && sysname && std::strncmp(sysname, "event", 5) == 0) { const char* name = udev_device_get_sysattr_value( udev_device_get_parent_with_subsystem_devtype(d, "input", nullptr), "name"); auto* dev = OpenDevice(devnode, syspath, name); if (dev) Crafter::Gamepad::onConnected.Invoke(dev); } udev_device_unref(d); } udev_enumerate_unref(en); } void HandleHotplugEvent(udev_device* d) { const char* action = udev_device_get_action(d); const char* devnode = udev_device_get_devnode(d); const char* sysname = udev_device_get_sysname(d); const char* syspath = udev_device_get_syspath(d); if (!action || !devnode || !sysname || !syspath) return; if (std::strncmp(sysname, "event", 5) != 0) return; if (std::strcmp(action, "add") == 0) { const char* name = udev_device_get_sysattr_value( udev_device_get_parent_with_subsystem_devtype(d, "input", nullptr), "name"); auto* dev = OpenDevice(devnode, syspath, name); if (dev) Crafter::Gamepad::onConnected.Invoke(dev); } else if (std::strcmp(action, "remove") == 0) { std::uint32_t id = SyspathToId(syspath); for (auto& up : Crafter::Gamepad::connected) { if (up->id == id) { Crafter::Gamepad::Device* dev = up.get(); Crafter::Gamepad::onDisconnected.Invoke(dev); CloseDevice(dev); return; } } } } void DrainHotplug() { if (!g_monitor) return; for (;;) { udev_device* d = udev_monitor_receive_device(g_monitor); if (!d) break; HandleHotplugEvent(d); udev_device_unref(d); } } void DrainDeviceEvents(Crafter::Gamepad::Device* dev, LinuxBackend* bp) { using A = Crafter::Gamepad::Axis; using B = Crafter::Gamepad::Button; // Walk libevdev's internal queue; SYNC means we missed events and // need to resync the state. We treat the synced events the same // way as normal events — net effect is the polled state ends up // correct and the event stream reports the deltas. input_event ev; int rc; while ((rc = libevdev_next_event(bp->evdev, LIBEVDEV_READ_FLAG_NORMAL | LIBEVDEV_READ_FLAG_SYNC, &ev)) >= 0) { if (rc == LIBEVDEV_READ_STATUS_SYNC) continue; // handled in next iter if (ev.type == EV_KEY) { int b = ButtonFromKernel(ev.code); if (b < 0) continue; bool pressed = (ev.value != 0); if (dev->buttons[b] == pressed) continue; dev->buttons[b] = pressed; if (pressed) dev->onButtonDown.Invoke((B)b); else dev->onButtonUp .Invoke((B)b); } else if (ev.type == EV_ABS) { // ABS_HAT0X / ABS_HAT0Y emulate the D-pad on some // controllers. Translate to the four DPad buttons so // bindings can be uniform. if (ev.code == ABS_HAT0X) { bool left = ev.value < 0; bool right = ev.value > 0; auto edge = [&](int idx, bool now) { if (dev->buttons[idx] == now) return; dev->buttons[idx] = now; if (now) dev->onButtonDown.Invoke((B)idx); else dev->onButtonUp .Invoke((B)idx); }; edge((int)B::DPadLeft, left); edge((int)B::DPadRight, right); continue; } if (ev.code == ABS_HAT0Y) { bool up = ev.value < 0; bool down = ev.value > 0; auto edge = [&](int idx, bool now) { if (dev->buttons[idx] == now) return; dev->buttons[idx] = now; if (now) dev->onButtonDown.Invoke((B)idx); else dev->onButtonUp .Invoke((B)idx); }; edge((int)B::DPadUp, up); edge((int)B::DPadDown, down); continue; } // Look up which Gamepad::Axis this kernel code maps to // (by scanning the calibration table — sticks first, // triggers second). for (std::size_t i = 0; i < (std::size_t)A::Max; ++i) { if (bp->axisInfo[i].code != ev.code) continue; bool isTrigger = (i == (std::size_t)A::LeftTrigger || i == (std::size_t)A::RightTrigger); float v = NormalizeAxis(bp->axisInfo[i], ev.value, isTrigger); if (dev->axes[i] == v) break; dev->axes[i] = v; dev->onAxisChanged.Invoke((A)i); break; } } } } void ExpireRumbles() { auto now = std::chrono::steady_clock::now(); for (auto& [dev, bp] : g_backends) { if (bp->ffEffectId >= 0 && now >= bp->rumbleExpiry) { input_event stop{}; stop.type = EV_FF; stop.code = bp->ffEffectId; stop.value = 0; if (bp->fd >= 0) ::write(bp->fd, &stop, sizeof(stop)); ioctl(bp->fd, EVIOCRMFF, bp->ffEffectId); bp->ffEffectId = -1; } } } void EnsureInit() { if (g_initialized) return; g_initialized = true; g_udev = udev_new(); if (!g_udev) return; g_monitor = udev_monitor_new_from_netlink(g_udev, "udev"); if (g_monitor) { udev_monitor_filter_add_match_subsystem_devtype(g_monitor, "input", nullptr); udev_monitor_enable_receiving(g_monitor); } EnumerateExisting(); } } void Crafter::Gamepad::Tick() { EnsureInit(); DrainHotplug(); for (auto& up : Gamepad::connected) { Device* dev = up.get(); auto it = g_backends.find(dev); if (it == g_backends.end()) continue; DrainDeviceEvents(dev, it->second.get()); } ExpireRumbles(); } void Crafter::Gamepad::Rumble(Device& dev, float low, float high, std::chrono::milliseconds duration) { auto it = g_backends.find(&dev); if (it == g_backends.end()) return; LinuxBackend* bp = it->second.get(); if (bp->fd < 0) return; // Cancel any active effect first. if (bp->ffEffectId >= 0) { input_event stop{}; stop.type = EV_FF; stop.code = bp->ffEffectId; stop.value = 0; ::write(bp->fd, &stop, sizeof(stop)); ioctl(bp->fd, EVIOCRMFF, bp->ffEffectId); bp->ffEffectId = -1; } if (duration.count() <= 0 || (low <= 0.0f && high <= 0.0f)) return; auto clamp01 = [](float v) { return v < 0 ? 0.0f : v > 1.0f ? 1.0f : v; }; ff_effect effect{}; effect.type = FF_RUMBLE; effect.id = -1; effect.u.rumble.strong_magnitude = (std::uint16_t)(clamp01(low) * 0xFFFFu); effect.u.rumble.weak_magnitude = (std::uint16_t)(clamp01(high) * 0xFFFFu); effect.replay.length = (std::uint16_t)std::min(duration.count(), 0xFFFF); if (ioctl(bp->fd, EVIOCSFF, &effect) < 0) return; bp->ffEffectId = effect.id; bp->rumbleExpiry = std::chrono::steady_clock::now() + duration; input_event play{}; play.type = EV_FF; play.code = effect.id; play.value = 1; ::write(bp->fd, &play, sizeof(play)); } #endif // ──────────────────────────────────────────────────────────────────── // Win32 backend (Windows.Gaming.Input, through Crafter.Graphics-GamepadWin32.c) // ──────────────────────────────────────────────────────────────────── #ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 namespace { // Maps a connected gamepad to its Crafter::Gamepad::Device. The pointer // is stable for as long as the gamepad stays connected. std::unordered_map g_byGamepad; std::uint32_t PointerToId(CrafterWgiGamepad* g) { // Hash the pointer for a stable-while-connected id. (NonRoamableId // would be more durable across reconnects but adds another COM // round-trip; pointer hash is fine for v1.) std::uintptr_t v = reinterpret_cast(g); v = (v ^ (v >> 16)) * 0x85EBCA6Bu; v = (v ^ (v >> 13)) * 0xC2B2AE35u; v = v ^ (v >> 16); std::uint32_t out = (std::uint32_t)v; return out == 0 ? 1 : out; } void ApplyReading(Crafter::Gamepad::Device* dev, const CrafterWgiReading& r) { using B = Crafter::Gamepad::Button; using A = Crafter::Gamepad::Axis; struct Map { B which; std::uint32_t mask; }; static const Map buttonMap[] = { { B::DPadUp, CRAFTER_WGI_BUTTON_DPAD_UP }, { B::DPadDown, CRAFTER_WGI_BUTTON_DPAD_DOWN }, { B::DPadLeft, CRAFTER_WGI_BUTTON_DPAD_LEFT }, { B::DPadRight, CRAFTER_WGI_BUTTON_DPAD_RIGHT }, { B::Start, CRAFTER_WGI_BUTTON_MENU }, { B::Select, CRAFTER_WGI_BUTTON_VIEW }, { B::LeftStickClick, CRAFTER_WGI_BUTTON_LEFT_THUMBSTICK }, { B::RightStickClick,CRAFTER_WGI_BUTTON_RIGHT_THUMBSTICK }, { B::LeftBumper, CRAFTER_WGI_BUTTON_LEFT_SHOULDER }, { B::RightBumper, CRAFTER_WGI_BUTTON_RIGHT_SHOULDER }, { B::South, CRAFTER_WGI_BUTTON_A }, { B::East, CRAFTER_WGI_BUTTON_B }, { B::West, CRAFTER_WGI_BUTTON_X }, { B::North, CRAFTER_WGI_BUTTON_Y }, }; for (const Map& m : buttonMap) { bool now = (r.buttons & m.mask) != 0; bool& cur = dev->buttons[(std::size_t)m.which]; if (cur == now) continue; cur = now; if (now) dev->onButtonDown.Invoke(m.which); else dev->onButtonUp .Invoke(m.which); } // WGI doesn't have a Home/Guide button in the standard Gamepad // interface (it's reserved for the OS). Leave dev->buttons[Home] // at false. auto axis = [&](A which, float v) { float& cur = dev->axes[(std::size_t)which]; if (cur == v) return; cur = v; dev->onAxisChanged.Invoke(which); }; axis(A::LeftStickX, (float)r.leftStickX); axis(A::LeftStickY, (float)r.leftStickY); axis(A::RightStickX, (float)r.rightStickX); axis(A::RightStickY, (float)r.rightStickY); axis(A::LeftTrigger, (float)r.leftTrigger); axis(A::RightTrigger, (float)r.rightTrigger); // Triggers also expose digital "pressed" buttons at a threshold. constexpr float kTriggerThreshold = 0.1f; auto trigBtn = [&](B which, double value) { bool now = value >= kTriggerThreshold; bool& cur = dev->buttons[(std::size_t)which]; if (cur == now) return; cur = now; if (now) dev->onButtonDown.Invoke(which); else dev->onButtonUp .Invoke(which); }; trigBtn(B::LeftTrigger, r.leftTrigger); trigBtn(B::RightTrigger, r.rightTrigger); } } void Crafter::Gamepad::Tick() { if (!CrafterWgiStart()) return; // The connects and disconnects since the last tick. std::array events; for (std::size_t count; (count = CrafterWgiTakeEvents(events.data(), events.size())) != 0;) { for (const CrafterWgiEvent& e : std::span(events.data(), count)) { if (e.added) { if (g_byGamepad.find(e.gamepad) != g_byGamepad.end()) { CrafterWgiRelease(e.gamepad); continue; } auto dev = std::make_unique(); dev->id = PointerToId(e.gamepad); dev->name = "Gamepad"; auto* raw = dev.get(); g_byGamepad[e.gamepad] = raw; connected.push_back(std::move(dev)); onConnected.Invoke(raw); // The event's reference is kept until the disconnect. } else { auto it = g_byGamepad.find(e.gamepad); if (it != g_byGamepad.end()) { Crafter::Gamepad::Device* raw = it->second; onDisconnected.Invoke(raw); for (auto vi = connected.begin(); vi != connected.end(); ++vi) { if (vi->get() == raw) { connected.erase(vi); break; } } // The connect's reference, then the disconnect's. CrafterWgiRelease(it->first); g_byGamepad.erase(it); } CrafterWgiRelease(e.gamepad); } } } // Poll each connected gamepad. for (auto& [gp, dev] : g_byGamepad) { CrafterWgiReading reading {}; if (!CrafterWgiRead(gp, &reading)) continue; ApplyReading(dev, reading); } } void Crafter::Gamepad::Rumble(Device& dev, float low, float high, std::chrono::milliseconds /*duration*/) { // WGI vibration is "set and forget" — no duration. Caller can stop // by re-calling with zero magnitudes. We honor 0 duration as stop. CrafterWgiGamepad* gp = nullptr; for (auto& [g, d] : g_byGamepad) { if (d == &dev) { gp = g; break; } } if (!gp) return; auto clamp01 = [](float v) { return v < 0 ? 0.0f : v > 1.0f ? 1.0f : v; }; CrafterWgiVibrate(gp, clamp01(low), clamp01(high)); } #endif // ──────────────────────────────────────────────────────────────────── // DOM backend (browser Gamepad API via env.js polling helpers) // ──────────────────────────────────────────────────────────────────── #ifdef CRAFTER_GRAPHICS_WINDOW_DOM namespace Crafter::DomEnv { // External linkage required for the import_module attribute to bind // against the env.js function. An anonymous namespace would drop the // attribute at link time. __attribute__((import_module("env"), import_name("gamepadPollConnected"))) bool gamepadPollConnected(); __attribute__((import_module("env"), import_name("gamepadPollDisconnected"))) bool gamepadPollDisconnected(); __attribute__((import_module("env"), import_name("gamepadCount"))) std::int32_t gamepadCount(); __attribute__((import_module("env"), import_name("gamepadGetButton"))) std::int32_t gamepadGetButton(std::int32_t idx, std::int32_t buttonIdx); __attribute__((import_module("env"), import_name("gamepadGetAxis"))) double gamepadGetAxis(std::int32_t idx, std::int32_t axisIdx); } namespace { using Crafter::DomEnv::gamepadPollConnected; using Crafter::DomEnv::gamepadPollDisconnected; using Crafter::DomEnv::gamepadCount; using Crafter::DomEnv::gamepadGetButton; using Crafter::DomEnv::gamepadGetAxis; // Standard W3C Gamepad mapping: indices match the "standard" layout // every browser exposes for Xbox / DualShock / DualSense controllers. // Mismatched / non-standard pads fall through with zeroed buttons. constexpr int kStdBtnSouth = 0; constexpr int kStdBtnEast = 1; constexpr int kStdBtnWest = 2; constexpr int kStdBtnNorth = 3; constexpr int kStdBtnLB = 4; constexpr int kStdBtnRB = 5; constexpr int kStdBtnLT = 6; constexpr int kStdBtnRT = 7; constexpr int kStdBtnSelect = 8; constexpr int kStdBtnStart = 9; constexpr int kStdBtnLStickClick = 10; constexpr int kStdBtnRStickClick = 11; constexpr int kStdBtnDPadUp = 12; constexpr int kStdBtnDPadDown = 13; constexpr int kStdBtnDPadLeft = 14; constexpr int kStdBtnDPadRight = 15; constexpr int kStdBtnHome = 16; constexpr int kStdAxisLX = 0, kStdAxisLY = 1, kStdAxisRX = 2, kStdAxisRY = 3; // Map our `Gamepad::Button` ordinal to the W3C standard button index. int StdButtonIdx(Crafter::Gamepad::Button b) { using B = Crafter::Gamepad::Button; switch (b) { case B::South: return kStdBtnSouth; case B::East: return kStdBtnEast; case B::West: return kStdBtnWest; case B::North: return kStdBtnNorth; case B::Select: return kStdBtnSelect; case B::Start: return kStdBtnStart; case B::Home: return kStdBtnHome; case B::LeftStickClick: return kStdBtnLStickClick; case B::RightStickClick: return kStdBtnRStickClick; case B::LeftBumper: return kStdBtnLB; case B::RightBumper: return kStdBtnRB; case B::DPadUp: return kStdBtnDPadUp; case B::DPadDown: return kStdBtnDPadDown; case B::DPadLeft: return kStdBtnDPadLeft; case B::DPadRight: return kStdBtnDPadRight; case B::LeftTrigger: return kStdBtnLT; case B::RightTrigger: return kStdBtnRT; default: return -1; } } } void Crafter::Gamepad::Tick() { // Handle connect/disconnect by rebuilding `connected` from scratch // when the JS flags say something changed. Cheap — only fires on // actual hot-plug, not every frame. if (gamepadPollConnected() || gamepadPollDisconnected() || connected.empty()) { // Snapshot previously-known ids so we can fire onDisconnected // for ones that went away in this rebuild. std::vector previousIds; previousIds.reserve(connected.size()); for (auto& d : connected) previousIds.push_back(d->id); std::int32_t n = gamepadCount(); connected.clear(); for (std::int32_t i = 0; i < n; ++i) { auto dev = std::make_unique(); dev->id = static_cast(i); // index-based; stable for the page dev->name = "Gamepad"; connected.push_back(std::move(dev)); } // Fire connected events for ids that weren't in the previous set. for (auto& d : connected) { bool wasKnown = false; for (std::uint32_t prev : previousIds) if (prev == d->id) { wasKnown = true; break; } if (!wasKnown) onConnected.Invoke(d.get()); } // No way to call onDisconnected with the right Device* once it's // gone — fire with nullptr so subscribers know SOMETHING changed. for (std::uint32_t prev : previousIds) { bool stillThere = false; for (auto& d : connected) if (d->id == prev) { stillThere = true; break; } if (!stillThere) onDisconnected.Invoke(nullptr); } } // Poll buttons + axes on every Tick — same shape as the native paths. for (auto& devUp : connected) { Device& dev = *devUp; std::int32_t idx = static_cast(dev.id); for (std::size_t b = 0; b < (std::size_t)Button::Max; ++b) { int stdIdx = StdButtonIdx(static_cast