From 5414fd897933c17b21ed2860a3a8f56a5f35fb07 Mon Sep 17 00:00:00 2001 From: Seton Carmichael Date: Tue, 16 Jun 2026 11:42:54 -0400 Subject: [PATCH] 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 --- .gitignore | 5 + ConsistFreezer.cs | 106 ++++++++++++++ ConsistLOD.cs | 193 ++++++++++++++++++++++++ ConsoleCommands.cs | 141 ++++++++++++++++++ Info.json | 11 ++ Main.cs | 49 +++++++ PhysicsOverhaulMod.csproj | 53 +++++++ PhysicsOverlayGUI.cs | 275 ++++++++++++++++++++++++++++++++++ PhysicsTimer.cs | 226 ++++++++++++++++++++++++++++ README.md | 103 +++++++++++++ Settings.cs | 301 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 1463 insertions(+) create mode 100644 .gitignore create mode 100644 ConsistFreezer.cs create mode 100644 ConsistLOD.cs create mode 100644 ConsoleCommands.cs create mode 100644 Info.json create mode 100644 Main.cs create mode 100644 PhysicsOverhaulMod.csproj create mode 100644 PhysicsOverlayGUI.cs create mode 100644 PhysicsTimer.cs create mode 100644 README.md create mode 100644 Settings.cs 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(); + } +}