diff --git a/GrabbableSteeringWheel/GrabbableSteeringWheel.csproj b/GrabbableSteeringWheel/GrabbableSteeringWheel.csproj
new file mode 100644
index 0000000..9855f81
--- /dev/null
+++ b/GrabbableSteeringWheel/GrabbableSteeringWheel.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net48
+
+
+
+ ..\.ManagedLibs\BTKUILib.dll
+
+
+ ..\.ManagedLibs\TheClapper.dll
+
+
+
+
+
+
diff --git a/GrabbableSteeringWheel/GrabbableSteeringWheel/BoneVertexBoundsUtility.cs b/GrabbableSteeringWheel/GrabbableSteeringWheel/BoneVertexBoundsUtility.cs
new file mode 100644
index 0000000..2864f49
--- /dev/null
+++ b/GrabbableSteeringWheel/GrabbableSteeringWheel/BoneVertexBoundsUtility.cs
@@ -0,0 +1,401 @@
+using System.Collections;
+using Unity.Burst;
+using Unity.Collections;
+using Unity.Jobs;
+using Unity.Profiling;
+using UnityEngine;
+using UnityEngine.Animations;
+
+public class BoneVertexBoundsUtility : MonoBehaviour
+{
+ private static readonly ProfilerMarker s_calculateBoundsMarker = new("BoneVertexBounds.Calculate");
+ private static readonly ProfilerMarker s_processChildRenderersMarker = new("BoneVertexBounds.ProcessChildRenderers");
+ private static readonly ProfilerMarker s_processSkinnedMeshesMarker = new("BoneVertexBounds.ProcessSkinnedMeshes");
+ private static readonly ProfilerMarker s_processVerticesMarker = new("BoneVertexBounds.ProcessVertices");
+ private static readonly ProfilerMarker s_processConstraintsMarker = new("BoneVertexBounds.ProcessConstraints");
+ private static readonly ProfilerMarker s_jobExecutionMarker = new("BoneVertexBounds.JobExecution");
+ private static readonly ProfilerMarker s_meshCopyMarker = new("BoneVertexBounds.MeshCopy");
+
+ private static BoneVertexBoundsUtility instance;
+
+ private static BoneVertexBoundsUtility Instance
+ {
+ get
+ {
+ if (instance != null) return instance;
+ GameObject go = new("BoneVertexBoundsUtility");
+ instance = go.AddComponent();
+ DontDestroyOnLoad(go);
+ return instance;
+ }
+ }
+
+ [Flags]
+ public enum BoundsCalculationFlags
+ {
+ None = 0,
+ IncludeChildren = 1 << 0,
+ IncludeSkinnedMesh = 1 << 1,
+ IncludeConstraints = 1 << 2,
+ All = IncludeChildren | IncludeSkinnedMesh | IncludeConstraints
+ }
+
+ public struct BoundsResult
+ {
+ public bool IsValid;
+ public Bounds LocalBounds;
+ }
+
+ ///
+ /// Calculates the bounds of a transform based on:
+ /// - Children Renderers
+ /// - Skinned Mesh Weights
+ /// - Constrained Child Renderers & Skinned Mesh Weights
+ ///
+ public static void CalculateBoneWeightedBounds(Transform bone, float weightThreshold, BoundsCalculationFlags flags, Action onComplete)
+ => Instance.StartCoroutine(Instance.CalculateBoundsCoroutine(bone, weightThreshold, flags, onComplete));
+
+ private IEnumerator CalculateBoundsCoroutine(Transform bone, float weightThreshold, BoundsCalculationFlags flags, Action onComplete)
+ {
+ using (s_calculateBoundsMarker.Auto())
+ {
+ BoundsResult result = new();
+ var allWeightedPoints = new List();
+ bool hasValidPoints = false;
+
+ // Child renderers
+ IEnumerator ProcessChildRenderersLocal(Transform targetBone, Action> onChildPoints)
+ {
+ using (s_processChildRenderersMarker.Auto())
+ {
+ var points = new List();
+ var childRenderers = targetBone.GetComponentsInChildren()
+ .Where(r => r is not SkinnedMeshRenderer)
+ .ToArray();
+
+ foreach (Renderer childRend in childRenderers)
+ {
+ Bounds bounds = childRend.localBounds;
+ var corners = new Vector3[8];
+ Vector3 ext = bounds.extents;
+ Vector3 center = bounds.center;
+
+ corners[0] = new Vector3(center.x - ext.x, center.y - ext.y, center.z - ext.z);
+ corners[1] = new Vector3(center.x + ext.x, center.y - ext.y, center.z - ext.z);
+ corners[2] = new Vector3(center.x - ext.x, center.y + ext.y, center.z - ext.z);
+ corners[3] = new Vector3(center.x + ext.x, center.y + ext.y, center.z - ext.z);
+ corners[4] = new Vector3(center.x - ext.x, center.y - ext.y, center.z + ext.z);
+ corners[5] = new Vector3(center.x + ext.x, center.y - ext.y, center.z + ext.z);
+ corners[6] = new Vector3(center.x - ext.x, center.y + ext.y, center.z + ext.z);
+ corners[7] = new Vector3(center.x + ext.x, center.y + ext.y, center.z + ext.z);
+
+ for (int i = 0; i < 8; i++)
+ points.Add(targetBone.InverseTransformPoint(childRend.transform.TransformPoint(corners[i])));
+ }
+
+ onChildPoints?.Invoke(points);
+ }
+ yield break;
+ }
+
+ // Skinned mesh renderers
+ IEnumerator ProcessSkinnedMeshRenderersLocal(Transform targetBone, float threshold, Action> onSkinnedPoints)
+ {
+ using (s_processSkinnedMeshesMarker.Auto())
+ {
+ var points = new List();
+ var siblingAndParentSkinnedMesh = targetBone.root.GetComponentsInChildren();
+ var relevantMeshes = siblingAndParentSkinnedMesh.Where(smr => DoesMeshUseBone(smr, targetBone)).ToArray();
+
+ foreach (SkinnedMeshRenderer smr in relevantMeshes)
+ {
+ yield return StartCoroutine(ProcessSkinnedMesh(smr, targetBone, threshold, meshPoints =>
+ {
+ if (meshPoints is { Length: > 0 })
+ points.AddRange(meshPoints);
+ }));
+ }
+
+ onSkinnedPoints?.Invoke(points);
+ }
+ }
+
+ // Constraints
+ IEnumerator ProcessConstraintsLocal(Transform targetBone, float threshold, BoundsCalculationFlags constraintFlags, Action> onConstraintPoints)
+ {
+ using (s_processConstraintsMarker.Auto())
+ {
+ var points = new List();
+ var processedTransforms = new HashSet();
+ var constrainedTransforms = new List();
+
+ // Find all constrained objects that reference our bone
+ var constraints = targetBone.root.GetComponentsInChildren();
+
+ foreach (IConstraint constraint in constraints)
+ {
+ for (int i = 0; i < constraint.sourceCount; i++)
+ {
+ if (constraint.GetSource(i).sourceTransform != targetBone) continue;
+ constrainedTransforms.Add(((Behaviour)constraint).transform);
+ break;
+ }
+ }
+
+ // Process each constrained transform
+ foreach (Transform constrainedTransform in constrainedTransforms)
+ {
+ if (!processedTransforms.Add(constrainedTransform))
+ continue;
+
+ var localPoints = new List();
+ bool hasLocalPoints = false;
+
+ // Process child renderers if enabled
+ if ((constraintFlags & BoundsCalculationFlags.IncludeChildren) != 0)
+ {
+ yield return StartCoroutine(ProcessChildRenderersLocal(constrainedTransform, childPoints =>
+ {
+ if (childPoints is not { Count: > 0 }) return;
+ localPoints.AddRange(childPoints);
+ hasLocalPoints = true;
+ }));
+ }
+
+ // Process skinned mesh if enabled
+ if ((constraintFlags & BoundsCalculationFlags.IncludeSkinnedMesh) != 0)
+ {
+ yield return StartCoroutine(ProcessSkinnedMeshRenderersLocal(constrainedTransform, threshold, skinnedPoints =>
+ {
+ if (skinnedPoints is not { Count: > 0 }) return;
+ localPoints.AddRange(skinnedPoints);
+ hasLocalPoints = true;
+ }));
+ }
+
+ if (!hasLocalPoints)
+ continue;
+
+ // Convert all points to bone space
+ foreach (Vector3 point in localPoints)
+ points.Add(targetBone.InverseTransformPoint(constrainedTransform.TransformPoint(point)));
+ }
+
+ onConstraintPoints?.Invoke(points);
+ }
+ }
+
+ // Process child renderers
+ if ((flags & BoundsCalculationFlags.IncludeChildren) != 0)
+ {
+ yield return StartCoroutine(ProcessChildRenderersLocal(bone, childPoints =>
+ {
+ if (childPoints is not { Count: > 0 }) return;
+ allWeightedPoints.AddRange(childPoints);
+ hasValidPoints = true;
+ }));
+ }
+
+ // Process skinned mesh renderers
+ if ((flags & BoundsCalculationFlags.IncludeSkinnedMesh) != 0)
+ {
+ yield return StartCoroutine(ProcessSkinnedMeshRenderersLocal(bone, weightThreshold, skinnedPoints =>
+ {
+ if (skinnedPoints == null || skinnedPoints.Count <= 0) return;
+ allWeightedPoints.AddRange(skinnedPoints);
+ hasValidPoints = true;
+ }));
+ }
+
+ // Process constraints
+ if ((flags & BoundsCalculationFlags.IncludeConstraints) != 0)
+ {
+ // Use only Children and SkinnedMesh flags for constraint processing to prevent recursion (maybe make optional)?
+ BoundsCalculationFlags constraintFlags = flags & ~BoundsCalculationFlags.IncludeConstraints;
+ yield return StartCoroutine(ProcessConstraintsLocal(bone, weightThreshold, constraintFlags, constraintPoints =>
+ {
+ if (constraintPoints is not { Count: > 0 }) return;
+ allWeightedPoints.AddRange(constraintPoints);
+ hasValidPoints = true;
+ }));
+ }
+
+ if (!hasValidPoints)
+ {
+ result.IsValid = false;
+ onComplete?.Invoke(result);
+ yield break;
+ }
+
+ // Calculate final bounds in bone space
+ Bounds bounds = new(allWeightedPoints[0], Vector3.zero);
+ foreach (Vector3 point in allWeightedPoints)
+ bounds.Encapsulate(point);
+
+ // Ensure minimum size
+ Vector3 size = bounds.size;
+ size = Vector3.Max(size, Vector3.one * 0.01f);
+ bounds.size = size;
+
+ result.IsValid = true;
+ result.LocalBounds = bounds;
+
+ onComplete?.Invoke(result);
+ }
+ }
+
+ private static bool DoesMeshUseBone(SkinnedMeshRenderer smr, Transform bone)
+ => smr.bones != null && smr.bones.Contains(bone);
+
+ private IEnumerator ProcessSkinnedMesh(SkinnedMeshRenderer smr, Transform bone, float weightThreshold,
+ Action onComplete)
+ {
+ Mesh mesh = smr.sharedMesh;
+ if (mesh == null)
+ {
+ onComplete?.Invoke(null);
+ yield break;
+ }
+
+ // Find bone index
+ int boneIndex = Array.IndexOf(smr.bones, bone);
+ if (boneIndex == -1)
+ {
+ onComplete?.Invoke(null);
+ yield break;
+ }
+
+ Mesh meshToUse = mesh;
+ GameObject tempGO = null;
+
+ try
+ {
+ // Handle non-readable meshes (ReadItAnyway lmao)
+ if (!mesh.isReadable)
+ {
+ using (s_meshCopyMarker.Auto())
+ {
+ tempGO = new GameObject("TempMeshReader");
+ SkinnedMeshRenderer tempSMR = tempGO.AddComponent();
+ tempSMR.sharedMesh = mesh;
+ meshToUse = new Mesh();
+ tempSMR.BakeMesh(meshToUse);
+ }
+ }
+
+ Mesh.MeshDataArray meshDataArray = Mesh.AcquireReadOnlyMeshData(meshToUse);
+ Mesh.MeshData meshData = meshDataArray[0];
+
+ var vertexCount = meshData.vertexCount;
+ var vertices = new NativeArray(vertexCount, Allocator.TempJob);
+ var weights = new NativeArray(vertexCount, Allocator.TempJob);
+ var results = new NativeArray(vertexCount, Allocator.TempJob);
+
+ meshData.GetVertices(vertices);
+ weights.CopyFrom(mesh.boneWeights);
+
+ // Debug.Log(vertices.Length);
+ // Debug.Log(weights.Length);
+
+ using (s_processVerticesMarker.Auto())
+ {
+ try
+ {
+ Transform rootBone = smr.rootBone ? smr.rootBone.transform : smr.transform;
+ Matrix4x4 meshToWorld = Matrix4x4.TRS(smr.transform.position, smr.transform.rotation, rootBone.lossyScale);
+
+ // Fixes setup where mesh was in diff hierarchy & 0.001 scale, bone & root bone outside & above
+ meshToWorld *= Matrix4x4.TRS(Vector3.zero, Quaternion.identity, smr.transform.localScale);
+
+ ProcessVerticesJob processJob = new()
+ {
+ Vertices = vertices,
+ BoneWeights = weights,
+ Results = results,
+ BoneIndex = boneIndex,
+ WeightThreshold = weightThreshold,
+ MeshToWorld = meshToWorld,
+ WorldToBone = bone.worldToLocalMatrix
+ };
+
+ using (s_jobExecutionMarker.Auto())
+ {
+ int batchCount = Mathf.Max(1, vertexCount / 64);
+ JobHandle jobHandle = processJob.Schedule(vertexCount, batchCount);
+ while (!jobHandle.IsCompleted)
+ yield return null;
+
+ jobHandle.Complete();
+ }
+
+ // Collect valid points
+ var validPoints = new List();
+ for (int i = 0; i < results.Length; i++)
+ if (results[i].IsValid) validPoints.Add(results[i].Position);
+
+ onComplete?.Invoke(validPoints.ToArray());
+ }
+ finally
+ {
+ vertices.Dispose();
+ weights.Dispose();
+ results.Dispose();
+ meshDataArray.Dispose();
+ }
+ }
+ }
+ finally
+ {
+ // Destroy duplicated baked mesh if we created one to read mesh data
+ if (!mesh.isReadable && meshToUse != mesh) Destroy(meshToUse);
+ if (tempGO != null) Destroy(tempGO);
+ }
+ }
+
+ private struct VertexResult
+ {
+ public Vector3 Position;
+ public bool IsValid;
+ }
+
+ [BurstCompile]
+ private struct ProcessVerticesJob : IJobParallelFor
+ {
+ [ReadOnly] public NativeArray Vertices;
+ [ReadOnly] public NativeArray BoneWeights;
+ [NativeDisableParallelForRestriction]
+ public NativeArray Results;
+ [ReadOnly] public int BoneIndex;
+ [ReadOnly] public float WeightThreshold;
+ [ReadOnly] public Matrix4x4 MeshToWorld;
+ [ReadOnly] public Matrix4x4 WorldToBone;
+
+ public void Execute(int i)
+ {
+ BoneWeight weight = BoneWeights[i];
+ float totalWeight = 0f;
+
+ if (weight.boneIndex0 == BoneIndex) totalWeight += weight.weight0;
+ if (weight.boneIndex1 == BoneIndex) totalWeight += weight.weight1;
+ if (weight.boneIndex2 == BoneIndex) totalWeight += weight.weight2;
+ if (weight.boneIndex3 == BoneIndex) totalWeight += weight.weight3;
+
+ if (totalWeight >= WeightThreshold)
+ {
+ // Transform vertex to bone space
+ Vector3 worldPos = MeshToWorld.MultiplyPoint3x4(Vertices[i]);
+ Vector3 boneLocalPos = WorldToBone.MultiplyPoint3x4(worldPos);
+
+ Results[i] = new VertexResult
+ {
+ Position = boneLocalPos,
+ IsValid = true
+ };
+ }
+ else
+ {
+ Results[i] = new VertexResult { IsValid = false };
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/GrabbableSteeringWheel/GrabbableSteeringWheel/SteeringWheelPickup.cs b/GrabbableSteeringWheel/GrabbableSteeringWheel/SteeringWheelPickup.cs
new file mode 100644
index 0000000..0807a2a
--- /dev/null
+++ b/GrabbableSteeringWheel/GrabbableSteeringWheel/SteeringWheelPickup.cs
@@ -0,0 +1,203 @@
+using ABI_RC.Core.InteractionSystem;
+using ABI_RC.Core.InteractionSystem.Base;
+using UnityEngine;
+
+// TODO:
+// Fix multi-grab (limitation of Pickupable)
+// Think this can be fixed by forcing ungrab & monitoring ourselves for release
+// Add configurable override for steering range
+// Fix steering wheel resetting immediatly on release
+// Fix input patch not being multiplicative (so joysticks can still work)
+// Prevent pickup in Desktop
+
+public class SteeringWheelPickup : Pickupable
+{
+ private RCC_CarControllerV3 _carController;
+
+ private static readonly Dictionary ActiveWheels = new();
+
+ public static float GetSteerInput(RCC_CarControllerV3 carController)
+ {
+ if (ActiveWheels.TryGetValue(carController, out SteeringWheelPickup wheel) && wheel.IsPickedUp)
+ return wheel.GetNormalizedValue();
+ return 0f;
+ }
+
+ public void SetupSteeringWheel(RCC_CarControllerV3 carController)
+ {
+ _carController = carController;
+ if (!ActiveWheels.ContainsKey(carController))
+ {
+ ActiveWheels[carController] = this;
+ carController.useCounterSteering = false;
+ carController.useSteeringSmoother = false;
+ }
+ }
+
+ private void OnDestroy()
+ {
+ if (_carController != null && ActiveWheels.ContainsKey(_carController))
+ ActiveWheels.Remove(_carController);
+ }
+
+ #region Configuration Properties Override
+
+ public override bool DisallowTheft => true;
+ public override float MaxGrabDistance => 0.8f;
+ public override float MaxPushDistance => 0f;
+ public override bool IsAutoHold => false;
+ public override bool IsObjectRotationAllowed => false;
+ public override bool IsObjectPushPullAllowed => false;
+ public override bool IsObjectUseAllowed => false;
+
+ public override bool CanPickup => IsPickupable && _carController?.SteeringWheel != null;
+
+ #endregion Configuration Properties Override
+
+ #region RCC Stuff
+
+ private float GetMaxSteeringRange()
+ => _carController.steerAngle * Mathf.Abs(_carController.steeringWheelAngleMultiplier);
+
+ private float GetSteeringWheelSign()
+ => Mathf.Sign(_carController.steeringWheelAngleMultiplier) * -1f; // Idk
+
+ private Vector3 GetSteeringWheelLocalAxis()
+ {
+ return _carController.steeringWheelRotateAround switch
+ {
+ RCC_CarControllerV3.SteeringWheelRotateAround.XAxis => Vector3.right,
+ RCC_CarControllerV3.SteeringWheelRotateAround.YAxis => Vector3.up,
+ RCC_CarControllerV3.SteeringWheelRotateAround.ZAxis => Vector3.forward,
+ _ => Vector3.forward
+ };
+ }
+
+ #endregion RCC Stuff
+
+ #region Rotation Tracking
+
+ private readonly List _trackedTransforms = new();
+ private readonly List _lastPositions = new();
+ private readonly List _totalAngles = new();
+ private bool _isTracking;
+ private float _averageAngle;
+
+ private void StartTrackingTransform(Transform trans)
+ {
+ if (trans == null) return;
+
+ _trackedTransforms.Add(trans);
+ _lastPositions.Add(GetLocalPositionWithoutRotation(transform.position));
+ _totalAngles.Add(0f);
+ _isTracking = true;
+ }
+
+ private void StopTrackingTransform(Transform trans)
+ {
+ int index = _trackedTransforms.IndexOf(trans);
+ if (index != -1)
+ {
+ _trackedTransforms.RemoveAt(index);
+ _lastPositions.RemoveAt(index);
+ _totalAngles.RemoveAt(index);
+ }
+
+ _isTracking = _trackedTransforms.Count > 0;
+ }
+
+ private void UpdateRotationTracking()
+ {
+ if (!_isTracking || _trackedTransforms.Count == 0) return;
+
+ Vector3 trackingAxis = GetSteeringWheelLocalAxis();
+
+ for (int i = 0; i < _trackedTransforms.Count; i++)
+ {
+ if (_trackedTransforms[i] == null) continue;
+
+ Vector3 currentPosition = GetLocalPositionWithoutRotation(_trackedTransforms[i].position);
+ if (currentPosition == _lastPositions[i]) continue;
+
+ Vector3 previousVector = _lastPositions[i];
+ Vector3 currentVector = currentPosition;
+
+ previousVector = Vector3.ProjectOnPlane(previousVector, trackingAxis).normalized;
+ currentVector = Vector3.ProjectOnPlane(currentVector, trackingAxis).normalized;
+
+ if (previousVector.sqrMagnitude > 0.001f && currentVector.sqrMagnitude > 0.001f)
+ {
+ float deltaAngle = Vector3.SignedAngle(previousVector, currentVector, trackingAxis);
+ if (Mathf.Abs(deltaAngle) < 90f) _totalAngles[i] += deltaAngle; // Prevent big tracking jumps
+ }
+
+ _lastPositions[i] = currentPosition;
+ }
+
+ // Calculate average every frame using only valid transforms
+ float sumAngles = 0f;
+ int validTransforms = 0;
+
+ for (int i = 0; i < _trackedTransforms.Count; i++)
+ {
+ if (_trackedTransforms[i] == null) continue;
+ sumAngles += _totalAngles[i];
+ validTransforms++;
+ }
+
+ if (validTransforms > 0)
+ _averageAngle = sumAngles / validTransforms;
+ }
+
+ private float GetNormalizedValue()
+ {
+ float maxRange = GetMaxSteeringRange();
+ // return Mathf.Clamp(_averageAngle / (maxRange * 0.5f), -1f, 1f);
+ return Mathf.Clamp(_averageAngle / (maxRange), -1f, 1f) * GetSteeringWheelSign();
+ }
+
+ private Vector3 GetLocalPositionWithoutRotation(Vector3 worldPosition)
+ {
+ Transform steeringTransform = _carController.SteeringWheel;
+
+ Quaternion localRotation = steeringTransform.localRotation;
+ steeringTransform.localRotation = _carController.orgSteeringWheelRot;
+
+ Vector3 localPosition = steeringTransform.InverseTransformPoint(worldPosition);
+ steeringTransform.localRotation = localRotation;
+
+ return localPosition;
+ }
+
+ #endregion Rotation Tracking
+
+ public override void OnGrab(InteractionContext context, Vector3 grabPoint)
+ {
+ if (ControllerRay?.pivotPoint == null)
+ return;
+
+ StartTrackingTransform(ControllerRay.transform);
+ }
+
+ public override void OnDrop(InteractionContext context)
+ {
+ if (ControllerRay?.transform != null)
+ StopTrackingTransform(ControllerRay.transform);
+ }
+
+ private void Update()
+ {
+ if (!IsPickedUp || ControllerRay?.pivotPoint == null || _carController == null)
+ return;
+
+ UpdateRotationTracking();
+ }
+
+ #region Unused Abstract Method Implementations
+
+ public override void OnUseDown(InteractionContext context) { }
+ public override void OnUseUp(InteractionContext context) { }
+ public override void OnFlingTowardsTarget(Vector3 target) { }
+
+ #endregion Unused Abstract Method Implementations
+}
\ No newline at end of file
diff --git a/GrabbableSteeringWheel/Main.cs b/GrabbableSteeringWheel/Main.cs
new file mode 100644
index 0000000..1ced7e0
--- /dev/null
+++ b/GrabbableSteeringWheel/Main.cs
@@ -0,0 +1,61 @@
+using MelonLoader;
+using NAK.GrabbableSteeringWheel.Patches;
+
+namespace NAK.GrabbableSteeringWheel;
+
+public class GrabbableSteeringWheelMod : MelonMod
+{
+ internal static MelonLogger.Instance Logger;
+
+ #region Melon Preferences
+
+ // private static readonly MelonPreferences_Category Category =
+ // MelonPreferences.CreateCategory(nameof(GrabbableSteeringWheelMod));
+ //
+ // private static readonly MelonPreferences_Entry EntryEnabled =
+ // Category.CreateEntry(
+ // "use_legacy_mitigation",
+ // true,
+ // "Enabled",
+ // description: "Enable legacy content camera hack when in Legacy worlds.");
+
+ #endregion Melon Preferences
+
+ #region Melon Events
+
+ public override void OnInitializeMelon()
+ {
+ Logger = LoggerInstance;
+
+ ApplyPatches(typeof(RCCCarControllerV3_Patches));
+ ApplyPatches(typeof(CVRInputManager_Patches));
+ }
+
+ #endregion Melon Events
+
+ #region Melon Mod Utilities
+
+ private static void InitializeIntegration(string modName, Action integrationAction)
+ {
+ if (RegisteredMelons.All(it => it.Info.Name != modName))
+ return;
+
+ Logger.Msg($"Initializing {modName} integration.");
+ integrationAction.Invoke();
+ }
+
+ private void ApplyPatches(Type type)
+ {
+ try
+ {
+ HarmonyInstance.PatchAll(type);
+ }
+ catch (Exception e)
+ {
+ LoggerInstance.Msg($"Failed while patching {type.Name}!");
+ LoggerInstance.Error(e);
+ }
+ }
+
+ #endregion Melon Mod Utilities
+}
\ No newline at end of file
diff --git a/GrabbableSteeringWheel/ModSettings.cs b/GrabbableSteeringWheel/ModSettings.cs
new file mode 100644
index 0000000..26a56be
--- /dev/null
+++ b/GrabbableSteeringWheel/ModSettings.cs
@@ -0,0 +1,24 @@
+using MelonLoader;
+
+namespace NAK.GrabbableSteeringWheel;
+
+internal static class ModSettings
+{
+ #region Constants
+
+ internal const string ModName = nameof(GrabbableSteeringWheel);
+ // internal const string LCM_SettingsCategory = "Legacy Content Mitigation";
+
+ #endregion Constants
+
+ #region Melon Preferences
+
+ private static readonly MelonPreferences_Category Category =
+ MelonPreferences.CreateCategory(ModName);
+
+ // internal static readonly MelonPreferences_Entry EntryAutoForLegacyWorlds =
+ // Category.CreateEntry("auto_for_legacy_worlds", true,
+ // "Auto For Legacy Worlds", description: "Should Legacy View be auto enabled for detected Legacy worlds?");
+ //
+ #endregion Melon Preferences
+}
\ No newline at end of file
diff --git a/GrabbableSteeringWheel/Patches.cs b/GrabbableSteeringWheel/Patches.cs
new file mode 100644
index 0000000..c6a828c
--- /dev/null
+++ b/GrabbableSteeringWheel/Patches.cs
@@ -0,0 +1,49 @@
+using ABI_RC.Systems.InputManagement;
+using ABI_RC.Systems.Movement;
+using HarmonyLib;
+using UnityEngine;
+
+namespace NAK.GrabbableSteeringWheel.Patches;
+
+internal static class RCCCarControllerV3_Patches
+{
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(RCC_CarControllerV3), nameof(RCC_CarControllerV3.Awake))]
+ private static void Postfix_RCC_CarControllerV3_Awake(ref RCC_CarControllerV3 __instance)
+ {
+ Transform steeringWheelTransform = __instance.SteeringWheel;
+ if (steeringWheelTransform == null)
+ return;
+
+ RCC_CarControllerV3 v3 = __instance;
+ BoneVertexBoundsUtility.CalculateBoneWeightedBounds(
+ steeringWheelTransform,
+ 0.8f,
+ BoneVertexBoundsUtility.BoundsCalculationFlags.All,
+ result =>
+ {
+ if (!result.IsValid)
+ return;
+
+ BoxCollider boxCollider = steeringWheelTransform.gameObject.AddComponent();
+ boxCollider.center = result.LocalBounds.center;
+ boxCollider.size = result.LocalBounds.size;
+
+ SteeringWheelPickup steeringWheel = steeringWheelTransform.gameObject.AddComponent();
+ steeringWheel.SetupSteeringWheel(v3);
+ });
+ }
+}
+
+internal static class CVRInputManager_Patches
+{
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(CVRInputManager), nameof(CVRInputManager.UpdateInput))]
+ private static void Postfix_CVRInputManager_UpdateInput(ref CVRInputManager __instance)
+ {
+ // Steering input is clamped in RCC component
+ if (BetterBetterCharacterController.Instance.IsSittingOnControlSeat())
+ __instance.steering += SteeringWheelPickup.GetSteerInput(
+ BetterBetterCharacterController.Instance._lastCvrSeat._carController);
+ }
+}
\ No newline at end of file
diff --git a/GrabbableSteeringWheel/Properties/AssemblyInfo.cs b/GrabbableSteeringWheel/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..6441f79
--- /dev/null
+++ b/GrabbableSteeringWheel/Properties/AssemblyInfo.cs
@@ -0,0 +1,33 @@
+using System.Reflection;
+using MelonLoader;
+using NAK.GrabbableSteeringWheel;
+using NAK.GrabbableSteeringWheel.Properties;
+
+[assembly: AssemblyVersion(AssemblyInfoParams.Version)]
+[assembly: AssemblyFileVersion(AssemblyInfoParams.Version)]
+[assembly: AssemblyInformationalVersion(AssemblyInfoParams.Version)]
+[assembly: AssemblyTitle(nameof(NAK.GrabbableSteeringWheel))]
+[assembly: AssemblyCompany(AssemblyInfoParams.Author)]
+[assembly: AssemblyProduct(nameof(NAK.GrabbableSteeringWheel))]
+
+[assembly: MelonInfo(
+ typeof(GrabbableSteeringWheelMod),
+ nameof(NAK.GrabbableSteeringWheel),
+ AssemblyInfoParams.Version,
+ AssemblyInfoParams.Author,
+ downloadLink: "https://github.com/NotAKidoS/NAK_CVR_Mods/tree/main/GrabbableSteeringWheel"
+)]
+
+[assembly: MelonGame("Alpha Blend Interactive", "ChilloutVR")]
+[assembly: MelonPlatform(MelonPlatformAttribute.CompatiblePlatforms.WINDOWS_X64)]
+[assembly: MelonPlatformDomain(MelonPlatformDomainAttribute.CompatibleDomains.MONO)]
+[assembly: MelonColor(255, 246, 25, 99)] // red-pink
+[assembly: MelonAuthorColor(255, 158, 21, 32)] // red
+[assembly: HarmonyDontPatchAll]
+
+namespace NAK.GrabbableSteeringWheel.Properties;
+internal static class AssemblyInfoParams
+{
+ public const string Version = "1.0.0";
+ public const string Author = "NotAKidoS";
+}
\ No newline at end of file
diff --git a/GrabbableSteeringWheel/README.md b/GrabbableSteeringWheel/README.md
new file mode 100644
index 0000000..08942df
--- /dev/null
+++ b/GrabbableSteeringWheel/README.md
@@ -0,0 +1,14 @@
+# GrabbableSteeringWheel
+
+Experiment with making rigged RCC vehicle steering wheels work for VR steering input.
+~~~~
+---
+
+Here is the block of text where I tell you this mod is not affiliated with or endorsed by ABI.
+https://documentation.abinteractive.net/official/legal/tos/#7-modding-our-games
+
+> This mod is an independent creation not affiliated with, supported by, or approved by Alpha Blend Interactive.
+
+> Use of this mod is done so at the user's own risk and the creator cannot be held responsible for any issues arising from its use.
+
+> To the best of my knowledge, I have adhered to the Modding Guidelines established by Alpha Blend Interactive.
diff --git a/GrabbableSteeringWheel/format.json b/GrabbableSteeringWheel/format.json
new file mode 100644
index 0000000..480d0fb
--- /dev/null
+++ b/GrabbableSteeringWheel/format.json
@@ -0,0 +1,23 @@
+{
+ "_id": -1,
+ "name": "LegacyContentMitigation",
+ "modversion": "1.0.1",
+ "gameversion": "2024r177",
+ "loaderversion": "0.6.1",
+ "modtype": "Mod",
+ "author": "Exterrata, NotAKidoS",
+ "description": "Experimental mod that manually renders both VR eyes separately while in Legacy Worlds.\n\nThe mod will automatically kick-in when loading NonSpi Worlds & prevent Shader Replacement for all content while active. You can also manually toggle it within the BTKUI Misc tab for experimenting.\n\n-# There is an obvious performance penalty with rendering the world 2x & toggling the head hiding per eye, so enabling the mod outside of Legacy Worlds is not recommended.",
+ "searchtags": [
+ "legacy",
+ "content",
+ "spi",
+ "shaders"
+ ],
+ "requirements": [
+ "BTKUILib"
+ ],
+ "downloadlink": "https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r44/LegacyContentMitigation.dll",
+ "sourcelink": "https://github.com/NotAKidoS/NAK_CVR_Mods/tree/main/LegacyContentMitigation/",
+ "changelog": "- Initial release",
+ "embedcolor": "#f61963"
+}
\ No newline at end of file