using System.Collections.Generic; using System.Reflection; using Helpers; using HarmonyLib; using Model; using Model.Physics; using Track; using UnityEngine; namespace RailroaderPhysicsOverhaul; public static class ConsistLOD { public static bool Enabled = true; public static float DistanceThreshold = 30f; // Must be a power of 2 (1, 2, 4, 8, 16). 1 = full path every tick (no dead-reckoning). public static int ResyncInterval = 4; // Blacklist/whitelist — when enabled, filters which consists get LOD treatment. public static bool ExcludeLocomotives = true; public static bool BlacklistEnabled = false; public static bool IsBlacklist = true; // true = listed consists skip LOD; false = only listed use LOD public static readonly HashSet RoadNumberList = new(System.StringComparer.OrdinalIgnoreCase); public static int _globalTick = 0; public static int LastFastPathCount; public static int LastFullPathCount; // Per-tick sets for debug visualization — cleared at FixedUpdate start, populated by the patch. public static readonly HashSet FastPathCars = new(); public static readonly HashSet FullPathCars = new(); static float DistanceSq => DistanceThreshold * DistanceThreshold; // Per-consist LOD eligibility cache — rebuilt lazily, flushed periodically and on settings change. // IntegrationSet is not a UnityEngine.Object, so we key by reference identity. static readonly Dictionary _consistCache = new(); public static void InvalidateConsistCache() => _consistCache.Clear(); internal static bool ShouldUseFastPath(Car car) { if (!Enabled) return false; if (ExcludeLocomotives && car is Model.BaseLocomotive) return false; if (!car.EndGearA.IsCoupled || !car.EndGearB.IsCoupled) return false; if (car.BodyTransform == null) return false; Camera cam = Camera.main; if (cam == null) return false; if ((car.BodyTransform.position - cam.transform.position).sqrMagnitude <= DistanceSq) return false; // Blacklist/whitelist — cached per IntegrationSet instance ID to avoid per-car scanning. if (BlacklistEnabled && car.set != null) { if (!_consistCache.TryGetValue(car.set, out bool allowed)) { allowed = IsConsistAllowed(car.set); _consistCache[car.set] = allowed; } if (!allowed) return false; } return true; } static bool IsConsistAllowed(IntegrationSet set) { if (RoadNumberList.Count == 0) return true; // empty list = no filtering bool hasMatch = false; foreach (Car c in set.Cars) { // Accept a match against the full display name ("UP 1492"), the road number // alone ("1492"), or the reporting mark alone ("UP") — users naturally type // whichever they remember, so don't require the exact concatenated string. if (RoadNumberList.Contains(c.DisplayName) || RoadNumberList.Contains(c.Ident.RoadNumber) || RoadNumberList.Contains(c.Ident.ReportingMark)) { hasMatch = true; break; } } // Blacklist: a match means this consist is NOT allowed to use LOD. // Whitelist: no match means this consist is NOT allowed to use LOD. return IsBlacklist ? !hasMatch : hasMatch; } } // Cached accessor for Car._mover (internal field — reflection needed from our assembly). // CarMover.Move() is public, so once we have the instance we call it directly. static class CarMoverAccess { static readonly FieldInfo Field = typeof(Car).GetField("_mover", BindingFlags.Instance | BindingFlags.NonPublic); public static CarMover Get(Car car) => (CarMover)Field.GetValue(car); } [HarmonyPatch(typeof(Car), "PositionWheelBoundsFront")] static class PositionWheelBoundsFrontLODPatch { static int _fastCount; static int _fullCount; public static void ResetFrameCounts() { ConsistLOD.LastFastPathCount = _fastCount; ConsistLOD.LastFullPathCount = _fullCount; _fastCount = 0; _fullCount = 0; } static bool Prefix(Car __instance, Location wheelBoundsF, Graph graph, bool update, ref Location __result) { if (!update || !ConsistLOD.ShouldUseFastPath(__instance)) { _fullCount++; ConsistLOD.FullPathCars.Add(__instance); return true; // run original } // Fibonacci hash: 4-bit result (0-15) distributes Unity InstanceIDs evenly // and supports ResyncInterval 1/2/4/8 via modular comparison. int id = __instance.GetInstanceID(); int bucket = (int)(((uint)id * 2654435761u) >> 28) & 0xF; bool isResync = ConsistLOD.ResyncInterval <= 1 || ConsistLOD._globalTick % ConsistLOD.ResyncInterval == bucket % ConsistLOD.ResyncInterval; if (isResync) { _fullCount++; ConsistLOD.FastPathCars.Add(__instance); // logically fast-path, just doing a scheduled sync return true; // let original run this tick } _fastCount++; ConsistLOD.FastPathCars.Add(__instance); // 1. Update track physics bounds — constraint solver and couplers need these. float boundsSpan = __instance.carLength - __instance.wheelInsetF - __instance.wheelInsetR; Location wbR = graph.LocationByMoving(wheelBoundsF, -boundsSpan); __instance.WheelBoundsF = wheelBoundsF; __instance.WheelBoundsR = wbR; __result = wbR; // Keep LogicalEnd locations in sync — used by CarDidPosition's segment-cache // and coupler placement when the culler transitions distance bands. __instance.LocationF = graph.LocationByMoving(wheelBoundsF, __instance.wheelInsetF, false, Graph.EndOfTrackHandling.Unclamped); __instance.LocationR = graph.LocationByMoving(wbR, -__instance.wheelInsetR, false, Graph.EndOfTrackHandling.Unclamped); // 2. Replicate SetBodyPosition's chord-based rotation: LookRotation between // the front and rear TRUCK positions, matching the game's exact formula. // Use the same graph instance and the same PositionAccuracy as PositionWheelBoundsFront // would choose (IsVisible ? High : Standard) so the position is byte-for-byte identical // to the full path — preventing a positional jump when the camera closes in. float halfTruckSpan = (__instance.carLength - __instance.truckSeparation) / 2f; Location truckFLoc = graph.LocationByMoving(wheelBoundsF, -(halfTruckSpan - __instance.wheelInsetF)); Location truckRLoc = graph.LocationByMoving(truckFLoc, -__instance.truckSeparation); PositionAccuracy accuracy = __instance.IsVisible ? PositionAccuracy.High : PositionAccuracy.Standard; var prF = graph.GetPositionRotation(truckFLoc, accuracy); var prR = graph.GetPositionRotation(truckRLoc, accuracy); Vector3 avgUp = Vector3.Lerp(prF.Rotation * Vector3.up, prR.Rotation * Vector3.up, 0.5f); Quaternion bodyRot = Quaternion.LookRotation(prF.Position - prR.Position, avgUp); Vector3 gameCenter = Vector3.Lerp(prF.Position, prR.Position, 0.5f); Vector3 worldCenter = WorldTransformer.GameToWorld(gameCenter); // 3. Drive _mover.Move() exactly as SetBodyPosition does. // This keeps _moverPosition current every fast-path tick so: // a) Camera-follow code (which reads _moverPosition) sees correct position. // b) On the next resync tick, _velocity = Δpos/dt ≈ actual v, not N×v, // so the rigidbody isn't slammed backward causing a snap. CarMoverAccess.Get(__instance).Move(worldCenter, bodyRot, immediate: false); // 4. TagCallout is only updated inside SetBodyPosition; keep it in sync here. __instance.TagCallout?.SetPosition(worldCenter); // 5. Fire OnPosition so TrainController.CarDidPosition runs: this updates the // spatial-hash, car-culler sphere (distance-band gating), and segment cache. // Without this the culler's bounding sphere stays frozen at the last resync // position, causing stale IsVisible / IsNearby states on fast-path ticks. __instance.OnPosition?.Invoke(gameCenter, bodyRot); return false; // skip original PositionWheelBoundsFront + SetBodyPosition } } [HarmonyPatch(typeof(TrainController), "FixedUpdate")] static class TrainControllerFixedUpdateCounterPatch { static void Prefix() { ConsistLOD._globalTick++; ConsistLOD.FastPathCars.Clear(); ConsistLOD.FullPathCars.Clear(); PositionWheelBoundsFrontLODPatch.ResetFrameCounts(); ConsistFreezer.FlushFrameCounts(); // Periodic consist cache flush — silently handles couple/decouple events // without needing to patch the coupler system. ~12 s at 50 Hz. if (ConsistLOD._globalTick % 600 == 0) ConsistLOD.InvalidateConsistCache(); } }