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