diff --git a/Config/WheelConfig.cs b/Config/WheelConfig.cs
new file mode 100644
index 0000000..cfc497c
--- /dev/null
+++ b/Config/WheelConfig.cs
@@ -0,0 +1,86 @@
+using BepInEx.Configuration;
+
+namespace com.drowhunter.NewStarGPTelemetryMod
+{
+ ///
+ /// Holds all BepInEx ConfigEntry bindings for wheel / FFB integration.
+ /// All values are live-adjustable at runtime via the BepInEx config UI.
+ ///
+ internal class WheelConfig
+ {
+ // ── Wheel / General ──────────────────────────────────────────────────
+
+ public ConfigEntry Enabled { get; }
+ public ConfigEntry Backend { get; }
+ public ConfigEntry RequireX64 { get; }
+
+ // ── Wheel / FFB ──────────────────────────────────────────────────────
+
+ public ConfigEntry OverallStrength { get; }
+ public ConfigEntry MaxTorque { get; }
+ public ConfigEntry BumpThreshold { get; }
+ public ConfigEntry BumpScale { get; }
+ public ConfigEntry BumpCooldownMs { get; }
+ public ConfigEntry CForceDampingScale { get; }
+ public ConfigEntry SmoothingAlpha { get; }
+
+ // ── Wheel / Input ────────────────────────────────────────────────────
+
+ public ConfigEntry SteerDeadzone { get; }
+ public ConfigEntry SteerSaturation { get; }
+
+ public WheelConfig(ConfigFile config)
+ {
+ // Wheel / General
+ Enabled = config.Bind(
+ "Wheel/General", "Enabled", false,
+ "Enable wheel integration (Moza FFB + ViGEm virtual gamepad). REQUIRES MOZA Pithouse software running!");
+
+ Backend = config.Bind(
+ "Wheel/General", "Backend", "Moza",
+ "Wheel backend to use. Supported: Moza.");
+
+ RequireX64 = config.Bind(
+ "Wheel/General", "RequireX64", true,
+ "Abort wheel init if the process is not 64-bit (required for Moza native DLLs).");
+
+ // Wheel / FFB
+ OverallStrength = config.Bind(
+ "Wheel/FFB", "OverallStrength", 1.0f,
+ new ConfigDescription("Overall FFB strength multiplier.", new AcceptableValueRange(0f, 2f)));
+
+ MaxTorque = config.Bind(
+ "Wheel/FFB", "MaxTorque", 1.0f,
+ new ConfigDescription("Maximum torque clamp (0..1 maps to DirectInput 0..10000).", new AcceptableValueRange(0f, 1f)));
+
+ BumpThreshold = config.Bind(
+ "Wheel/FFB", "BumpThreshold", 0.15f,
+ new ConfigDescription("Minimum normalised tire delta required to trigger a bump impulse.", new AcceptableValueRange(0f, 1f)));
+
+ BumpScale = config.Bind(
+ "Wheel/FFB", "BumpScale", 0.35f,
+ new ConfigDescription("Scales the lateral tire delta into a bump impulse magnitude.", new AcceptableValueRange(0f, 2f)));
+
+ BumpCooldownMs = config.Bind(
+ "Wheel/FFB", "BumpCooldownMs", 60,
+ new ConfigDescription("Minimum milliseconds between successive bump impulses.", new AcceptableValueRange(0, 500)));
+
+ CForceDampingScale = config.Bind(
+ "Wheel/FFB", "CForceDampingScale", 0.4f,
+ new ConfigDescription("Scales centripetal force into damper coefficient.", new AcceptableValueRange(0f, 2f)));
+
+ SmoothingAlpha = config.Bind(
+ "Wheel/FFB", "SmoothingAlpha", 0.25f,
+ new ConfigDescription("Exponential smoothing factor for FFB output (0 = no update, 1 = no smoothing).", new AcceptableValueRange(0f, 1f)));
+
+ // Wheel / Input
+ SteerDeadzone = config.Bind(
+ "Wheel/Input", "SteerDeadzone", 0.05f,
+ new ConfigDescription("Normalised steer axis deadzone (0..1).", new AcceptableValueRange(0f, 0.5f)));
+
+ SteerSaturation = config.Bind(
+ "Wheel/Input", "SteerSaturation", 1.0f,
+ new ConfigDescription("Normalised steer axis saturation point (0..1). Values above this map to ±1.", new AcceptableValueRange(0.1f, 1f)));
+ }
+ }
+}
diff --git a/Contracts/IVirtualGamepadOutput.cs b/Contracts/IVirtualGamepadOutput.cs
new file mode 100644
index 0000000..538603a
--- /dev/null
+++ b/Contracts/IVirtualGamepadOutput.cs
@@ -0,0 +1,17 @@
+namespace com.drowhunter.NewStarGPTelemetryMod
+{
+ ///
+ /// Writes virtual gamepad state to the OS (e.g. via ViGEm).
+ ///
+ internal interface IVirtualGamepadOutput
+ {
+ /// Initialise and connect the virtual gamepad. Returns true on success.
+ bool Initialize();
+
+ /// Submit a new gamepad state to the virtual device.
+ void Submit(VirtualPadState state);
+
+ /// Disconnect and release the virtual gamepad.
+ void Dispose();
+ }
+}
diff --git a/Contracts/IWheelFfbProvider.cs b/Contracts/IWheelFfbProvider.cs
new file mode 100644
index 0000000..9ff9de1
--- /dev/null
+++ b/Contracts/IWheelFfbProvider.cs
@@ -0,0 +1,17 @@
+namespace com.drowhunter.NewStarGPTelemetryMod
+{
+ ///
+ /// Sends force-feedback commands to the physical wheel.
+ ///
+ internal interface IWheelFfbProvider
+ {
+ /// Initialise the FFB provider. Returns true on success.
+ bool Initialize();
+
+ /// Apply the given FFB command to the wheel hardware.
+ void SendFfb(FfbCommand command);
+
+ /// Stop all active FFB effects and release resources.
+ void Dispose();
+ }
+}
diff --git a/Contracts/IWheelInputProvider.cs b/Contracts/IWheelInputProvider.cs
new file mode 100644
index 0000000..a071324
--- /dev/null
+++ b/Contracts/IWheelInputProvider.cs
@@ -0,0 +1,17 @@
+namespace com.drowhunter.NewStarGPTelemetryMod
+{
+ ///
+ /// Provides physical wheel input state each frame.
+ ///
+ internal interface IWheelInputProvider
+ {
+ /// Initialise the input provider. Returns true on success.
+ bool Initialize();
+
+ /// Poll and return the latest wheel input state.
+ WheelInputState Poll();
+
+ /// Release all resources held by this provider.
+ void Dispose();
+ }
+}
diff --git a/Directory.Build.targets b/Directory.Build.targets
index 9abcb86..0561a76 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -1,7 +1,8 @@
-
- Y:\SteamLibrary\steamapps\common\New Star GP\release\BepInEx\plugins\
+ $(APPDATA)\raicuparta\rai-pal\data\installed-mods\8148729079362356270\bepinex\BepInEx\plugins\
+
+
@@ -9,18 +10,32 @@
$(GameDir)$(TargetName)
+
+ $(MSBuildThisFileDirectory)libs\moza\x64\
+
+
+
+
+
+
+
+
+
+
-
+
+
+
diff --git a/Ffb/FfbModel.cs b/Ffb/FfbModel.cs
new file mode 100644
index 0000000..e98ab9b
--- /dev/null
+++ b/Ffb/FfbModel.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Diagnostics;
+using BepInEx.Logging;
+
+namespace com.drowhunter.NewStarGPTelemetryMod
+{
+ ///
+ /// Converts enhanced telemetry data into an each frame.
+ ///
+ /// Implemented effects (v1):
+ /// 1. CForce damper – centripetal force → lateral damper coefficient.
+ /// 2. Tire impulses – front left/right wheel delta → short ConstantForce burst.
+ /// 3. Global smoothing – exponential smoothing on damper coefficient.
+ /// 4. Threshold + cooldown on impulses.
+ ///
+ internal class FfbModel
+ {
+ private readonly WheelConfig _cfg;
+ private readonly ManualLogSource _log;
+
+ // Smoothing state
+ private float _smoothedDamper;
+
+ // Impulse cooldown: tracks elapsed ms since last impulse using a Stopwatch.
+ private readonly Stopwatch _impulseStopwatch = new Stopwatch();
+
+ public FfbModel(WheelConfig cfg, ManualLogSource log)
+ {
+ _cfg = cfg;
+ _log = log;
+ }
+
+ ///
+ /// Compute the FFB command for the current physics frame.
+ ///
+ public FfbCommand Compute(in NewStarTelemetryData data)
+ {
+ // ── 1. CForce-based damper ────────────────────────────────────────
+ // cForce is the centripetal acceleration (m/s² or g depending on extractor).
+ // We scale and clamp it to produce a lateral resistance coefficient.
+ float rawDamper = data.cForce * _cfg.CForceDampingScale.Value
+ * _cfg.OverallStrength.Value;
+ rawDamper = Clamp(rawDamper, -_cfg.MaxTorque.Value, _cfg.MaxTorque.Value);
+
+ // ── 3. Exponential smoothing ──────────────────────────────────────
+ float alpha = _cfg.SmoothingAlpha.Value;
+ _smoothedDamper = _smoothedDamper + alpha * (rawDamper - _smoothedDamper);
+
+ // ── 2. Tire impulse ───────────────────────────────────────────────
+ // Lateral impulse = FR delta minus FL delta → positive = right-side hit.
+ float lateralDelta = data.TireFR - data.TireFL;
+ float impulse = lateralDelta * _cfg.BumpScale.Value
+ * _cfg.OverallStrength.Value;
+ impulse = Clamp(impulse, -_cfg.MaxTorque.Value, _cfg.MaxTorque.Value);
+
+ bool hasImpulse = false;
+ float impulseMagnitude = 0f;
+
+ if (Math.Abs(impulse) >= _cfg.BumpThreshold.Value)
+ {
+ long elapsedMs = _impulseStopwatch.IsRunning
+ ? _impulseStopwatch.ElapsedMilliseconds
+ : long.MaxValue;
+
+ if (elapsedMs >= _cfg.BumpCooldownMs.Value)
+ {
+ hasImpulse = true;
+ impulseMagnitude = impulse;
+ _impulseStopwatch.Restart();
+ }
+ }
+
+ return new FfbCommand
+ {
+ DamperCoefficient = _smoothedDamper,
+ HasImpulse = hasImpulse,
+ ImpulseMagnitude = impulseMagnitude,
+ };
+ }
+
+ private static float Clamp(float value, float min, float max)
+ => value < min ? min : value > max ? max : value;
+ }
+}
diff --git a/Models/FfbCommand.cs b/Models/FfbCommand.cs
new file mode 100644
index 0000000..b0f8fd2
--- /dev/null
+++ b/Models/FfbCommand.cs
@@ -0,0 +1,27 @@
+namespace com.drowhunter.NewStarGPTelemetryMod
+{
+ ///
+ /// The computed force-feedback command for one physics frame.
+ ///
+ internal struct FfbCommand
+ {
+ ///
+ /// Lateral damper coefficient derived from centripetal force. Range -1..1.
+ /// Positive values push the wheel right; negative push left.
+ /// Sent to the ETDamper effect as positive/negative coefficient.
+ ///
+ public float DamperCoefficient;
+
+ ///
+ /// Whether a short bump impulse should fire this frame.
+ ///
+ public bool HasImpulse;
+
+ ///
+ /// Magnitude of the bump impulse. Range -1..1.
+ /// Positive = right-side bump, negative = left-side bump.
+ /// Sent to the ETConstantForce effect.
+ ///
+ public float ImpulseMagnitude;
+ }
+}
diff --git a/Models/VirtualPadState.cs b/Models/VirtualPadState.cs
new file mode 100644
index 0000000..ca3c4d4
--- /dev/null
+++ b/Models/VirtualPadState.cs
@@ -0,0 +1,29 @@
+namespace com.drowhunter.NewStarGPTelemetryMod
+{
+ ///
+ /// Represents the desired state of the virtual Xbox 360 gamepad to be submitted to ViGEm.
+ ///
+ internal struct VirtualPadState
+ {
+ /// Left stick X axis: -1..1 (left/right). Mapped from wheel steer angle.
+ public float LeftStickX;
+
+ /// Left stick Y axis: -1..1 (down/up). Reserved for future use.
+ public float LeftStickY;
+
+ /// Right stick X axis: -1..1. Reserved for future use.
+ public float RightStickX;
+
+ /// Right stick Y axis: -1..1. Reserved for future use.
+ public float RightStickY;
+
+ /// Left trigger (gas/throttle): 0..1.
+ public float LeftTrigger;
+
+ /// Right trigger (brake): 0..1.
+ public float RightTrigger;
+
+ /// Xbox button bitmask. See Nefarius.ViGEm.Client.Targets.Xbox360.Xbox360Button.
+ public ushort Buttons;
+ }
+}
diff --git a/Models/WheelInputState.cs b/Models/WheelInputState.cs
new file mode 100644
index 0000000..f937c43
--- /dev/null
+++ b/Models/WheelInputState.cs
@@ -0,0 +1,26 @@
+namespace com.drowhunter.NewStarGPTelemetryMod
+{
+ ///
+ /// Snapshot of the physical wheel's current input state, normalised to -1..1 or 0..1 where applicable.
+ ///
+ internal struct WheelInputState
+ {
+ /// Steering angle normalised to -1..1 (left to right).
+ public float SteerAngle;
+
+ /// Steering velocity in normalised units per second.
+ public float SteerVelocity;
+
+ /// Throttle axis 0..1 (0 = released, 1 = fully pressed).
+ public float Throttle;
+
+ /// Brake axis 0..1.
+ public float Brake;
+
+ /// Clutch axis 0..1.
+ public float Clutch;
+
+ /// Raw 128-element button state array from the HID report. May be null if no data available.
+ public bool[] Buttons;
+ }
+}
diff --git a/Moza/MozaSdkAdapter.cs b/Moza/MozaSdkAdapter.cs
new file mode 100644
index 0000000..df2a5ae
--- /dev/null
+++ b/Moza/MozaSdkAdapter.cs
@@ -0,0 +1,229 @@
+using System;
+using BepInEx.Logging;
+using mozaAPI;
+
+namespace com.drowhunter.NewStarGPTelemetryMod
+{
+ ///
+ /// Single boundary around all MOZA SDK C# calls.
+ ///
+ /// Design notes:
+ /// - installMozaSDK() connects to the MOZA Pithouz background process.
+ /// If Pithouz is not running, subsequent calls will fail gracefully.
+ /// - createWheelbaseET*(IntPtr hwndId, out ERRORCODE err) takes the
+ /// game window handle. Passing works when the SDK
+ /// acquires the DirectInput device independently.
+ /// - The returned effect objects are typed as dynamic because the exact
+ /// C# wrapper class names cannot be confirmed without a full decompile; the
+ /// runtime types are resolved from MOZA_API_CSharp.dll at execution time.
+ /// - getHIDData(out ERRORCODE err) is global (not per-device); the SDK
+ /// aggregates input from all connected MOZA devices.
+ /// - Native DLLs MOZA_API_C.dll and MOZA_SDK.dll must be present
+ /// in the game root directory (on the OS DLL search path).
+ ///
+ internal class MozaSdkAdapter : IDisposable
+ {
+ private readonly ManualLogSource _log;
+ private bool _initialised;
+ private bool _disposed;
+
+ // DirectInput "nominal max" constant: force values are in the range ±10000.
+ public const int DI_FF_NOMINAL_MAX = 10000;
+
+ public MozaSdkAdapter(ManualLogSource log)
+ {
+ _log = log;
+ }
+
+ ///
+ /// Load the MOZA SDK. Returns true if successful.
+ ///
+ public bool Install()
+ {
+ try
+ {
+ mozaAPI.mozaAPI.installMozaSDK();
+ _initialised = true;
+ _log.LogInfo("[MozaSdkAdapter] installMozaSDK succeeded.");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning($"[MozaSdkAdapter] installMozaSDK failed: {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Check if any MOZA devices are connected and responding.
+ /// Must be called AFTER Install() succeeds.
+ ///
+ public bool IsDeviceConnected()
+ {
+ if (!_initialised)
+ {
+ _log.LogWarning("[MozaSdkAdapter] IsDeviceConnected called before Install()");
+ return false;
+ }
+
+ try
+ {
+ // Try to read HID data to verify device presence
+ ERRORCODE err = ERRORCODE.NORMAL;
+ var data = mozaAPI.mozaAPI.getHIDData(ref err);
+
+ if (err == ERRORCODE.NORMAL)
+ {
+ _log.LogInfo("[MozaSdkAdapter] Device detected and responding.");
+ return true;
+ }
+
+ // COLLECTIONCYCLEDATALOSS typically means no device connected
+ if (err == ERRORCODE.COLLECTIONCYCLEDATALOSS)
+ {
+ _log.LogInfo("[MozaSdkAdapter] No MOZA device connected (COLLECTIONCYCLEDATALOSS).");
+ }
+ else
+ {
+ _log.LogWarning($"[MozaSdkAdapter] Device check failed. Error: {err}");
+ }
+
+ return false;
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning($"[MozaSdkAdapter] Device detection failed: {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Poll the global HID data from all connected MOZA devices.
+ /// Returns null if the SDK is not initialised or an error occurs.
+ ///
+ public HIDData? GetHIDData()
+ {
+ if (!_initialised) return null;
+ try
+ {
+ ERRORCODE err = ERRORCODE.NORMAL;
+ var data = mozaAPI.mozaAPI.getHIDData(ref err);
+ if (err != ERRORCODE.NORMAL)
+ {
+ _log.LogDebug($"[MozaSdkAdapter] getHIDData error: {err}");
+ return null;
+ }
+ return data;
+ }
+ catch (Exception ex)
+ {
+ _log.LogDebug($"[MozaSdkAdapter] getHIDData exception: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Create a continuous ETDamper effect. Returns the effect object or null on failure.
+ ///
+ public dynamic CreateDamper(IntPtr hwnd)
+ {
+ if (!_initialised) return null;
+ try
+ {
+ ERRORCODE err = ERRORCODE.NORMAL;
+ var effect = mozaAPI.mozaAPI.createWheelbaseETDamper(hwnd, ref err);
+ if (err != ERRORCODE.NORMAL)
+ {
+ _log.LogWarning($"[MozaSdkAdapter] createWheelbaseETDamper error: {err}");
+ return null;
+ }
+ _log.LogInfo("[MozaSdkAdapter] ETDamper effect created.");
+ return effect;
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning($"[MozaSdkAdapter] createWheelbaseETDamper failed: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Create a short-burst ETConstantForce effect for impulse feedback.
+ /// Returns the effect object or null on failure.
+ ///
+ public dynamic CreateConstantForce(IntPtr hwnd)
+ {
+ if (!_initialised) return null;
+ try
+ {
+ ERRORCODE err = ERRORCODE.NORMAL;
+ var effect = mozaAPI.mozaAPI.createWheelbaseETConstantForce(hwnd, ref err);
+ if (err != ERRORCODE.NORMAL)
+ {
+ _log.LogWarning($"[MozaSdkAdapter] createWheelbaseETConstantForce error: {err}");
+ return null;
+ }
+ _log.LogInfo("[MozaSdkAdapter] ETConstantForce effect created.");
+ return effect;
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning($"[MozaSdkAdapter] createWheelbaseETConstantForce failed: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Stop all active FFB effects on the wheelbase.
+ ///
+ public void StopAllFfb()
+ {
+ if (!_initialised) return;
+ try
+ {
+ mozaAPI.mozaAPI.stopForceFeedback();
+ }
+ catch (Exception ex)
+ {
+ _log.LogDebug($"[MozaSdkAdapter] stopForceFeedback exception: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Center the wheel (reset to zero position).
+ ///
+ public void CenterWheel()
+ {
+ if (!_initialised) return;
+ try
+ {
+ mozaAPI.mozaAPI.CenterWheel();
+ }
+ catch (Exception ex)
+ {
+ _log.LogDebug($"[MozaSdkAdapter] CenterWheel exception: {ex.Message}");
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ if (_initialised)
+ {
+ StopAllFfb();
+ try
+ {
+ mozaAPI.mozaAPI.removeMozaSDK();
+ _log.LogInfo("[MozaSdkAdapter] removeMozaSDK called.");
+ }
+ catch (Exception ex)
+ {
+ _log.LogDebug($"[MozaSdkAdapter] removeMozaSDK exception: {ex.Message}");
+ }
+ _initialised = false;
+ }
+ }
+ }
+}
diff --git a/Moza/MozaWheelFfbProvider.cs b/Moza/MozaWheelFfbProvider.cs
new file mode 100644
index 0000000..c6c57f2
--- /dev/null
+++ b/Moza/MozaWheelFfbProvider.cs
@@ -0,0 +1,127 @@
+using System;
+using BepInEx.Logging;
+
+namespace com.drowhunter.NewStarGPTelemetryMod
+{
+ ///
+ /// implementation using the MOZA SDK.
+ ///
+ /// Manages two persistent DirectInput effects:
+ /// - _damper (ETDamper) – continuous lateral resistance from cForce.
+ /// - _impulse (ETConstantForce) – short bump burst from tire deltas.
+ ///
+ /// DirectInput force range: ±10000 (DI_FFNOMINALMAX).
+ /// Our values are in ±1 and are scaled to ±10000 here.
+ ///
+ internal class MozaWheelFfbProvider : IWheelFfbProvider
+ {
+ private readonly MozaSdkAdapter _sdk;
+ private readonly ManualLogSource _log;
+
+ // Effect objects returned by the Moza SDK (runtime-typed via dynamic).
+ private dynamic _damper;
+ private dynamic _impulse;
+
+ // Duration of bump impulse in milliseconds.
+ private const int ImpulseDurationMs = 80;
+
+ public MozaWheelFfbProvider(MozaSdkAdapter sdk, ManualLogSource log)
+ {
+ _sdk = sdk;
+ _log = log;
+ }
+
+ public bool Initialize()
+ {
+ // Pass IntPtr.Zero: the SDK acquires the DirectInput device independently.
+ _damper = _sdk.CreateDamper(IntPtr.Zero);
+ if (_damper == null)
+ {
+ _log.LogWarning("[MozaWheelFfbProvider] Failed to create ETDamper effect.");
+ return false;
+ }
+
+ _impulse = _sdk.CreateConstantForce(IntPtr.Zero);
+ if (_impulse == null)
+ {
+ _log.LogWarning("[MozaWheelFfbProvider] Failed to create ETConstantForce effect.");
+ return false;
+ }
+
+ // Start both effects in a "zero" state so they are acquired by DirectInput.
+ try
+ {
+ _damper.setPositiveCoefficient(0);
+ _damper.setNegativeCoefficient(0);
+ _damper.start();
+
+ _impulse.setMagnitude(0);
+ _impulse.setDuration(ImpulseDurationMs);
+ // Do not start impulse yet – fired on demand.
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning($"[MozaWheelFfbProvider] Effect init failed: {ex.Message}");
+ return false;
+ }
+
+ _log.LogInfo("[MozaWheelFfbProvider] FFB effects initialised.");
+ return true;
+ }
+
+ public void SendFfb(FfbCommand cmd)
+ {
+ UpdateDamper(cmd.DamperCoefficient);
+ if (cmd.HasImpulse)
+ FireImpulse(cmd.ImpulseMagnitude);
+ }
+
+ private void UpdateDamper(float coefficient)
+ {
+ if (_damper == null) return;
+ try
+ {
+ // coefficient > 0 → resistance to right movement (positive coeff)
+ // coefficient < 0 → resistance to left movement (negative coeff)
+ int scaledPos = (int)(Math.Max(0f, coefficient) * MozaSdkAdapter.DI_FF_NOMINAL_MAX);
+ int scaledNeg = (int)(Math.Max(0f, -coefficient) * MozaSdkAdapter.DI_FF_NOMINAL_MAX);
+
+ _damper.setPositiveCoefficient(scaledPos);
+ _damper.setNegativeCoefficient(scaledNeg);
+ }
+ catch (Exception ex)
+ {
+ _log.LogDebug($"[MozaWheelFfbProvider] UpdateDamper failed: {ex.Message}");
+ }
+ }
+
+ private void FireImpulse(float magnitude)
+ {
+ if (_impulse == null) return;
+ try
+ {
+ int scaled = (int)(magnitude * MozaSdkAdapter.DI_FF_NOMINAL_MAX);
+ _impulse.setMagnitude(scaled);
+ _impulse.start();
+ }
+ catch (Exception ex)
+ {
+ _log.LogDebug($"[MozaWheelFfbProvider] FireImpulse failed: {ex.Message}");
+ }
+ }
+
+ public void Dispose()
+ {
+ StopEffects();
+ _damper = null;
+ _impulse = null;
+ }
+
+ private void StopEffects()
+ {
+ try { _damper?.stop(); } catch { }
+ try { _impulse?.stop(); } catch { }
+ _sdk.StopAllFfb();
+ }
+ }
+}
diff --git a/Moza/MozaWheelInputProvider.cs b/Moza/MozaWheelInputProvider.cs
new file mode 100644
index 0000000..da34a23
--- /dev/null
+++ b/Moza/MozaWheelInputProvider.cs
@@ -0,0 +1,109 @@
+using System;
+using BepInEx.Logging;
+using mozaAPI;
+
+namespace com.drowhunter.NewStarGPTelemetryMod
+{
+ ///
+ /// implementation using the MOZA SDK.
+ ///
+ /// Calls getHIDData each frame and converts the raw HID report into
+ /// a normalised .
+ ///
+ /// Pedal axes in HIDData use int16 with 0x8000 (-32768) as the rest position
+ /// and 0x7FFF (32767) as fully pressed. These are linearly mapped to 0..1.
+ ///
+ /// Threading: getHIDData is expected to be non-blocking (global read of
+ /// last-received HID report). If latency issues arise, move to a background
+ /// thread and guard with a lock.
+ ///
+ internal class MozaWheelInputProvider : IWheelInputProvider
+ {
+ private readonly MozaSdkAdapter _sdk;
+ private readonly WheelConfig _cfg;
+ private readonly ManualLogSource _log;
+
+ // Maximum steering angle the wheel can physically rotate (degrees).
+ // Used to normalise fSteeringWheelAngle to -1..1.
+ private const float MaxSteerDegrees = 540f;
+
+ public MozaWheelInputProvider(MozaSdkAdapter sdk, WheelConfig cfg, ManualLogSource log)
+ {
+ _sdk = sdk;
+ _cfg = cfg;
+ _log = log;
+ }
+
+ public bool Initialize()
+ {
+ // The SDK itself is initialised by MozaSdkAdapter.Install() before this is called.
+ _log.LogInfo("[MozaWheelInputProvider] Initialised.");
+ return true;
+ }
+
+ public WheelInputState Poll()
+ {
+ HIDData? result = _sdk.GetHIDData();
+
+ if (result == null)
+ return default;
+
+ var raw = result.Value;
+ float steerNorm = NormaliseSteer(raw.fSteeringWheelAngle);
+ steerNorm = ApplyDeadzoneSaturation(steerNorm,
+ _cfg.SteerDeadzone.Value,
+ _cfg.SteerSaturation.Value);
+
+ return new WheelInputState
+ {
+ SteerAngle = steerNorm,
+ SteerVelocity = float.IsNaN(raw.fSteeringWheelVelocity) ? 0f : raw.fSteeringWheelVelocity / MaxSteerDegrees,
+ Throttle = Int16ToAxis(raw.throttle),
+ Brake = Int16ToAxis(raw.brake),
+ Clutch = Int16ToAxis(raw.clutch),
+ Buttons = null, // HIDButton array not yet mapped
+ };
+ }
+
+ public void Dispose()
+ {
+ // Resources owned by MozaSdkAdapter; nothing to release here.
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────
+
+ private static float NormaliseSteer(float angleDegrees)
+ {
+ if (float.IsNaN(angleDegrees)) return 0f;
+ return Math.Max(-1f, Math.Min(1f, angleDegrees / MaxSteerDegrees));
+ }
+
+ ///
+ /// Applies deadzone removal and saturation rescaling to a normalised axis value.
+ ///
+ private static float ApplyDeadzoneSaturation(float value, float deadzone, float saturation)
+ {
+ float absVal = Math.Abs(value);
+
+ if (absVal < deadzone) return 0f;
+
+ float sign = value < 0f ? -1f : 1f;
+
+ // Rescale from [deadzone..saturation] to [0..1]
+ float range = saturation - deadzone;
+ if (range <= 0f) return sign;
+ float scaled = Math.Min(1f, (absVal - deadzone) / range);
+ return sign * scaled;
+ }
+
+ ///
+ /// Converts an int16 pedal axis (rest = 0x8000 = -32768) to 0..1.
+ ///
+ private static float Int16ToAxis(short raw)
+ {
+ // 0x8000 (-32768) = rest/released; 0x7FFF (32767) = fully pressed.
+ int shifted = (int)raw - short.MinValue; // 0..65535
+ return Math.Max(0f, Math.Min(1f, shifted / 65535f));
+ }
+ }
+}
diff --git a/NativeDllLoader.cs b/NativeDllLoader.cs
new file mode 100644
index 0000000..b1fed59
--- /dev/null
+++ b/NativeDllLoader.cs
@@ -0,0 +1,107 @@
+using System;
+using System.IO;
+using System.Reflection;
+using System.Runtime.InteropServices;
+
+namespace com.drowhunter.NewStarGPTelemetryMod
+{
+ ///
+ /// Module initializer that loads native DLLs BEFORE any other code in this assembly runs.
+ /// This uses a static constructor with no dependencies to ensure earliest possible execution.
+ ///
+ internal static class NativeDllLoader
+ {
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern bool SetDllDirectory(string lpPathName);
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern IntPtr LoadLibrary(string lpFileName);
+
+ private static bool _loaded = false;
+ public static bool IsLoaded => _loaded;
+ public static string LoadError { get; private set; }
+
+ // Static constructor - runs when this class is FIRST referenced
+ // Place a call to EnsureLoaded() at the very top of NewStarTelemetryPlugin.Awake()
+ static NativeDllLoader()
+ {
+ LoadNativeDlls();
+ }
+
+ ///
+ /// Explicitly loads the native DLLs. Call this before ANY code that uses mozaAPI.
+ ///
+ public static void EnsureLoaded()
+ {
+ // Static constructor already ran when this method was called
+ // This method just provides an explicit entry point
+ }
+
+ private static void LoadNativeDlls()
+ {
+ try
+ {
+ // Detect whether the game process is 32-bit or 64-bit
+ var is64Bit = Environment.Is64BitProcess;
+ var archFolder = is64Bit ? "x64" : "x86";
+ var archDesc = is64Bit ? "64-bit (x64)" : "32-bit (x86)";
+
+ var assemblyLocation = Assembly.GetExecutingAssembly().Location;
+ var pluginDir = Path.GetDirectoryName(assemblyLocation);
+ var nativeDir = Path.Combine(pluginDir, archFolder);
+
+ if (!Directory.Exists(nativeDir))
+ {
+ LoadError = $"Process is {archDesc} but {archFolder} directory not found at: {nativeDir}";
+ return;
+ }
+
+ // Set the DLL search path
+ if (!SetDllDirectory(nativeDir))
+ {
+ var error = Marshal.GetLastWin32Error();
+ LoadError = $"Process is {archDesc}. SetDllDirectory({archFolder}) failed with error code: {error}";
+ return;
+ }
+
+ // Explicitly preload native DLLs in dependency order
+ var mozaSdkPath = Path.Combine(nativeDir, "MOZA_SDK.dll");
+ if (!File.Exists(mozaSdkPath))
+ {
+ LoadError = $"Process is {archDesc}. MOZA_SDK.dll not found at: {mozaSdkPath}";
+ return;
+ }
+
+ var handleSdk = LoadLibrary(mozaSdkPath);
+ if (handleSdk == IntPtr.Zero)
+ {
+ var error = Marshal.GetLastWin32Error();
+ LoadError = $"Process is {archDesc}. LoadLibrary({archFolder}/MOZA_SDK.dll) failed with error code: {error}";
+ return;
+ }
+
+ var mozaApiCPath = Path.Combine(nativeDir, "MOZA_API_C.dll");
+ if (!File.Exists(mozaApiCPath))
+ {
+ LoadError = $"Process is {archDesc}. MOZA_API_C.dll not found at: {mozaApiCPath}";
+ return;
+ }
+
+ var handleApiC = LoadLibrary(mozaApiCPath);
+ if (handleApiC == IntPtr.Zero)
+ {
+ var error = Marshal.GetLastWin32Error();
+ LoadError = $"Process is {archDesc}. LoadLibrary({archFolder}/MOZA_API_C.dll) failed with error code: {error}";
+ return;
+ }
+
+ _loaded = true;
+ LoadError = $"SUCCESS: Process is {archDesc}. Loaded native DLLs from {archFolder} folder.";
+ }
+ catch (Exception ex)
+ {
+ LoadError = $"Exception during native DLL loading: {ex.Message}";
+ }
+ }
+ }
+}
diff --git a/NewStarGPTelemetryMod.csproj b/NewStarGPTelemetryMod.csproj
index 1c97993..0dcfa05 100644
--- a/NewStarGPTelemetryMod.csproj
+++ b/NewStarGPTelemetryMod.csproj
@@ -1,10 +1,10 @@
- net46
+ net48
NewStarGPTelemetryMod
Newstar Telemetry Plugin
- 1.0.2
+ 2.0.0
true
latest
@@ -31,9 +31,8 @@
-
-
-
+
+
@@ -48,12 +47,43 @@
-
+
+
+
+ libs\moza\x64\MOZA_API_CSharp.dll
+ true
+
+
+ libs\vigem\Nefarius.ViGEm.Client.dll
+
-
-
-
-
-
+
+
+
+
+
+
+ PreserveNewest
+ x64\MOZA_SDK.dll
+
+
+ PreserveNewest
+ x64\MOZA_API_C.dll
+
+
+
+
+ PreserveNewest
+ x86\MOZA_SDK.dll
+
+
+ PreserveNewest
+ x86\MOZA_API_C.dll
+
+
+
+
+
+
diff --git a/NewStarTelemetryPlugin.cs b/NewStarTelemetryPlugin.cs
index 9e43d46..4fa67c8 100644
--- a/NewStarTelemetryPlugin.cs
+++ b/NewStarTelemetryPlugin.cs
@@ -2,8 +2,12 @@
using BepInEx.Configuration;
using BepInEx.Logging;
using com.drowhunter.TelemetryLib;
+using System;
+using System.IO;
using System.Linq;
using System.Net;
+using System.Reflection;
+using System.Runtime.InteropServices;
using TelemetryLib;
@@ -24,12 +28,15 @@ public class NewStarTelemetryPlugin : BaseUnityPlugin
internal NewStarTelemetryData data;
- UdpTelemetry _udp;
+ UdpTelemetry _dataOut;
RacingContextManager _racingContextManager;
private ConfigEntry Port;
+ WheelConfig _wheelConfig;
+ WheelIntegrationRuntime _wheelRuntime;
+
CarControl _carControl
{
get
@@ -53,7 +60,7 @@ CarControl _carControl
// {
// paused = __instance.paused;
-
+
// }
//}
@@ -61,6 +68,10 @@ CarControl _carControl
private void Awake()
{
+ // CRITICAL: Load native DLLs BEFORE any other code runs
+ // This must be the FIRST line to ensure DLLs are loaded before any reference to mozaAPI
+ NativeDllLoader.EnsureLoaded();
+
//Harmony.CreateAndPatchAll(typeof(NewStarTelemetryPlugin));
//var harmony = new Harmony("com.drowhunter.NewStarTelemetryPlugin");
//harmony.PatchAll();
@@ -69,15 +80,62 @@ private void Awake()
// Plugin startup logic
Logger = base.Logger;
+ // Report process architecture and native DLL loading status
+ var processArch = Environment.Is64BitProcess ? "64-bit (x64)" : "32-bit (x86)";
+ Logger.LogInfo($"[MozaNative] *** PROCESS ARCHITECTURE: {processArch} ***");
+
+ // NativeDllLoader.LoadError contains the detailed status message
+ if (NativeDllLoader.IsLoaded)
+ {
+ Logger.LogInfo($"[MozaNative] {NativeDllLoader.LoadError}");
+ }
+ else
+ {
+ Logger.LogError($"[MozaNative] FAILED: {NativeDllLoader.LoadError}");
+ }
+
+ // Verify both architecture folders exist (for debugging)
+ var pluginDir = Path.GetDirectoryName(Info.Location);
+ var x86Dir = Path.Combine(pluginDir, "x86");
+ var x64Dir = Path.Combine(pluginDir, "x64");
+ Logger.LogInfo($"[MozaNative] x86 folder exists: {Directory.Exists(x86Dir)}");
+ Logger.LogInfo($"[MozaNative] x64 folder exists: {Directory.Exists(x64Dir)}");
+
- Port = Config.Bind("Telemetry", "UDP Port", 12345, "Port to Send Telemetry");
+ Port = Config.Bind("Telemetry", "UDP Port", 12345, "Port to send telemetry data on.");
- _udp = new UdpTelemetry(new UdpTelemetryConfig
+ _dataOut = new UdpTelemetry(new UdpTelemetryConfig
{
SendAddress = new IPEndPoint(IPAddress.Loopback, Port.Value)
}, new MarshalByteConverter());
+
+
Logger.LogInfo($"Plugin {MyPluginInfo.PLUGIN_GUID} is loaded!");
+
+ _wheelConfig = new WheelConfig(Config);
+ Logger.LogInfo($"[Startup] WheelConfig created. Enabled={_wheelConfig.Enabled.Value}");
+
+ if (_wheelConfig.Enabled.Value)
+ {
+ try
+ {
+ Logger.LogInfo("[Startup] Creating WheelIntegrationRuntime...");
+ _wheelRuntime = new WheelIntegrationRuntime(Logger);
+
+ Logger.LogInfo("[Startup] Calling Initialize...");
+ _wheelRuntime.Initialize(_wheelConfig);
+
+ Logger.LogInfo("[Startup] Initialize completed successfully.");
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError($"[Startup] FATAL: Wheel integration crashed during init: {ex}");
+ _wheelRuntime = null;
+ }
+ }
+
+ Logger.LogInfo("[Startup] Awake() completed.");
}
@@ -180,9 +238,17 @@ private void FixedUpdate()
};
- _udp.Send(data);
+ _dataOut.Send(data);
+
+ _wheelRuntime?.Update(in data);
}
+
+ private void OnDestroy()
+ {
+ _wheelRuntime?.Dispose();
+ _wheelRuntime = null;
+ }
}
public class Delta : MonoBehaviour
diff --git a/Output/VigemXboxGamepadOutput.cs b/Output/VigemXboxGamepadOutput.cs
new file mode 100644
index 0000000..74ed3d0
--- /dev/null
+++ b/Output/VigemXboxGamepadOutput.cs
@@ -0,0 +1,91 @@
+using System;
+using BepInEx.Logging;
+using Nefarius.ViGEm.Client;
+using Nefarius.ViGEm.Client.Targets;
+using Nefarius.ViGEm.Client.Targets.Xbox360;
+
+namespace com.drowhunter.NewStarGPTelemetryMod
+{
+ ///
+ /// implementation backed by ViGEm Bus.
+ ///
+ /// Maps fields to a virtual Xbox 360 controller:
+ /// - LeftStickX → left thumb X (steer)
+ /// - LeftTrigger → left trigger (throttle/gas)
+ /// - RightTrigger → right trigger (brake)
+ ///
+ /// Requires ViGEm Bus driver to be installed on the host PC.
+ ///
+ internal class VigemXboxGamepadOutput : IVirtualGamepadOutput
+ {
+ private readonly ManualLogSource _log;
+ private ViGEmClient _client;
+ private IXbox360Controller _controller;
+
+ public VigemXboxGamepadOutput(ManualLogSource log)
+ {
+ _log = log;
+ }
+
+ public bool Initialize()
+ {
+ try
+ {
+ _client = new ViGEmClient();
+ _controller = _client.CreateXbox360Controller();
+ _controller.Connect();
+ _log.LogInfo("[VigemXboxGamepadOutput] Virtual Xbox 360 controller connected.");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning($"[VigemXboxGamepadOutput] Init failed: {ex.Message}");
+ _client = null;
+ _controller = null;
+ return false;
+ }
+ }
+
+ public void Submit(VirtualPadState state)
+ {
+ if (_controller == null) return;
+ try
+ {
+ _controller.SetAxisValue(Xbox360Axis.LeftThumbX, NormToShort(state.LeftStickX));
+ _controller.SetAxisValue(Xbox360Axis.LeftThumbY, NormToShort(state.LeftStickY));
+ _controller.SetAxisValue(Xbox360Axis.RightThumbX, NormToShort(state.RightStickX));
+ _controller.SetAxisValue(Xbox360Axis.RightThumbY, NormToShort(state.RightStickY));
+
+ _controller.SetSliderValue(Xbox360Slider.LeftTrigger, NormToByte(state.LeftTrigger));
+ _controller.SetSliderValue(Xbox360Slider.RightTrigger, NormToByte(state.RightTrigger));
+
+ _controller.SubmitReport();
+ }
+ catch (Exception ex)
+ {
+ _log.LogDebug($"[VigemXboxGamepadOutput] Submit failed: {ex.Message}");
+ }
+ }
+
+ public void Dispose()
+ {
+ try { _controller?.Disconnect(); } catch { }
+ try { _client?.Dispose(); } catch { }
+ _controller = null;
+ _client = null;
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────
+
+ private static short NormToShort(float value)
+ {
+ float clamped = value < -1f ? -1f : value > 1f ? 1f : value;
+ // Map -1..0 → -32768..0, 0..1 → 0..32767 to use the full short range.
+ float scaled = clamped < 0f ? clamped * 32768f : clamped * 32767f;
+ return (short)scaled;
+ }
+
+ private static byte NormToByte(float value)
+ => (byte)Math.Max(0, Math.Min(255, (int)(value * 255f)));
+ }
+}
diff --git a/README.md b/README.md
index db141a6..636f5d3 100644
--- a/README.md
+++ b/README.md
@@ -47,4 +47,50 @@
v 1.0 First Release
v 1.0.2 Updated for new game version as of 2026-06-18
-v 1.0.3 Minor update . Refactor to use com.dowhunter.TelemetryLib
\ No newline at end of file
+v 1.0.3 Minor update . Refactor to use com.dowhunter.TelemetryLib
+
+---
+
+## Wheel / Force Feedback Integration
+
+### Requirements
+
+- **MOZA wheel hardware** (Moza-first implementation)
+- **MOZA Pithouz** software running in the background (connects the SDK to the wheel)
+- **ViGEm Bus Driver** installed — [download here](https://github.com/nefarius/ViGEmBus/releases)
+
+### Native DLL Setup
+
+The MOZA C++ native DLLs must be on the Windows DLL search path at game launch.
+Copy both files from `libs/moza/x64/` into the **game root directory**
+(the folder that contains `NewStarGP.exe`):
+
+```
+New Star GP\
+ NewStarGP.exe
+ MOZA_API_C.dll ← copy from libs/moza/x64/
+ MOZA_SDK.dll ← copy from libs/moza/x64/
+```
+
+The managed C# wrapper (`MOZA_API_CSharp.dll`) is copied automatically alongside the plugin DLL into `BepInEx/plugins/NewStarGPTelemetryMod/` during build.
+
+### Config
+
+All wheel / FFB settings are in `BepInEx/config/com.drowhunter.NewStarGPTelemetryMod.cfg`
+under the `Wheel/General`, `Wheel/FFB`, and `Wheel/Input` sections.
+They are live-adjustable at runtime via the BepInEx config UI.
+
+| Key | Default | Description |
+|---|---|---|
+| `Wheel/General / Enabled` | true | Master on/off switch |
+| `Wheel/General / Backend` | Moza | Wheel backend (only Moza supported in v1) |
+| `Wheel/General / RequireX64` | true | Abort if process is not 64-bit |
+| `Wheel/FFB / OverallStrength` | 1.0 | Global FFB multiplier (0..2) |
+| `Wheel/FFB / MaxTorque` | 1.0 | Torque clamp (0..1) |
+| `Wheel/FFB / BumpThreshold` | 0.15 | Min tire delta to trigger bump |
+| `Wheel/FFB / BumpScale` | 0.35 | Bump impulse magnitude scale |
+| `Wheel/FFB / BumpCooldownMs` | 60 | Minimum ms between bumps |
+| `Wheel/FFB / CForceDampingScale` | 0.4 | cForce → damper coefficient scale |
+| `Wheel/FFB / SmoothingAlpha` | 0.25 | Exponential smoothing (0=frozen, 1=raw) |
+| `Wheel/Input / SteerDeadzone` | 0.05 | Normalised steer deadzone |
+| `Wheel/Input / SteerSaturation` | 1.0 | Normalised steer saturation |
diff --git a/Runtime/TelemetryStateStore.cs b/Runtime/TelemetryStateStore.cs
new file mode 100644
index 0000000..2093daa
--- /dev/null
+++ b/Runtime/TelemetryStateStore.cs
@@ -0,0 +1,26 @@
+using System.Threading;
+
+namespace com.drowhunter.NewStarGPTelemetryMod
+{
+ ///
+ /// Thread-safe store for the latest enhanced telemetry snapshot.
+ /// Written from the Unity main thread (FixedUpdate) and readable from any thread.
+ ///
+ internal class TelemetryStateStore
+ {
+ private readonly object _lock = new object();
+ private NewStarTelemetryData _snapshot;
+
+ /// Gets the most recently published telemetry snapshot. Thread-safe.
+ public NewStarTelemetryData Latest
+ {
+ get { lock (_lock) { return _snapshot; } }
+ }
+
+ /// Publishes a new telemetry snapshot. Thread-safe.
+ public void Publish(NewStarTelemetryData data)
+ {
+ lock (_lock) { _snapshot = data; }
+ }
+ }
+}
diff --git a/Runtime/WheelIntegrationRuntime.cs b/Runtime/WheelIntegrationRuntime.cs
new file mode 100644
index 0000000..264618c
--- /dev/null
+++ b/Runtime/WheelIntegrationRuntime.cs
@@ -0,0 +1,184 @@
+using System;
+using BepInEx.Logging;
+
+namespace com.drowhunter.NewStarGPTelemetryMod
+{
+ ///
+ /// Orchestrates wheel input polling, FFB output, and virtual gamepad output
+ /// for the lifetime of the plugin.
+ ///
+ /// Lifecycle:
+ /// 1. – called once on plugin startup.
+ /// 2. – called every physics frame (FixedUpdate).
+ /// 3. – called on plugin unload / OnDestroy.
+ ///
+ /// Safety:
+ /// - Disabled automatically if init fails or if the process is not 64-bit
+ /// and RequireX64 is true.
+ /// - All provider exceptions are caught; a failure sets _disabled to
+ /// prevent further calls and protect plugin stability.
+ ///
+ internal class WheelIntegrationRuntime : IDisposable
+ {
+ private readonly ManualLogSource _log;
+
+ private WheelConfig _cfg;
+ private MozaSdkAdapter _mozaSdk;
+ private IWheelInputProvider _inputProvider;
+ private IWheelFfbProvider _ffbProvider;
+ private IVirtualGamepadOutput _padOutput;
+ private FfbModel _ffbModel;
+
+ private bool _initialised;
+ private bool _disabled;
+
+ public WheelIntegrationRuntime(ManualLogSource log)
+ {
+ _log = log;
+ }
+
+ ///
+ /// Initialise all providers using the given config.
+ /// Safe to call even if wheel hardware is absent — failures disable the
+ /// runtime without throwing.
+ ///
+ public void Initialize(WheelConfig cfg)
+ {
+ _log.LogInfo("[WheelIntegrationRuntime] *** Initialize() called ***");
+ _cfg = cfg;
+
+ // ── x64 guard ────────────────────────────────────────────────────
+ if (cfg.RequireX64.Value && !Environment.Is64BitProcess)
+ {
+ _log.LogWarning("[WheelIntegrationRuntime] Process is not x64. " +
+ "Wheel integration disabled (RequireX64 = true).");
+ _disabled = true;
+ return;
+ }
+
+ try
+ {
+ // ── MOZA SDK ─────────────────────────────────────────────────
+ _mozaSdk = new MozaSdkAdapter(_log);
+ if (!_mozaSdk.Install())
+ {
+ _log.LogWarning("[WheelIntegrationRuntime] MOZA SDK init failed. " +
+ "Ensure MOZA Pithouz is running and native DLLs are present.");
+ _disabled = true;
+ return;
+ }
+
+ // -- Device presence check ---------------------------------------------
+ if (!_mozaSdk.IsDeviceConnected())
+ {
+ _log.LogWarning("[WheelIntegrationRuntime] No MOZA device detected. " +
+ "Wheel integration disabled. Connect your wheel and restart the game.");
+ _disabled = true;
+ return;
+ }
+
+ // ── Input provider ────────────────────────────────────────────
+ _inputProvider = new MozaWheelInputProvider(_mozaSdk, cfg, _log);
+ if (!_inputProvider.Initialize())
+ {
+ _log.LogWarning("[WheelIntegrationRuntime] Wheel input provider failed to initialise.");
+ _disabled = true;
+ return;
+ }
+
+ // ── FFB model ─────────────────────────────────────────────────
+ _ffbModel = new FfbModel(cfg, _log);
+
+ // ── FFB provider ──────────────────────────────────────────────
+ _ffbProvider = new MozaWheelFfbProvider(_mozaSdk, _log);
+ if (!_ffbProvider.Initialize())
+ {
+ _log.LogWarning("[WheelIntegrationRuntime] FFB provider failed to initialise. " +
+ "Input mapping will continue without FFB.");
+ // Not fatal – pad output can still work.
+ }
+
+ // ── Virtual gamepad ───────────────────────────────────────────
+ _padOutput = new VigemXboxGamepadOutput(_log);
+ if (!_padOutput.Initialize())
+ {
+ _log.LogWarning("[WheelIntegrationRuntime] ViGEm gamepad output failed. " +
+ "Ensure ViGEm Bus driver is installed.");
+ // Not fatal – FFB can still work without virtual pad.
+ }
+
+ _initialised = true;
+ _log.LogInfo("[WheelIntegrationRuntime] Wheel integration ready.");
+ }
+ catch (Exception ex)
+ {
+ _log.LogError($"[WheelIntegrationRuntime] Unexpected init error: {ex}");
+ _disabled = true;
+ }
+ }
+
+ ///
+ /// Called every FixedUpdate with the latest telemetry snapshot.
+ ///
+ public void Update(in NewStarTelemetryData telemetry)
+ {
+ if (!_initialised || _disabled) return;
+
+ try
+ {
+ // 1. Poll wheel input
+ WheelInputState wheelInput = _inputProvider?.Poll() ?? default;
+
+ // 2. Build virtual pad state from wheel input
+ var padState = BuildPadState(in wheelInput);
+ _padOutput?.Submit(padState);
+
+ // 3. Compute FFB from telemetry
+ if (_ffbModel != null && _ffbProvider != null)
+ {
+ FfbCommand cmd = _ffbModel.Compute(in telemetry);
+ _ffbProvider.SendFfb(cmd);
+ }
+ }
+ catch (Exception ex)
+ {
+ _log.LogError($"[WheelIntegrationRuntime] Update error (disabling): {ex}");
+ _disabled = true;
+ }
+ }
+
+ public void Dispose()
+ {
+ _initialised = false;
+
+ try { _ffbProvider?.Dispose(); } catch (Exception ex) { _log.LogDebug($"FFB dispose: {ex.Message}"); }
+ try { _padOutput?.Dispose(); } catch (Exception ex) { _log.LogDebug($"Pad dispose: {ex.Message}"); }
+ try { _inputProvider?.Dispose(); } catch (Exception ex) { _log.LogDebug($"Input dispose: {ex.Message}"); }
+ try { _mozaSdk?.Dispose(); } catch (Exception ex) { _log.LogDebug($"SDK dispose: {ex.Message}"); }
+
+ _ffbProvider = null;
+ _padOutput = null;
+ _inputProvider = null;
+ _mozaSdk = null;
+ _ffbModel = null;
+
+ _log.LogInfo("[WheelIntegrationRuntime] Disposed.");
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────
+
+ private static VirtualPadState BuildPadState(in WheelInputState wheel)
+ {
+ return new VirtualPadState
+ {
+ LeftStickX = wheel.SteerAngle,
+ LeftStickY = 0f,
+ RightStickX = 0f,
+ RightStickY = 0f,
+ LeftTrigger = wheel.Throttle,
+ RightTrigger = wheel.Brake,
+ Buttons = 0,
+ };
+ }
+ }
+}
diff --git a/libs/vigem/Nefarius.ViGEm.Client.dll b/libs/vigem/Nefarius.ViGEm.Client.dll
new file mode 100644
index 0000000..690ef0d
Binary files /dev/null and b/libs/vigem/Nefarius.ViGEm.Client.dll differ