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:
commit
5414fd8979
11 changed files with 1463 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
obj/
|
||||||
|
bin/
|
||||||
|
*.user
|
||||||
|
.vs/
|
||||||
|
*.suo
|
||||||
106
ConsistFreezer.cs
Normal file
106
ConsistFreezer.cs
Normal 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
193
ConsistLOD.cs
Normal 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
141
ConsoleCommands.cs
Normal 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
11
Info.json
Normal 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
49
Main.cs
Normal 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
53
PhysicsOverhaulMod.csproj
Normal 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
275
PhysicsOverlayGUI.cs
Normal 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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
226
PhysicsTimer.cs
Normal file
226
PhysicsTimer.cs
Normal 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
103
README.md
Normal 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
301
Settings.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue