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
This commit is contained in:
Seton Carmichael 2026-06-16 11:42:54 -04:00
commit 5414fd8979
11 changed files with 1463 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
obj/
bin/
*.user
.vs/
*.suo

106
ConsistFreezer.cs Normal file
View file

@ -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<uint> _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
}
}

193
ConsistLOD.cs Normal file
View file

@ -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<string> 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<IntegrationSet, bool> _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();
}
}

141
ConsoleCommands.cs Normal file
View file

@ -0,0 +1,141 @@
using System.Linq;
using System.Text;
using HarmonyLib;
using Model;
using Model.Physics;
using UI.Console;
namespace RailroaderPhysicsOverhaul;
/// <summary>
/// Hooks into ConsoleCommandHandler._HandleSlashCommand to add /rpf commands
/// before the game's switch block sees them.
/// </summary>
[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 <meters|off>";
}
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 <meters> 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 <freeze|unfreeze|dump|timing|overlay|forceactive|lod|help>";
}

11
Info.json Normal file
View file

@ -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": []
}

49
Main.cs Normal file
View file

@ -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<PhysicsOverlayGUI>();
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;
}
}

53
PhysicsOverhaulMod.csproj Normal file
View file

@ -0,0 +1,53 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>10</LangVersion>
<AssemblyName>RailroaderPhysicsOverhaul</AssemblyName>
<RootNamespace>RailroaderPhysicsOverhaul</RootNamespace>
<OutputPath>..\..\Railroader\Mods\RailroaderPhysicsOverhaul\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<!-- Don't copy game DLLs to output — they live in the game folder -->
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<!-- Info.json is required by UMM in the output mod folder -->
<None Include="Info.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Reference Include="Assembly-CSharp">
<HintPath>..\Railroader_Data\Managed\Assembly-CSharp.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityModManager">
<HintPath>..\Railroader_Data\Managed\UnityModManager\UnityModManager.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>..\Railroader_Data\Managed\UnityModManager\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule">
<HintPath>..\Railroader_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.IMGUIModule">
<HintPath>..\Railroader_Data\Managed\UnityEngine.IMGUIModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.PhysicsModule">
<HintPath>..\Railroader_Data\Managed\UnityEngine.PhysicsModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.JSONSerializeModule">
<HintPath>..\Railroader_Data\Managed\UnityEngine.JSONSerializeModule.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>

275
PhysicsOverlayGUI.cs Normal file
View file

@ -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 0100%
// 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 0100% 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;
}
}

226
PhysicsTimer.cs Normal file
View file

@ -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);
}

103
README.md Normal file
View file

@ -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 `<Railroader install folder>\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 <subcommand>`:
| 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 <meters>` | 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 `<Railroader install folder>\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.

301
Settings.cs Normal file
View file

@ -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<string>();
// 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<ModSettings>(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("<b>Physics Optimizer</b>", 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("<b>Auto Freeze</b> (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("<b>Road Number Filter</b>", 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("<b>Profiler Overlay</b>", 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();
}
}