railroader-physics-optimizer/Settings.cs
Seton Carmichael 766822d15a release: 0.1.2 - debug visualization, loco exclusions, tuned defaults
New in 0.1.2:
- Debug color overlay: tint cars by physics state (yellow=frozen,
  cyan=fast-path, magenta=full-accuracy), individual toggles per state
- Exclude locomotives from Physics Optimizer and Auto Freeze
- Debug stats line in overlay showing live counts per state
- Resync quality 1/16 option added

Defaults tuned for new installs:
- Resync quality: 1/4 -> 1/8
- Speed threshold: ~0.7 mph -> 0.2 mph
- Overlay opacity: 100% -> 75%

Rename: Physics Overhaul -> Physics Optimizer throughout
2026-06-16 22:14:34 -04:00

349 lines
14 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 = 8;
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.09f; // m/s (~0.2 mph)
// Road number filter — serialized as a flat string array in Settings.xml.
public float OverlayOpacity = 0.75f;
// Locomotive exclusions
public bool ExcludeLocosFromLOD = true;
public bool ExcludeLocosFromFreeze = true;
// Debug visualization — tint cars by their current physics state.
public bool DebugHighlightFrozen = false;
public bool DebugHighlightFastPath = false;
public bool DebugHighlightFullPath = false;
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, 16 };
static readonly string[] ResyncLabels = { "1/1 (max quality)", "1/2", "1/4", "1/8 (default)", "1/16" };
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);
// ── Locomotive Exclusions ─────────────────────────────────────────────────
GUILayout.Label("<b>Locomotive Exclusions</b>", GUILayout.ExpandWidth(false));
GUILayout.Space(4f);
bool newExLOD = GUILayout.Toggle(s.ExcludeLocosFromLOD, " Exclude locomotives from Physics Optimizer");
if (newExLOD != s.ExcludeLocosFromLOD)
{
s.ExcludeLocosFromLOD = newExLOD;
ConsistLOD.ExcludeLocomotives = newExLOD;
changed = true;
}
bool newExFreeze = GUILayout.Toggle(s.ExcludeLocosFromFreeze, " Exclude locomotives from Auto Freeze");
if (newExFreeze != s.ExcludeLocosFromFreeze)
{
s.ExcludeLocosFromFreeze = newExFreeze;
ConsistFreezer.ExcludeLocomotives = newExFreeze;
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.Space(12f);
// ── Debug Visualization ───────────────────────────────────────────────────
GUILayout.Label("<b>Debug Visualization</b>", GUILayout.ExpandWidth(false));
GUILayout.Space(4f);
GUILayout.Label(" Tints car models by physics state. Useful for verifying LOD and freeze behaviour.", GUI.skin.label);
GUILayout.Space(4f);
bool newHF = GUILayout.Toggle(s.DebugHighlightFrozen, " Highlight frozen cars (yellow)");
if (newHF != s.DebugHighlightFrozen) { s.DebugHighlightFrozen = newHF; changed = true; }
bool newHFP = GUILayout.Toggle(s.DebugHighlightFastPath, " Highlight fast-path cars (cyan)");
if (newHFP != s.DebugHighlightFastPath) { s.DebugHighlightFastPath = newHFP; changed = true; }
bool newHFU = GUILayout.Toggle(s.DebugHighlightFullPath, " Highlight full-accuracy cars (magenta)");
if (newHFU != s.DebugHighlightFullPath) { s.DebugHighlightFullPath = newHFU; 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();
}
}