using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Text; using System.Threading; using BepInEx; using BepInEx.Bootstrap; using BepInEx.Configuration; using BepInEx.Logging; using HarmonyLib; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: TargetFramework(".NETFramework,Version=v4.8", FrameworkDisplayName = ".NET Framework 4.8")] [assembly: AssemblyCompany("MagiCorp.ValheimTickProfiler")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyFileVersion("1.0.0.0")] [assembly: AssemblyInformationalVersion("1.0.0+8a7348b5dbdb8712868f3841af346c25901bdbff")] [assembly: AssemblyProduct("MagiCorp.ValheimTickProfiler")] [assembly: AssemblyTitle("MagiCorp.ValheimTickProfiler")] [assembly: AssemblyVersion("1.0.0.0")] namespace MagiCorp.ValheimTickProfiler; [BepInPlugin("magicorp.valheim.tickprofiler", "MagiCorp Valheim Tick Profiler", "0.2.5")] public sealed class TickProfilerPlugin : BaseUnityPlugin { private readonly struct TargetSpec { public readonly string TypeName; public readonly string MethodName; public readonly string Label; public readonly bool Deep; public TargetSpec(string typeName, string methodName, string label, bool deep) { TypeName = typeName; MethodName = methodName; Label = label; Deep = deep; } } private struct ScopeFrame { public Metric Metric; public long StartTicks; public long ChildTicks; } private sealed class Metric { public readonly string Name; public readonly string Kind; private long _calls; private long _selfTicks; private long _inclusiveTicks; private long _maxInclusiveTicks; public Metric(string name, string kind) { Name = name; Kind = kind; } public void Add(long selfTicks, long inclusiveTicks) { Interlocked.Increment(ref _calls); Interlocked.Add(ref _selfTicks, selfTicks); Interlocked.Add(ref _inclusiveTicks, inclusiveTicks); long num = Volatile.Read(in _maxInclusiveTicks); while (inclusiveTicks > num) { long num2 = Interlocked.CompareExchange(ref _maxInclusiveTicks, inclusiveTicks, num); if (num2 != num) { num = num2; continue; } break; } } public MetricRaw SnapshotAndReset() { return new MetricRaw(Interlocked.Exchange(ref _calls, 0L), Interlocked.Exchange(ref _selfTicks, 0L), Interlocked.Exchange(ref _inclusiveTicks, 0L), Interlocked.Exchange(ref _maxInclusiveTicks, 0L)); } } private readonly struct MetricRaw { public readonly long Calls; public readonly long SelfTicks; public readonly long InclusiveTicks; public readonly long MaxInclusiveTicks; public MetricRaw(long calls, long selfTicks, long inclusiveTicks, long maxInclusiveTicks) { Calls = calls; SelfTicks = selfTicks; InclusiveTicks = inclusiveTicks; MaxInclusiveTicks = maxInclusiveTicks; } } private sealed class MetricSnapshot { public string Name; public string Kind; public double SelfMsPerSec; public double InclusiveMsPerSec; public double CpuPct; public double CallsPerSec; public double AvgSelfUs; public double MaxInclusiveMs; public static MetricSnapshot From(Metric metric, MetricRaw raw, double seconds) { double num = ToMilliseconds(raw.SelfTicks); return new MetricSnapshot { Name = metric.Name, Kind = metric.Kind, SelfMsPerSec = num / seconds, InclusiveMsPerSec = ToMilliseconds(raw.InclusiveTicks) / seconds, CpuPct = num / (seconds * 1000.0) * 100.0, CallsPerSec = (double)raw.Calls / seconds, AvgSelfUs = ((raw.Calls > 0) ? (num * 1000.0 / (double)raw.Calls) : 0.0), MaxInclusiveMs = ToMilliseconds(raw.MaxInclusiveTicks) }; } } private sealed class GapStats { private readonly double[] _samples; private int _sampleCount; private int _next; private long _count; private double _sum; private double _max; private long _over20; private long _over40; private long _over100; public GapStats(int capacity) { _samples = new double[capacity]; } public void Add(double ms) { _count++; _sum += ms; if (ms > _max) { _max = ms; } if (ms > 20.0) { _over20++; } if (ms > 40.0) { _over40++; } if (ms > 100.0) { _over100++; } _samples[_next++] = ms; if (_next == _samples.Length) { _next = 0; } if (_sampleCount < _samples.Length) { _sampleCount++; } } public GapSnapshot SnapshotAndReset() { double p95Ms = 0.0; if (_sampleCount > 0) { double[] array = new double[_sampleCount]; Array.Copy(_samples, array, _sampleCount); Array.Sort(array); int val = Math.Min(array.Length - 1, (int)Math.Ceiling((double)array.Length * 0.95) - 1); p95Ms = array[Math.Max(0, val)]; } GapSnapshot result = new GapSnapshot { Count = _count, AvgMs = ((_count > 0) ? (_sum / (double)_count) : 0.0), P95Ms = p95Ms, MaxMs = _max, Over20Ms = _over20, Over40Ms = _over40, Over100Ms = _over100 }; _sampleCount = 0; _next = 0; _count = 0L; _sum = 0.0; _max = 0.0; _over20 = 0L; _over40 = 0L; _over100 = 0L; return result; } } private struct GapSnapshot { public long Count; public double AvgMs; public double P95Ms; public double MaxMs; public long Over20Ms; public long Over40Ms; public long Over100Ms; } private sealed class ReportSnapshot { public DateTime TimestampUtc; public bool Requested; public double WallSeconds; public double UpdateHz; public double FixedHz; public double NominalFixedHz; public double FixedDeltaMs; public double TimeScale; public GapSnapshot UpdateGap; public GapSnapshot FixedGap; public double CpuOneCorePct; public double CpuVmPct; public double ManagedMiB; public int Gc0; public int Gc1; public int Gc2; public int PatchedMethods; public List Metrics; public string[] PatchFailures; } public const string PluginGuid = "magicorp.valheim.tickprofiler"; public const string PluginName = "MagiCorp Valheim Tick Profiler"; public const string PluginVersion = "0.2.5"; private static readonly long Frequency = Stopwatch.Frequency; private static readonly Dictionary MethodMetrics = new Dictionary(); private static readonly List Metrics = new List(); private static Harmony _harmony; private static ManualLogSource _log; private static bool _profilingEnabled; private bool _serverMode; private bool _rpcRegistered; private readonly HashSet _overlayPeers = new HashSet(); private string _latestOverlayText = string.Empty; private const string RpcHello = "MagiCorp_TickProfiler_Hello_v1"; private const string RpcSnapshot = "MagiCorp_TickProfiler_Snapshot_v1"; [ThreadStatic] private static ScopeFrame[] _scopeStack; [ThreadStatic] private static int _scopeDepth; private ConfigEntry _enabled; private ConfigEntry _reportInterval; private ConfigEntry _topConsumers; private ConfigEntry _deepGameProfiling; private ConfigEntry _profileModPatches; private ConfigEntry _logPeriodicSummary; private ConfigEntry _writeStatusJson; private ConfigEntry _spikeThresholdMs; private readonly GapStats _updateGaps = new GapStats(1024); private readonly GapStats _fixedGaps = new GapStats(1024); private readonly List _patchFailures = new List(); private Process _process; private TimeSpan _lastCpu; private long _lastReportTicks; private long _lastUpdateTicks; private long _lastFixedTicks; private long _updates; private long _fixedUpdates; private int _lastGc0; private int _lastGc1; private int _lastGc2; private string _statusPath; private string _textPath; private string _requestPath; private static readonly TargetSpec[] CoreTargets = new TargetSpec[38] { new TargetSpec("Game", "Update", "Game.Update", deep: false), new TargetSpec("Game", "FixedUpdate", "Game.FixedUpdate", deep: false), new TargetSpec("ZNet", "Update", "Network/ZNet.Update", deep: false), new TargetSpec("ZNet", "FixedUpdate", "Network/ZNet.FixedUpdate", deep: false), new TargetSpec("ZNet", "LateUpdate", "Network/ZNet.LateUpdate", deep: false), new TargetSpec("ZNet", "UpdatePeers", "Network/ZNet.UpdatePeers", deep: false), new TargetSpec("ZNet", "UpdateSave", "World/ZNet.UpdateSave", deep: false), new TargetSpec("ZDOMan", "Update", "Network/ZDOMan.Update", deep: false), new TargetSpec("ZDOMan", "SendZDOToPeers2", "Network/ZDOMan.SendZDOToPeers2", deep: false), new TargetSpec("ZDOMan", "SendZDOs", "Network/ZDOMan.SendZDOs", deep: false), new TargetSpec("ZDOMan", "CreateSyncList", "Network/ZDOMan.CreateSyncList", deep: false), new TargetSpec("ZDOMan", "RPC_ZDOData", "Network/ZDOMan.RPC_ZDOData", deep: false), new TargetSpec("ZNetScene", "Update", "World/ZNetScene.Update", deep: false), new TargetSpec("ZNetScene", "CreateDestroyObjects", "World/ZNetScene.CreateDestroyObjects", deep: false), new TargetSpec("ZoneSystem", "Update", "World/ZoneSystem.Update", deep: false), new TargetSpec("SpawnSystem", "UpdateSpawning", "World/SpawnSystem.UpdateSpawning", deep: false), new TargetSpec("EnvMan", "Update", "World/EnvMan.Update", deep: false), new TargetSpec("EnvMan", "FixedUpdate", "World/EnvMan.FixedUpdate", deep: false), new TargetSpec("Character", "CustomFixedUpdate", "Entities/Character.CustomFixedUpdate", deep: true), new TargetSpec("MonsterAI", "UpdateAI", "AI/MonsterAI.UpdateAI", deep: true), new TargetSpec("BaseAI", "UpdateAI", "AI/BaseAI.UpdateAI", deep: true), new TargetSpec("AnimalAI", "UpdateAI", "AI/AnimalAI.UpdateAI", deep: true), new TargetSpec("ZSyncTransform", "CustomFixedUpdate", "Network/ZSyncTransform.CustomFixedUpdate", deep: true), new TargetSpec("ZSyncTransform", "OwnerSync", "Network/ZSyncTransform.OwnerSync", deep: true), new TargetSpec("ZSyncTransform", "ClientSync", "Network/ZSyncTransform.ClientSync", deep: true), new TargetSpec("ZSyncAnimation", "CustomFixedUpdate", "Network/ZSyncAnimation.CustomFixedUpdate", deep: true), new TargetSpec("Projectile", "FixedUpdate", "Entities/Projectile.FixedUpdate", deep: true), new TargetSpec("Ship", "CustomFixedUpdate", "Entities/Ship.CustomFixedUpdate", deep: true), new TargetSpec("Fish", "CustomFixedUpdate", "Entities/Fish.CustomFixedUpdate", deep: true), new TargetSpec("ItemDrop", "SlowUpdate", "Items/ItemDrop.SlowUpdate", deep: true), new TargetSpec("ItemDrop", "PickupUpdate", "Items/ItemDrop.PickupUpdate", deep: true), new TargetSpec("ItemDrop", "EatUpdate", "Items/ItemDrop.EatUpdate", deep: true), new TargetSpec("WearNTear", "UpdateWear", "Build/WearNTear.UpdateWear", deep: true), new TargetSpec("WearNTear", "UpdateSupport", "Build/WearNTear.UpdateSupport", deep: true), new TargetSpec("Smelter", "UpdateSmelter", "Production/Smelter.UpdateSmelter", deep: true), new TargetSpec("LootSpawner", "UpdateSpawner", "World/LootSpawner.UpdateSpawner", deep: true), new TargetSpec("Heightmap", "CustomLateUpdate", "World/Heightmap.CustomLateUpdate", deep: true), new TargetSpec("ClutterSystem", "LateUpdate", "World/ClutterSystem.LateUpdate", deep: true) }; private void Awake() { //IL_0214: Unknown result type (might be due to invalid IL or missing references) //IL_021e: Expected O, but got Unknown _log = ((BaseUnityPlugin)this).Logger; _serverMode = IsDedicatedServerProcess(); if (!_serverMode) { _profilingEnabled = false; ((BaseUnityPlugin)this).Logger.LogInfo((object)"MagiCorp Valheim Tick Profiler 0.2.5: client process detected; server profiler disabled. Install the companion client overlay from the same package for F8 UI."); return; } _enabled = ((BaseUnityPlugin)this).Config.Bind("General", "Enabled", true, "Enable collection. Patches remain installed but become a fast no-op when disabled."); _reportInterval = ((BaseUnityPlugin)this).Config.Bind("General", "ReportIntervalSeconds", 10f, "Snapshot/report interval in seconds."); _topConsumers = ((BaseUnityPlugin)this).Config.Bind("General", "TopConsumers", 12, "Number of consumers written to the BepInEx summary."); _deepGameProfiling = ((BaseUnityPlugin)this).Config.Bind("Profiling", "DeepGameProfiling", true, "Profile per-entity AI, sync, item, building and production hot paths."); _profileModPatches = ((BaseUnityPlugin)this).Config.Bind("Profiling", "ProfileModHarmonyMethods", true, "Profile Harmony Prefix/Postfix/Finalizer methods belonging to other BepInEx plugins."); _logPeriodicSummary = ((BaseUnityPlugin)this).Config.Bind("Output", "LogPeriodicSummary", true, "Write a compact summary to BepInEx/LogOutput.log each interval."); _writeStatusJson = ((BaseUnityPlugin)this).Config.Bind("Output", "WriteStatusJson", true, "Refresh BepInEx/tickprofiler-status.json each interval."); _spikeThresholdMs = ((BaseUnityPlugin)this).Config.Bind("Output", "SpikeThresholdMs", 20f, "Mark a consumer as spiking when its worst observed call exceeds this many milliseconds."); _profilingEnabled = _enabled.Value; _enabled.SettingChanged += delegate { _profilingEnabled = _enabled.Value; }; _statusPath = Path.Combine(Paths.BepInExRootPath, "tickprofiler-status.json"); _textPath = Path.Combine(Paths.BepInExRootPath, "tickprofiler-latest.txt"); _requestPath = Path.Combine(Paths.BepInExRootPath, "tickprofiler.request"); _process = Process.GetCurrentProcess(); _lastCpu = _process.TotalProcessorTime; _lastGc0 = GC.CollectionCount(0); _lastGc1 = GC.CollectionCount(1); _lastGc2 = GC.CollectionCount(2); _lastReportTicks = Stopwatch.GetTimestamp(); _lastUpdateTicks = _lastReportTicks; _lastFixedTicks = _lastReportTicks; _harmony = new Harmony("magicorp.valheim.tickprofiler"); PatchGameTargets(); ((BaseUnityPlugin)this).Logger.LogInfo((object)string.Format("{0} {1}: patched {2} game methods; deep={3}", "MagiCorp Valheim Tick Profiler", "0.2.5", MethodMetrics.Count, _deepGameProfiling.Value)); } private void Start() { if (_serverMode) { TryRegisterProfilerRpc(); if (_profileModPatches.Value) { ProfileLoadedMods(); ((BaseUnityPlugin)this).Logger.LogInfo((object)string.Format("{0}: total instrumented methods after mod scan: {1}", "MagiCorp Valheim Tick Profiler", MethodMetrics.Count)); } } } private void OnDestroy() { try { Harmony harmony = _harmony; if (harmony != null) { harmony.UnpatchSelf(); } } catch { } } private void Update() { if (!_serverMode) { return; } TryRegisterProfilerRpc(); if (!_profilingEnabled) { return; } long timestamp = Stopwatch.GetTimestamp(); _updates++; if (_lastUpdateTicks != 0L) { _updateGaps.Add(ToMilliseconds(timestamp - _lastUpdateTicks)); } _lastUpdateTicks = timestamp; bool flag = false; try { if (File.Exists(_requestPath)) { File.Delete(_requestPath); flag = true; } } catch (Exception ex) { ((BaseUnityPlugin)this).Logger.LogWarning((object)("Unable to consume tickprofiler.request: " + ex.Message)); } double num = ToSeconds(timestamp - _lastReportTicks); double num2 = Math.Max(1.0, _reportInterval.Value); if (flag || num >= num2) { Report(timestamp, flag); } } private void FixedUpdate() { if (_serverMode && _profilingEnabled) { long timestamp = Stopwatch.GetTimestamp(); _fixedUpdates++; if (_lastFixedTicks != 0L) { _fixedGaps.Add(ToMilliseconds(timestamp - _lastFixedTicks)); } _lastFixedTicks = timestamp; } } private void PatchGameTargets() { TargetSpec[] coreTargets = CoreTargets; for (int i = 0; i < coreTargets.Length; i++) { TargetSpec spec = coreTargets[i]; if (spec.Deep && !_deepGameProfiling.Value) { continue; } Type type = AccessTools.TypeByName(spec.TypeName); if (type == null) { AddPatchFailure(spec.Label + ": type not found"); continue; } MethodInfo[] array; try { array = (from m in type.GetMethods(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic) where m.Name == spec.MethodName && !m.IsAbstract && !m.ContainsGenericParameters select m).ToArray(); } catch (Exception ex) { AddPatchFailure(spec.Label + ": enumeration failed: " + ex.Message); continue; } if (array.Length == 0) { AddPatchFailure(spec.Label + ": method not found"); continue; } MethodInfo[] array2 = array; foreach (MethodInfo method in array2) { InstrumentMethod(method, spec.Label, "game"); } } } private void ProfileLoadedMods() { foreach (KeyValuePair pluginInfo in Chainloader.PluginInfos) { PluginInfo value = pluginInfo.Value; if (value == null || (Object)(object)value.Instance == (Object)null) { continue; } Assembly assembly = ((object)value.Instance).GetType().Assembly; if (assembly == typeof(TickProfilerPlugin).Assembly) { continue; } string text = ((value.Metadata != null && !string.IsNullOrEmpty(value.Metadata.Name)) ? value.Metadata.Name : pluginInfo.Key); foreach (Type item in SafeGetTypes(assembly)) { if (IsOneShotPatchType(item.Name)) { continue; } bool flag = HasAttributeNamed(item.GetCustomAttributes(inherit: false), "HarmonyPatch"); MethodInfo[] methods; try { methods = item.GetMethods(BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); } catch { continue; } MethodInfo[] array = methods; foreach (MethodInfo methodInfo in array) { if (!methodInfo.IsAbstract && !methodInfo.ContainsGenericParameters && methodInfo.GetMethodBody() != null) { object[] attrs; try { attrs = methodInfo.GetCustomAttributes(inherit: false); } catch { attrs = Array.Empty(); } bool num = HasAttributeNamed(attrs, "HarmonyPrefix") || HasAttributeNamed(attrs, "HarmonyPostfix") || HasAttributeNamed(attrs, "HarmonyFinalizer") || (flag && (methodInfo.Name == "Prefix" || methodInfo.Name == "Postfix" || methodInfo.Name == "Finalizer")); bool flag2 = typeof(BaseUnityPlugin).IsAssignableFrom(item) && (methodInfo.Name == "Update" || methodInfo.Name == "FixedUpdate" || methodInfo.Name == "LateUpdate"); if (num || flag2) { string label = "Mod/" + text + "/" + item.Name + "." + methodInfo.Name; InstrumentMethod(methodInfo, label, "mod"); } } } } } } private static IEnumerable SafeGetTypes(Assembly assembly) { try { return assembly.GetTypes(); } catch (ReflectionTypeLoadException ex) { return ex.Types.Where((Type t) => t != null); } catch { return Array.Empty(); } } private static bool IsOneShotPatchType(string typeName) { if (string.IsNullOrEmpty(typeName)) { return false; } if (typeName.IndexOf("Awake", StringComparison.OrdinalIgnoreCase) < 0 && typeName.IndexOf("Start_Patch", StringComparison.OrdinalIgnoreCase) < 0) { return typeName.IndexOf("OnDestroy", StringComparison.OrdinalIgnoreCase) >= 0; } return true; } private static bool HasAttributeNamed(object[] attrs, string name) { for (int i = 0; i < attrs.Length; i++) { Type type = attrs[i]?.GetType(); if (type != null && (type.Name == name || type.FullName == "HarmonyLib." + name)) { return true; } } return false; } private void InstrumentMethod(MethodBase method, string label, string kind) { //IL_0046: Unknown result type (might be due to invalid IL or missing references) //IL_004b: Unknown result type (might be due to invalid IL or missing references) //IL_0057: Expected O, but got Unknown //IL_0067: Unknown result type (might be due to invalid IL or missing references) //IL_006c: Unknown result type (might be due to invalid IL or missing references) //IL_0074: Expected O, but got Unknown if (method == null || MethodMetrics.ContainsKey(method)) { return; } Metric metric = new Metric(label, kind); MethodMetrics.Add(method, metric); Metrics.Add(metric); try { HarmonyMethod val = new HarmonyMethod(typeof(TickProfilerPlugin), "ProfilePrefix", (Type[])null) { priority = 800 }; HarmonyMethod val2 = new HarmonyMethod(typeof(TickProfilerPlugin), "ProfilePostfix", (Type[])null) { priority = 0 }; _harmony.Patch(method, val, val2, (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null); } catch (Exception ex) { MethodMetrics.Remove(method); Metrics.Remove(metric); AddPatchFailure(label + ": " + ex.GetType().Name + " " + ex.Message); } } private static void ProfilePrefix(MethodBase __originalMethod, out int __state) { __state = -1; if (_profilingEnabled && MethodMetrics.TryGetValue(__originalMethod, out var value)) { if (_scopeStack == null) { _scopeStack = new ScopeFrame[64]; } if (_scopeDepth >= _scopeStack.Length) { Array.Resize(ref _scopeStack, _scopeStack.Length * 2); } int num = _scopeDepth++; _scopeStack[num].Metric = value; _scopeStack[num].StartTicks = Stopwatch.GetTimestamp(); _scopeStack[num].ChildTicks = 0L; __state = num; } } private static void ProfilePostfix(int __state) { if (__state >= 0 && _scopeStack != null && __state < _scopeStack.Length) { long num = Stopwatch.GetTimestamp() - _scopeStack[__state].StartTicks; long childTicks = _scopeStack[__state].ChildTicks; long num2 = num - childTicks; if (num2 < 0) { num2 = 0L; } _scopeStack[__state].Metric?.Add(num2, num); _scopeDepth = __state; if (__state > 0) { _scopeStack[__state - 1].ChildTicks += num; } } } private void Report(long nowTicks, bool requested) { double num = Math.Max(0.001, ToSeconds(nowTicks - _lastReportTicks)); long updates = _updates; long fixedUpdates = _fixedUpdates; _updates = 0L; _fixedUpdates = 0L; _lastReportTicks = nowTicks; double updateHz = (double)updates / num; double fixedHz = (double)fixedUpdates / num; double nominalFixedHz = ((Time.fixedDeltaTime > 0f) ? (1.0 / (double)Time.fixedDeltaTime) : 0.0); GapSnapshot updateGap = _updateGaps.SnapshotAndReset(); GapSnapshot fixedGap = _fixedGaps.SnapshotAndReset(); TimeSpan totalProcessorTime = _process.TotalProcessorTime; double totalMilliseconds = (totalProcessorTime - _lastCpu).TotalMilliseconds; _lastCpu = totalProcessorTime; double num2 = totalMilliseconds / (num * 1000.0) * 100.0; double cpuVmPct = num2 / (double)Math.Max(1, Environment.ProcessorCount); int num3 = GC.CollectionCount(0); int num4 = GC.CollectionCount(1); int num5 = GC.CollectionCount(2); int gc = num3 - _lastGc0; int gc2 = num4 - _lastGc1; int gc3 = num5 - _lastGc2; _lastGc0 = num3; _lastGc1 = num4; _lastGc2 = num5; List list = new List(Metrics.Count); foreach (Metric metric in Metrics) { MetricRaw raw = metric.SnapshotAndReset(); if (raw.Calls != 0L) { list.Add(MetricSnapshot.From(metric, raw, num)); } } list.Sort((MetricSnapshot a, MetricSnapshot b) => b.SelfMsPerSec.CompareTo(a.SelfMsPerSec)); double managedMiB = (double)GC.GetTotalMemory(forceFullCollection: false) / 1048576.0; ReportSnapshot s = new ReportSnapshot { TimestampUtc = DateTime.UtcNow, Requested = requested, WallSeconds = num, UpdateHz = updateHz, FixedHz = fixedHz, NominalFixedHz = nominalFixedHz, FixedDeltaMs = (double)Time.fixedDeltaTime * 1000.0, TimeScale = Time.timeScale, UpdateGap = updateGap, FixedGap = fixedGap, CpuOneCorePct = num2, CpuVmPct = cpuVmPct, ManagedMiB = managedMiB, Gc0 = gc, Gc1 = gc2, Gc2 = gc3, Metrics = list, PatchFailures = _patchFailures.ToArray(), PatchedMethods = MethodMetrics.Count }; string contents = BuildText(s); _latestOverlayText = BuildOverlayText(s); BroadcastOverlaySnapshot(); try { File.WriteAllText(_textPath, contents); } catch (Exception ex) { ((BaseUnityPlugin)this).Logger.LogWarning((object)("Unable to write tickprofiler-latest.txt: " + ex.Message)); } if (_writeStatusJson.Value) { try { WriteAtomic(_statusPath, BuildJson(s)); } catch (Exception ex2) { ((BaseUnityPlugin)this).Logger.LogWarning((object)("Unable to write tickprofiler-status.json: " + ex2.Message)); } } if (_logPeriodicSummary.Value || requested) { string[] array = BuildConsoleText(s).Split(new char[2] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); foreach (string text in array) { ((BaseUnityPlugin)this).Logger.LogInfo((object)text); } } } private static bool IsDedicatedServerProcess() { try { return (Process.GetCurrentProcess().ProcessName ?? string.Empty).IndexOf("valheim_server", StringComparison.OrdinalIgnoreCase) >= 0; } catch { return false; } } private void TryRegisterProfilerRpc() { if (_rpcRegistered) { return; } ZRoutedRpc instance = ZRoutedRpc.instance; if (instance == null) { return; } try { instance.Register("MagiCorp_TickProfiler_Hello_v1", (Action)OnProfilerHello); _rpcRegistered = true; ((BaseUnityPlugin)this).Logger.LogInfo((object)"TickProfiler client-overlay RPC registered."); } catch (Exception ex) { ((BaseUnityPlugin)this).Logger.LogWarning((object)("TickProfiler RPC registration failed: " + ex.Message)); } } private void OnProfilerHello(long sender) { if (!_serverMode || sender == 0L) { return; } if (_overlayPeers.Add(sender)) { ((BaseUnityPlugin)this).Logger.LogInfo((object)("TickProfiler overlay client registered: peer=" + sender + " clients=" + _overlayPeers.Count)); } if (string.IsNullOrEmpty(_latestOverlayText)) { return; } try { ZRoutedRpc instance = ZRoutedRpc.instance; if (instance != null) { instance.InvokeRoutedRPC(sender, "MagiCorp_TickProfiler_Snapshot_v1", new object[1] { _latestOverlayText }); } } catch { } } private void BroadcastOverlaySnapshot() { if (string.IsNullOrEmpty(_latestOverlayText) || _overlayPeers.Count == 0) { return; } ZRoutedRpc instance = ZRoutedRpc.instance; ZNet instance2 = ZNet.instance; if (instance == null || (Object)(object)instance2 == (Object)null) { return; } long[] array = _overlayPeers.ToArray(); foreach (long num in array) { try { if (instance2.GetPeer(num) == null) { _overlayPeers.Remove(num); continue; } instance.InvokeRoutedRPC(num, "MagiCorp_TickProfiler_Snapshot_v1", new object[1] { _latestOverlayText }); } catch { _overlayPeers.Remove(num); } } } private string BuildOverlayText(ReportSnapshot s) { StringBuilder stringBuilder = new StringBuilder(2048); double value = ((s.NominalFixedHz > 0.0) ? Math.Max(0.0, (s.NominalFixedHz - s.FixedHz) / s.NominalFixedHz * 100.0) : 0.0); stringBuilder.Append("SERVER TICK PROFILER\n"); stringBuilder.Append("Fixed ").Append(F(s.FixedHz, 1)).Append('/') .Append(F(s.NominalFixedHz, 1)) .Append(" Hz ") .Append("Update ") .Append(F(s.UpdateHz, 1)) .Append(" Hz ") .Append("Behind ") .Append(F(value, 1)) .Append("%\n"); stringBuilder.Append("Fixed gaps: p95 ").Append(F(s.FixedGap.P95Ms, 1)).Append(" ms worst ") .Append(F(s.FixedGap.MaxMs, 1)) .Append(" ms >40 ") .Append(s.FixedGap.Over40Ms) .Append(" >100 ") .Append(s.FixedGap.Over100Ms) .Append("\n"); stringBuilder.Append("CPU ").Append(F(s.CpuOneCorePct, 1)).Append("% core VM ") .Append(F(s.CpuVmPct, 1)) .Append("% managed ") .Append(F(s.ManagedMiB, 0)) .Append(" MiB GC ") .Append(s.Gc0) .Append('/') .Append(s.Gc1) .Append('/') .Append(s.Gc2) .Append("\n\n"); stringBuilder.Append("TOP CONSUMERS\n"); int num = Math.Min(8, s.Metrics.Count); for (int i = 0; i < num; i++) { MetricSnapshot metricSnapshot = s.Metrics[i]; stringBuilder.Append(i + 1).Append(". ").Append(metricSnapshot.Name) .Append(" ") .Append(F(metricSnapshot.SelfMsPerSec, 2)) .Append(" ms/s") .Append(" max ") .Append(F(metricSnapshot.MaxInclusiveMs, 1)) .Append(" ms"); if (metricSnapshot.MaxInclusiveMs >= (double)_spikeThresholdMs.Value) { stringBuilder.Append(" SPIKE"); } stringBuilder.Append('\n'); } return stringBuilder.ToString().TrimEnd(Array.Empty()); } private string BuildConsoleText(ReportSnapshot s) { StringBuilder stringBuilder = new StringBuilder(2048); double value = ((s.NominalFixedHz > 0.0) ? Math.Max(0.0, (s.NominalFixedHz - s.FixedHz) / s.NominalFixedHz * 100.0) : 0.0); stringBuilder.Append("HEALTH Fixed ").Append(F(s.FixedHz, 1)).Append('/') .Append(F(s.NominalFixedHz, 1)) .Append(" Hz") .Append(" | Update ") .Append(F(s.UpdateHz, 1)) .Append(" Hz") .Append(" | Lag ") .Append(F(value, 1)) .Append('%') .Append(" | CPU ") .Append(F(s.CpuOneCorePct, 1)) .Append("% core") .Append(" | RAM ") .Append(F(s.ManagedMiB, 0)) .Append(" MiB") .Append(" | GC ") .Append(s.Gc0) .Append('/') .Append(s.Gc1) .Append('/') .Append(s.Gc2) .AppendLine(); stringBuilder.Append("STALLS Fixed p95 ").Append(F(s.FixedGap.P95Ms, 1)).Append(" ms") .Append(" | worst ") .Append(F(s.FixedGap.MaxMs, 1)) .Append(" ms") .Append(" | >40ms ") .Append(s.FixedGap.Over40Ms) .Append(" | >100ms ") .Append(s.FixedGap.Over100Ms) .Append(" | Update worst ") .Append(F(s.UpdateGap.MaxMs, 1)) .Append(" ms") .AppendLine(); stringBuilder.Append("TOP CONSUMERS").AppendLine(); stringBuilder.Append("# Consumer ms/s worst calls/s").AppendLine(); stringBuilder.Append("- -------------------------------------------- ----- ------ -------").AppendLine(); int num = Math.Min(5, s.Metrics.Count); for (int i = 0; i < num; i++) { MetricSnapshot metricSnapshot = s.Metrics[i]; string text = metricSnapshot.Name ?? "unknown"; if (text.Length > 44) { text = text.Substring(0, 41) + "..."; } string text2 = F(metricSnapshot.MaxInclusiveMs, 1) + ((metricSnapshot.MaxInclusiveMs >= (double)_spikeThresholdMs.Value) ? "!" : ""); stringBuilder.Append((i + 1).ToString(CultureInfo.InvariantCulture)).Append(" ").Append(text.PadRight(44)) .Append(" ") .Append(F(metricSnapshot.SelfMsPerSec, 1).PadLeft(5)) .Append(" ") .Append(text2.PadLeft(6)) .Append(" ") .Append(F(metricSnapshot.CallsPerSec, 1).PadLeft(7)) .AppendLine(); } if (_patchFailures.Count > 0) { stringBuilder.Append("WARN patch failures: ").Append(_patchFailures.Count).Append(" | instrumented: ") .Append(s.PatchedMethods) .AppendLine(); } return stringBuilder.ToString().TrimEnd(Array.Empty()); } private string BuildText(ReportSnapshot s) { StringBuilder stringBuilder = new StringBuilder(4096); double value = ((s.NominalFixedHz > 0.0) ? Math.Max(0.0, (s.NominalFixedHz - s.FixedHz) / s.NominalFixedHz * 100.0) : 0.0); stringBuilder.Append("[TickProfiler] ").Append(s.TimestampUtc.ToString("O")).Append(" fixed=") .Append(F(s.FixedHz, 1)) .Append('/') .Append(F(s.NominalFixedHz, 1)) .Append("Hz") .Append(" behind=") .Append(F(value, 1)) .Append('%') .Append(" update=") .Append(F(s.UpdateHz, 1)) .Append("Hz") .Append(" CPU=") .Append(F(s.CpuOneCorePct, 1)) .Append("% of one core") .Append(" VMCPU=") .Append(F(s.CpuVmPct, 1)) .Append('%') .Append(" managed=") .Append(F(s.ManagedMiB, 1)) .Append("MiB") .Append(" GC=") .Append(s.Gc0) .Append('/') .Append(s.Gc1) .Append('/') .Append(s.Gc2) .AppendLine(); stringBuilder.Append("[TickProfiler] gaps fixed p95=").Append(F(s.FixedGap.P95Ms, 2)).Append("ms worst=") .Append(F(s.FixedGap.MaxMs, 2)) .Append("ms >40ms=") .Append(s.FixedGap.Over40Ms) .Append(" >100ms=") .Append(s.FixedGap.Over100Ms) .Append(" | update p95=") .Append(F(s.UpdateGap.P95Ms, 2)) .Append("ms worst=") .Append(F(s.UpdateGap.MaxMs, 2)) .Append("ms") .AppendLine(); int num = Math.Min(Math.Max(1, _topConsumers.Value), s.Metrics.Count); for (int i = 0; i < num; i++) { MetricSnapshot metricSnapshot = s.Metrics[i]; stringBuilder.Append("[TickProfiler] #").Append(i + 1).Append(' ') .Append(metricSnapshot.Name) .Append(" self=") .Append(F(metricSnapshot.SelfMsPerSec, 3)) .Append("ms/s") .Append(" (") .Append(F(metricSnapshot.CpuPct, 3)) .Append("% core)") .Append(" incl=") .Append(F(metricSnapshot.InclusiveMsPerSec, 3)) .Append("ms/s") .Append(" calls=") .Append(F(metricSnapshot.CallsPerSec, 1)) .Append("/s") .Append(" avg=") .Append(F(metricSnapshot.AvgSelfUs, 2)) .Append("us") .Append(" max=") .Append(F(metricSnapshot.MaxInclusiveMs, 3)) .Append("ms"); if (metricSnapshot.MaxInclusiveMs >= (double)_spikeThresholdMs.Value) { stringBuilder.Append(" SPIKE"); } stringBuilder.AppendLine(); } if (_patchFailures.Count > 0) { stringBuilder.Append("[TickProfiler] patchFailures=").Append(_patchFailures.Count).Append(" patchedMethods=") .Append(s.PatchedMethods) .AppendLine(); } else { stringBuilder.Append("[TickProfiler] patchedMethods=").Append(s.PatchedMethods).AppendLine(); } return stringBuilder.ToString(); } private string BuildJson(ReportSnapshot s) { StringBuilder stringBuilder = new StringBuilder(16384); stringBuilder.Append('{'); JsonProp(stringBuilder, "plugin", "MagiCorp Valheim Tick Profiler", first: true); JsonProp(stringBuilder, "version", "0.2.5", first: false); JsonProp(stringBuilder, "timestampUtc", s.TimestampUtc.ToString("O"), first: false); JsonProp(stringBuilder, "requested", s.Requested, first: false); JsonProp(stringBuilder, "wallSeconds", s.WallSeconds, first: false); JsonProp(stringBuilder, "updateHz", s.UpdateHz, first: false); JsonProp(stringBuilder, "fixedHz", s.FixedHz, first: false); JsonProp(stringBuilder, "nominalFixedHz", s.NominalFixedHz, first: false); JsonProp(stringBuilder, "fixedDeltaMs", s.FixedDeltaMs, first: false); JsonProp(stringBuilder, "timeScale", s.TimeScale, first: false); JsonProp(stringBuilder, "processCpuOneCorePct", s.CpuOneCorePct, first: false); JsonProp(stringBuilder, "processCpuVmPct", s.CpuVmPct, first: false); JsonProp(stringBuilder, "managedMemoryMiB", s.ManagedMiB, first: false); stringBuilder.Append(",\"gcCollections\":{"); JsonProp(stringBuilder, "gen0", s.Gc0, first: true); JsonProp(stringBuilder, "gen1", s.Gc1, first: false); JsonProp(stringBuilder, "gen2", s.Gc2, first: false); stringBuilder.Append('}'); stringBuilder.Append(",\"fixedGap\":"); AppendGapJson(stringBuilder, s.FixedGap); stringBuilder.Append(",\"updateGap\":"); AppendGapJson(stringBuilder, s.UpdateGap); JsonProp(stringBuilder, "patchedMethods", s.PatchedMethods, first: false); stringBuilder.Append(",\"patchFailures\":["); for (int i = 0; i < s.PatchFailures.Length; i++) { if (i > 0) { stringBuilder.Append(','); } stringBuilder.Append(JsonString(s.PatchFailures[i])); } stringBuilder.Append(']'); stringBuilder.Append(",\"consumers\":["); int num = Math.Min(Math.Max(_topConsumers.Value, 30), s.Metrics.Count); for (int j = 0; j < num; j++) { if (j > 0) { stringBuilder.Append(','); } MetricSnapshot metricSnapshot = s.Metrics[j]; stringBuilder.Append('{'); JsonProp(stringBuilder, "name", metricSnapshot.Name, first: true); JsonProp(stringBuilder, "kind", metricSnapshot.Kind, first: false); JsonProp(stringBuilder, "selfMsPerSec", metricSnapshot.SelfMsPerSec, first: false); JsonProp(stringBuilder, "inclusiveMsPerSec", metricSnapshot.InclusiveMsPerSec, first: false); JsonProp(stringBuilder, "cpuPctOneCore", metricSnapshot.CpuPct, first: false); JsonProp(stringBuilder, "callsPerSec", metricSnapshot.CallsPerSec, first: false); JsonProp(stringBuilder, "avgSelfUs", metricSnapshot.AvgSelfUs, first: false); JsonProp(stringBuilder, "maxInclusiveMs", metricSnapshot.MaxInclusiveMs, first: false); JsonProp(stringBuilder, "spike", metricSnapshot.MaxInclusiveMs >= (double)_spikeThresholdMs.Value, first: false); stringBuilder.Append('}'); } stringBuilder.Append("]}"); return stringBuilder.ToString(); } private static void AppendGapJson(StringBuilder b, GapSnapshot g) { b.Append('{'); JsonProp(b, "count", g.Count, first: true); JsonProp(b, "avgMs", g.AvgMs, first: false); JsonProp(b, "p95Ms", g.P95Ms, first: false); JsonProp(b, "maxMs", g.MaxMs, first: false); JsonProp(b, "over20Ms", g.Over20Ms, first: false); JsonProp(b, "over40Ms", g.Over40Ms, first: false); JsonProp(b, "over100Ms", g.Over100Ms, first: false); b.Append('}'); } private static void JsonProp(StringBuilder b, string name, string value, bool first) { if (!first) { b.Append(','); } b.Append(JsonString(name)).Append(':').Append(JsonString(value)); } private static void JsonProp(StringBuilder b, string name, bool value, bool first) { if (!first) { b.Append(','); } b.Append(JsonString(name)).Append(':').Append(value ? "true" : "false"); } private static void JsonProp(StringBuilder b, string name, int value, bool first) { if (!first) { b.Append(','); } b.Append(JsonString(name)).Append(':').Append(value.ToString(CultureInfo.InvariantCulture)); } private static void JsonProp(StringBuilder b, string name, long value, bool first) { if (!first) { b.Append(','); } b.Append(JsonString(name)).Append(':').Append(value.ToString(CultureInfo.InvariantCulture)); } private static void JsonProp(StringBuilder b, string name, double value, bool first) { if (!first) { b.Append(','); } b.Append(JsonString(name)).Append(':').Append(value.ToString("0.######", CultureInfo.InvariantCulture)); } private static string JsonString(string value) { if (value == null) { return "null"; } StringBuilder stringBuilder = new StringBuilder(value.Length + 8); stringBuilder.Append('"'); foreach (char c in value) { switch (c) { case '"': stringBuilder.Append("\\\""); continue; case '\\': stringBuilder.Append("\\\\"); continue; case '\n': stringBuilder.Append("\\n"); continue; case '\r': stringBuilder.Append("\\r"); continue; case '\t': stringBuilder.Append("\\t"); continue; } if (c < ' ') { StringBuilder stringBuilder2 = stringBuilder.Append("\\u"); int num = c; stringBuilder2.Append(num.ToString("x4")); } else { stringBuilder.Append(c); } } stringBuilder.Append('"'); return stringBuilder.ToString(); } private static void WriteAtomic(string path, string content) { string text = path + ".tmp"; File.WriteAllText(text, content); if (File.Exists(path)) { File.Delete(path); } File.Move(text, path); } private void AddPatchFailure(string message) { _patchFailures.Add(message); ((BaseUnityPlugin)this).Logger.LogWarning((object)("TickProfiler patch skipped: " + message)); } private static string F(double value, int decimals) { return value.ToString("F" + decimals, CultureInfo.InvariantCulture); } private static double ToMilliseconds(long ticks) { return (double)ticks * 1000.0 / (double)Frequency; } private static double ToSeconds(long ticks) { return (double)ticks / (double)Frequency; } }