mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-04 22:00:08 +02:00
Add USB steering wheel and pedal support
Ported from heurazy's mario-kart-wii-VR-port. F10 > Controllers > USB wheel and pedals (also in the headset panel) picks and calibrates the steering axis and both pedals by moving them, and assigns buttons by pressing them, over raw SDL joysticks: no per-model table or gamepad mapping, and separate pedals, reversed axes and combined pedal axes all calibrate the same way. Settings live in PhysicalWheel.toml beside Config.toml; heurazy's five-button files load unchanged. The wheel is player 1's GameCube controller. In a race it owns port 0 (steering on the stick, the accelerator on A, the brake pedal braking then reversing over A and drift, the paddles on R and L), keeping only the other source's pause and item aim. In menus it adds only deliberate presses. It arms once every pedal and button is released, stays neutral in a race while its setup is incomplete, and nothing is opened until it is enabled. Optional light rumble follows the game's own, capped at 15 %. Added here: its confirm, a new back button and the steering device's first hat (D-pad) work the menus, since our VR controllers default to a Wii Remote and the wheel needs to navigate on its own. Devices SDL classifies as wheels are marked in the list. In the VR cockpit the wheel follows the hardware steering and hand steering steps aside while it drives. README documents setup and Logitech notes (G HUB, the G29's PS3 switch, shifter gears as buttons); not yet tried on a physical wheel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
131da8a7a9
commit
c11a7c9a61
11 files changed
+923
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <dolphin/pad.h>
|
||||
|
||||
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<int8_t>(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
|
||||
@@ -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 <algorithm>
|
||||
@@ -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));
|
||||
@@ -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();
|
||||
|
||||
@@ -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 <SDL3/SDL_haptic.h>
|
||||
#include <SDL3/SDL_init.h>
|
||||
#include <SDL3/SDL_joystick.h>
|
||||
#include <SDL3/SDL_keyboard.h>
|
||||
#include <SDL3/SDL_stdinc.h>
|
||||
#include <imgui.h>
|
||||
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
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<Device> devices;
|
||||
std::array<Axis, kAxisCount> axes;
|
||||
std::array<Button, kButtonCount> buttons;
|
||||
constexpr std::array<const char*, kAxisCount> kAxisNames{"Steering wheel", "Accelerator pedal", "Brake / reverse pedal"};
|
||||
constexpr std::array<const char*, kButtonCount> 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<std::pair<SDL_JoystickID, int>> 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<std::string>(axisList[i], "device");
|
||||
a.index = toml::find<int>(axisList[i], "axis");
|
||||
a.low = std::clamp(toml::find<int>(axisList[i], "low"), -32768, 32767);
|
||||
a.center = std::clamp(toml::find<int>(axisList[i], "center"), -32768, 32767);
|
||||
a.high = std::clamp(toml::find<int>(axisList[i], "high"), -32768, 32767);
|
||||
}
|
||||
for (size_t i = 0; i < buttonList.size(); ++i) {
|
||||
newButtons[i].device = toml::find<std::string>(buttonList[i], "device");
|
||||
newButtons[i].index = toml::find<int>(buttonList[i], "button");
|
||||
}
|
||||
axes = newAxes;
|
||||
buttons = newButtons;
|
||||
enabled = toml::find_or<bool>(c, "enabled", false);
|
||||
vibration = toml::find_or<bool>(c, "vibration", false);
|
||||
const double dz = toml::find_or<double>(c, "deadzone", .02), gain = toml::find_or<double>(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<Uint16>(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<std::pair<SDL_JoystickID, int>> 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
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 <iostream>
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user