railroader-setons-special-s.../src/Modules/PhysicsOptimizer/PhysicsTimer.cs
seton a1fcf01125 Initial commit: S3 - Seton's Special Sauce v0.2.0
Consolidates two standalone Railroader mods into one UMM "everything mod"
with an optional-module framework. Modules are disabled by default and
toggled per-module from the S3 settings page.

Core framework:
- IModule contract plus ModuleRegistry, with each module owning a Harmony
  instance scoped by its id so only enabled modules patch the game
- Per-module flat JSON settings (SettingsStore). On this Mono runtime
  JsonUtility silently drops nested custom-class fields, so settings stay flat
- Foldout-per-module settings panel plus a detector that offers to disable the
  old standalone mods if they are still installed

Modules (moved over to parity, verified in-game):
- Physics Optimizer (was RailroaderPhysicsOverhaul): LOD fast-path and
  auto-freeze, profiler overlay, debug car tinting, /rpf console commands
- Map Popout (was RRPopout): native map detach window. Pure-UMM install that
  drops the winhttp proxy and LoadLibrary's RRPopout.dll from the mod folder.
  Native Win32 + D3D11 + Dear ImGui engine included. Fixes a latent break where
  the now-private MapBuilder.UpdateForZoom() is reached via Traverse.

Build: dotnet for the managed assembly (netstandard2.1) and CMake for the
native DLL. build-local.ps1 installs into the game, build-release.ps1 packages
the UMM drag-install zip.
2026-06-17 14:17:41 -04:00

227 lines
8.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Diagnostics;
using System.Reflection;
using System.Text;
using HarmonyLib;
using Model.Physics;
using UnityModManagerNet;
namespace S3.Modules.PhysicsOptimizer;
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 PhysicsOptimizerModule.OnEnable() after the static patches.
// 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($"[S3.physics] 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("[S3.physics] 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($"[S3.physics] {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);
}