railroader-physics-optimizer/Settings.cs
Seton Carmichael 5414fd8979 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
2026-06-16 12:24:56 -04:00

301 lines
12 KiB
C#

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