commit 5414fd897933c17b21ed2860a3a8f56a5f35fb07 Author: Seton Carmichael Date: Tue Jun 16 11:42:54 2026 -0400 Initial commit - Physics Overhaul v0.1.1 Features: - ConsistLOD: fast-path Bezier position update for distant cars (PositionAccuracy matched, LocationF/R synced, OnPosition fired, culler sphere kept current) - ConsistFreezer: per-consist auto-freeze replacing stock all-or-nothing optimizer - PhysicsTimer: stacked ring buffers (render, FixedUpdate, Tick, PosCars) + report - PhysicsOverlayGUI: draggable stacked-area chart, reference lines always visible, ON/OFF toggles with persistence, opacity control - Settings: JSON persistence, full UMM settings panel with auto-save - ConsoleCommands: /rpf with freeze/unfreeze/dump/timing/overlay/forceactive/lod diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84960d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +obj/ +bin/ +*.user +.vs/ +*.suo diff --git a/ConsistFreezer.cs b/ConsistFreezer.cs new file mode 100644 index 0000000..627ce24 --- /dev/null +++ b/ConsistFreezer.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using HarmonyLib; +using Model; +using Model.Physics; +using UnityEngine; + +namespace RailroaderPhysicsOverhaul; + +public static class ConsistFreezer +{ + // Manual freeze — explicit /rpf freeze command + static readonly HashSet _frozenSetIds = new(); + public static bool IsFrozen(uint setId) => _frozenSetIds.Contains(setId); + public static bool Freeze(uint setId) => _frozenSetIds.Add(setId); + public static bool Unfreeze(uint setId) => _frozenSetIds.Remove(setId); + public static void ClearAll() => _frozenSetIds.Clear(); + + // Auto-freeze: skip the Verlet tick for consists that are far from the camera + // and moving slowly enough that physics quality there doesn't matter. + // This is our integrated replacement for the stock optimizer (AllCarsAtRest only). + public static bool AutoFreezeEnabled = true; + public static float AutoFreezeDistance = 200f; // meters + public static float AutoFreezeSpeedThreshold = 0.3f; // m/s (~0.7 mph) + + static float AutoFreezeDistanceSq => AutoFreezeDistance * AutoFreezeDistance; + + // Per-tick frozen-car counters — flushed to Last* by FlushFrameCounts(). + // internal so ShouldSkipTickPatch (same file, different class) can increment them. + internal static int _frameAtRestCars; + internal static int _frameDistanceCars; + public static int LastAtRestCars { get; private set; } + public static int LastDistanceCars { get; private set; } + + internal static void FlushFrameCounts() + { + LastAtRestCars = _frameAtRestCars; + LastDistanceCars = _frameDistanceCars; + _frameAtRestCars = 0; + _frameDistanceCars = 0; + } + + // Called from ShouldSkipTickPatch. Internal so the patch class (same file) can reach it. + internal static bool ShouldAutoFreeze(IntegrationSet set) + { + if (!AutoFreezeEnabled) return false; + Camera cam = Camera.main; + if (cam == null) return false; + Vector3 camPos = cam.transform.position; + float distSqThresh = AutoFreezeDistanceSq; + float speedThresh = AutoFreezeSpeedThreshold; + + foreach (Car car in set.Cars) + { + Transform body = car.BodyTransform; + if (body == null) continue; + + // If ANY car is within distance → don't freeze the consist. + if ((body.position - camPos).sqrMagnitude < distSqThresh) return false; + + // If ANY car is moving above threshold → don't freeze. + if (Mathf.Abs(car.velocity) >= speedThresh) return false; + } + + // Every car is both far and slow — safe to skip the Verlet tick. + return true; + } +} + +// Completely replaces the stock ShouldSkipTick getter. +// Priority order: +// 1. Manual freeze → always skip +// 2. ForceActive → never skip (profiling bypass) +// 3. AllCarsAtRest → stock optimizer behaviour, replicated explicitly +// 4. Auto-freeze → our distance + speed tier +[HarmonyPatch(typeof(IntegrationSet), "get_ShouldSkipTick")] +static class ShouldSkipTickPatch +{ + static bool Prefix(IntegrationSet __instance, ref bool __result) + { + if (ConsistFreezer.IsFrozen(__instance.Id)) + { + __result = true; + return false; + } + + if (PhysicsTimer.ForceActive) + { + __result = false; + return false; + } + + // Replicate stock optimizer behaviour explicitly so it works with or without + // the stock optimizer mod installed. + if (__instance.AllCarsAtRest()) + { + ConsistFreezer._frameAtRestCars += __instance.NumberOfCars; + __result = true; + return false; + } + + __result = ConsistFreezer.ShouldAutoFreeze(__instance); + if (__result) + ConsistFreezer._frameDistanceCars += __instance.NumberOfCars; + return false; // always override — stock getter never runs + } +} diff --git a/ConsistLOD.cs b/ConsistLOD.cs new file mode 100644 index 0000000..8524909 --- /dev/null +++ b/ConsistLOD.cs @@ -0,0 +1,193 @@ +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). 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 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; + + 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 (!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++; + 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++; + return true; // let original run this tick + } + + _fastCount++; + + // 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++; + 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(); + } +} diff --git a/ConsoleCommands.cs b/ConsoleCommands.cs new file mode 100644 index 0000000..69d9a96 --- /dev/null +++ b/ConsoleCommands.cs @@ -0,0 +1,141 @@ +using System.Linq; +using System.Text; +using HarmonyLib; +using Model; +using Model.Physics; +using UI.Console; + +namespace RailroaderPhysicsOverhaul; + +/// +/// Hooks into ConsoleCommandHandler._HandleSlashCommand to add /rpf commands +/// before the game's switch block sees them. +/// +[HarmonyPatch(typeof(ConsoleCommandHandler))] +[HarmonyPatch("_HandleSlashCommand")] +static class ConsoleCommandPatch +{ + static bool Prefix(string[] comps, ref string __result) + { + if (comps.Length == 0 || comps[0].ToLower() != "/rpf") + return true; // not our command, let original handle it + + __result = RpfCommands.Handle(comps); + return false; // swallow — don't pass to original + } +} + +static class RpfCommands +{ + internal static string Handle(string[] comps) + { + if (comps.Length < 2) + return Usage(); + + return comps[1].ToLower() switch + { + "freeze" => Freeze(), + "unfreeze" => Unfreeze(), + "dump" => Dump(), + "timing" => PhysicsTimer.GetReport(), + "overlay" => ToggleOverlay(), + "forceactive" => ToggleForceActive(), + "lod" => SetLod(comps), + "help" => Usage(), + _ => $"Unknown subcommand '{comps[1]}'. {Usage()}", + }; + } + + static string Freeze() + { + var (car, set) = GetSelection(); + if (car == null) return "No car selected."; + if (set == null) return $"{car.id} has no IntegrationSet."; + + // Zero velocities so cars stop cleanly before we freeze the solver. + set.SetVelocity(0f, set.Cars.ToList()); + bool added = ConsistFreezer.Freeze(set.Id); + return added + ? $"Froze set #{set.Id} ({set.NumberOfCars} cars). Use /rpf unfreeze to release." + : $"Set #{set.Id} was already frozen."; + } + + static string Unfreeze() + { + var (car, set) = GetSelection(); + if (car == null) return "No car selected."; + if (set == null) return $"{car.id} has no IntegrationSet."; + + bool removed = ConsistFreezer.Unfreeze(set.Id); + return removed + ? $"Unfroze set #{set.Id} — solver resumed." + : $"Set #{set.Id} was not frozen."; + } + + static string Dump() + { + var (car, set) = GetSelection(); + if (car == null) return "No car selected."; + if (set == null) return $"{car.id} has no IntegrationSet."; + + var sb = new StringBuilder(); + sb.AppendLine($"=== IntegrationSet #{set.Id} | {set.NumberOfCars} cars | frozen={ConsistFreezer.IsFrozen(set.Id)} ==="); + + float totalWeightLbs = 0f; + int idx = 0; + foreach (Car c in set.Cars) + { + totalWeightLbs += c.Weight; + float mph = c.velocity * 2.23694f; + string coupled = c.EndGearA.IsCoupled ? "A" : ""; + coupled += c.EndGearB.IsCoupled ? "B" : ""; + if (coupled == "") coupled = "solo"; + sb.AppendLine($" [{idx++}] {c.id} {c.DisplayName} | {mph:F1}mph | {c.Weight:F0}lb | coupled={coupled} | loc={c.WheelBoundsF.segment?.id}"); + } + + sb.AppendLine($"Total: {totalWeightLbs / 2000f:F0} short tons"); + return sb.ToString(); + } + + static (Car car, IntegrationSet set) GetSelection() + { + Car car = TrainController.Shared?.SelectedCar; + return (car, car?.set); + } + + static string ToggleOverlay() + { + var gui = PhysicsOverlayGUI.Instance; + if (gui == null) return "Overlay not initialized."; + gui.Visible = !gui.Visible; + return $"Overlay {(gui.Visible ? "shown" : "hidden")}."; + } + + static string SetLod(string[] comps) + { + if (comps.Length < 3) + { + ConsistLOD.Enabled = !ConsistLOD.Enabled; + return $"LOD {(ConsistLOD.Enabled ? "enabled" : "disabled")}. Threshold: {ConsistLOD.DistanceThreshold:F0}m. Usage: /rpf lod "; + } + if (comps[2].ToLower() == "off") { ConsistLOD.Enabled = false; return "LOD disabled."; } + if (float.TryParse(comps[2], out float dist) && dist > 0f) + { + ConsistLOD.DistanceThreshold = dist; + ConsistLOD.Enabled = true; + return $"LOD enabled, fast path for interior cars beyond {dist:F0}m."; + } + return "Usage: /rpf lod or /rpf lod off"; + } + + static string ToggleForceActive() + { + PhysicsTimer.ForceActive = !PhysicsTimer.ForceActive; + return PhysicsTimer.ForceActive + ? "ForceActive ON — stock optimizer bypassed, solver always runs." + : "ForceActive OFF — stock optimizer active."; + } + + static string Usage() => + "Usage: /rpf "; +} diff --git a/Info.json b/Info.json new file mode 100644 index 0000000..3f962c6 --- /dev/null +++ b/Info.json @@ -0,0 +1,11 @@ +{ + "Id": "RailroaderPhysicsOverhaul", + "DisplayName": "Physics Overhaul", + "Author": "seton", + "Version": "0.1.1", + "ManagerVersion": "0.27.0", + "GameVersionPoint": "0", + "AssemblyName": "RailroaderPhysicsOverhaul.dll", + "EntryMethod": "RailroaderPhysicsOverhaul.Main.Load", + "Requirements": [] +} diff --git a/Main.cs b/Main.cs new file mode 100644 index 0000000..d138543 --- /dev/null +++ b/Main.cs @@ -0,0 +1,49 @@ +using HarmonyLib; +using UnityEngine; +using UnityModManagerNet; + +namespace RailroaderPhysicsOverhaul; + +public static class Main +{ + internal static UnityModManager.ModEntry ModEntry { get; private set; } + public static ModSettings Settings { get; private set; } + + public static bool Load(UnityModManager.ModEntry modEntry) + { + ModEntry = modEntry; + + // Load persisted settings (creates defaults if Settings.xml doesn't exist yet). + Settings = ModSettings.Load(modEntry); + + // Apply settings to runtime state before any patches run. + ConsistLOD.Enabled = Settings.OptimizerEnabled; + ConsistLOD.DistanceThreshold = Settings.DistanceThreshold; + ConsistLOD.ResyncInterval = Settings.ResyncInterval; + ConsistLOD.BlacklistEnabled = Settings.BlacklistEnabled; + ConsistLOD.IsBlacklist = Settings.IsBlacklist; + foreach (string name in Settings.RoadNumberList) + ConsistLOD.RoadNumberList.Add(name); + + ConsistFreezer.AutoFreezeEnabled = Settings.AutoFreezeEnabled; + ConsistFreezer.AutoFreezeDistance = Settings.AutoFreezeDistance; + ConsistFreezer.AutoFreezeSpeedThreshold = Settings.AutoFreezeSpeedThreshold; + + var harmony = new Harmony(modEntry.Info.Id); + harmony.PatchAll(); + PhysicsTimer.TryPatchPositionCars(harmony, modEntry.Logger); + + var go = new GameObject("RailroaderPhysicsOverhaul.Overlay"); + Object.DontDestroyOnLoad(go); + var overlay = go.AddComponent(); + overlay.Visible = Settings.ShowOverlay; + overlay.Opacity = Settings.OverlayOpacity; + + // UMM settings panel callbacks. + modEntry.OnGUI = entry => SettingsGUI.Draw(entry, Settings); + modEntry.OnSaveGUI = entry => Settings.Save(entry); + + modEntry.Logger.Log("RailroaderPhysicsOverhaul loaded."); + return true; + } +} diff --git a/PhysicsOverhaulMod.csproj b/PhysicsOverhaulMod.csproj new file mode 100644 index 0000000..93eaacf --- /dev/null +++ b/PhysicsOverhaulMod.csproj @@ -0,0 +1,53 @@ + + + + netstandard2.1 + 10 + RailroaderPhysicsOverhaul + RailroaderPhysicsOverhaul + ..\..\Railroader\Mods\RailroaderPhysicsOverhaul\ + false + false + + false + + + + + + Always + + + + + + ..\Railroader_Data\Managed\Assembly-CSharp.dll + false + + + ..\Railroader_Data\Managed\UnityModManager\UnityModManager.dll + false + + + ..\Railroader_Data\Managed\UnityModManager\0Harmony.dll + false + + + ..\Railroader_Data\Managed\UnityEngine.CoreModule.dll + false + + + ..\Railroader_Data\Managed\UnityEngine.IMGUIModule.dll + false + + + ..\Railroader_Data\Managed\UnityEngine.PhysicsModule.dll + false + + + ..\Railroader_Data\Managed\UnityEngine.JSONSerializeModule.dll + false + + + + diff --git a/PhysicsOverlayGUI.cs b/PhysicsOverlayGUI.cs new file mode 100644 index 0000000..29b83f3 --- /dev/null +++ b/PhysicsOverlayGUI.cs @@ -0,0 +1,275 @@ +using UnityEngine; + +namespace RailroaderPhysicsOverhaul; + +public class PhysicsOverlayGUI : MonoBehaviour +{ + public static PhysicsOverlayGUI Instance { get; private set; } + public bool Visible = true; + public float Opacity = 1.0f; + + Rect _windowRect = new(10f, 10f, 420f, 10f); + GUIStyle _blueLabel; + GUIStyle _greenLabel; + GUIStyle _yellowLabel; + GUIStyle _orangeLabel; + GUIStyle _dimLabel; + GUIStyle _fps60Label; + GUIStyle _fps30Label; + GUIStyle _onStyle; // bold green, no button background + GUIStyle _offStyle; // bold red, no button background + + string _lodStatsStr = ""; + string _freezeStatsStr = ""; + int _lodStatsFrame; + + GUIStyle _windowStyle; + Texture2D _solidTex; // 1×1 white — lets GUI.backgroundColor.a control opacity 0–100% + + // Graph rendered as a texture — avoids all GL coordinate-space issues. + // Unity textures are Y-up (row 0 = bottom pixel), IMGUI is Y-down, + // but GUI.DrawTexture just stretches the texture into the target rect, + // so we flip Y in our write formula and the result renders correctly. + Texture2D _graphTex; + Color32[] _graphPixels; + const int TexW = 300; // matches RingSize exactly — 1 pixel column per sample + const int TexH = 90; + + static readonly Color32 ColBg = new(16, 16, 16, 220); + static readonly Color32 Col60 = new(210, 210, 210, 255); // 60fps reference line + static readonly Color32 Col30 = new(210, 210, 210, 255); // 30fps reference line + static readonly Color32 ColRender = new(50, 140, 215, 255); // render frame time (blue) + static readonly Color32 ColFrame = new(70, 200, 70, 255); // FixedUpdate total (green) + static readonly Color32 ColTick = new(210, 185, 50, 255); // Tick() only (yellow) + static readonly Color32 ColPosCars = new(220, 110, 30, 255); // PositionCars (orange) + + static readonly int WindowId = "RPFOverlay".GetHashCode(); + + const float RefFps60Ms = 16.7f; + const float RefFps30Ms = 33.3f; + + void Awake() + { + Instance = this; + _graphTex = new Texture2D(TexW, TexH, TextureFormat.RGBA32, false) + { filterMode = FilterMode.Point, hideFlags = HideFlags.HideAndDontSave }; + _graphPixels = new Color32[TexW * TexH]; + + _solidTex = new Texture2D(1, 1, TextureFormat.RGBA32, false) + { hideFlags = HideFlags.HideAndDontSave }; + _solidTex.SetPixel(0, 0, Color.white); + _solidTex.Apply(); + } + + void LateUpdate() + { + // Record render frame time each rendered frame (not each physics tick). + // Time.unscaledDeltaTime = wall-clock ms since last render frame. + PhysicsTimer.RecordRenderFrame(Time.unscaledDeltaTime * 1000f); + } + + void OnDestroy() + { + if (_graphTex != null) Destroy(_graphTex); + if (_solidTex != null) Destroy(_solidTex); + Instance = null; + } + + void OnGUI() + { + if (!Visible) return; + EnsureStyles(); + // Tint the solid-white window background to dark gray at the chosen opacity. + // Using a custom style (solid texture) means Opacity=1 → fully opaque, not + // capped by whatever alpha the default IMGUI skin has baked in. + Color prevBg = GUI.backgroundColor; + GUI.backgroundColor = new Color(0.063f, 0.063f, 0.063f, Opacity); + _windowRect = GUILayout.Window(WindowId, _windowRect, DrawWindow, + "Physics Profiler", _windowStyle, GUILayout.MinWidth(420f)); + GUI.backgroundColor = prevBg; + } + + void DrawWindow(int _) + { + // The window background was already drawn with the opacity tint — restore + // backgroundColor so buttons and labels inside use their normal skin colors. + GUI.backgroundColor = Color.white; + GUILayout.Label(PhysicsTimer.GetReport()); + + // Reserve space for graph in window-local coords. + Rect localRect = GUILayoutUtility.GetRect( + GUIContent.none, GUIStyle.none, + GUILayout.Height(TexH), GUILayout.ExpandWidth(true)); + + if (Event.current.type == EventType.Repaint) + { + UpdateGraphTexture(); + // GUI.DrawTexture uses window-local coords — no coordinate conversion needed. + GUI.DrawTexture(localRect, _graphTex, ScaleMode.StretchToFill); + + // 10% headroom above the 30fps line so it's never squashed against the top pixel row. + float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f); + + // Reference-line labels: centered vertically on the line, anchored to right edge. + float y60Gui = LocalGaugeY(localRect, maxMs, RefFps60Ms); + GUI.Label(new Rect(localRect.xMax - 54f, y60Gui - 9f, 52f, 18f), "60 fps", _fps60Label); + float y30Gui = LocalGaugeY(localRect, maxMs, RefFps30Ms); + GUI.Label(new Rect(localRect.xMax - 54f, y30Gui - 9f, 52f, 18f), "30 fps", _fps30Label); + + // Current sample readout (top-left of graph). + int prev = (PhysicsTimer.RingWrite - 1 + PhysicsTimer.RingSize) % PhysicsTimer.RingSize; + GUI.Label(new Rect(localRect.x + 4f, localRect.y + 2f, 340f, 20f), + $"render:{PhysicsTimer.RingRender[prev]:F2}ms phys:{PhysicsTimer.RingFrame[prev]:F2}ms tick:{PhysicsTimer.RingTick[prev]:F2}ms", + _dimLabel); + } + + // Legend row + GUILayout.BeginHorizontal(); + GUILayout.Label(" ■ Render", _blueLabel); + GUILayout.Label(" ■ FixedUpdate", _greenLabel); + GUILayout.Label(" ■ Tick()", _yellowLabel); + if (PhysicsTimer.HasPosCarsData) + GUILayout.Label(" ■ PosCars", _orangeLabel); + GUILayout.Label($" [{PhysicsTimer.RingSize} frames]", _dimLabel); + GUILayout.EndHorizontal(); + + // Physics Optimizer row: plain-text toggle (no button background), then slow-updating stats + GUILayout.BeginHorizontal(); + GUILayout.Label("Physics Optimizer", _dimLabel, GUILayout.Width(130f)); + if (GUILayout.Button(ConsistLOD.Enabled ? "ON" : "OFF", + ConsistLOD.Enabled ? _onStyle : _offStyle, GUILayout.Width(34f))) + { + ConsistLOD.Enabled = !ConsistLOD.Enabled; + // Persist toggle state so it survives a game restart. + Main.Settings.OptimizerEnabled = ConsistLOD.Enabled; + Main.Settings.Save(Main.ModEntry); + } + + // Stats refresh once per second — readable, not flickering + if (Time.frameCount != _lodStatsFrame && Time.frameCount % 60 == 0) + { + _lodStatsFrame = Time.frameCount; + if (ConsistLOD.Enabled) + { + int fast = ConsistLOD.LastFastPathCount; + int full = ConsistLOD.LastFullPathCount; + int total = fast + full; + _lodStatsStr = $"fast:{fast}/{total} full:{full}/{total} dist:{ConsistLOD.DistanceThreshold:F0}m resync:1/{ConsistLOD.ResyncInterval}"; + } + else + { + _lodStatsStr = ""; + } + + int atRest = ConsistFreezer.LastAtRestCars; + int byDist = ConsistFreezer.LastDistanceCars; + _freezeStatsStr = $"stopped:{atRest} dist/spd:{byDist}"; + } + GUILayout.Label(_lodStatsStr, _dimLabel); + GUILayout.EndHorizontal(); + + // Auto-freeze row — shows cars skipping the Verlet tick each physics step + GUILayout.BeginHorizontal(); + GUILayout.Label("Auto Freeze", _dimLabel, GUILayout.Width(130f)); + if (GUILayout.Button(ConsistFreezer.AutoFreezeEnabled ? "ON" : "OFF", + ConsistFreezer.AutoFreezeEnabled ? _onStyle : _offStyle, GUILayout.Width(34f))) + { + ConsistFreezer.AutoFreezeEnabled = !ConsistFreezer.AutoFreezeEnabled; + Main.Settings.AutoFreezeEnabled = ConsistFreezer.AutoFreezeEnabled; + Main.Settings.Save(Main.ModEntry); + } + GUILayout.Label(_freezeStatsStr, _dimLabel); + GUILayout.EndHorizontal(); + + GUI.DragWindow(new Rect(0f, 0f, _windowRect.width, 20f)); + } + + // Y in IMGUI window-local space: top of rect = maxMs, bottom = 0ms. + static float LocalGaugeY(Rect r, float maxMs, float ms) => + r.yMax - Mathf.Clamp01(ms / maxMs) * r.height; + + void UpdateGraphTexture() + { + float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f); + int write = PhysicsTimer.RingWrite; + int n = PhysicsTimer.RingSize; + float[] render = PhysicsTimer.RingRender; + float[] frame = PhysicsTimer.RingFrame; + float[] tick = PhysicsTimer.RingTick; + float[] posCars = PhysicsTimer.RingPosCars; + + // Background fill. + for (int i = 0; i < _graphPixels.Length; i++) + _graphPixels[i] = ColBg; + + // Stacked filled areas — drawn back-to-front so later layers paint over earlier ones. + // + // Layer 1 (bottom): render frame time. + DrawFilledArea(render, null, n, write, maxMs, ColRender); + // Layer 2: FixedUpdate total, stacked on top of render. + DrawFilledArea(frame, render, n, write, maxMs, ColFrame); + // Layer 3: Tick(), painted over the lower part of the FixedUpdate band (same baseline). + DrawFilledArea(tick, render, n, write, maxMs, ColTick); + // Layer 4: PosCars, painted over the lower part of the Tick band (same baseline). + if (PhysicsTimer.HasPosCarsData) + DrawFilledArea(posCars, render, n, write, maxMs, ColPosCars); + + // Reference lines on top of all data. + DrawHLine(maxMs, RefFps60Ms, Col60); + DrawHLine(maxMs, RefFps30Ms, Col30); + + _graphTex.SetPixels32(_graphPixels); + _graphTex.Apply(false); + } + + // Pixel row for a given ms value: row 0 = bottom (0ms), row TexH-1 = top (maxMs). + static int MsToRow(float ms, float maxMs) => + Mathf.Clamp((int)(ms / maxMs * TexH), 0, TexH - 1); + + void DrawHLine(float maxMs, float ms, Color32 color) + { + int row = MsToRow(ms, maxMs); + int offset = row * TexW; + for (int x = 0; x < TexW; x++) + _graphPixels[offset + x] = color; + } + + // Fills a solid area from [baselines[i]] to [baselines[i] + values[i]] for each sample column. + // baselines == null means 0 (fill from the bottom of the chart). + void DrawFilledArea(float[] values, float[] baselines, int n, int write, float maxMs, Color32 color) + { + for (int x = 0; x < TexW; x++) + { + int si = (write + (int)((float)x / TexW * n)) % n; + float bot = baselines != null ? baselines[si] : 0f; + int rowBot = MsToRow(bot, maxMs); + int rowTop = MsToRow(bot + values[si], maxMs); + int lo = Mathf.Min(rowBot, rowTop); + int hi = Mathf.Max(rowBot, rowTop); + for (int r = lo; r <= hi; r++) + _graphPixels[r * TexW + x] = color; + } + } + + void EnsureStyles() + { + if (_blueLabel != null) return; + _blueLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.2f, 0.55f, 0.85f) } }; + _greenLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.4f, 1f, 0.4f) } }; + _yellowLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(1f, 0.85f, 0.2f) } }; + _orangeLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.9f, 0.45f, 0.1f) } }; + _dimLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.55f, 0.55f, 0.55f) } }; + _fps60Label = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.82f, 0.82f, 0.82f) } }; + _fps30Label = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.82f, 0.82f, 0.82f) } }; + _onStyle = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.4f, 1f, 0.4f) }, hover = { textColor = new Color(0.7f, 1f, 0.7f) } }; + _offStyle = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(1f, 0.35f, 0.3f) }, hover = { textColor = new Color(1f, 0.6f, 0.55f) } }; + + // Custom window style: solid white background texture so GUI.backgroundColor.a + // gives true 0–100% opacity rather than being capped by the skin's baked-in alpha. + _windowStyle = new GUIStyle(GUI.skin.window); + _windowStyle.normal.background = _solidTex; + _windowStyle.onNormal.background = _solidTex; + _windowStyle.focused.background = _solidTex; + _windowStyle.onFocused.background = _solidTex; + } +} diff --git a/PhysicsTimer.cs b/PhysicsTimer.cs new file mode 100644 index 0000000..aadb4ef --- /dev/null +++ b/PhysicsTimer.cs @@ -0,0 +1,226 @@ +using System.Diagnostics; +using System.Reflection; +using System.Text; +using HarmonyLib; +using Model.Physics; +using UnityModManagerNet; + +namespace RailroaderPhysicsOverhaul; + +public static class PhysicsTimer +{ + // --- Config --- + public const int RingSize = 300; // ~5 seconds at 60fps + const int SampleFrames = 60; + + // --- ForceActive: bypass AllCarsAtRest() so we always get solver data --- + public static bool ForceActive = false; + + // --- PositionCars sub-timing (dynamically patched — may not be present) --- + public static bool HasPosCarsData { get; private set; } = false; + + // --- Rolling average accumulators --- + static long _tickElapsed; + static long _frameElapsed; + static long _posCarsElapsed; + static int _tickCarSum; + static int _frames; + + static long _lastTickElapsed; + static long _lastFrameElapsed; + static long _lastPosCarsElapsed; + static int _lastTickCarSum; + static int _lastFrames; + + // --- Render frame rolling average (recorded from LateUpdate, not FixedUpdate) --- + static float _renderMsAccum; + static int _renderFrameCount; + static float _lastAvgRenderMs; + public static float LastAvgRenderMs => _lastAvgRenderMs; + + // --- Per-physics-frame ring buffers (graph) --- + public static readonly float[] RingFrame = new float[RingSize]; // total FixedUpdate ms + public static readonly float[] RingTick = new float[RingSize]; // Tick() only ms + public static readonly float[] RingPosCars = new float[RingSize]; // PositionCars ms (0 if not patched) + public static readonly float[] RingRender = new float[RingSize]; // render frame ms at time of physics tick + public static int RingWrite { get; private set; } + + // Most recent render-frame time — updated each LateUpdate, sampled into RingRender each FixedUpdate. + static float _lastInstantRenderMs; + + // Intra-frame accumulators — reset each FixedUpdate + static long _frameTickElapsed; + static long _framePosCarsElapsed; + static int _frameCarCount; + + static readonly double TicksPerMs = 1000.0 / Stopwatch.Frequency; + + // --- Recording --- + + public static void RecordTick(long ticks, int cars) + { + _tickElapsed += ticks; + _frameTickElapsed += ticks; + _tickCarSum += cars; + _frameCarCount += cars; + } + + public static void RecordPosCars(long ticks) + { + _posCarsElapsed += ticks; + _framePosCarsElapsed += ticks; + } + + public static void RecordFrame(long ticks) + { + // Write ring entry + RingFrame[RingWrite] = (float)(ticks * TicksPerMs); + RingTick[RingWrite] = (float)(_frameTickElapsed * TicksPerMs); + RingPosCars[RingWrite] = (float)(_framePosCarsElapsed * TicksPerMs); + RingRender[RingWrite] = _lastInstantRenderMs; + RingWrite = (RingWrite + 1) % RingSize; + + _frameTickElapsed = 0; + _framePosCarsElapsed = 0; + _frameCarCount = 0; + + // Rolling average + _frameElapsed += ticks; + _frames++; + if (_frames < SampleFrames) return; + + _lastTickElapsed = _tickElapsed; + _lastFrameElapsed = _frameElapsed; + _lastPosCarsElapsed = _posCarsElapsed; + _lastTickCarSum = _tickCarSum; + _lastFrames = _frames; + + _tickElapsed = 0; + _frameElapsed = 0; + _posCarsElapsed = 0; + _tickCarSum = 0; + _frames = 0; + } + + // Called from PhysicsOverlayGUI.LateUpdate() to capture render frame time. + public static void RecordRenderFrame(float deltaMs) + { + _lastInstantRenderMs = deltaMs; // sampled into RingRender on the next RecordFrame call + _renderMsAccum += deltaMs; + _renderFrameCount++; + if (_renderFrameCount < SampleFrames) return; + _lastAvgRenderMs = _renderMsAccum / _renderFrameCount; + _renderMsAccum = 0; + _renderFrameCount = 0; + } + + // --- Graph scaling --- + // Returns the max stacked total (render + FixedUpdate) so the chart scales to show all layers. + public static float GetRingMax() + { + float max = 0f; + for (int i = 0; i < RingSize; i++) + { + float total = RingRender[i] + RingFrame[i]; + if (total > max) max = total; + } + return max * 1.15f; + } + + // --- Text report --- + public static string GetReport() + { + if (_lastFrames == 0) + return "Collecting samples..."; + + double frameMs = _lastFrameElapsed * TicksPerMs / _lastFrames; + double tickMs = _lastTickElapsed * TicksPerMs / _lastFrames; + double posCarsMs = _lastPosCarsElapsed * TicksPerMs / _lastFrames; + double otherMs = frameMs - tickMs; + double pct = frameMs > 0 ? tickMs / frameMs * 100.0 : 0; + + var sb = new StringBuilder(); + sb.AppendLine($"FixedUpdate: {frameMs:F3}ms/frame"); + sb.AppendLine($" Tick(): {tickMs:F3}ms/frame ({pct:F0}% of FixedUpdate)"); + + int avgCars = _lastFrames > 0 ? _lastTickCarSum / _lastFrames : 0; + if (avgCars > 0) + sb.AppendLine($" per car: {tickMs / avgCars * 1000.0:F1}µs × {avgCars} cars"); + + if (HasPosCarsData) + { + double integrateMs = tickMs - posCarsMs; + sb.AppendLine($" PosCars: {posCarsMs:F3}ms (3D update)"); + sb.AppendLine($" Integrate: {integrateMs:F3}ms (Verlet+constraints)"); + } + + sb.AppendLine($" Air/other: {otherMs:F3}ms/frame"); + + if (_lastAvgRenderMs > 0) + { + float fps = 1000f / _lastAvgRenderMs; + sb.Append($"Render: {_lastAvgRenderMs:F1}ms/frame ({fps:F0}fps)"); + } + + return sb.ToString(); + } + + // --- Dynamic patch for PositionCars --- + // Called from Main.Load() after PatchAll(). Gracefully skips if the method + // doesn't exist under this name (just logs a message and leaves RingPosCars empty). + public static void TryPatchPositionCars(Harmony harmony, UnityModManager.ModEntry.ModLogger log) + { + string[] candidates = { + "PositionCars", "UpdateCarPositions", "UpdatePositions", + "PositionAllCars", "MoveAllCars", "UpdateElementPositions" + }; + foreach (string name in candidates) + { + var m = typeof(IntegrationSet).GetMethod(name, + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance); + if (m == null) continue; + + harmony.Patch(m, + new HarmonyMethod(typeof(PosCarsTimerPatch), nameof(PosCarsTimerPatch.Prefix)), + new HarmonyMethod(typeof(PosCarsTimerPatch), nameof(PosCarsTimerPatch.Postfix))); + HasPosCarsData = true; + log.Log($"[RPF] Patched IntegrationSet.{name} for PositionCars timing."); + return; + } + + // None of the guesses matched — dump all non-property methods so we know what to use. + log.Log("[RPF] PositionCars not found. IntegrationSet methods:"); + foreach (var m in typeof(IntegrationSet).GetMethods( + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) + { + if (m.IsSpecialName) continue; // skip property accessors + string parms = string.Join(", ", + System.Array.ConvertAll(m.GetParameters(), p => p.ParameterType.Name + " " + p.Name)); + log.Log($"[RPF] {m.ReturnType.Name} {m.Name}({parms})"); + } + } +} + +// Static patch class used by TryPatchPositionCars — must be visible to Harmony's IL. +static class PosCarsTimerPatch +{ + public static void Prefix(out long __state) => __state = Stopwatch.GetTimestamp(); + public static void Postfix(long __state) => + PhysicsTimer.RecordPosCars(Stopwatch.GetTimestamp() - __state); +} + +[HarmonyPatch(typeof(IntegrationSet), "Tick")] +static class TickTimerPatch +{ + static void Prefix(out long __state) => __state = Stopwatch.GetTimestamp(); + static void Postfix(IntegrationSet __instance, long __state) => + PhysicsTimer.RecordTick(Stopwatch.GetTimestamp() - __state, __instance.NumberOfCars); +} + +[HarmonyPatch(typeof(TrainController), "FixedUpdate")] +static class FixedUpdateTimerPatch +{ + static void Prefix(out long __state) => __state = Stopwatch.GetTimestamp(); + static void Postfix(long __state) => + PhysicsTimer.RecordFrame(Stopwatch.GetTimestamp() - __state); +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..2db6b50 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# Railroader Physics Overhaul + +A [Unity Mod Manager](https://www.nexusmods.com/site/mods/21) mod for [Railroader](https://store.steampowered.com/app/1638770/Railroader/) that reduces CPU time spent on train physics without sacrificing gameplay. + +This mod is **HEAVILY** experimental. Although it should be fully compatible and shouldnt corrupt your save, any use on your existing saves is your own risk and I accept no liability. + +## Features + +### Physics Optimizer (LOD fast-path) +Cars farther than a configurable distance threshold (default 30 m) get a lightweight position update instead of running the full Bezier-curve solver every physics tick. Every few ticks the car resyncs against the full solver to stay numerically correct. The result: far fewer expensive curve evaluations per frame, with limited to no visible difference to gameplay. + +### Auto Freeze +Consists that are both slow (< 0.3 m/s) and far from the camera (> 200 m) are skipped entirely by the Verlet integration step. If running the wonderful Stock Optimizer mod please disable this feature and beware that there might be instability. + +### In-Game Profiler Overlay +A movable HUD window shows a stacked line chart of frame time over the last 300 physics ticks (~5 seconds). Layers from the bottom up: + +| Color | Layer | +|-------|-------| +| Blue | Render frame time (GPU + CPU render work) | +| Green | `FixedUpdate` total (all physics) | +| Yellow | `Tick()` only (Verlet integration) | +| Orange | `PositionCars` (3D position update) | + +Reference lines at 16.7 ms (60 fps) and 33.3 ms (30 fps) are always on screen regardless of how well the game is running. Toggle the overlay with `/rpf overlay` or from the UMM settings panel. + +## Theory of Operation + +### Why physics is expensive in Railroader +Each physics tick (`FixedUpdate`), `TrainController` calls `PositionWheelBoundsFront` for every car. This method walks the spline to find the 3D position and orientation for each truck — two expensive distance-to-parameter lookups per car. With long consists on curved track, this dominates frame time. + +### Physics Optimizer detail +The mod patches `Car.PositionWheelBoundsFront` with a Harmony prefix. When a car is far from the camera and all its couplers are coupled: +1. **Track bounds** (`WheelBoundsF`/`R`) are still updated every tick — the constraint solver needs them. +2. **Truck positions** are computed, but only every N ticks (controlled by `ResyncInterval`). Between resyncs, the car keeps the body rotation from the previous tick. +3. **`PositionAccuracy`** matches what the full path would use (`High` when visible, `Standard` otherwise) so there is no positional jump when the camera enters the threshold. +4. **`OnPosition`** is fired so `TrainController.CarDidPosition` runs — this keeps the car-culler bounding sphere, spatial hash, and segment cache current. +5. **`LocationF`/`LocationR`** are updated each tick so the segment cache never goes stale. + +### Auto Freeze detail +`ShouldSkipTick` is replaced. The stock implementation skips the entire solver when all cars are at rest; this mod tracks each car individually and skips cars that are far away and *very* slow, reducing wasteful simulation of parked cars. + +### Sampling model (the "% of FixedUpdate" number) +The profiler measures time with `Stopwatch.GetTimestamp()` (nanosecond resolution, no GC pressure). It accumulates over 60-tick windows. "% of FixedUpdate" is how much of the total physics budget `Tick()` alone consumed that window — a high percentage means Verlet integration dominates; a low percentage means air brakes, coupler forces, or position updates are the bottleneck. + +## Installation + +### Requirements +- [Unity Mod Manager](https://www.nexusmods.com/site/mods/21) installed and configured for Railroader +- Railroader (Steam) + +### Installing with Unity Mod Manager (recommended) +1. Download the latest release zip. +2. Open Unity Mod Manager (Ctrl+F10 in game, or the standalone installer). +3. Drag the zip onto the **Mods** tab, or click **Install Mod** and select the zip. +4. Launch the game. + +### Manual installation +1. Unzip the release into `\Mods\RailroaderPhysicsOverhaul\`. +2. The folder must contain `Info.json` and `RailroaderPhysicsOverhaul.dll`. +3. Launch the game. + +## Console Commands + +Open the in-game console and type `/rpf `: + +| Command | Description | +|---------|-------------| +| `/rpf help` | List all subcommands | +| `/rpf overlay` | Toggle the profiler HUD | +| `/rpf timing` | Print the current timing report to the console | +| `/rpf dump` | Dump the selected consist's state (cars, speed, coupling, segment) | +| `/rpf lod ` | Set the LOD distance threshold | +| `/rpf lod off` | Disable the LOD fast-path entirely | +| `/rpf freeze` | Freeze the selected consist's physics | +| `/rpf unfreeze` | Unfreeze the selected consist | +| `/rpf forceactive` | Toggle ForceActive (bypasses the at-rest check for profiling) | + +## Settings + +Open UMM (Ctrl+F10), select **Physics Overhaul**. All settings auto-save on change. + +| Setting | Default | Description | +|---------|---------|-------------| +| Physics Optimizer | On | Enable/disable the LOD fast-path | +| Distance Threshold | 30 m | Cars beyond this distance use the fast path | +| Resync Interval | 4 ticks | Full recalculation every N ticks (1 = every tick, no dead-reckoning) | +| Auto Freeze | On | Enable/disable the individual-consist freeze | +| Auto Freeze Distance | 200 m | Consists beyond this distance are eligible for freezing | +| Auto Freeze Speed | 0.3 m/s | Maximum speed for a consist to be considered stopped | +| Show Overlay | On | Show/hide the profiler HUD on game load | +| Overlay Opacity | 100% | Profiler window background opacity | +| Blacklist/Whitelist | Off | Filter specific road numbers in or out of LOD treatment | + +## Building from Source + +``` +dotnet build PhysicsOverhaulMod.csproj +``` + +The build output goes directly to `\Mods\RailroaderPhysicsOverhaul\`. The project references game DLLs relative to the source folder, so it must be placed inside the Railroader game directory. + +**Prerequisites:** .NET SDK 6 or later, Railroader installed in the same directory tree. diff --git a/Settings.cs b/Settings.cs new file mode 100644 index 0000000..b96ecf4 --- /dev/null +++ b/Settings.cs @@ -0,0 +1,301 @@ +using System; +using System.IO; +using System.Linq; +using UnityEngine; +using UnityModManagerNet; + +namespace RailroaderPhysicsOverhaul; + +[Serializable] +public class ModSettings : UnityModManager.ModSettings +{ + public bool OptimizerEnabled = true; + public float DistanceThreshold = 30f; + public int ResyncInterval = 4; + public bool ShowOverlay = true; + + // Auto-freeze: skip the Verlet tick for far+slow consists + public bool AutoFreezeEnabled = true; + public float AutoFreezeDistance = 200f; // meters + public float AutoFreezeSpeedThreshold = 0.3f; // m/s (~0.7 mph) + + // Road number filter — serialized as a flat string array in Settings.xml. + public float OverlayOpacity = 1.0f; + + public bool BlacklistEnabled = false; + public bool IsBlacklist = true; // true = blacklist, false = whitelist + public string[] RoadNumberList = Array.Empty(); + + // UMM's XML serializer silently fails on Unity's Mono runtime — use JsonUtility instead. + public override void Save(UnityModManager.ModEntry modEntry) + { + try + { + File.WriteAllText(Path.Combine(modEntry.Path, "Settings.json"), + JsonUtility.ToJson(this, prettyPrint: true)); + } + catch (Exception e) + { + modEntry.Logger.Error($"Settings save failed: {e.Message}"); + } + } + + public static ModSettings Load(UnityModManager.ModEntry modEntry) + { + try + { + string path = Path.Combine(modEntry.Path, "Settings.json"); + if (File.Exists(path)) + return JsonUtility.FromJson(File.ReadAllText(path)); + } + catch (Exception e) + { + modEntry.Logger.Log($"Settings load failed, using defaults: {e.Message}"); + } + return new ModSettings(); + } +} + +// Draws the UMM in-game settings panel. +static class SettingsGUI +{ + static readonly int[] ResyncOptions = { 1, 2, 4, 8 }; + static readonly string[] ResyncLabels = { "1/1 (max quality)", "1/2", "1/4 (default)", "1/8" }; + + static string _addInput = ""; + static string _toRemove = null; // deferred to avoid mutating list during enumeration + + public static void Draw(UnityModManager.ModEntry modEntry, ModSettings s) + { + bool changed = false; + GUILayout.BeginVertical(); + + // ── Physics Optimizer ───────────────────────────────────────────────────── + GUILayout.Label("Physics Optimizer", GUILayout.ExpandWidth(false)); + GUILayout.Space(4f); + + bool newEnabled = GUILayout.Toggle(s.OptimizerEnabled, + " Enabled (skips expensive 3D updates for cars far from camera)"); + if (newEnabled != s.OptimizerEnabled) + { + s.OptimizerEnabled = newEnabled; + ConsistLOD.Enabled = newEnabled; + changed = true; + } + + GUILayout.Space(6f); + + // Distance threshold slider + GUILayout.BeginHorizontal(); + GUILayout.Label($"Distance threshold: {s.DistanceThreshold:F0} m", GUILayout.Width(220f)); + float newDist = GUILayout.HorizontalSlider(s.DistanceThreshold, 5f, 200f, GUILayout.Width(200f)); + GUILayout.EndHorizontal(); + GUILayout.Label(" Cars farther than this from the camera use dead-reckoning.", GUI.skin.label); + + if (Mathf.Abs(newDist - s.DistanceThreshold) > 0.5f) + { + s.DistanceThreshold = Mathf.Round(newDist); + ConsistLOD.DistanceThreshold = s.DistanceThreshold; + changed = true; + } + + GUILayout.Space(6f); + + // Resync quality radio buttons + GUILayout.Label("Resync quality (fraction of far cars fully updated per tick):"); + GUILayout.BeginHorizontal(); + for (int i = 0; i < ResyncOptions.Length; i++) + { + bool selected = s.ResyncInterval == ResyncOptions[i]; + if (GUILayout.Toggle(selected, ResyncLabels[i], GUI.skin.button, GUILayout.Width(130f)) && !selected) + { + s.ResyncInterval = ResyncOptions[i]; + ConsistLOD.ResyncInterval = s.ResyncInterval; + changed = true; + } + } + GUILayout.EndHorizontal(); + GUILayout.Label(" Lower = smoother visuals; higher = cheaper. Cost spread evenly via stagger.", GUI.skin.label); + + GUILayout.Space(12f); + + // ── Auto Freeze ─────────────────────────────────────────────────────────── + GUILayout.Label("Auto Freeze (replaces stock optimizer)", GUILayout.ExpandWidth(false)); + GUILayout.Space(4f); + + bool newAF = GUILayout.Toggle(s.AutoFreezeEnabled, + " Skip Verlet tick for consists that are far away and nearly stopped"); + if (newAF != s.AutoFreezeEnabled) + { + s.AutoFreezeEnabled = newAF; + ConsistFreezer.AutoFreezeEnabled = newAF; + changed = true; + } + + if (s.AutoFreezeEnabled) + { + GUILayout.Space(4f); + + GUILayout.BeginHorizontal(); + GUILayout.Label($"Freeze distance: {s.AutoFreezeDistance:F0} m", GUILayout.Width(200f)); + float newFDist = GUILayout.HorizontalSlider(s.AutoFreezeDistance, 50f, 1000f, GUILayout.Width(200f)); + GUILayout.EndHorizontal(); + GUILayout.Label(" Consists with no car closer than this are eligible to freeze.", GUI.skin.label); + if (Mathf.Abs(newFDist - s.AutoFreezeDistance) > 1f) + { + s.AutoFreezeDistance = Mathf.Round(newFDist); + ConsistFreezer.AutoFreezeDistance = s.AutoFreezeDistance; + changed = true; + } + + GUILayout.Space(4f); + + GUILayout.BeginHorizontal(); + GUILayout.Label($"Speed threshold: {s.AutoFreezeSpeedThreshold * 2.23694f:F1} mph", GUILayout.Width(200f)); + float newSpd = GUILayout.HorizontalSlider(s.AutoFreezeSpeedThreshold * 2.23694f, 0f, 10f, GUILayout.Width(200f)); + GUILayout.EndHorizontal(); + GUILayout.Label(" Consist must be slower than this to be eligible.", GUI.skin.label); + float newSpdMs = newSpd / 2.23694f; + if (Mathf.Abs(newSpdMs - s.AutoFreezeSpeedThreshold) > 0.01f) + { + s.AutoFreezeSpeedThreshold = newSpdMs; + ConsistFreezer.AutoFreezeSpeedThreshold = s.AutoFreezeSpeedThreshold; + changed = true; + } + } + + GUILayout.Space(12f); + + // ── Road Number Filter ──────────────────────────────────────────────────── + GUILayout.Label("Road Number Filter", GUILayout.ExpandWidth(false)); + GUILayout.Space(4f); + + bool newBlEnabled = GUILayout.Toggle(s.BlacklistEnabled, " Enable road number filter"); + if (newBlEnabled != s.BlacklistEnabled) + { + s.BlacklistEnabled = newBlEnabled; + ConsistLOD.BlacklistEnabled = newBlEnabled; + ConsistLOD.InvalidateConsistCache(); + changed = true; + } + + if (s.BlacklistEnabled) + { + GUILayout.Space(4f); + + // Mode toggle + GUILayout.BeginHorizontal(); + GUILayout.Label("Mode:", GUILayout.Width(45f)); + if (GUILayout.Toggle(s.IsBlacklist, " Blacklist (listed consists skip optimizer)", + GUILayout.ExpandWidth(false)) && !s.IsBlacklist) + { + s.IsBlacklist = true; + ConsistLOD.IsBlacklist = true; + ConsistLOD.InvalidateConsistCache(); + changed = true; + } + GUILayout.EndHorizontal(); + GUILayout.BeginHorizontal(); + GUILayout.Space(45f); + if (GUILayout.Toggle(!s.IsBlacklist, " Whitelist (only listed consists use optimizer)", + GUILayout.ExpandWidth(false)) && s.IsBlacklist) + { + s.IsBlacklist = false; + ConsistLOD.IsBlacklist = false; + ConsistLOD.InvalidateConsistCache(); + changed = true; + } + GUILayout.EndHorizontal(); + + GUILayout.Space(6f); + + // Add entry + GUILayout.BeginHorizontal(); + GUILayout.Label("Road number:", GUILayout.Width(110f)); + _addInput = GUILayout.TextField(_addInput, GUILayout.Width(160f)); + if (GUILayout.Button("Add", GUILayout.Width(50f))) + { + string trimmed = _addInput.Trim(); + if (trimmed.Length > 0 && !s.RoadNumberList.Contains(trimmed)) + { + s.RoadNumberList = s.RoadNumberList.Append(trimmed).ToArray(); + SyncList(s); + changed = true; + } + _addInput = ""; + } + GUILayout.EndHorizontal(); + GUILayout.Label(" Enter the full display name as shown in-game (e.g. \"UP 1234\").", GUI.skin.label); + + GUILayout.Space(4f); + + // Current entries + if (s.RoadNumberList.Length == 0) + { + GUILayout.Label(" (no entries)", GUI.skin.label); + } + else + { + foreach (string name in s.RoadNumberList) + { + GUILayout.BeginHorizontal(); + GUILayout.Label(name, GUILayout.Width(180f)); + if (GUILayout.Button("Remove", GUILayout.Width(70f))) + _toRemove = name; + GUILayout.EndHorizontal(); + } + + // Apply deferred removal (can't mutate inside foreach) + if (_toRemove != null) + { + s.RoadNumberList = s.RoadNumberList.Where(n => n != _toRemove).ToArray(); + SyncList(s); + _toRemove = null; + changed = true; + } + } + } + + GUILayout.Space(12f); + + // ── Profiler Overlay ────────────────────────────────────────────────────── + GUILayout.Label("Profiler Overlay", GUILayout.ExpandWidth(false)); + GUILayout.Space(4f); + + bool newOverlay = GUILayout.Toggle(s.ShowOverlay, " Show in-game profiler overlay"); + if (newOverlay != s.ShowOverlay) + { + s.ShowOverlay = newOverlay; + if (PhysicsOverlayGUI.Instance != null) + PhysicsOverlayGUI.Instance.Visible = newOverlay; + changed = true; + } + + GUILayout.Space(4f); + + GUILayout.BeginHorizontal(); + GUILayout.Label($"Opacity: {s.OverlayOpacity * 100f:F0}%", GUILayout.Width(110f)); + float newOpacity = GUILayout.HorizontalSlider(s.OverlayOpacity, 0.1f, 1.0f, GUILayout.Width(200f)); + GUILayout.EndHorizontal(); + if (Mathf.Abs(newOpacity - s.OverlayOpacity) > 0.01f) + { + s.OverlayOpacity = newOpacity; + if (PhysicsOverlayGUI.Instance != null) + PhysicsOverlayGUI.Instance.Opacity = s.OverlayOpacity; + changed = true; + } + + GUILayout.EndVertical(); + + if (changed) + s.Save(modEntry); + } + + static void SyncList(ModSettings s) + { + ConsistLOD.RoadNumberList.Clear(); + foreach (string n in s.RoadNumberList) + ConsistLOD.RoadNumberList.Add(n); + ConsistLOD.InvalidateConsistCache(); + } +}