Skip to content

Commit 9ff12ae

Browse files
mherboldclaude
andcommitted
Add vJoy steering passthrough to the game bridge
For games like Assetto Corsa that cannot disable their own force feedback: MAIRA passes the wheelbase steering axis through to vJoy, the game is bound to the vJoy X axis, and its FFB goes to vJoy instead of the real wheel. Includes a steering test mode with position and sweep buttons for binding the axis in the game, a warning when the vJoy driver is missing, and a fix so the wheel position is tracked from the selected steering device before force feedback initializes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7eafef7 commit 9ff12ae

49 files changed

Lines changed: 1158 additions & 6 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

MarvinsAIRARefactored/App.xaml.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -710,14 +710,14 @@ private static void WorkerThread()
710710
app.Sounds.Tick( app );
711711
app.Graph.Tick( app );
712712
app.SteeringEffects.Tick( app );
713+
app.GameBridge.Tick( app );
713714
app.VirtualJoystick.Tick( app );
714715
app.TimingMarkers.Tick( app );
715716
app.Telemetry.Tick( app );
716717
app.Commentary.Tick( app );
717718
app.TyphoonWind.Tick( app );
718719
app.GTensioner.Tick( app );
719720
app.CloudService.Tick( app );
720-
app.GameBridge.Tick( app );
721721

722722
app.GripOMeterWindow?.Tick( app );
723723
app.GapMonitorWindow?.Tick( app );
1.47 KB
Loading
2.32 KB
Loading
2.02 KB
Loading

MarvinsAIRARefactored/Components/DirectInput.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,13 @@ public void PollDevices( float deltaSeconds )
245245

246246
var app = App.Instance!;
247247

248+
// track the wheel position on the SELECTED steering device from settings, not on the initialized
249+
// force feedback device - force feedback is only initialized once a simulator connects (RacingWheel
250+
// runs on the multimedia timer, which is suspended until then), but the wheel position is needed
251+
// earlier than that (the game bridge's vJoy steering passthrough runs while the game is still in
252+
// its menus, before any telemetry flows)
253+
var steeringDeviceInstanceGuid = ( _forceFeedbackDeviceInstanceGuid != Guid.Empty ) ? _forceFeedbackDeviceInstanceGuid : DataContext.DataContext.Instance.Settings.RacingWheelSteeringDeviceGuid;
254+
248255
if ( _joystickInfoListNeedsToBeUpdated )
249256
{
250257
_joystickInfoListNeedsToBeUpdated = false;
@@ -284,7 +291,7 @@ public void PollDevices( float deltaSeconds )
284291

285292
joystickInfo._joystickUpdates = joystickInfo._joystick.GetBufferedData();
286293

287-
if ( joystickInfo._instanceGuid == _forceFeedbackDeviceInstanceGuid )
294+
if ( joystickInfo._instanceGuid == steeringDeviceInstanceGuid )
288295
{
289296
if ( joystickInfo._xAxisProperties != null )
290297
{

MarvinsAIRARefactored/Components/GameBridge.cs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11

2+
using MarvinsAIRARefactored.Controls;
23
using MarvinsAIRARefactored.GameBridges;
34

45
using static MarvinsAIRARefactored.Windows.MainWindow;
@@ -124,6 +125,8 @@ public void Pump( double totalSeconds )
124125

125126
public void Tick( App app )
126127
{
128+
UpdateSteeringPassthrough( app );
129+
127130
ActiveAdapter?.Tick( app );
128131

129132
_updateCounter--;
@@ -135,6 +138,15 @@ public void Tick( App app )
135138

136139
_updateCounter = UpdateInterval;
137140

141+
// keep the vJoy driver warning and the sweep button's blink fresh while the game bridge page is
142+
// showing - the fault is only discovered after the passthrough first tries to initialize the
143+
// device, and the sweep can be cancelled by the toggles rather than by its own button
144+
if ( MairaAppMenuPopup.CurrentAppPage == AppPage.GameBridge )
145+
{
146+
_gameBridgePage.UpdateVJoyStatus( app );
147+
_gameBridgePage.UpdateSweepButton( app );
148+
}
149+
138150
if ( _transitioning )
139151
{
140152
return;
@@ -161,6 +173,86 @@ public void Tick( App app )
161173
}
162174
}
163175

176+
// When enabled, the wheelbase's steering axis is passed through to the vJoy device's X axis every tick.
177+
// This is for games that provide no way to disable their built-in force feedback (Assetto Corsa, for
178+
// example): the user binds the game's steering input to the vJoy X axis instead of the real wheel, so the
179+
// game sends its force feedback to the vJoy device (which discards it) and MAIRA keeps sole control of
180+
// the real wheelbase. This tick runs BEFORE VirtualJoystick.Tick in the app worker loop, so the position
181+
// written here goes out to vJoy in the same frame.
182+
private bool _steeringPassthroughActive = false;
183+
private int _steeringPassthroughShutdownCountdown = 0;
184+
185+
// the steering sweep continuously oscillates the vJoy axis (one full left-right-left cycle every two
186+
// seconds) - games like AC ignore controller input while their window is not in the foreground, so the
187+
// user arms the sweep in MAIRA, clicks over to the game, and the game then sees the axis moving
188+
public bool SteeringSweepActive { get; set; } = false;
189+
190+
private const double SteeringSweepPeriodSeconds = 2.0;
191+
192+
private double _steeringSweepPhase = 0.0;
193+
194+
private void UpdateSteeringPassthrough( App app )
195+
{
196+
var settings = DataContext.DataContext.Instance.Settings;
197+
198+
if ( settings.GameBridgeSendSteeringToVJoy || settings.GameBridgeSteeringTestEnabled )
199+
{
200+
if ( !app.VirtualJoystick.Initialized && !app.VirtualJoystick.Faulted )
201+
{
202+
app.VirtualJoystick.Initialize();
203+
}
204+
205+
// in steering test mode the vJoy axis is driven by the test buttons on the game bridge page
206+
// instead of the real wheel - this lets the user move ONLY the vJoy axis while binding the
207+
// steering input in the game, so the game cannot mistake the real wheel for the moving axis
208+
if ( !settings.GameBridgeSteeringTestEnabled )
209+
{
210+
SteeringSweepActive = false;
211+
212+
app.VirtualJoystick.Steering = app.DirectInput.ForceFeedbackWheelPosition;
213+
}
214+
else if ( SteeringSweepActive )
215+
{
216+
_steeringSweepPhase += 2.0 * Math.PI / ( SteeringSweepPeriodSeconds * App.TimerTicksPerSecond );
217+
218+
app.VirtualJoystick.Steering = (float) Math.Sin( _steeringSweepPhase );
219+
}
220+
else
221+
{
222+
_steeringSweepPhase = 0.0;
223+
}
224+
225+
if ( !_steeringPassthroughActive && app.VirtualJoystick.Initialized )
226+
{
227+
app.Logger.WriteLine( "[GameBridge] vJoy steering passthrough started" );
228+
}
229+
230+
_steeringPassthroughActive = true;
231+
_steeringPassthroughShutdownCountdown = 2;
232+
}
233+
else if ( _steeringPassthroughActive )
234+
{
235+
// on the way out, center the axis first and give VirtualJoystick.Tick one frame to actually push
236+
// the zero to the device before it is released
237+
app.VirtualJoystick.Steering = 0f;
238+
239+
_steeringPassthroughShutdownCountdown--;
240+
241+
if ( _steeringPassthroughShutdownCountdown <= 0 )
242+
{
243+
_steeringPassthroughActive = false;
244+
SteeringSweepActive = false;
245+
246+
app.Logger.WriteLine( "[GameBridge] vJoy steering passthrough stopped" );
247+
248+
if ( app.VirtualJoystick.Initialized )
249+
{
250+
app.VirtualJoystick.Shutdown();
251+
}
252+
}
253+
}
254+
}
255+
164256
private void Activate( App app, GameBridgeAdapter adapter )
165257
{
166258
app.Logger.WriteLine( $"[GameBridge] Activating the {adapter.GameName} bridge" );

MarvinsAIRARefactored/Components/SteeringEffects.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1302,7 +1302,9 @@ public void Tick( App app )
13021302

13031303
if ( app.Simulator.TrackDisplayName != "Centripetal Circuit" )
13041304
{
1305-
if ( app.VirtualJoystick.Initialized )
1305+
// the game bridge's steering passthrough shares the vJoy device - if it is enabled, leave
1306+
// the device alone here or the two features would fight over acquire/release every tick
1307+
if ( app.VirtualJoystick.Initialized && !DataContext.DataContext.Instance.Settings.GameBridgeSendSteeringToVJoy )
13061308
{
13071309
app.VirtualJoystick.Shutdown();
13081310
}

MarvinsAIRARefactored/Components/VirtualJoystick.cs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ public class VirtualJoystick
3232
private bool _initialized = false;
3333
private bool _faulted = false;
3434

35+
private int _consecutiveUpdateFailures = 0;
36+
3537
public bool Initialized { get => _initialized; }
3638
public bool Faulted { get => _faulted; }
3739

@@ -84,6 +86,10 @@ public void Initialize()
8486
{
8587
_vJoy.ResetVJD( JoystickId );
8688

89+
var xAxisExists = _vJoy.GetVJDAxisExist( JoystickId, HID_USAGES.HID_USAGE_X );
90+
var yAxisExists = _vJoy.GetVJDAxisExist( JoystickId, HID_USAGES.HID_USAGE_Y );
91+
var zAxisExists = _vJoy.GetVJDAxisExist( JoystickId, HID_USAGES.HID_USAGE_Z );
92+
8793
_vJoy.GetVJDAxisMin( JoystickId, HID_USAGES.HID_USAGE_X, ref _minimumX );
8894
_vJoy.GetVJDAxisMax( JoystickId, HID_USAGES.HID_USAGE_X, ref _maximumX );
8995

@@ -93,7 +99,22 @@ public void Initialize()
9399
_vJoy.GetVJDAxisMin( JoystickId, HID_USAGES.HID_USAGE_Z, ref _minimumZ );
94100
_vJoy.GetVJDAxisMax( JoystickId, HID_USAGES.HID_USAGE_Z, ref _maximumZ );
95101

96-
_initialized = true;
102+
app.Logger.WriteLine( $"[VirtualJoystick] Axes - X: {xAxisExists} ({_minimumX}..{_maximumX}), Y: {yAxisExists} ({_minimumY}..{_maximumY}), Z: {zAxisExists} ({_minimumZ}..{_maximumZ})" );
103+
104+
// without an X axis the steering can never move - flag it as a fault instead of silently
105+
// writing a constant zero forever (the same goes for a degenerate axis range)
106+
if ( !xAxisExists || ( _minimumX == _maximumX ) )
107+
{
108+
app.Logger.WriteLine( $"[VirtualJoystick] Joystick {JoystickId} has no usable X axis - reconfigure the vJoy device with an X axis!" );
109+
110+
_vJoy.RelinquishVJD( JoystickId );
111+
112+
_faulted = true;
113+
}
114+
else
115+
{
116+
_initialized = true;
117+
}
97118
}
98119
}
99120
}
@@ -141,13 +162,26 @@ public void Tick( App app )
141162

142163
if ( !_vJoy.UpdateVJD( JoystickId, ref _joystickState ) )
143164
{
165+
// log the first failure and then once every ~5 seconds - if the re-acquire below keeps
166+
// succeeding this would otherwise loop forever in silence with the axes frozen
167+
_consecutiveUpdateFailures++;
168+
169+
if ( ( _consecutiveUpdateFailures == 1 ) || ( _consecutiveUpdateFailures % 300 == 0 ) )
170+
{
171+
app.Logger.WriteLine( $"[VirtualJoystick] UpdateVJD failed on joystick {JoystickId} ({_consecutiveUpdateFailures}x)" );
172+
}
173+
144174
if ( !_vJoy.AcquireVJD( JoystickId ) )
145175
{
146176
app.Logger.WriteLine( $"[VirtualJoystick] Joystick {JoystickId} could not be re-acquired" );
147177

148178
_initialized = false;
149179
}
150180
}
181+
else
182+
{
183+
_consecutiveUpdateFailures = 0;
184+
}
151185
}
152186
}
153187
}

MarvinsAIRARefactored/DataContext/Settings.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14286,6 +14286,55 @@ public bool GameBridgeRaceRoomRacingExperienceEnabled
1428614286

1428714287
#endregion
1428814288

14289+
#region Game bridge - send steering to vJoy
14290+
14291+
private bool _gameBridgeSendSteeringToVJoy = false;
14292+
14293+
public bool GameBridgeSendSteeringToVJoy
14294+
{
14295+
get => _gameBridgeSendSteeringToVJoy;
14296+
14297+
set
14298+
{
14299+
if ( value != _gameBridgeSendSteeringToVJoy )
14300+
{
14301+
_gameBridgeSendSteeringToVJoy = value;
14302+
14303+
// the steering test mode rides on top of the vJoy passthrough, so switching the passthrough
14304+
// off also switches the test mode off (its switch is disabled in the UI while this is off)
14305+
if ( !value )
14306+
{
14307+
GameBridgeSteeringTestEnabled = false;
14308+
}
14309+
14310+
OnPropertyChanged();
14311+
}
14312+
}
14313+
}
14314+
14315+
#endregion
14316+
14317+
#region Game bridge - steering test enabled
14318+
14319+
private bool _gameBridgeSteeringTestEnabled = false;
14320+
14321+
public bool GameBridgeSteeringTestEnabled
14322+
{
14323+
get => _gameBridgeSteeringTestEnabled;
14324+
14325+
set
14326+
{
14327+
if ( value != _gameBridgeSteeringTestEnabled )
14328+
{
14329+
_gameBridgeSteeringTestEnabled = value;
14330+
14331+
OnPropertyChanged();
14332+
}
14333+
}
14334+
}
14335+
14336+
#endregion
14337+
1428914338
#region App Manager - Enabled
1429014339

1429114340
private bool _appManagerEnabled = true;

MarvinsAIRARefactored/MarvinsAIRARefactored.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,10 @@
187187
<Resource Include="Artwork\Buttons\square-default.png" />
188188
<Resource Include="Artwork\Buttons\square-pressed.png" />
189189
<Resource Include="Artwork\Buttons\steering-wheel-90-left.png" />
190+
<Resource Include="Artwork\Buttons\steering-wheel-90-right.png" />
190191
<Resource Include="Artwork\Buttons\steering-wheel-center.png" />
192+
<Resource Include="Artwork\Buttons\steering-wheel-sweep.png" />
193+
<Resource Include="Artwork\Buttons\steering-wheel-sweep-orange.png" />
191194
<Resource Include="Artwork\Buttons\steering-wheel-left.png" />
192195
<Resource Include="Artwork\Buttons\steering-wheel-right.png" />
193196
<Resource Include="Artwork\Buttons\stop-calibration.png" />

0 commit comments

Comments
 (0)