diff --git a/OPENXR.md b/OPENXR.md index 2cc5cec..e903e6d 100644 --- a/OPENXR.md +++ b/OPENXR.md @@ -386,6 +386,11 @@ holding grip no longer reaches the game (it would press C on the Nunchuk or a sh gamepad); the triggers, A and the right stick are unchanged. Releasing both grips gives steering back to the stick. The settings panel withholds the wheel like any other input. +**A USB wheel.** With a USB wheel and pedals set up (see the README), the wheel drives the race as +player 1's GameCube controller. The cockpit's wheel follows its calibrated steering, at the same +full-lock angle as the stick (`wheel_kart_degrees`, `wheel_bike_degrees`), and hand steering steps +aside while it drives. + **Hands and the separate wheel.** Hands are drawn while hand steering is on: the runtime's own hand mesh where it offers one (`XR_EXT_hand_tracking` and `XR_FB_hand_tracking_mesh`, requested only when hand steering is on at launch), otherwise procedural gloves that curl with the squeeze. They and the diff --git a/README.md b/README.md index 4c72da7..4a8dbdf 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,38 @@ Known limitations of the Wii Remote path: - Turn the Wii Remote support off in that menu if you use a Mayflash DolphinBar, which already presents the remote as a regular gamepad. +**USB steering wheels and pedals.** +Ported from heurazy's [mario-kart-wii-VR-port](https://github.com/heurazy/mario-kart-wii-VR-port). +Open **F10 > Controllers > USB wheel and pedals (player 1)**; it is also in the headset's settings +panel. Pick the steering device and axis and record full left, full right and centre, then each +pedal's released and fully pressed positions. Assign the right paddle to drift and the left paddle to +items; trick, confirm, pause and back are optional. Any wheel SDL sees as a joystick works this way, +with no gamepad mapping: separate USB pedals, reversed axes and combined pedal axes (select the same +axis for both pedals) all calibrate the same. The settings are saved in `PhysicalWheel.toml` beside +`Config.toml`. + +The wheel is player 1's GameCube controller. Press its confirm button at the title screen so the game +uses a GameCube controller; its D-pad, confirm and back then work the menus. In a race it owns +steering and the pedals. The brake pedal brakes, then reverses, and beats the accelerator and drift. +In VR, the cockpit's wheel turns with it and hand steering steps aside. Setting the VR controllers to +**Gamepad** keeps them for menus, pause and item aiming alongside the wheel. Light vibration is +optional, off by default, capped at 15 % and follows the game's own rumble. No centering spring or +steering force is requested. + +Logitech wheels (G29, G920, G923, G27, G25, Driving Force GT, PRO Racing Wheel) are recognised by SDL +as wheels and marked "(wheel)" in the device list. This has not been tried on a physical wheel yet: +- Install Logitech G HUB (Logitech Gaming Software for a G27 or G25). Without the driver a Logitech + wheel starts in a compatibility mode, typically with a smaller rotation range and both pedals on + one axis. A G920 or G923 for Xbox also starts as an Xbox controller, which the game would read as + an ordinary pad. +- Set a G29's mode switch to PS3 on PC. +- Full lock is wherever you record full left and right. Recording them a quarter turn each way + (90°) matches the VR cockpit's wheel, or lower the operating range in G HUB. +- A Driving Force Shifter's gears reach the game as buttons of the wheel and can be assigned like + any other. A gear stays pressed while it is engaged: on the item button it keeps the item held + behind you until you shift back to neutral. The clutch is not used. +- Turn on the centering spring in G HUB if you want the wheel to self-centre. + ## Requirements - Windows 10 or 11, 64-bit diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 73838f7..c3893e6 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -84,7 +84,9 @@ the runtime hand-mesh loader (`runtime/include/vr/openxr_hand_mesh.h`), the per- (`aurora-main/include/aurora/native_wheel_match.hpp`, `aurora-main/lib/gx/native_wheel.hpp`), the cockpit overlay renderer (`aurora-main/lib/gfx/cockpit.hpp`), their tests, and the seat, eye and wheel geometry and guest reads in `runtime/src/vr/mkw_vr_first_person.cpp` and -`runtime/include/vr/mkw_vr_first_person.h`. The files carry that attribution in their headers. +`runtime/include/vr/mkw_vr_first_person.h`. The USB wheel and pedal support is ported from it too +(`runtime/include/physical_wheel.h`, `runtime/src/physical_wheel.cpp` and their test). The files +carry that attribution in their headers. ### pugixml - MIT diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 7b187fa..b40e302 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -418,6 +418,15 @@ foreach(test_name mkw_steering_wheel_tests mkw_vr_cockpit_tests mkw_vr_hand_stee add_test(NAME ${test_name} COMMAND ${test_name}) endforeach() +# The USB wheel's calibration and GameCube pad mapping (physical_wheel.h), +# ported from heurazy's mario-kart-wii-VR-port. Header-only; needs dolphin/pad.h. +add_executable(mkw_physical_wheel_tests "${CMAKE_CURRENT_LIST_DIR}/tests/physical_wheel_tests.cpp") +target_include_directories(mkw_physical_wheel_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include" + "${CMAKE_CURRENT_LIST_DIR}/../aurora-main/include") +target_compile_definitions(mkw_physical_wheel_tests PRIVATE TARGET_PC) +target_compile_features(mkw_physical_wheel_tests PRIVATE cxx_std_17) +add_test(NAME mkw_physical_wheel_tests COMMAND mkw_physical_wheel_tests) + # The VR controllers' Wii Remote presentation (accelerometer frame, pointer # raycast, debounce, button profile) is header-only for the same reason. add_executable(mkw_vr_wii_remote_tests "${CMAKE_CURRENT_LIST_DIR}/tests/vr_wii_remote_tests.cpp") diff --git a/runtime/include/physical_wheel.h b/runtime/include/physical_wheel.h new file mode 100644 index 0000000..fee5ea8 --- /dev/null +++ b/runtime/include/physical_wheel.h @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// USB steering wheel and pedals for player 1. Ported from heurazy's +// mario-kart-wii-VR-port (GPL-3.0-or-later). +// +// Any SDL joystick works: the steering axis, both pedals and the buttons are +// chosen and calibrated by moving or pressing them (F10 > Controllers > USB +// wheel and pedals), so no per-model table or gamepad mapping is involved, and +// separate pedals, reversed axes and combined pedal axes all calibrate the same +// way. The wheel is a GameCube controller on port 0: in a race it owns +// steering, pedals and the assigned buttons; in menus it adds its confirm, +// back, pause and D-pad (the steering device's first hat) to whatever else +// drives port 0. The settings live in PhysicalWheel.toml beside Config.toml. +// +// The mapping below is pure and tested (tests/physical_wheel_tests.cpp). + +#pragma once + +#include +#include +#include +#include + +namespace physical_wheel { + +// Endpoint calibration also handles reversed and combined pedal axes. +inline float Pedal(int raw, int released, int pressed) { + if (std::abs(pressed - released) < 1024) return 0; + return std::clamp(float(raw - released) / float(pressed - released), 0.f, 1.f); +} +inline float Steering(int raw, int left, int center, int right, float deadzone) { + if (std::abs(left - center) < 1024 || std::abs(right - center) < 1024 || (left < center) == (right < center)) + return 0; + const float direction = float(raw - center) / float(right - center); + const float x = direction >= 0 ? Pedal(raw, center, right) : -Pedal(raw, center, left); + deadzone = std::clamp(deadzone, 0.f, .25f); + return std::copysign(std::max(0.f, (std::abs(x) - deadzone) / (1 - deadzone)), x); +} +inline PADStatus Map(float steering, float throttle, float brake, bool drift, bool item) { + PADStatus p{}; + p.err = PAD_ERR_NONE; + p.stickX = static_cast(std::lround(std::clamp(steering, -1.f, 1.f) * 100)); + if (throttle > .1f) { + p.button |= PAD_BUTTON_A; + p.analogA = 255; + } + if (drift) { + p.button |= PAD_TRIGGER_R; + p.triggerR = 255; + } + if (item) { + p.button |= PAD_TRIGGER_L; + p.triggerL = 255; + } + if (brake > .1f) { + p.button &= ~(PAD_BUTTON_A | PAD_TRIGGER_R); + p.analogA = p.triggerR = 0; + p.button |= PAD_BUTTON_B; + p.analogB = 255; + } + return p; +} + +// SDL_HAT_UP/RIGHT/DOWN/LEFT bits as the GameCube D-pad. +inline uint16_t HatButtons(uint8_t hat) { + uint16_t buttons = 0; + if (hat & 0x01) buttons |= PAD_BUTTON_UP; + if (hat & 0x02) buttons |= PAD_BUTTON_RIGHT; + if (hat & 0x04) buttons |= PAD_BUTTON_DOWN; + if (hat & 0x08) buttons |= PAD_BUTTON_LEFT; + return buttons; +} + +// One sample of the calibrated hardware. +struct Controls { + float steering = 0, throttle = 0, brake = 0; + bool drift = false, item = false, trick = false, confirm = false, pause = false, back = false; + uint8_t hat = 0; +}; + +// Race: steering on the stick, the accelerator on A, the brake pedal on B +// (braking, then reversing, over the accelerator and drift), R drift, L item, +// the D-pad and the trick button for tricks and wheelies. +inline PADStatus RacePad(const Controls& c) { + auto p = Map(c.steering, c.throttle, c.brake, c.drift, c.item); + p.button |= HatButtons(c.hat); + if (c.trick) p.button |= PAD_BUTTON_UP; + if (c.confirm && !(p.button & PAD_BUTTON_B)) { + p.button |= PAD_BUTTON_A; + p.analogA = 255; + } + if (c.pause) p.button |= PAD_BUTTON_START; + return p; +} + +// Menus: only deliberate presses, so a resting foot or a turned wheel never +// moves a cursor or confirms. The pedals and the steering stay out. +inline PADStatus MenuPad(const Controls& c) { + PADStatus p{}; + p.err = PAD_ERR_NONE; + p.button = HatButtons(c.hat); + if (c.confirm) { + p.button |= PAD_BUTTON_A; + p.analogA = 255; + } + if (c.back) { + p.button |= PAD_BUTTON_B; + p.analogB = 255; + } + if (c.pause) p.button |= PAD_BUTTON_START; + return p; +} + +// Input that was held while blocked (settings open, not yet armed) stays out of +// the game until it is released. +class PadFilter { +public: + PADStatus Apply(PADStatus pad, bool blocked) noexcept { + if (blocked) { + suppressed_ = pad.button; + suppress_stick_ = pad.stickX != 0 || pad.stickY != 0; + pad = {}; + pad.err = PAD_ERR_NONE; + return pad; + } + suppressed_ &= pad.button; + pad.button &= ~suppressed_; + if (!(pad.button & PAD_BUTTON_A)) pad.analogA = 0; + if (!(pad.button & PAD_BUTTON_B)) pad.analogB = 0; + if (!(pad.button & PAD_TRIGGER_L)) pad.triggerL = 0; + if (!(pad.button & PAD_TRIGGER_R)) pad.triggerR = 0; + if (suppress_stick_) { + suppress_stick_ = pad.stickX != 0 || pad.stickY != 0; + pad.stickX = pad.stickY = 0; + } + return pad; + } + +private: + uint16_t suppressed_ = 0; + bool suppress_stick_ = false; +}; + +// Folds the wheel into port 0. A race gives the wheel the whole pad, keeping +// only the other source's pause and its stick's vertical axis (item aiming); +// menus add the wheel's buttons to it. +inline void Merge(PADStatus& pad, PADStatus wheel, bool race, bool blocked, PadFilter& filter) { + if (race) { + const auto pause = pad.button & PAD_BUTTON_START; + const auto aim = pad.stickY; + pad = filter.Apply(wheel, blocked); + pad.button |= pause; + pad.stickY = blocked ? 0 : aim; + } else { + wheel.stickX = wheel.stickY = 0; + wheel = filter.Apply(wheel, blocked); + pad.button |= wheel.button; + pad.analogA = std::max(pad.analogA, wheel.analogA); + pad.analogB = std::max(pad.analogB, wheel.analogB); + pad.err = PAD_ERR_NONE; + } +} + +// Device and UI operations run on the game thread (PADRead and the settings +// overlay); the XR pacing thread reads a locked snapshot. +bool ReadPad(PADStatus& pad, bool blocked, bool race); +void DrawSettings(); +// The calibrated steering, -1..1, while the wheel is driving a race. +bool SteeringSnapshot(float& steering); +// PADControlMotor for port 0; false when the wheel does not own it. +bool Motor(int channel, unsigned command); +void Shutdown(); + +} // namespace physical_wheel diff --git a/runtime/src/hle/input/pad.cpp b/runtime/src/hle/input/pad.cpp index 00d8fed..41a2bfd 100644 --- a/runtime/src/hle/input/pad.cpp +++ b/runtime/src/hle/input/pad.cpp @@ -2,6 +2,8 @@ #include "memory.h" #include "hle/controller_status_contract.h" #include "input_bindings.h" +#include "physical_wheel.h" +#include "vr/mkw_vr_policy.h" #include "wii_remote_input.h" #include @@ -124,6 +126,12 @@ extern "C" uint32_t PAD__Read_HLE(uint32_t statusPtr) FillTriggersHeldByButtons(statuses); InputBindings::Apply(statuses); + // A USB wheel is player 1's GameCube controller: it owns port 0 in a race + // and adds its buttons to it in menus. It advertises port 0's rumble. + if (physical_wheel::ReadPad(statuses[0], InputBindings::InputBlocked(), + mkw::vr::MkwVRPolicyGetSnapshot().scene.mode == mkw::vr::VRSceneMode::Race)) { + rumbleMask |= PAD_CHAN0_BIT; + } try { for (uint32_t i = 0; i < PAD_CHANMAX; ++i) { @@ -155,6 +163,8 @@ extern "C" void PAD__ControlMotor_HLE(int32_t chan, uint32_t command) if (command == PAD_MOTOR_RUMBLE && !g_rumbleEnabled.load(std::memory_order_relaxed)) { command = PAD_MOTOR_STOP; } - PADControlMotor(chan, command); + if (!physical_wheel::Motor(chan, command)) { + PADControlMotor(chan, command); + } } PPC_NATIVE_OVERRIDE_VOID(801AF908, PAD__ControlMotor_HLE, (int32_t chan, uint32_t command), (chan, command)); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 14012f1..5a61273 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -62,6 +62,7 @@ #include "aurora_events.h" #include "wii_remote_input.h" #include "discord_presence.h" +#include "physical_wheel.h" #include "fiber_manager.h" #include "hle_stubs.h" #include "runtime_config.h" @@ -1574,6 +1575,7 @@ int RuntimeMain(int argc, char** argv) { WindowPlacementPersistence::Flush(true); GxThread::Stop(); mkw::vr::OpenXRShutdownBeforeAurora(); + physical_wheel::Shutdown(); aurora_shutdown(); DiscordPresence::Shutdown(); SetRuntimeExitCodeImpl(0); @@ -1594,6 +1596,7 @@ int RuntimeMain(int argc, char** argv) { WindowPlacementPersistence::Flush(true); GxThread::Stop(); mkw::vr::OpenXRShutdownBeforeAurora(); + physical_wheel::Shutdown(); aurora_shutdown(); DiscordPresence::Shutdown(); ShutdownProcessTranscript(); @@ -1608,6 +1611,7 @@ int RuntimeMain(int argc, char** argv) { WindowPlacementPersistence::Flush(true); GxThread::Stop(); mkw::vr::OpenXRShutdownBeforeAurora(); + physical_wheel::Shutdown(); aurora_shutdown(); DiscordPresence::Shutdown(); ShutdownProcessTranscript(); diff --git a/runtime/src/physical_wheel.cpp b/runtime/src/physical_wheel.cpp new file mode 100644 index 0000000..9a2269a --- /dev/null +++ b/runtime/src/physical_wheel.cpp @@ -0,0 +1,530 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// USB steering wheel and pedals for player 1. Ported from heurazy's +// mario-kart-wii-VR-port (GPL-3.0-or-later); see physical_wheel.h. + +#include "physical_wheel.h" + +#include "input_bindings.h" +#include "runtime_config.h" +#include "runtime_log.h" +#include "vr/openxr_integration.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace physical_wheel { +// Named rather than anonymous: runtime sources are unity-built in groups. +namespace hardware { + +using Clock = std::chrono::steady_clock; +struct Device { + SDL_JoystickID id; + SDL_Joystick* joystick; + std::string key, name; + // SDL knows the model as a steering wheel (Logitech, Thrustmaster, Fanatec...). + bool wheel; +}; +struct Axis { + std::string device; + int index = -1, low = 0, center = 0, high = 0; +}; +struct Button { + std::string device; + int index = -1; +}; +enum AxisSlot { kSteering, kThrottle, kBrake, kAxisCount }; +enum ButtonSlot { kDrift, kItem, kTrick, kConfirm, kPause, kBack, kButtonCount }; +// heurazy's PhysicalWheel.toml has the first five; Back was added here. +constexpr size_t kLegacyButtonCount = 5; + +std::vector devices; +std::array axes; +std::array buttons; +constexpr std::array kAxisNames{"Steering wheel", "Accelerator pedal", "Brake / reverse pedal"}; +constexpr std::array kButtonNames{ + "Drift (R): RIGHT paddle", "Item (L): LEFT paddle", "Trick / wheelie", "Confirm / accelerate (A)", + "Pause (Start)", "Back (B, menus)"}; +bool loaded = false, enabled = false, vibration = false, ready = false, focused = false, driving = false; +bool armed = false, loggedReady = false; +float deadzone = .02f, strength = .05f; +std::string error; +int learning = -1; +std::vector> previousButtons; +Clock::time_point scanned{}, rumbleTick{}, motorTime{}, settingsUntil{}; +bool motorOn = false; +SDL_Haptic* haptic = nullptr; +SDL_JoystickID hapticId = 0, attemptedHaptic = 0; +bool hapticSubsystem = false; +std::mutex snapshotMutex; +float snapshotSteering = 0; +bool snapshotActive = false; +Clock::time_point snapshotTime{}; +PadFilter inputFilter; + +std::filesystem::path ConfigPath() { + return RuntimeConfigFile::ResolveConfigPath().parent_path() / "PhysicalWheel.toml"; +} + +Device* Find(const std::string& key) { + Device* match = nullptr; + for (auto& d : devices) { + if (d.key == key && SDL_JoystickConnected(d.joystick)) { + if (match) return nullptr; // Ambiguous identical devices must not control the wrong pedal. + match = &d; + } + } + return match; +} +bool AxisReady(const Axis& a) { + const auto* d = Find(a.device); + return d && a.index >= 0 && a.index < SDL_GetNumJoystickAxes(d->joystick) && std::abs(a.high - a.low) >= 1024; +} +int Raw(const Axis& a) { + const auto* d = Find(a.device); + return d && a.index >= 0 && a.index < SDL_GetNumJoystickAxes(d->joystick) ? SDL_GetJoystickAxis(d->joystick, a.index) + : 0; +} +bool ButtonReady(const Button& b) { + const auto* d = Find(b.device); + return d && b.index >= 0 && b.index < SDL_GetNumJoystickButtons(d->joystick); +} +bool Pressed(const Button& b) { + const auto* d = Find(b.device); + return ButtonReady(b) && SDL_GetJoystickButton(d->joystick, b.index); +} +// The steering device's first hat is the D-pad (a G29's, for one). +uint8_t Hat() { + const auto* d = Find(axes[kSteering].device); + return d && SDL_GetNumJoystickHats(d->joystick) > 0 ? SDL_GetJoystickHat(d->joystick, 0) : 0; +} +Controls ReadControls() { + Controls c; + const auto& s = axes[kSteering]; + c.steering = Steering(Raw(s), s.low, s.center, s.high, deadzone); + c.throttle = Pedal(Raw(axes[kThrottle]), axes[kThrottle].low, axes[kThrottle].high); + c.brake = Pedal(Raw(axes[kBrake]), axes[kBrake].low, axes[kBrake].high); + c.drift = Pressed(buttons[kDrift]); + c.item = Pressed(buttons[kItem]); + c.trick = Pressed(buttons[kTrick]); + c.confirm = Pressed(buttons[kConfirm]); + c.pause = Pressed(buttons[kPause]); + c.back = Pressed(buttons[kBack]); + c.hat = Hat(); + return c; +} + +void StopFeedback() { + if (!motorOn && !haptic) return; + if (haptic) SDL_StopHapticRumble(haptic); + if (auto* d = Find(axes[kSteering].device)) SDL_RumbleJoystick(d->joystick, 0, 0, 0); + motorOn = false; +} +void CloseHaptic() { + StopFeedback(); + if (haptic) SDL_CloseHaptic(haptic); + haptic = nullptr; + hapticId = attemptedHaptic = 0; +} + +bool WriteReplacing(const std::filesystem::path& path, const std::string& text) { + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + auto temp = path; + temp += ".tmp"; + { + std::ofstream out(temp, std::ios::binary | std::ios::trunc); + if (!out || !(out << text) || !out.flush()) return false; + } + std::filesystem::rename(temp, path, ec); + if (ec) { + std::filesystem::remove(temp, ec); + return false; + } + return true; +} + +void Save() { + toml::value root(toml::table{}); + root["enabled"] = enabled; + root["vibration"] = vibration; + root["deadzone"] = double(deadzone); + root["strength"] = double(strength); + toml::array axisList, buttonList; + for (const auto& a : axes) { + axisList.emplace_back( + toml::table{{"device", a.device}, {"axis", a.index}, {"low", a.low}, {"center", a.center}, {"high", a.high}}); + } + for (const auto& b : buttons) buttonList.emplace_back(toml::table{{"device", b.device}, {"button", b.index}}); + root["axes"] = axisList; + root["buttons"] = buttonList; + if (!WriteReplacing(ConfigPath(), toml::format(root))) { + error = "Could not save PhysicalWheel.toml."; + } else { + error.clear(); + } +} + +void Load() { + loaded = true; + std::error_code ec; + if (!std::filesystem::exists(ConfigPath(), ec)) return; + try { + const auto c = toml::parse(RuntimeConfigFile::PathToUtf8(ConfigPath())); + auto newAxes = axes; + auto newButtons = buttons; + const auto& axisList = toml::find(c, "axes").as_array(); + const auto& buttonList = toml::find(c, "buttons").as_array(); + if (axisList.size() != kAxisCount || + (buttonList.size() != kButtonCount && buttonList.size() != kLegacyButtonCount)) { + throw std::runtime_error("Invalid wheel configuration"); + } + for (size_t i = 0; i < kAxisCount; ++i) { + auto& a = newAxes[i]; + a.device = toml::find(axisList[i], "device"); + a.index = toml::find(axisList[i], "axis"); + a.low = std::clamp(toml::find(axisList[i], "low"), -32768, 32767); + a.center = std::clamp(toml::find(axisList[i], "center"), -32768, 32767); + a.high = std::clamp(toml::find(axisList[i], "high"), -32768, 32767); + } + for (size_t i = 0; i < buttonList.size(); ++i) { + newButtons[i].device = toml::find(buttonList[i], "device"); + newButtons[i].index = toml::find(buttonList[i], "button"); + } + axes = newAxes; + buttons = newButtons; + enabled = toml::find_or(c, "enabled", false); + vibration = toml::find_or(c, "vibration", false); + const double dz = toml::find_or(c, "deadzone", .02), gain = toml::find_or(c, "strength", .05); + deadzone = std::isfinite(dz) ? std::clamp(float(dz), 0.f, .25f) : .02f; + strength = std::isfinite(gain) ? std::clamp(float(gain), 0.f, .15f) : .05f; + } catch (const std::exception& e) { + enabled = false; + error = std::string("Wheel configuration: ") + e.what(); + RT_LOG(RT_TAG_CONFIG) << "PhysicalWheel.toml ignored: " << e.what() << std::endl; + } +} + +void Scan() { + // Keep open handles stable; closing/reopening a wheel can disturb its driver. + int count = 0; + auto* ids = SDL_GetJoysticks(&count); + if (!ids) return; + for (auto it = devices.begin(); it != devices.end();) { + if (!SDL_JoystickConnected(it->joystick)) { + if (hapticId == it->id || attemptedHaptic == it->id) CloseHaptic(); + SDL_CloseJoystick(it->joystick); + it = devices.erase(it); + } else { + ++it; + } + } + for (int i = 0; i < count; ++i) { + if (std::any_of(devices.begin(), devices.end(), [&](const auto& d) { return d.id == ids[i]; })) continue; + auto* j = SDL_OpenJoystick(ids[i]); + if (!j) continue; + char guid[33]{}; + SDL_GUIDToString(SDL_GetJoystickGUID(j), guid, sizeof(guid)); + const char* serial = SDL_GetJoystickSerial(j); + const char* path = SDL_GetJoystickPath(j); + std::string key = guid; + key += '|'; + key += serial && *serial ? serial : path ? path : ""; + const char* name = SDL_GetJoystickName(j); + devices.push_back({ids[i], j, key, name ? name : "Unnamed device", + SDL_GetJoystickType(j) == SDL_JOYSTICK_TYPE_WHEEL}); + } + SDL_free(ids); +} + +void Feedback() { + if (!enabled || !ready || !focused || !driving || InputBindings::InputBlocked() || Clock::now() < settingsUntil || + !vibration || Clock::now() - motorTime > std::chrono::milliseconds(250)) { + StopFeedback(); + return; + } + if (!motorOn) return; + if (Clock::now() - rumbleTick < std::chrono::milliseconds(40)) return; + rumbleTick = Clock::now(); + auto* d = Find(axes[kSteering].device); + if (!d) return; + // Short bounded effects only. No spring, damper or constant steering torque. + const auto amplitude = static_cast(std::clamp(strength, 0.f, .15f) * 65535); + if (SDL_RumbleJoystick(d->joystick, amplitude, amplitude, 100)) return; + if (!haptic && attemptedHaptic != d->id) { + attemptedHaptic = d->id; + if (!hapticSubsystem) hapticSubsystem = SDL_InitSubSystem(SDL_INIT_HAPTIC); + if (hapticSubsystem && SDL_IsJoystickHaptic(d->joystick)) { + haptic = SDL_OpenHapticFromJoystick(d->joystick); + hapticId = d->id; + if (haptic && (!SDL_SetHapticGain(haptic, 15) || !SDL_InitHapticRumble(haptic))) { + SDL_CloseHaptic(haptic); + haptic = nullptr; + } + } + if (!haptic) error = "This driver does not expose rumble. Driving still works."; + } + if (haptic) SDL_PlayHapticRumble(haptic, std::clamp(strength, 0.f, .15f), 100); +} + +void PublishSnapshot(bool active, float steering) { + std::lock_guard lock(snapshotMutex); + snapshotActive = active; + snapshotSteering = active ? steering : 0; + snapshotTime = Clock::now(); +} + +void Poll() { + if (!loaded) Load(); + if (Clock::now() - scanned > std::chrono::seconds(1)) { + Scan(); + scanned = Clock::now(); + } + // In VR the headset has the player's attention even when the desktop + // window does not have the keyboard. + focused = SDL_GetKeyboardFocus() != nullptr || mkw::vr::OpenXRIsRunning(); + const auto& s = axes[kSteering]; + ready = AxisReady(s) && AxisReady(axes[kThrottle]) && AxisReady(axes[kBrake]) && std::abs(s.low - s.center) >= 1024 && + std::abs(s.high - s.center) >= 1024 && (s.low < s.center) != (s.high < s.center) && + ButtonReady(buttons[kDrift]) && ButtonReady(buttons[kItem]) && + (buttons[kDrift].device != buttons[kItem].device || buttons[kDrift].index != buttons[kItem].index); + if (enabled && ready != loggedReady) { + loggedReady = ready; + const auto* d = Find(s.device); + RT_LOG(RT_TAG_RUNTIME) << "[wheel] USB wheel " << (ready ? "ready: " : "not ready (setup incomplete or disconnected)") + << (ready && d ? d->name : std::string()) << std::endl; + } + const bool active = enabled && ready && focused && driving && !InputBindings::InputBlocked() && + Clock::now() >= settingsUntil; + PublishSnapshot(active, active ? Steering(Raw(s), s.low, s.center, s.high, deadzone) : 0); + Feedback(); +} + +} // namespace hardware + +bool ReadPad(PADStatus& pad, bool blocked, bool race) { + using namespace hardware; + if (!loaded) Load(); + driving = race; + if (!enabled) { + // Nothing is opened or read until the wheel is turned on. + armed = false; + inputFilter = {}; + PublishSnapshot(false, 0); + return false; + } + blocked = blocked || Clock::now() < settingsUntil; + Poll(); + if (!ready || !focused) { + armed = false; + StopFeedback(); + inputFilter = {}; + if (race) { + // An incomplete or disconnected setup is neutral in a race, never + // a stale steering angle. + const auto pause = pad.button & PAD_BUTTON_START; + pad = {}; + pad.err = PAD_ERR_NONE; + pad.button = pause; + } + return true; + } + const Controls controls = ReadControls(); + PADStatus wheel = race ? RacePad(controls) : MenuPad(controls); + if (!armed) { + // Arm only once every pedal and button is released, so enabling the + // wheel or reconnecting it never fires a held input. + armed = RacePad(controls).button == 0 && HatButtons(controls.hat) == 0 && !controls.back && !blocked; + if (!armed) { + wheel = {}; + wheel.err = PAD_ERR_NONE; + StopFeedback(); + } + } + Merge(pad, wheel, race, blocked, inputFilter); + return true; +} + +bool SteeringSnapshot(float& steering) { + using namespace hardware; + std::lock_guard lock(snapshotMutex); + steering = snapshotSteering; + return snapshotActive && Clock::now() - snapshotTime < std::chrono::milliseconds(250); +} + +bool Motor(int channel, unsigned command) { + using namespace hardware; + if (channel != 0 || !enabled) return false; + motorTime = Clock::now(); + if (command != PAD_MOTOR_RUMBLE) { + StopFeedback(); + } else { + motorOn = true; + Feedback(); + } + return true; +} + +void Shutdown() { + using namespace hardware; + CloseHaptic(); + for (auto& d : devices) SDL_CloseJoystick(d.joystick); + devices.clear(); + if (hapticSubsystem) SDL_QuitSubSystem(SDL_INIT_HAPTIC); + hapticSubsystem = false; + ready = false; + PublishSnapshot(false, 0); +} + +void DrawSettings() { + using namespace hardware; + settingsUntil = Clock::now() + std::chrono::milliseconds(200); + Poll(); + ImGui::TextWrapped("%s", + "USB steering wheel and pedals for player 1 (by heurazy). Calibrate each axis, then assign the " + "RIGHT paddle to drift and the LEFT paddle to items. Separate USB pedals and combined pedal axes " + "are supported."); + if (ImGui::Checkbox("Enable USB wheel", &enabled)) { + armed = false; + loggedReady = false; + StopFeedback(); + Save(); + } + ImGui::TextWrapped("%s", + "The wheel is a GameCube controller: press its Confirm button at the title screen so the game " + "uses a GameCube controller. Its D-pad, Confirm and Back also work the menus. In VR, set the VR " + "controllers to Gamepad to steer menus and aim items with them too."); + ImGui::TextWrapped("%s", "After enabling or reconnecting, close settings and release all pedals and buttons to arm " + "driving."); + ImGui::TextWrapped("%s", ready ? "Devices and required bindings ready." + : "Setup incomplete or device disconnected. Calibrate all axes and assign both " + "paddles. The wheel stays neutral in a race until ready."); + for (size_t i = 0; i < kAxisCount; ++i) { + ImGui::PushID(int(i)); + auto& a = axes[i]; + ImGui::Separator(); + ImGui::TextUnformatted(kAxisNames[i]); + auto* selected = Find(a.device); + if (ImGui::BeginCombo("Device", selected ? selected->name.c_str() : "Select device")) { + for (const auto& d : devices) { + ImGui::PushID(int(d.id)); + const std::string label = d.wheel ? d.name + " (wheel)" : d.name; + if (ImGui::Selectable(label.c_str(), a.device == d.key)) { + if (i == kSteering) CloseHaptic(); + a = {d.key, -1, 0, 0, 0}; + Save(); + } + ImGui::PopID(); + } + ImGui::EndCombo(); + } + selected = Find(a.device); + if (selected) { + const std::string label = a.index < 0 ? "Select axis" : std::to_string(a.index); + if (ImGui::BeginCombo("Axis", label.c_str())) { + for (int n = 0; n < SDL_GetNumJoystickAxes(selected->joystick); ++n) { + const std::string text = + std::to_string(n) + " : " + std::to_string(SDL_GetJoystickAxis(selected->joystick, n)); + if (ImGui::Selectable(text.c_str(), n == a.index)) { + a.index = n; + a.low = a.center = a.high = 0; + Save(); + } + } + ImGui::EndCombo(); + } + ImGui::Text("Raw: %d", Raw(a)); + if (ImGui::Button(i == kSteering ? "Set full LEFT" : "Set RELEASED")) { + a.low = Raw(a); + Save(); + } + ImGui::SameLine(); + if (ImGui::Button(i == kSteering ? "Set full RIGHT" : "Set fully PRESSED")) { + a.high = Raw(a); + Save(); + } + if (i == kSteering) { + ImGui::SameLine(); + if (ImGui::Button("Set CENTER")) { + a.center = Raw(a); + Save(); + } + } + const float value = i == kSteering ? Steering(Raw(a), a.low, a.center, a.high, deadzone) + : Pedal(Raw(a), a.low, a.high); + ImGui::Text("Calibrated: %.2f", value); + if (i == kSteering) { + ImGui::TextDisabled("%s", "Full lock is wherever you record full left and right: turn the wheel as far " + "as you want full steering to take (90 degrees each way matches the VR " + "cockpit's wheel)."); + if (SDL_GetNumJoystickHats(selected->joystick) > 0) { + ImGui::TextDisabled("D-pad: this device's hat (menus, tricks)."); + } + } + } + ImGui::PopID(); + } + std::vector> down; + for (const auto& d : devices) { + for (int n = 0; n < SDL_GetNumJoystickButtons(d.joystick); ++n) { + if (SDL_GetJoystickButton(d.joystick, n)) down.emplace_back(d.id, n); + } + } + if (learning >= 0) { + for (const auto& press : down) { + if (std::find(previousButtons.begin(), previousButtons.end(), press) != previousButtons.end()) continue; + for (const auto& d : devices) { + if (d.id == press.first) { + buttons[learning] = {d.key, press.second}; + learning = -1; + Save(); + break; + } + } + break; + } + } + previousButtons = down; + ImGui::Separator(); + for (size_t i = 0; i < kButtonCount; ++i) { + ImGui::PushID(100 + int(i)); + ImGui::TextUnformatted(kButtonNames[i]); + ImGui::SameLine(); + if (ImGui::Button(learning == int(i) ? "Press the hardware button..." : "Assign")) learning = int(i); + ImGui::SameLine(); + if (ImGui::Button("Clear")) { + buttons[i] = {}; + learning = -1; + Save(); + } + if (const auto* d = Find(buttons[i].device)) ImGui::Text("%s / button %d", d->name.c_str(), buttons[i].index); + ImGui::PopID(); + } + if (learning >= 0 && ImGui::Button("Cancel assignment")) learning = -1; + ImGui::TextDisabled("%s", "A shifter's gears are buttons too; a gear stays pressed while engaged."); + if (ImGui::SliderFloat("Wheel deadzone", &deadzone, 0, .25f)) Save(); + if (ImGui::Checkbox("Light game vibration (off by default)", &vibration)) { + CloseHaptic(); + Save(); + } + if (ImGui::SliderFloat("Vibration strength (maximum 15%)", &strength, 0, .15f)) Save(); + ImGui::TextWrapped("%s", + "No constant force or centering effect: turn on the centering spring in your wheel's own software " + "if you want one. Vibration follows Mario Kart's original rumble events; hardware and driver " + "support varies."); + if (!error.empty()) ImGui::TextWrapped("%s", error.c_str()); +} + +} // namespace physical_wheel diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index ad695f9..d8f67b0 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -7,6 +7,7 @@ #include "input_bindings.h" #include "game_graphics_options.h" #include "log_export.h" +#include "physical_wheel.h" #include "music_attenuation.h" #include "runtime_config.h" #include "runtime_log.h" @@ -843,6 +844,10 @@ void DrawRumbleSettings() { } void DrawControllerSettings() { + if (ImGui::CollapsingHeader("USB wheel and pedals (player 1)")) { + physical_wheel::DrawSettings(); + ImGui::Separator(); + } for (int port = 0; port < PAD_MAX_CONTROLLERS; ++port) { const std::string label = "Port " + std::to_string(port + 1); ImGui::RadioButton(label.c_str(), &g_controllerPort, port); diff --git a/runtime/src/vr/openxr_input.cpp b/runtime/src/vr/openxr_input.cpp index 6299a03..7bec497 100644 --- a/runtime/src/vr/openxr_input.cpp +++ b/runtime/src/vr/openxr_input.cpp @@ -10,6 +10,7 @@ #endif #include "vr/openxr_input.h" +#include "physical_wheel.h" #include "runtime_config.h" #include "vr/mkw_vr_first_person.h" #include "vr/openxr_diagnostics.h" @@ -864,7 +865,11 @@ void OpenXRInput::UpdateDriving(XrTime display_time, const driving::SeatFrame& s wheel_hands[hand] = geometry.ToWheel(wheel_hands[hand]); } } - const bool active = hand_steering && !withheld; + // A USB wheel drives the race through the GameCube pad: it steers, the + // cockpit's wheel shows its angle, and the hands cannot take hold. + float hardware_steering = 0.0f; + const bool hardware_wheel = physical_wheel::SteeringSnapshot(hardware_steering); + const bool active = hand_steering && !withheld && !hardware_wheel; const WheelState wheel = m_wheel.Update(wheel_hands, active, dt, uses_geometry ? geometry.radius : SteeringWheel::Radius, anchor.bike, tuning); @@ -879,10 +884,16 @@ void OpenXRInput::UpdateDriving(XrTime display_time, const driving::SeatFrame& s m_wheel_held = wheel.held; snapshot.held = wheel.held; driving::ApplyHandSteering(hands, wheel); - snapshot.steering_input = withheld ? 0.0f : hands[0].stick_x; - snapshot.visual_angle = - m_wheel_visual.Update(wheel.held[0] || wheel.held[1], wheel.visualAngle, snapshot.steering_input, - driving::MaxWheelAngle(anchor.bike, tuning), dt); + const float max_angle = driving::MaxWheelAngle(anchor.bike, tuning); + if (hardware_wheel) { + snapshot.steering_input = std::clamp(hardware_steering, -1.0f, 1.0f); + // The hardware wheel is already smooth; follow it directly. + snapshot.visual_angle = m_wheel_visual.Update(true, snapshot.steering_input * max_angle, 0.0f, max_angle, dt); + } else { + snapshot.steering_input = withheld ? 0.0f : hands[0].stick_x; + snapshot.visual_angle = m_wheel_visual.Update(wheel.held[0] || wheel.held[1], wheel.visualAngle, + snapshot.steering_input, max_angle, dt); + } m_driving = snapshot; OpenXRPublishDriving(snapshot); } diff --git a/runtime/tests/physical_wheel_tests.cpp b/runtime/tests/physical_wheel_tests.cpp new file mode 100644 index 0000000..ccc2c67 --- /dev/null +++ b/runtime/tests/physical_wheel_tests.cpp @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// USB wheel calibration and its GameCube pad mapping (physical_wheel.h). The +// calibration and race-mapping cases are heurazy's (mario-kart-wii-VR-port). + +#include "physical_wheel.h" + +#include + +using namespace physical_wheel; + +namespace { + +int g_failures = 0; + +void Check(bool condition, const char* what) { + if (!condition) { + ++g_failures; + std::cerr << "FAILED: " << what << '\n'; + } +} + +void TestCalibration() { + Check(Steering(-32768, -32768, 100, 32767, .02f) == -1, "left endpoint"); + Check(Steering(32767, -32768, 100, 32767, .02f) == 1, "right endpoint"); + Check(Steering(200, -32768, 100, 32767, .02f) == 0, "calibrated center deadzone"); + Check(Steering(-32768, 32767, 0, -32768, 0) == 1, "inverted steering"); + Check(Steering(100, 0, 0, 100, 0) == 0, "reject missing calibration"); + // A 900-degree wheel calibrated at a quarter turn each way: full lock there, + // and clamped beyond it. + Check(Steering(-8192, -8192, 0, 8192, 0) == -1 && Steering(-30000, -8192, 0, 8192, 0) == -1, + "full lock at the recorded turn, clamped past it"); + Check(Pedal(-32768, 32767, -32768) == 1, "reversed pedals (released at +32767, as Logitech pedals report)"); + Check(Pedal(32767, 32767, -32768) == 0, "released reversed pedal"); + Check(Pedal(-16000, 0, 32767) == 0 && Pedal(16000, 0, -32768) == 0, "combined pedals isolate opposite direction"); + Check(Pedal(30000, 0, 10000) == 1, "pedal clamps beyond calibration"); +} + +void TestRace() { + auto p = Map(.5f, 1, 0, true, true); + Check(p.stickX == 50 && (p.button & PAD_BUTTON_A) && (p.button & PAD_TRIGGER_R) && (p.button & PAD_TRIGGER_L), + "accelerate drift and item together"); + p = Map(-1, 1, 1, true, true); + Check(p.stickX == -100 && (p.button & PAD_BUTTON_B) && !(p.button & (PAD_BUTTON_A | PAD_TRIGGER_R)) && + p.analogA == 0 && p.triggerR == 0 && (p.button & PAD_TRIGGER_L), + "brake overrides throttle and drift while preserving item"); + + Controls c; + c.steering = .25f; + c.throttle = 1; + c.hat = 0x08; // left + c.trick = true; + c.pause = true; + p = RacePad(c); + Check(p.stickX == 25 && (p.button & PAD_BUTTON_A) && (p.button & PAD_BUTTON_LEFT) && (p.button & PAD_BUTTON_UP) && + (p.button & PAD_BUTTON_START), + "race: steering, accelerator, D-pad trick and pause"); + c = {}; + c.brake = 1; + c.confirm = true; + p = RacePad(c); + Check((p.button & PAD_BUTTON_B) && !(p.button & PAD_BUTTON_A), "the brake pedal beats the confirm button"); +} + +void TestMenu() { + Controls c; + c.steering = 1; + c.throttle = 1; + c.brake = 1; + c.drift = c.item = true; + PADStatus p = MenuPad(c); + Check(p.button == 0 && p.stickX == 0, "menus ignore the wheel, pedals and paddles"); + c.confirm = true; + c.back = true; + c.pause = true; + c.hat = 0x01 | 0x02; // up-right + p = MenuPad(c); + Check((p.button & PAD_BUTTON_A) && (p.button & PAD_BUTTON_B) && (p.button & PAD_BUTTON_START) && + (p.button & PAD_BUTTON_UP) && (p.button & PAD_BUTTON_RIGHT) && !(p.button & PAD_BUTTON_DOWN), + "menus: confirm, back, pause and the D-pad"); + Check(HatButtons(0x04 | 0x08) == (PAD_BUTTON_DOWN | PAD_BUTTON_LEFT), "hat down-left"); +} + +void TestMerge() { + // Race: the wheel owns port 0 but keeps the other source's pause and item aim. + PADStatus port{}; + port.err = PAD_ERR_NO_CONTROLLER; + port.button = PAD_BUTTON_START | PAD_BUTTON_A; + port.stickX = -80; + port.stickY = 70; + PadFilter filter; + Controls c; + c.steering = .5f; + Merge(port, RacePad(c), true, false, filter); + Check(port.err == PAD_ERR_NONE && port.stickX == 50 && port.stickY == 70, "race: wheel steers, stick still aims"); + Check((port.button & PAD_BUTTON_START) && !(port.button & PAD_BUTTON_A), "race: other source keeps only pause"); + + // Menus: the wheel's buttons join the other source's navigation. + port = {}; + port.err = PAD_ERR_NONE; + port.stickY = 60; + c = {}; + c.confirm = true; + PadFilter menus; + Merge(port, MenuPad(c), false, false, menus); + Check((port.button & PAD_BUTTON_A) && port.analogA == 255 && port.stickY == 60, "menus: wheel adds confirm"); + + // A button held while blocked stays out until it is released. + PadFilter held; + port = {}; + Merge(port, MenuPad(c), false, true, held); + Check(port.button == 0, "blocked: nothing reaches the game"); + port = {}; + Merge(port, MenuPad(c), false, false, held); + Check(!(port.button & PAD_BUTTON_A), "still held after unblocking: suppressed"); + port = {}; + Merge(port, MenuPad(Controls{}), false, false, held); + port = {}; + Merge(port, MenuPad(c), false, false, held); + Check((port.button & PAD_BUTTON_A) != 0, "a fresh press after release goes through"); +} + +} // namespace + +int main() { + TestCalibration(); + TestRace(); + TestMenu(); + TestMerge(); + if (g_failures != 0) { + std::cerr << g_failures << " check(s) failed\n"; + return 1; + } + std::cout << "physical wheel tests passed\n"; + return 0; +}