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