From 508d4f776a9a765a56720fc350ccb347515e3897 Mon Sep 17 00:00:00 2001 From: tharbad08 <10381553+tharbad08@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:05:13 +0300 Subject: [PATCH] Port Benchmark & Optimizer profiling to Timberborn 1.1 Restore on-demand 120-second CSV and frame/GC profiling, protected per-system update intervals, optional TimberPhysics timing, and the pause-menu settings shortcut. Port 1.1.104 is validated in game by tester and remains independent of stable TB Optimizer. Build and packaging workflow included; original U7 mod remains untouched. --- .github/workflows/build-benchmark-v11port.yml | 39 + .../BenchmarkAndOptimizerV11Port.csproj | 33 + .../DispatcherPatcher.cs | 83 + .../FrameProfileSampler.cs | 76 + .../IsExternalInit.cs | 6 + BenchmarkAndOptimizerV11Port/ModStarter.cs | 20 + BenchmarkAndOptimizerV11Port/README.md | 49 + BenchmarkAndOptimizerV11Port/Runtime.cs | 1416 +++++++++++++++++ .../SettingsIntegration.cs | 556 +++++++ .../Bindito.Core/Bindito.Core.Stub.csproj | 3 + .../Stubs/Bindito.Core/Stub.cs | 16 + .../ModSettings.CommonUI.Stub.csproj | 14 + .../Stubs/ModSettings.CommonUI/Stub.cs | 11 + .../ModSettings.Core.Stub.csproj | 13 + .../Stubs/ModSettings.Core/Stub.cs | 27 + .../ModSettings.CoreUI.Stub.csproj | 13 + .../Stubs/ModSettings.CoreUI/Stub.cs | 13 + .../Stubs/TimberUi/Stub.cs | 56 + .../Stubs/TimberUi/TimberUi.Stub.csproj | 1 + .../Stubs/Timberborn.CoreUI/Stub.cs | 11 + .../Timberborn.CoreUI.Stub.csproj | 12 + .../Stubs/Timberborn.ModManagerScene/Stub.cs | 12 + .../Timberborn.ModManagerScene.Stub.csproj | 9 + .../Stubs/Timberborn.Modding/Stub.cs | 5 + .../Timberborn.Modding.Stub.csproj | 9 + .../Stubs/Timberborn.Options/Stub.cs | 4 + .../Timberborn.Options.Stub.csproj | 9 + .../Stubs/Timberborn.OptionsGame/Stub.cs | 9 + .../Timberborn.OptionsGame.Stub.csproj | 13 + .../Stubs/Timberborn.SettingsSystem/Stub.cs | 1 + .../Timberborn.SettingsSystem.Stub.csproj | 9 + .../Stubs/Timberborn.SingletonSystem/Stub.cs | 22 + .../Timberborn.SingletonSystem.Stub.csproj | 9 + .../UnityEngine.UIElementsModule/Stub.cs | 110 ++ .../UnityEngine.UIElementsModule.Stub.csproj | 9 + BenchmarkAndOptimizerV11Port/TICK_SAFETY.md | 15 + .../TimberPhysicsBenchmarkPatcher.cs | 159 ++ BenchmarkAndOptimizerV11Port/manifest.json | 25 + 38 files changed, 2897 insertions(+) create mode 100644 .github/workflows/build-benchmark-v11port.yml create mode 100644 BenchmarkAndOptimizerV11Port/BenchmarkAndOptimizerV11Port.csproj create mode 100644 BenchmarkAndOptimizerV11Port/DispatcherPatcher.cs create mode 100644 BenchmarkAndOptimizerV11Port/FrameProfileSampler.cs create mode 100644 BenchmarkAndOptimizerV11Port/IsExternalInit.cs create mode 100644 BenchmarkAndOptimizerV11Port/ModStarter.cs create mode 100644 BenchmarkAndOptimizerV11Port/README.md create mode 100644 BenchmarkAndOptimizerV11Port/Runtime.cs create mode 100644 BenchmarkAndOptimizerV11Port/SettingsIntegration.cs create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/Bindito.Core/Bindito.Core.Stub.csproj create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/Bindito.Core/Stub.cs create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/ModSettings.CommonUI/ModSettings.CommonUI.Stub.csproj create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/ModSettings.CommonUI/Stub.cs create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/ModSettings.Core/ModSettings.Core.Stub.csproj create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/ModSettings.Core/Stub.cs create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/ModSettings.CoreUI/ModSettings.CoreUI.Stub.csproj create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/ModSettings.CoreUI/Stub.cs create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/TimberUi/Stub.cs create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/TimberUi/TimberUi.Stub.csproj create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/Timberborn.CoreUI/Stub.cs create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/Timberborn.CoreUI/Timberborn.CoreUI.Stub.csproj create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/Timberborn.ModManagerScene/Stub.cs create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/Timberborn.ModManagerScene/Timberborn.ModManagerScene.Stub.csproj create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/Timberborn.Modding/Stub.cs create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/Timberborn.Modding/Timberborn.Modding.Stub.csproj create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/Timberborn.Options/Stub.cs create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/Timberborn.Options/Timberborn.Options.Stub.csproj create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/Timberborn.OptionsGame/Stub.cs create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/Timberborn.OptionsGame/Timberborn.OptionsGame.Stub.csproj create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/Timberborn.SettingsSystem/Stub.cs create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/Timberborn.SettingsSystem/Timberborn.SettingsSystem.Stub.csproj create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/Timberborn.SingletonSystem/Stub.cs create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/Timberborn.SingletonSystem/Timberborn.SingletonSystem.Stub.csproj create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/UnityEngine.UIElementsModule/Stub.cs create mode 100644 BenchmarkAndOptimizerV11Port/Stubs/UnityEngine.UIElementsModule/UnityEngine.UIElementsModule.Stub.csproj create mode 100644 BenchmarkAndOptimizerV11Port/TICK_SAFETY.md create mode 100644 BenchmarkAndOptimizerV11Port/TimberPhysicsBenchmarkPatcher.cs create mode 100644 BenchmarkAndOptimizerV11Port/manifest.json diff --git a/.github/workflows/build-benchmark-v11port.yml b/.github/workflows/build-benchmark-v11port.yml new file mode 100644 index 00000000..498379c7 --- /dev/null +++ b/.github/workflows/build-benchmark-v11port.yml @@ -0,0 +1,39 @@ +name: Build BenchmarkAndOptimizerV11Port +on: + push: + branches: + - pr/benchmark-optimizer-v11-clean + paths: + - 'BenchmarkAndOptimizerV11Port/**' + - '.github/workflows/build-benchmark-v11port.yml' + pull_request: + paths: + - 'BenchmarkAndOptimizerV11Port/**' + - '.github/workflows/build-benchmark-v11port.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + - name: Build CSV profiler + run: dotnet build BenchmarkAndOptimizerV11Port/BenchmarkAndOptimizerV11Port.csproj -c Release + - name: Stage ready-to-install mod + shell: bash + run: | + set -euxo pipefail + ROOT="${RUNNER_TEMP}/timberborn-artifact/BenchmarkAndOptimizerV11Port" + mkdir -p "$ROOT/version-1.1/Scripts" + cp BenchmarkAndOptimizerV11Port/manifest.json "$ROOT/version-1.1/manifest.json" + cp BenchmarkAndOptimizerV11Port/bin/Release/netstandard2.1/BenchmarkAndOptimizerV11.dll "$ROOT/version-1.1/Scripts/" + test -s "$ROOT/version-1.1/Scripts/BenchmarkAndOptimizerV11.dll" + - uses: actions/upload-artifact@v4 + with: + name: BenchmarkAndOptimizerV11Port_1.1.104 + path: ${{ runner.temp }}/timberborn-artifact/ + if-no-files-found: error diff --git a/BenchmarkAndOptimizerV11Port/BenchmarkAndOptimizerV11Port.csproj b/BenchmarkAndOptimizerV11Port/BenchmarkAndOptimizerV11Port.csproj new file mode 100644 index 00000000..3a7bca00 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/BenchmarkAndOptimizerV11Port.csproj @@ -0,0 +1,33 @@ + + + netstandard2.1 + latest + enable + enable + BenchmarkAndOptimizerV11 + BenchmarkAndOptimizerV11 + 1.1.104.0 + 1.1.104.0 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BenchmarkAndOptimizerV11Port/DispatcherPatcher.cs b/BenchmarkAndOptimizerV11Port/DispatcherPatcher.cs new file mode 100644 index 00000000..439bd836 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/DispatcherPatcher.cs @@ -0,0 +1,83 @@ +using System.Collections; +using System.Reflection; +using System.Linq.Expressions; +using System.Runtime.CompilerServices; +using Newtonsoft.Json; +using HarmonyLib; +using Timberborn.ModManagerScene; + +namespace BenchmarkAndOptimizerV11; + +static class DispatcherPatcher +{ + static bool isPatched; + + readonly record struct Target(string TypeName, string MethodName, string PrefixName); + + static readonly Target[] Targets = + { + new("Timberborn.TickSystem.TickableSingletonService", "TickSingletons", nameof(PrefixTickSingletons)), + new("Timberborn.TickSystem.TickableEntity", "TickTickableComponents", nameof(PrefixTickableComponents)), + new("Timberborn.SingletonSystem.SingletonLifecycleService", "UpdateSingletons", nameof(PrefixUpdateSingletons)), + new("Timberborn.SingletonSystem.SingletonLifecycleService", "LateUpdateSingletons", nameof(PrefixLateUpdateSingletons)), + }; + + public static void Patch(Harmony harmony) + { + if (isPatched) + { + return; + } + + foreach (var target in Targets) + { + var type = AccessTools.TypeByName(target.TypeName); + var original = type is null ? null : AccessTools.Method(type, target.MethodName); + var prefix = AccessTools.Method(typeof(DispatcherPatcher), target.PrefixName); + + if (original is null || prefix is null) + { + Runtime.Log($"warning: target not found: {target.TypeName}.{target.MethodName}; leaving vanilla behavior"); + continue; + } + + harmony.Patch(original, prefix: new HarmonyMethod(prefix) { priority = Priority.Low }); + Runtime.Log($"benchmark patch installed: {target.TypeName}.{target.MethodName}"); + } + + isPatched = true; + } + + public static void Unpatch(Harmony harmony) + { + if (!isPatched) + { + return; + } + + foreach (var target in Targets) + { + var type = AccessTools.TypeByName(target.TypeName); + var original = type is null ? null : AccessTools.Method(type, target.MethodName); + if (original is not null) + { + harmony.Unpatch(original, HarmonyPatchType.Prefix, harmony.Id); + } + } + + isPatched = false; + Runtime.Log("benchmark dispatcher patches removed"); + } + + public static bool PrefixTickSingletons(object __instance) => + Runtime.TryDispatch(__instance, "_tickableSingletons", "_tickableSingleton", "Tick"); + + public static bool PrefixTickableComponents(object __instance) => + Runtime.TryDispatch(__instance, "_tickableComponents", "_tickableComponent", "StartAndTick", "Enabled"); + + public static bool PrefixUpdateSingletons(object __instance) => + Runtime.TryDispatch(__instance, "_updatableSingletons", null, "UpdateSingleton"); + + public static bool PrefixLateUpdateSingletons(object __instance) => + Runtime.TryDispatch(__instance, "_lateUpdatableSingletons", null, "LateUpdateSingleton"); +} diff --git a/BenchmarkAndOptimizerV11Port/FrameProfileSampler.cs b/BenchmarkAndOptimizerV11Port/FrameProfileSampler.cs new file mode 100644 index 00000000..a6e87e70 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/FrameProfileSampler.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using HarmonyLib; + +namespace BenchmarkAndOptimizerV11; + +/// +/// Optional, read-only frame/GC samples during the existing 120s CSV benchmark. +/// Records one row per ten Unity UpdateSingletons calls, and never skips game logic. +/// This does not turn on legacy scene-wide, mod-specific or invasive profilers. +/// +static class FrameProfileSampler +{ + static MethodBase? targetMethod; + static bool isPatched; + static long startTick; + static long sampleTick; + static int frames; + static int lastSampleFrame; + static readonly List Rows = []; + + public static void Patch(Harmony harmony) + { + if (isPatched) { return; } + var lifecycle = AccessTools.TypeByName("Timberborn.SingletonSystem.SingletonLifecycleService"); + targetMethod = lifecycle is null ? null : AccessTools.Method(lifecycle, "UpdateSingletons"); + if (targetMethod is null) { return; } + try + { + harmony.Patch(targetMethod, postfix: new HarmonyMethod(AccessTools.Method(typeof(FrameProfileSampler), nameof(AfterUpdate)))); + Rows.Clear(); + Rows.Add("Frame,ElapsedSeconds,FrameMsAvg,FPS,GC0,GC1,GC2,ManagedHeapBytes"); + startTick = sampleTick = Stopwatch.GetTimestamp(); + frames = lastSampleFrame = 0; + isPatched = true; + } + catch { targetMethod = null; isPatched = false; } + } + + static void AfterUpdate() + { + if (!isPatched || !Runtime.IsBenchmarking) { return; } + ++frames; + if (frames - lastSampleFrame < 10) { return; } + var now = Stopwatch.GetTimestamp(); + var n = frames - lastSampleFrame; + var seconds = (now - sampleTick) / (double)Stopwatch.Frequency; + if (seconds <= 0) { return; } + Rows.Add(FormattableString.Invariant( + $"{frames},{(now - startTick) / (double)Stopwatch.Frequency:F3},{1000 * seconds / n:F3},{n / seconds:F2},{GC.CollectionCount(0)},{GC.CollectionCount(1)},{GC.CollectionCount(2)},{GC.GetTotalMemory(false)}")); + sampleTick = now; + lastSampleFrame = frames; + } + + public static void StopAndWrite(Harmony? harmony, string modPath) + { + if (!isPatched) { return; } + isPatched = false; + if (harmony is not null && targetMethod is not null) + { + try { harmony.Unpatch(targetMethod, HarmonyPatchType.Postfix, harmony.Id); } + catch { /* Never alter the caller's benchmark completion. */ } + } + targetMethod = null; + try + { + File.WriteAllLines(Path.Combine(modPath, + $"optimizer-v11-frames-{DateTime.Now:yyyyMMdd-HHmmss}.csv"), Rows); + } + catch { /* CSV profiling failure must not break gameplay. */ } + Rows.Clear(); + } +} diff --git a/BenchmarkAndOptimizerV11Port/IsExternalInit.cs b/BenchmarkAndOptimizerV11Port/IsExternalInit.cs new file mode 100644 index 00000000..2632f761 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/IsExternalInit.cs @@ -0,0 +1,6 @@ +namespace System.Runtime.CompilerServices +{ + internal static class IsExternalInit + { + } +} diff --git a/BenchmarkAndOptimizerV11Port/ModStarter.cs b/BenchmarkAndOptimizerV11Port/ModStarter.cs new file mode 100644 index 00000000..d2277720 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/ModStarter.cs @@ -0,0 +1,20 @@ +using System.Collections; +using System.Reflection; +using System.Linq.Expressions; +using System.Runtime.CompilerServices; +using Newtonsoft.Json; +using HarmonyLib; +using Timberborn.ModManagerScene; + +namespace BenchmarkAndOptimizerV11; + +public sealed class ModStarter : IModStarter +{ + public void StartMod(IModEnvironment modEnvironment) + { + var harmony = new Harmony("tharbad08.BenchmarkAndOptimizerV11Port"); + Runtime.Initialize(modEnvironment.ModPath, harmony); + + Runtime.Log($"loaded; config={Runtime.ConfigPath}; normal throttling uses direct method patches"); + } +} diff --git a/BenchmarkAndOptimizerV11Port/README.md b/BenchmarkAndOptimizerV11Port/README.md new file mode 100644 index 00000000..cbe95ce1 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/README.md @@ -0,0 +1,49 @@ +# Benchmark & Optimizer — isolated Timberborn v1.1 port + +This branch is based on `chatgpt/BenchmarkAndOptimizerV11Port`. It retains only the original benchmark/CSV profiler and opt-in interval controls. It is **not** the TB Optimizer gameplay mod. + +- **Per-system CSV benchmark:** start the 120-second sampling session from Mod Settings. Results `optimizer-v11-benchmark-*.csv` contain type, mod origin, total time, invocation count, average/min/max duration. TimberPhysics has observational StepAll/Physics.Simulate timing when installed. +- **Frame/GC CSV profile:** the same session writes `optimizer-v11-frames-*.csv` with ten-frame-average FPS/frame-time plus GC generation counts and managed heap. FPS is actual rendered-frame cadence, **not** simulation speed; no game-speed metric is inferred. +- **Update intervals:** per-system controls default to 1 (vanilla). All simulation-critical/global schedulers are pinned to interval 1. The benchmark temporarily removes interval patches to time unthrottled gameplay and restores them after completion. +- **No gameplay optimizations** (BFR, EBR, preview, fauna, soil, SuperCursor, TimberPhysics catch-up/collider, GC tweaks), no startup guard or save serialization patches. +- **No always-on tracing:** profiler hooks are installed only during a requested benchmark. The full historical diagnostic and experiment sources remain on the original parent branch; heavy legacy profilers are not installed in the isolated branch. + +The benchmark's existing dispatcher instrumentation calls original tick/update methods via validated reflected dispatch. In-game verification is still required even if GitHub Actions succeeds. + +## Running alongside TB Optimizer + +**Keep both mods enabled.** This port profiles the running colony, while [TB Optimizer](https://github.com/tharbad08/TB-Optimizer) applies stable, individually configurable fixes. Their mod IDs, DLL assembly names and Harmony owners are distinct. + +- During benchmarking, this mod temporarily disables **only its own optional update-interval throttles**. TB Optimizer remains active and the CSV measures the actual optimized configuration, not a vanilla baseline. +- At the default interval **1**, Benchmark & Optimizer does not throttle game systems between benchmark sessions. Avoid increasing intervals when comparing the stable TB Optimizer fixes: skipped callbacks can change simulation behavior. +- The mods intentionally share observation/optimization targets without sharing ownership: `SingletonLifecycleService.UpdateSingletons` (benchmark dispatcher and frame/GC sampling versus TB Optimizer's optional GC postfix), and TimberPhysics `PhysicsSimulator.UpdateSingleton` (read-only split timings versus TB Optimizer's optional catch-up limiter). +- This has been checked at the source and Harmony patch-owner level. Full in-game concurrency and benchmark results still require validation. + + + +## System discovery, filter, and future improvements + +**Existing behavior** +- A startup scan discovers eligible tick/update systems from assemblies that are already loaded. +- The **120-second benchmark** can discover additional systems encountered during gameplay. Currently it is the way to exercise runtime discovery as well as collect timing data; the initial startup scan alone might not provide the full list. +- **Neither startup scanning nor benchmarking guarantees discovering every system:** a mod may load later, or its code may never run during the benchmark. +- The Update Intervals panel includes a filter for system name and mod origin. Its crash fix changes visibility of existing rows instead of destroying and recreating controls during each keystroke. +- **Refresh detected systems** refreshes the displayed in-memory list; it does not independently perform a new scan. `optimizer-v11.json` stores interval configuration, **not** a complete catalog of detected systems. + +**Future work — documentation only, not implemented** +1. Add a standalone **Detect systems** button that performs safe discovery without needing to start the 120-second benchmark. +2. Consider triggering discovery **after the save finishes loading** and/or **when opening the mod settings window**, so systems unavailable at initial startup can be detected. Avoid expensive repeated full scans and never disturb simulation/tick scheduling. +3. Save the **entire discovered-system catalog** to a human-readable, manually editable JSON file, including full type name, mod/assembly origin, protected status, and current interval. Design safe validation/reload of manual edits; manual configuration must never override protections for critical simulation systems. +4. Make it clear in the UI that **even a separate discovery scan may not detect systems loaded later or never activated**. Offer a way to refresh the catalog as gameplay or mod loading reveals more systems. + +These suggestions are future enhancements only; no discovery timing, JSON export, or UI behavior is changed by this note. + + +## Verified source ownership + +- [TB-Optimizer](https://github.com/tharbad08/TimberbornMods/tree/TB-Optimizer/BenchmarkAndOptimizerV11Port/Optimizations): gameplay fixes are split into per-mod files there. +- [TBSave](https://github.com/tharbad08/TimberbornMods/tree/chatgpt/TBSave): save/loading investigations and the Bindito generated-assembly startup guard are preserved there. They are research snapshots, not independently verified isolated save-only binaries. +- [TBGC](https://github.com/tharbad08/TimberbornMods/tree/chatgpt/TBGC): GC investigation/prototypes are preserved there; this is not proof of an independent live GC optimization. +- [EBR-Optimizations](https://github.com/tharbad08/TimberbornMods/tree/chatgpt/EBR-Optimizations): legacy EBR-specific experiments and profiling remain preserved; active EBR performance fix is in TB-Optimizer. + +See [TICK_SAFETY.md](TICK_SAFETY.md) for protected systems and discovered failure cases. diff --git a/BenchmarkAndOptimizerV11Port/Runtime.cs b/BenchmarkAndOptimizerV11Port/Runtime.cs new file mode 100644 index 00000000..325799a6 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Runtime.cs @@ -0,0 +1,1416 @@ +using System.Collections; +using System.Reflection; +using System.Linq.Expressions; +using System.Runtime.CompilerServices; +using Newtonsoft.Json; +using HarmonyLib; +using Timberborn.ModManagerScene; + +namespace BenchmarkAndOptimizerV11; + +static class Runtime +{ + static readonly object Sync = new(); + static readonly ConditionalWeakTable Counters = new(); + static readonly ConditionalWeakTable DispatchPlans = new(); + static readonly Dictionary AdapterCache = new(StringComparer.Ordinal); + static readonly HashSet UnsupportedAdapters = new(StringComparer.Ordinal); + static readonly HashSet LoggedWarnings = new(StringComparer.Ordinal); + static readonly Dictionary BenchStats = new(StringComparer.Ordinal); + static readonly HashSet Discovered = new(StringComparer.Ordinal); + static readonly Dictionary OriginByType = new(StringComparer.Ordinal); + static readonly Dictionary AssemblyNameByType = new(StringComparer.Ordinal); + static readonly Dictionary OriginByAssemblyPath = new(StringComparer.OrdinalIgnoreCase); + static readonly Dictionary OriginByAssemblyName = new(StringComparer.OrdinalIgnoreCase); + static readonly Dictionary DiscoveredTypes = new(StringComparer.Ordinal); + static readonly Dictionary DirectMethodsByType = new(StringComparer.Ordinal); + static readonly HashSet DirectPatchedMethods = []; + static Harmony? harmonyInstance; + static bool benchmarkRunning; + static int settingsGeneration; + + static Settings currentSettings = new(); + static DateTime nextReloadUtc = DateTime.MinValue; + static DateTime configWriteUtc = DateTime.MinValue; + static long benchmarkStart; + static bool benchmarkCompleted; + + public static string ModPath { get; private set; } = ""; + public static string ConfigPath => Path.Combine(ModPath, "optimizer-v11.json"); + static string DiscoveredPath => Path.Combine(ModPath, "optimizer-v11-discovered.txt"); + static string FreezeLogPath => Path.Combine(ModPath, "optimizer-v11-freezes.log"); + + public const int BenchmarkDurationSeconds = 120; + + // These systems own or synchronize authoritative simulation state. Skipping + // their updates can break ordering assumptions and leave the world in a + // partially-updated state, so they are permanently pinned to vanilla cadence. + static readonly HashSet ProtectedTypeNames = new(StringComparer.Ordinal) + { + "TickableBucketService", + "TickableEntity", + "TickableSingletonService", + "SingletonLifecycleService", + "PhysicsSimulator", + "BehaviorManager", + "NavMeshObserver", + "NavigationSynchronizer", + "AutomationRunner", + // Work dispatch and soil-contamination state must be updated at vanilla cadence. + // Never suppress state transitions in these gameplay-authoritative services. + "PlantingControler", + "PlantMonitor", + "SoilContaminationService", + "WaterSimulator", + "SpeedManager", + "ConstructionSite", + // Preserve FPS and read-only simulation throughput measurement cadence. + "FramesPerSecondPanel", + "FramesPerSecondCounter", + }; + + public static bool Enabled => currentSettings.Enabled; + public static int DefaultInterval => currentSettings.DefaultInterval; + public static bool IsProtected(string typeName) => + ProtectedTypeNames.Contains(ShortTypeName(typeName)); + public static IReadOnlyCollection KnownTypes + { + get + { + lock (Sync) + { + return Discovered.Select(ShortTypeName).Distinct(StringComparer.Ordinal).OrderBy(x => x).ToArray(); + } + } + } + + public static string GetOrigin(string typeName) + { + lock (Sync) + { + if (OriginByType.TryGetValue(typeName, out var direct)) + { + return direct; + } + + var full = Discovered.FirstOrDefault(x => ShortTypeName(x) == typeName); + return full is not null && OriginByType.TryGetValue(full, out var origin) + ? origin + : "UNKNOWN"; + } + } + + public static string? GetThrottleOwnershipNote(string typeName) + { + Type? type; + lock (Sync) + { + type = ResolveDiscoveredTypeLocked(typeName); + } + + if (type is null) + { + return null; + } + + var method = FindDirectTickMethod(type); + var declaringType = method?.DeclaringType; + if (method is null || declaringType is null || declaringType == type) + { + return null; + } + + var depth = InheritanceDepth(type, declaringType); + var depthText = depth > 0 + ? $" ({depth} inheritance level{(depth == 1 ? "" : "s")})" + : ""; + + return $"Inherited {method.Name}{depthText}. Effective throttle target: {FormatMethodTarget(method)}. " + + "Shared base method is patched once; this concrete type keeps its own interval."; + } + + public static string Mode => + BenchmarkActive ? "Benchmarking" : + HasActiveThrottles() ? "Throttling" : + "Vanilla fast path"; + + public static bool IsBenchmarking => BenchmarkActive; + public static double BenchmarkRemainingSeconds + { + get + { + if (!BenchmarkActive) { return 0; } + var elapsed = (System.Diagnostics.Stopwatch.GetTimestamp() - benchmarkStart) + / (double)System.Diagnostics.Stopwatch.Frequency; + return Math.Max(0, BenchmarkDurationSeconds - elapsed); + } + } + + public static void SetEnabled(bool value) + { + currentSettings.Enabled = value; + settingsGeneration++; + SaveSettings(currentSettings); + RefreshDirectThrottlePatches(); + } + + public static void SetDefaultInterval(int value) + { + currentSettings.DefaultInterval = Math.Clamp(value, 1, 1000); + settingsGeneration++; + SaveSettings(currentSettings); + RefreshDirectThrottlePatches(); + } + + public static int GetInterval(string typeName) + { + if (IsProtected(typeName)) + { + return 1; + } + + if (currentSettings.Intervals.TryGetValue(typeName, out var value)) + { + return Math.Clamp(value, 1, 1000); + } + + var full = Discovered.FirstOrDefault(x => ShortTypeName(x) == typeName); + if (full is not null && currentSettings.Intervals.TryGetValue(full, out value)) + { + return Math.Clamp(value, 1, 1000); + } + + return currentSettings.DefaultInterval; + } + + public static void SetInterval(string typeName, int value) + { + var key = Discovered.FirstOrDefault(x => ShortTypeName(x) == typeName) ?? typeName; + + if (IsProtected(typeName)) + { + currentSettings.Intervals[key] = 1; + settingsGeneration++; + SaveSettings(currentSettings); + RefreshDirectThrottlePatches(); + LogOnce($"protected-set:{ShortTypeName(typeName)}", + $"protected system {typeName} is permanently pinned to interval=1"); + return; + } + + value = Math.Clamp(value, 1, 1000); + currentSettings.Intervals[key] = value; + settingsGeneration++; + SaveSettings(currentSettings); + RefreshDirectThrottlePatches(); + } + + public static void ResetIntervals() + { + currentSettings.DefaultInterval = 1; + currentSettings.Intervals.Clear(); + settingsGeneration++; + SaveSettings(currentSettings); + RefreshDirectThrottlePatches(); + Log("all optimizer intervals reset to vanilla (1)"); + } + + public static void StartBenchmark() + { + if (BenchmarkActive) { return; } + lock (Sync) BenchStats.Clear(); + // Benchmark dispatcher is observational at protected vanilla cadence: + // the per-class interval shortcut is suppressed during benchmarks. + RemoveDirectThrottlePatches(); + if (harmonyInstance is not null) + { + DispatcherPatcher.Patch(harmonyInstance); + TimberPhysicsBenchmarkPatcher.Patch(harmonyInstance); + FrameProfileSampler.Patch(harmonyInstance); + } + benchmarkStart = System.Diagnostics.Stopwatch.GetTimestamp(); + benchmarkCompleted = false; + benchmarkRunning = true; + } + + static string ShortTypeName(string value) + { + var i = value.LastIndexOf('.'); + return i < 0 ? value : value[(i + 1)..]; + } + + static Type? ResolveDiscoveredTypeLocked(string typeName) + { + if (DiscoveredTypes.TryGetValue(typeName, out var exact)) + { + return exact; + } + + foreach (var pair in DiscoveredTypes) + { + if (string.Equals(ShortTypeName(pair.Key), typeName, StringComparison.Ordinal)) + { + return pair.Value; + } + } + + return null; + } + + static int InheritanceDepth(Type concreteType, Type declaringType) + { + var depth = 0; + for (var current = concreteType; current is not null; current = current.BaseType) + { + if (current == declaringType) + { + return depth; + } + + depth++; + } + + return -1; + } + + static string FormatMethodTarget(MethodInfo method) + { + var declaring = method.DeclaringType; + return declaring is null + ? method.Name + : $"{FormatTypeName(declaring)}.{method.Name}"; + } + + static string FormatTypeName(Type type) + { + if (type.IsByRef) + { + return FormatTypeName(type.GetElementType()!) + "&"; + } + + if (type.IsArray) + { + return FormatTypeName(type.GetElementType()!) + "[]"; + } + + if (!type.IsGenericType) + { + return type.FullName?.Replace('+', '.') ?? type.Name; + } + + var genericDefinition = type.GetGenericTypeDefinition(); + var baseName = genericDefinition.FullName ?? genericDefinition.Name; + var tick = baseName.IndexOf((char)96); + if (tick >= 0) + { + baseName = baseName[..tick]; + } + + baseName = baseName.Replace('+', '.'); + var arguments = string.Join(", ", type.GetGenericArguments().Select(FormatTypeName)); + return $"{baseName}<{arguments}>"; + } + + public static void Initialize(string modPath, Harmony harmony) + { + harmonyInstance = harmony; + ModPath = modPath; + Directory.CreateDirectory(ModPath); + // CSV and frame samples are written only for an explicitly started benchmark. + + if (!File.Exists(ConfigPath)) + { + currentSettings = Settings.CreateDefault(); + SaveSettings(currentSettings); + } + + ReloadConfig(force: true); + + // Benchmarks are session-only and must never be resumed from persisted settings. + currentSettings.BenchmarkSeconds = 0; + benchmarkRunning = false; + benchmarkCompleted = true; + benchmarkStart = System.Diagnostics.Stopwatch.GetTimestamp(); + + // Benchmark-only startup: no game optimizers, startup type-discovery + // hooks, GC interventions, save hooks or broad profiler scans. + DiscoverLoadedOptimizableTypes(); + RefreshDirectThrottlePatches(); + } + + public static void RefreshDirectThrottlePatches() + { + if (harmonyInstance is null || BenchmarkActive) + { + return; + } + + RemoveDirectThrottlePatches(); + + if (!currentSettings.Enabled) + { + return; + } + + foreach (var pair in DiscoveredTypes) + { + var type = pair.Value; + if (IsProtected(type) || GetInterval(type) <= 1) + { + continue; + } + + var method = FindDirectTickMethod(type); + if (method is null) + { + LogOnce( + $"no-direct-method:{type.FullName}", + $"warning: no directly patchable Tick/Update method found for {type.FullName}; leaving vanilla"); + continue; + } + + method = ResolveImplementedMethod(method); + var declaringType = method.DeclaringType; + var inherited = declaringType is not null && declaringType != type; + + if (inherited) + { + var depth = InheritanceDepth(type, declaringType!); + var effectiveTarget = FormatMethodTarget(method); + LogOnce( + $"inherited-direct-method:{type.FullName}", + $"nested throttle mapping: {type.FullName}.{method.Name} is inherited" + + $"{(depth > 0 ? $" through {depth} inheritance level(s)" : "")}; " + + $"effective throttle target={effectiveTarget}; sibling runtime types keep their own intervals"); + } + + try + { + var prefix = AccessTools.Method(typeof(Runtime), nameof(DirectThrottlePrefix)); + + // Several concrete types can share the same inherited method. Patch the + // effective method only once; DirectThrottlePrefix uses __instance.GetType() + // so each concrete sibling still gets its own configured interval. + if (DirectPatchedMethods.Add(method)) + { + harmonyInstance.Patch( + method, + prefix: new HarmonyMethod(prefix) { priority = Priority.First }); + + Log( + inherited + ? $"direct throttle patch installed: effective target {FormatMethodTarget(method)}" + : $"direct throttle patch installed: {type.FullName}.{method.Name}"); + } + + DirectMethodsByType[pair.Key] = method; + Log( + inherited + ? $"direct throttle binding: {type.FullName} interval={GetInterval(type)} -> {FormatMethodTarget(method)}" + : $"direct throttle binding: {type.FullName} interval={GetInterval(type)}"); + } + catch (Exception ex) + { + DirectPatchedMethods.Remove(method); + LogOnce( + $"direct-patch-failed:{type.FullName}:{method.Name}", + $"warning: direct throttle patch failed for {type.FullName}.{method.Name}: {ex.GetType().Name}: {ex.Message}"); + } + } + } + + static void RemoveDirectThrottlePatches() + { + if (harmonyInstance is null || DirectPatchedMethods.Count == 0) + { + return; + } + + foreach (var method in DirectPatchedMethods.ToArray()) + { + try + { + harmonyInstance.Unpatch(method, HarmonyPatchType.Prefix, harmonyInstance.Id); + } + catch + { + // A stale direct patch must never break settings changes. + } + } + + DirectPatchedMethods.Clear(); + DirectMethodsByType.Clear(); + } + + public static bool DirectThrottlePrefix(object __instance, MethodBase __originalMethod) + { + if (!currentSettings.Enabled || BenchmarkActive || IsProtected(__instance.GetType())) + { + return true; + } + + return ShouldRun(__instance); + } + + static MethodInfo ResolveImplementedMethod(MethodInfo method) + { + var declaringType = method.DeclaringType; + if (declaringType is null || method.ReflectedType == declaringType) + { + return method; + } + + var parameterTypes = method.GetParameters() + .Select(parameter => parameter.ParameterType) + .ToArray(); + + return AccessTools.DeclaredMethod(declaringType, method.Name, parameterTypes) + ?? method; + } + + static MethodInfo? FindDirectTickMethod(Type type) + { + foreach (var methodName in new[] { "Tick", "UpdateSingleton", "LateUpdateSingleton", "StartAndTick" }) + { + var method = AccessTools.Method(type, methodName, Type.EmptyTypes); + if (method is not null && method.ReturnType == typeof(void)) + { + return method; + } + } + + return null; + } + + static void DiscoverLoadedOptimizableTypes() + { + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + types = ex.Types.Where(x => x is not null).Cast().ToArray(); + } + catch + { + continue; + } + + foreach (var type in types) + { + if (type.IsAbstract || type.IsInterface) + { + continue; + } + + var method = FindDirectTickMethod(type); + if (method is null) + { + continue; + } + + var interfaces = type.GetInterfaces() + .Select(i => i.FullName ?? i.Name) + .ToArray(); + + var looksOptimizable = + interfaces.Any(n => + n == "Timberborn.TickSystem.ITickableSingleton" || + n == "Timberborn.SingletonSystem.IUpdatableSingleton" || + n == "Timberborn.SingletonSystem.ILateUpdatableSingleton" || + n.Contains("ITickable", StringComparison.Ordinal)) || + type.BaseType?.FullName == "Timberborn.TickSystem.TickableComponent"; + + if (looksOptimizable) + { + Discover(type); + } + } + } + + Log($"direct throttle discovery complete: {DiscoveredTypes.Count} type(s)"); + } + + public static bool TryDispatch( + object owner, + string listFieldName, + string? actualFieldName, + string invokeMethodName, + string? enabledMemberName = null) + { + ReloadConfig(force: false); + + if (!currentSettings.Enabled) + { + return true; + } + + if (!BenchmarkActive && !HasActiveThrottles()) + { + return true; + } + + try + { + var listField = AccessTools.Field(owner.GetType(), listFieldName); + if (listField?.GetValue(owner) is not IList list) + { + LogOnce( + $"missing-list:{owner.GetType().FullName}:{listFieldName}", + $"warning: {owner.GetType().FullName}.{listFieldName} unavailable; falling back to vanilla"); + return true; + } + + var plan = GetDispatchPlan( + owner, + list, + listFieldName, + actualFieldName, + invokeMethodName, + enabledMemberName); + + if (plan is null) + { + return true; + } + + // Critical fast path: an active optimization elsewhere must not force this + // owner through our dispatcher. Let Timberborn run it natively unless this + // exact owner contains a throttled type. + if (!BenchmarkActive && !plan.HasThrottle) + { + return true; + } + + foreach (var entry in plan.Entries) + { + if (!entry.Adapter.IsEnabled(entry.Item)) + { + continue; + } + + // Benchmarking must never skip a tick, even if an old config + // contains non-vanilla intervals. + if (!BenchmarkActive && !ShouldRun(entry.Actual)) + { + continue; + } + + if (BenchmarkActive) + { + var started = System.Diagnostics.Stopwatch.GetTimestamp(); + entry.Adapter.Invoke(entry.Item); + RecordBenchmark(entry.ActualType, System.Diagnostics.Stopwatch.GetTimestamp() - started); + } + else + { + entry.Adapter.Invoke(entry.Item); + } + } + + FinishBenchmarkIfNeeded(); + return false; + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; + } + catch (Exception ex) + { + LogOnce( + $"dispatch:{owner.GetType().FullName}:{listFieldName}:{ex.GetType().FullName}:{ex.Message}", + $"warning: optimizer dispatch failed before safe completion: {ex.GetType().Name}: {ex.Message}; falling back to vanilla"); + return true; + } + } + + static DispatchPlan? GetDispatchPlan( + object owner, + IList list, + string listFieldName, + string? actualFieldName, + string invokeMethodName, + string? enabledMemberName) + { + var ownerPlans = DispatchPlans.GetOrCreateValue(owner); + + lock (ownerPlans.Gate) + { + if (ownerPlans.Plans.TryGetValue(listFieldName, out var cached) + && cached.SettingsGeneration == settingsGeneration + && cached.ListCount == list.Count + && SameBoundaryItems(cached, list)) + { + return cached; + } + + var entries = new List(list.Count); + var hasThrottle = false; + + for (var i = 0; i < list.Count; i++) + { + var item = list[i]; + if (item is null) + { + continue; + } + + var adapter = GetAdapter( + item.GetType(), + actualFieldName, + invokeMethodName, + enabledMemberName); + + if (adapter is null) + { + LogOnce( + $"unsupported:{item.GetType().FullName}:{listFieldName}", + $"warning: cannot adapt {item.GetType().FullName} in {listFieldName}; falling back to vanilla"); + return null; + } + + var actual = adapter.GetActual(item); + if (actual is null) + { + continue; + } + + var actualType = actual.GetType(); + Discover(actualType); + + if (GetInterval(actualType) > 1) + { + hasThrottle = true; + } + + entries.Add(new DispatchEntry(item, adapter, actual, actualType)); + } + + var plan = new DispatchPlan( + settingsGeneration, + list.Count, + list.Count > 0 ? list[0] : null, + list.Count > 1 ? list[list.Count - 1] : null, + hasThrottle, + entries.ToArray()); + + ownerPlans.Plans[listFieldName] = plan; + return plan; + } + } + + static bool SameBoundaryItems(DispatchPlan plan, IList list) + { + if (list.Count == 0) + { + return true; + } + + if (!ReferenceEquals(plan.FirstItem, list[0])) + { + return false; + } + + return list.Count <= 1 || ReferenceEquals(plan.LastItem, list[list.Count - 1]); + } + + static ItemAdapter? GetAdapter(Type itemType, string? actualFieldName, string methodName, string? enabledMemberName) + { + var cacheKey = $"{itemType.AssemblyQualifiedName}|{actualFieldName}|{methodName}|{enabledMemberName}"; + + lock (Sync) + { + if (AdapterCache.TryGetValue(cacheKey, out var cached)) + { + return cached; + } + + if (UnsupportedAdapters.Contains(cacheKey)) + { + return null; + } + + FieldInfo? actualField = null; + if (!string.IsNullOrEmpty(actualFieldName)) + { + actualField = AccessTools.Field(itemType, actualFieldName); + if (actualField is null) + { + UnsupportedAdapters.Add(cacheKey); + return null; + } + } + + var method = AccessTools.Method(itemType, methodName, Type.EmptyTypes); + if (method is null && methodName == "StartAndTick") + { + method = AccessTools.Method(itemType, "Tick", Type.EmptyTypes); + } + + if (method is null) + { + UnsupportedAdapters.Add(cacheKey); + return null; + } + + try + { + var itemParameter = Expression.Parameter(typeof(object), "item"); + var typedItem = Expression.Convert(itemParameter, itemType); + + Func getActual; + if (actualField is not null) + { + var fieldAccess = Expression.Field(typedItem, actualField); + var boxedField = Expression.Convert(fieldAccess, typeof(object)); + getActual = Expression.Lambda>(boxedField, itemParameter).Compile(); + } + else + { + getActual = item => item; + } + + var call = Expression.Call(typedItem, method); + var invoke = Expression.Lambda>(call, itemParameter).Compile(); + + Func enabled = _ => true; + if (!string.IsNullOrEmpty(enabledMemberName)) + { + var prop = AccessTools.Property(itemType, enabledMemberName); + var field = AccessTools.Field(itemType, enabledMemberName); + Expression? enabledExpression = null; + + if (prop?.PropertyType == typeof(bool) && prop.GetMethod is not null) + { + enabledExpression = Expression.Property(typedItem, prop); + } + else if (field?.FieldType == typeof(bool)) + { + enabledExpression = Expression.Field(typedItem, field); + } + + if (enabledExpression is not null) + { + enabled = Expression.Lambda>(enabledExpression, itemParameter).Compile(); + } + } + + var adapter = new ItemAdapter( + actualFieldName, + enabledMemberName, + getActual, + invoke, + enabled); + + AdapterCache[cacheKey] = adapter; + return adapter; + } + catch + { + UnsupportedAdapters.Add(cacheKey); + return null; + } + } + } + + static bool ShouldRun(object actual) + { + if (IsProtected(actual.GetType())) + { + return true; + } + + var interval = GetInterval(actual.GetType()); + if (interval <= 1) + { + return true; + } + + var state = Counters.GetOrCreateValue(actual); + if (state.Remaining <= 0) + { + state.Remaining = interval - 1; + return true; + } + + state.Remaining--; + return false; + } + + static bool IsProtected(Type type) => + ProtectedTypeNames.Contains(type.Name); + + static bool NormalizeProtectedIntervals(Settings settings) + { + var changed = false; + + foreach (var key in settings.Intervals.Keys.ToArray()) + { + if (IsProtected(key) && settings.Intervals[key] != 1) + { + settings.Intervals[key] = 1; + changed = true; + } + } + + return changed; + } + + static bool HasActiveThrottles() + { + if (currentSettings.DefaultInterval > 1) + { + return true; + } + + return currentSettings.Intervals.Values.Any(value => value > 1); + } + + static int GetInterval(Type type) + { + if (IsProtected(type)) + { + return 1; + } + + var full = type.FullName ?? type.Name; + + if (currentSettings.Intervals.TryGetValue(full, out var exact)) + { + return Math.Clamp(exact, 1, 1000); + } + + if (currentSettings.Intervals.TryGetValue(type.Name, out var shortName)) + { + return Math.Clamp(shortName, 1, 1000); + } + + return Math.Clamp(currentSettings.DefaultInterval, 1, 1000); + } + + static void Discover(Type type) + { + var name = type.FullName ?? type.Name; + + lock (Sync) + { + if (Discovered.Contains(name)) + { + return; + } + } + + // Origin resolution can touch paths/manifests. Do it only once per discovered type, + // never on the per-frame hot path. + var origin = ResolveOrigin(type); + var assemblyName = type.Assembly.GetName().Name ?? ""; + + lock (Sync) + { + if (!Discovered.Add(name)) + { + return; + } + + OriginByType[name] = origin; + AssemblyNameByType[name] = assemblyName; + DiscoveredTypes[name] = type; + + // Type discovery remains in-memory for live settings; no text dump. + } + } + + public static void RegisterModAssemblyOrigin(string assemblyName, string origin) + { + if (string.IsNullOrWhiteSpace(assemblyName) || string.IsNullOrWhiteSpace(origin)) + { + return; + } + + lock (Sync) + { + OriginByAssemblyName[assemblyName] = origin; + + foreach (var pair in AssemblyNameByType) + { + if (string.Equals(pair.Value, assemblyName, StringComparison.OrdinalIgnoreCase)) + { + OriginByType[pair.Key] = origin; + } + } + } + } + + public static void RegisterModPathOrigin(string path, string origin) + { + if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(origin)) + { + return; + } + + try + { + path = Path.GetFullPath(path); + } + catch + { + return; + } + + lock (Sync) + { + OriginByAssemblyPath[path] = origin; + } + } + + static string ResolveOrigin(Type type) + { + string location; + try + { + location = type.Assembly.Location ?? ""; + } + catch + { + location = ""; + } + + if (string.IsNullOrWhiteSpace(location)) + { + var assemblyName = type.Assembly.GetName().Name ?? ""; + lock (Sync) + { + return OriginByAssemblyName.TryGetValue(assemblyName, out var mapped) + ? mapped + : $"UNKNOWN: {assemblyName}"; + } + } + + var fullPath = Path.GetFullPath(location); + lock (Sync) + { + if (OriginByAssemblyPath.TryGetValue(fullPath, out var cached)) + { + return cached; + } + } + + var normalized = fullPath.Replace('\\', '/'); + string origin; + + lock (Sync) + { + if (OriginByAssemblyPath.TryGetValue(fullPath, out var exactPathOrigin)) + { + return exactPathOrigin; + } + + var assemblyName = type.Assembly.GetName().Name ?? ""; + if (OriginByAssemblyName.TryGetValue(assemblyName, out var assemblyOrigin)) + { + return assemblyOrigin; + } + } + + if (normalized.Contains("/Timberborn_Data/Managed/", StringComparison.OrdinalIgnoreCase)) + { + origin = "CORE"; + } + else + { + var manifest = FindNearestManifest(fullPath); + if (manifest is not null) + { + origin = DescribeModOrigin(manifest, normalized); + } + else + { + var workshopMarker = "/steamapps/workshop/content/1062090/"; + var workshopIndex = normalized.IndexOf(workshopMarker, StringComparison.OrdinalIgnoreCase); + if (workshopIndex >= 0) + { + var rest = normalized[(workshopIndex + workshopMarker.Length)..]; + var workshopId = rest.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "?"; + origin = $"MOD: Workshop {workshopId}"; + } + else + { + var localMarker = "/Timberborn/Mods/"; + var localIndex = normalized.IndexOf(localMarker, StringComparison.OrdinalIgnoreCase); + if (localIndex >= 0) + { + var rest = normalized[(localIndex + localMarker.Length)..]; + var folder = rest.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "local"; + origin = $"MOD: {folder} (local)"; + } + else + { + origin = $"OTHER: {type.Assembly.GetName().Name}"; + } + } + } + } + + lock (Sync) + { + OriginByAssemblyPath[fullPath] = origin; + } + + return origin; + } + + static string? FindNearestManifest(string assemblyPath) + { + try + { + var directory = new DirectoryInfo(Path.GetDirectoryName(assemblyPath)!); + for (var depth = 0; directory is not null && depth < 6; depth++, directory = directory.Parent) + { + var manifest = Path.Combine(directory.FullName, "manifest.json"); + if (File.Exists(manifest)) + { + return manifest; + } + } + } + catch + { + // Origin enrichment is diagnostic only. + } + + return null; + } + + static string DescribeModOrigin(string manifestPath, string normalizedAssemblyPath) + { + try + { + var json = File.ReadAllText(manifestPath); + var manifest = JsonConvert.DeserializeObject>(json); + manifest ??= new Dictionary(); + + var name = manifest.TryGetValue("Name", out var nameValue) + ? Convert.ToString(nameValue) + : null; + var id = manifest.TryGetValue("Id", out var idValue) + ? Convert.ToString(idValue) + : null; + + var workshopMarker = "/steamapps/workshop/content/1062090/"; + var workshopIndex = normalizedAssemblyPath.IndexOf(workshopMarker, StringComparison.OrdinalIgnoreCase); + string? suffix = null; + + if (workshopIndex >= 0) + { + var rest = normalizedAssemblyPath[(workshopIndex + workshopMarker.Length)..]; + var workshopId = rest.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(workshopId)) + { + suffix = $"Workshop {workshopId}"; + } + } + else if (normalizedAssemblyPath.Contains("/Timberborn/Mods/", StringComparison.OrdinalIgnoreCase)) + { + suffix = "local"; + } + + var display = !string.IsNullOrWhiteSpace(name) + ? name! + : !string.IsNullOrWhiteSpace(id) ? id! : Path.GetFileName(Path.GetDirectoryName(manifestPath)); + + if (!string.IsNullOrWhiteSpace(id) && !string.Equals(display, id, StringComparison.Ordinal)) + { + display += $" ({id})"; + } + + return suffix is null + ? $"MOD: {display}" + : $"MOD: {display} [{suffix}]"; + } + catch + { + return normalizedAssemblyPath.Contains("/Timberborn/Mods/", StringComparison.OrdinalIgnoreCase) + ? "MOD: local" + : "MOD"; + } + } + + static bool BenchmarkActive => + benchmarkRunning && !benchmarkCompleted; + + static void RecordBenchmark(Type type, long elapsedTicks) + { + var name = type.FullName ?? type.Name; + + lock (Sync) + { + if (!BenchStats.TryGetValue(name, out var stat)) + { + stat = new BenchStat { Origin = ResolveOrigin(type) }; + BenchStats[name] = stat; + } + + stat.Count++; + stat.TotalTicks += elapsedTicks; + if (elapsedTicks < stat.MinTicks) stat.MinTicks = elapsedTicks; + if (elapsedTicks > stat.MaxTicks) stat.MaxTicks = elapsedTicks; + } + + } + + public static string ResolveDiagnosticOrigin(Type type) => ResolveOrigin(type); + + public static void RecordDiagnosticBenchmark(string name, string origin, long elapsedTicks) + { + if (!BenchmarkActive || elapsedTicks < 0) + { + return; + } + + lock (Sync) + { + if (!BenchStats.TryGetValue(name, out var stat)) + { + stat = new BenchStat { Origin = origin }; + BenchStats[name] = stat; + } + + stat.Count++; + stat.TotalTicks += elapsedTicks; + if (elapsedTicks < stat.MinTicks) stat.MinTicks = elapsedTicks; + if (elapsedTicks > stat.MaxTicks) stat.MaxTicks = elapsedTicks; + } + + } + + static void FinishBenchmarkIfNeeded() + { + if (!BenchmarkActive) + { + return; + } + + var elapsed = (System.Diagnostics.Stopwatch.GetTimestamp() - benchmarkStart) + / (double)System.Diagnostics.Stopwatch.Frequency; + if (elapsed < BenchmarkDurationSeconds) + { + return; + } + + benchmarkCompleted = true; + benchmarkRunning = false; + + var file = Path.Combine(ModPath, $"optimizer-v11-benchmark-{DateTime.Now:yyyyMMdd-HHmmss}.csv"); + var lines = new List { "Type,Origin,TotalMs,Count,AverageMs,MinMs,MaxMs" }; + + lock (Sync) + { + foreach (var pair in BenchStats.OrderByDescending(x => x.Value.TotalTicks)) + { + var s = pair.Value; + var totalMs = TicksToMs(s.TotalTicks); + var minMs = s.Count == 0 ? 0 : TicksToMs(s.MinTicks); + var maxMs = s.Count == 0 ? 0 : TicksToMs(s.MaxTicks); + var avgMs = s.Count == 0 ? 0 : totalMs / s.Count; + lines.Add($"{Csv(pair.Key)},{Csv(s.Origin)},{totalMs:F6},{s.Count},{avgMs:F6},{minMs:F6},{maxMs:F6}"); + } + } + + File.WriteAllLines(file, lines); + + FrameProfileSampler.StopAndWrite(harmonyInstance, ModPath); + + if (harmonyInstance is not null) + { + TimberPhysicsBenchmarkPatcher.Unpatch(harmonyInstance); + DispatcherPatcher.Unpatch(harmonyInstance); + + // Return to the configured interval patches after observational CSV benchmarking. + RefreshDirectThrottlePatches(); + } + + Log($"benchmark complete: {file}"); + } + + static double TicksToMs(long ticks) => + ticks * 1000.0 / System.Diagnostics.Stopwatch.Frequency; + + static string Csv(string value) => + "\"" + value.Replace("\"", "\"\"") + "\""; + + static void ReloadConfig(bool force) + { + var now = DateTime.UtcNow; + if (!force && now < nextReloadUtc) + { + return; + } + + nextReloadUtc = now.AddSeconds(Math.Max(1, currentSettings.ReloadSeconds)); + + try + { + if (!File.Exists(ConfigPath)) + { + return; + } + + var writeUtc = File.GetLastWriteTimeUtc(ConfigPath); + if (!force && writeUtc == configWriteUtc) + { + return; + } + + var json = File.ReadAllText(ConfigPath); + var loaded = JsonConvert.DeserializeObject(json); + if (loaded is null) + { + return; + } + + loaded.Normalize(); + var protectedValuesCorrected = NormalizeProtectedIntervals(loaded); + currentSettings = loaded; + settingsGeneration++; + configWriteUtc = writeUtc; + + if (protectedValuesCorrected) + { + SaveSettings(currentSettings); + Log("protected optimizer intervals found in config and reset to 1"); + } + + if (!BenchmarkActive) + { + RefreshDirectThrottlePatches(); + } + Log($"config reloaded: {currentSettings.Intervals.Count} explicit interval(s)"); + } + catch (Exception ex) + { + Log($"warning: config reload failed: {ex.GetType().Name}: {ex.Message}; keeping previous settings"); + } + } + + static void SaveSettings(Settings settings) + { + NormalizeProtectedIntervals(settings); + var json = JsonConvert.SerializeObject(settings, Formatting.Indented); + File.WriteAllText(ConfigPath, json + Environment.NewLine); + configWriteUtc = File.GetLastWriteTimeUtc(ConfigPath); + } + + + [System.Diagnostics.Conditional("OPTIMIZER_DIAGNOSTICS")] + public static void Log(string message) + { + // Intentionally empty. The Conditional attribute removes all call + // sites and their interpolation/formatting work at compile time. + } + + [System.Diagnostics.Conditional("OPTIMIZER_DIAGNOSTICS")] + public static void LogFreeze(string message) + { + // All freeze log writes and detectors are disabled. + } + + [System.Diagnostics.Conditional("OPTIMIZER_DIAGNOSTICS")] + static void LogOnce(string key, string message) + { + // Diagnostics are retired; no warning tracking or disk I/O. + } + + static void ResetSessionLogs() + { + // Do not touch pre-existing log files and do not create new ones. + } + + sealed class CounterState + { + public int Remaining; + } + + sealed class OwnerDispatchPlans + { + public object Gate { get; } = new(); + public Dictionary Plans { get; } = new(StringComparer.Ordinal); + } + + sealed record DispatchPlan( + int SettingsGeneration, + int ListCount, + object? FirstItem, + object? LastItem, + bool HasThrottle, + DispatchEntry[] Entries); + + sealed record DispatchEntry( + object Item, + ItemAdapter Adapter, + object Actual, + Type ActualType); + + sealed class BenchStat + { + public string Origin = "UNKNOWN"; + public long Count; + public long TotalTicks; + public long MinTicks = long.MaxValue; + public long MaxTicks; + } + + sealed record ItemAdapter( + string? ActualFieldName, + string? EnabledMemberName, + Func ActualGetter, + Action Invoke, + Func Enabled) + { + public object? GetActual(object item) => ActualGetter(item); + public bool IsEnabled(object item) => Enabled(item); + } +} +sealed class Settings +{ + public bool Enabled { get; set; } = true; + public int DefaultInterval { get; set; } = 1; + public int ReloadSeconds { get; set; } = 2; + public bool WriteDiscoveredTypes { get; set; } = true; + + // 0 = disabled. Set e.g. 30 to benchmark the first 30 real seconds after loading a game. + public int BenchmarkSeconds { get; set; } = 0; + + // Key may be a full type name (preferred) or a short class name. + // 1 = vanilla frequency; 20 = run once, then skip the next 19 dispatches. + public Dictionary Intervals { get; set; } = new(StringComparer.Ordinal); + + public static Settings CreateDefault() => new() + { + Enabled = true, + DefaultInterval = 1, + ReloadSeconds = 2, + WriteDiscoveredTypes = true, + BenchmarkSeconds = 0, + Intervals = new Dictionary(StringComparer.Ordinal) + }; + + public void Normalize() + { + DefaultInterval = Math.Clamp(DefaultInterval, 1, 1000); + ReloadSeconds = Math.Clamp(ReloadSeconds, 1, 60); + // Legacy field kept only for backwards-compatible config parsing. + // Benchmark execution is session-only. + BenchmarkSeconds = 0; + Intervals ??= new Dictionary(StringComparer.Ordinal); + + foreach (var key in Intervals.Keys.ToArray()) + { + Intervals[key] = Math.Clamp(Intervals[key], 1, 1000); + } + } +} diff --git a/BenchmarkAndOptimizerV11Port/SettingsIntegration.cs b/BenchmarkAndOptimizerV11Port/SettingsIntegration.cs new file mode 100644 index 00000000..99ad6218 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/SettingsIntegration.cs @@ -0,0 +1,556 @@ +using System.Reflection; +using Bindito.Core; +using ModSettings.CommonUI; +using ModSettings.Core; +using ModSettings.CoreUI; +using Timberborn.CoreUI; +using Timberborn.Modding; +using Timberborn.Options; +using Timberborn.OptionsGame; +using Timberborn.SettingsSystem; +using Timberborn.SingletonSystem; +using TimberUi.CommonUi; +using UnityEngine.UIElements; + +namespace BenchmarkAndOptimizerV11; + +[Context("MainMenu")] +public sealed class MainMenuConfig : Configurator +{ + public override void Configure() + { + this.BindSingleton() + .MultiBindSingleton() + .BindSingleton(); + } +} + +[Context("Game")] +public sealed class GameConfig : Configurator +{ + public override void Configure() + { + this.BindSingleton() + .MultiBindSingleton() + .BindSingleton() + .BindSingleton() + .BindSingleton(); + } +} + +public sealed class OptimizerSettingsOwner( + ISettings settings, + ModSettingsOwnerRegistry registry, + ModRepository repository +) : ModSettingsOwner(settings, registry, repository) +{ + public override string ModId => "BenchmarkAndOptimizerV11Port"; + public override ModSettingsContext ChangeableOn => ModSettingsContext.All; + + public OptimizerUiSetting CsvBenchmark { get; } = new(); + public IntervalSettingsUiSetting UpdateIntervals { get; } = new(); +} + +public sealed class OptimizerUiSetting() + : NonPersistentSetting(ModSettingDescriptor.Create("Benchmark & CSV profiler")) +{ + public override void Reset() { } +} + +public sealed class IntervalSettingsUiSetting() + : NonPersistentSetting(ModSettingDescriptor.Create("Advanced update intervals")) +{ + public override void Reset() => Runtime.ResetIntervals(); +} + +public sealed class CsvBenchmarkPanel : VisualElement +{ + public CsvBenchmarkPanel() + { + this.SetPadding(8); + this.AddGameLabel("Timberborn benchmark / CSV", bold: true).SetMarginBottom(6); + this.AddGameLabel("Collect per-system timings for 120 seconds.\nSimulation-critical ticks run at vanilla cadence.") + .SetMarginBottom(10); + this.AddMenuButton("Start 120s CSV benchmark", onClick: Runtime.StartBenchmark) + .SetMarginBottom(6); + this.AddGameLabel("CSV output: the mod folder.\nBenchmark stops automatically after 120 seconds.") + .SetMarginBottom(6); + } +} + +public sealed class OptimizerSettingElementFactory( + VisualElementInitializer veInit +) : IModSettingElementFactory +{ + public int Priority { get; } + + public bool TryCreateElement(ModSetting modSetting, out IModSettingElement? element) + { + if (modSetting is OptimizerUiSetting) + { + element = new ModSettingElement(new CsvBenchmarkPanel(), modSetting); + return true; + } + if (modSetting is IntervalSettingsUiSetting) + { + element = new ModSettingElement(new OptimizerPanel(veInit), modSetting); + return true; + } + + element = null; + return false; + } +} + +public sealed class OptimizerSettingsBoxWidthService( + IContainer container +) : IPostLoadableSingleton +{ + public void PostLoad() + { + try + { + // Same pattern as datvm/TImprove4Mods/Services/ModSettingBoxService.cs: + // resolve ModSettingsBox from the finished container, then modify its panel. + var box = container.GetInstance(); + var panelBox = box.GetPanel().Q("Box"); + if (panelBox is null) + { + Runtime.Log("warning: ModSettings Box element not found; keeping default width"); + return; + } + + panelBox.SetWidth(885f); + Runtime.Log("ModSettings box width set to 885px (1.5x default)"); + } + catch (Exception ex) + { + Runtime.Log($"warning: failed to widen ModSettings box: {ex.GetType().Name}: {ex.Message}"); + } + } +} + +public sealed class OptimizerPanel : VisualElement +{ + readonly VisualElement wellKnownPanel; + readonly VisualElement otherSystemsPanel; + readonly Label statusLabel; + string filterText = ""; + // Cache rendered controls: filtering must not tear down a focused UI tree. + readonly List<(VisualElement Element, string Name, string Origin)> systemRows = []; + + static readonly string[] WellKnown = + { + "PhysicsSimulator", + "BehaviorManager", + "NavMeshObserver", + "NavigationSynchronizer", + "AutomationRunner", + "WaterSimulator", + "SpeedManager", + "ConstructionSite", + "ResourceCountingService" + }; + + public OptimizerPanel(VisualElementInitializer veInit) + { + // Follow datvm's own TimberUi/ModSettings pattern: + // use AddToggle/AddSliderInt/RegisterChange instead of raw UIElements callbacks. + this.SetPadding(8); + + this.AddGameLabel("Benchmark & Optimizer — safe update intervals", bold: true); + this.AddGameLabel("1 = vanilla frequency. Higher values run that system once every N dispatcher calls. Changes apply immediately.") + .SetMarginBottom(8); + + var enabled = this.AddToggle("Optimizer enabled", onValueChanged: Runtime.SetEnabled); + enabled.value = Runtime.Enabled; + + this.AddSliderInt( + label: "Default interval", + values: new SliderValues(1, 50, Runtime.DefaultInterval)) + .AddEndLabel(v => v == 1 ? "vanilla" : $"1/{v}") + .RegisterChange(Runtime.SetDefaultInterval) + .SetWidthPercent(100) + .SetMarginBottom(8); + + var actions = this.AddRow(); + actions.AddMenuButton("Reset all to 1", onClick: () => + { + Runtime.ResetIntervals(); + Rebuild(); + }); + actions.AddMenuButton("Refresh detected systems", onClick: Rebuild); + actions.SetMarginBottom(8); + + statusLabel = this.AddGameLabel(""); + statusLabel.SetMarginBottom(8); + + // Never Clear()/recreate UI controls during a text-field change callback. + // The input event may run inside Unity's LateUpdate/UI dispatch. + this.AddTextField("Filter", keyword => + { + filterText = keyword?.Trim() ?? ""; + try + { + ApplyFilter(); + } + catch (Exception ex) + { + Runtime.Log($"warning: optimizer UI filter failed: {ex.GetType().Name}: {ex.Message}"); + } + }) + .SetWidthPercent(100) + .SetMarginBottom(8); + + // Match datvm's TechTree horizontal-scroll pattern: initialize the + // ScrollView through Timberborn's VisualElementInitializer before use. + // Unlike TechTree, this list would otherwise shrink to the viewport, so + // give its content a wider minimum width to create real horizontal overflow. + var scroll = this.AddScrollView().Initialize(veInit); + scroll.mode = ScrollViewMode.VerticalAndHorizontal; + scroll.horizontalScrollerVisibility = scroll.verticalScrollerVisibility = ScrollerVisibility.Auto; + scroll.SetMaxHeight(650); + + var scrollContent = scroll.AddChild() + .SetMinSize(1200f, null); + + wellKnownPanel = scrollContent.AddChild(); + otherSystemsPanel = scrollContent.AddChild(); + + Rebuild(); + } + + void UpdateStatus() + { + statusLabel.text = Runtime.IsBenchmarking + ? $"Mode: {Runtime.Mode} — {Runtime.BenchmarkRemainingSeconds:F1}s remaining" + : $"Mode: {Runtime.Mode} — Detected systems: {Runtime.KnownTypes.Count}"; + } + + void Rebuild() + { + UpdateStatus(); + wellKnownPanel.Clear(); + otherSystemsPanel.Clear(); + systemRows.Clear(); + + wellKnownPanel.AddGameLabel("Well-known systems", bold: true).SetMarginBottom(5); + otherSystemsPanel.AddGameLabel("Other detected systems", bold: true).SetMargin(10, 0, 5, 0); + + var names = WellKnown + .Concat(Runtime.KnownTypes) + .Distinct(StringComparer.Ordinal) + .OrderBy(n => Array.IndexOf(WellKnown, n) < 0 ? 1 : 0) + .ThenBy(n => n, StringComparer.OrdinalIgnoreCase); + + foreach (var name in names) + { + var origin = Runtime.GetOrigin(name); + var row = AddSystemRow( + Array.IndexOf(WellKnown, name) >= 0 ? wellKnownPanel : otherSystemsPanel, + name, origin); + systemRows.Add((row, name, origin)); + } + + ApplyFilter(); + } + + void ApplyFilter() + { + // Hiding existing rows is safe even during a UI text-change callback. + // Refresh/reset buttons may still explicitly rebuild the list. + foreach (var row in systemRows) + { + var matches = string.IsNullOrEmpty(filterText) + || row.Name.Contains(filterText, StringComparison.OrdinalIgnoreCase) + || row.Origin.Contains(filterText, StringComparison.OrdinalIgnoreCase); + row.Element.style.display = matches ? DisplayStyle.Flex : DisplayStyle.None; + } + } + + static VisualElement AddSystemRow(VisualElement parent, string typeName, string origin) + { + var current = Runtime.GetInterval(typeName); + var isProtected = Runtime.IsProtected(typeName); + + // Two-line layout so long type/origin text never pushes the interval control + // out of the visible settings panel. + var item = parent.AddChild() + .SetMarginBottom(10); + + item.AddGameLabel($"{typeName} [{origin}]") + .SetMarginBottom(3); + + var ownershipNote = Runtime.GetThrottleOwnershipNote(typeName); + if (!string.IsNullOrWhiteSpace(ownershipNote)) + { + item.AddGameLabel(ownershipNote) + .SetMarginBottom(3); + } + + // Keep the interval text outside BaseSlider's fixed-width label area. + // Otherwise Timberborn clips the label and the slider thumb overlaps it. + var intervalRow = item.AddRow() + .AlignItems(Align.Center); + + var intervalLabel = intervalRow.AddGameLabel( + isProtected ? "Tick interval - 1/1 (protected)" : TickIntervalLabel(current)) + .SetMinSize(isProtected ? 220f : 125f, null) + .SetMarginRight(10f); + + if (isProtected) + { + return item; + } + + var slider = intervalRow.AddSliderInt( + values: new SliderValues(1, 50, current)) + .SetFlexGrow() + .SetMinSize(700f, null); + + slider.RegisterChange(v => + { + intervalLabel.text = TickIntervalLabel(v); + Runtime.SetInterval(typeName, v); + }); + + return item; + } + + static string TickIntervalLabel(int value) => $"Tick interval - 1/{value}"; +} + + +public sealed class OptimizerOriginRegistryService( + ModRepository repository +) : ILoadableSingleton +{ + public void Load() + { + try + { + var enabledMods = repository.GetType() + .GetProperty("EnabledMods", BindingFlags.Public | BindingFlags.Instance) + ?.GetValue(repository) as System.Collections.IEnumerable; + + if (enabledMods is null) + { + Runtime.Log("warning: origin registry could not read ModRepository.EnabledMods"); + return; + } + + var registeredMods = 0; + var registeredAssemblies = 0; + + foreach (var mod in enabledMods) + { + if (mod is null) + { + continue; + } + + var modType = mod.GetType(); + var manifest = modType + .GetProperty("Manifest", BindingFlags.Public | BindingFlags.Instance) + ?.GetValue(mod); + var modDirectory = modType + .GetProperty("ModDirectory", BindingFlags.Public | BindingFlags.Instance) + ?.GetValue(mod); + + if (manifest is null || modDirectory is null) + { + continue; + } + + var manifestType = manifest.GetType(); + var name = manifestType + .GetProperty("Name", BindingFlags.Public | BindingFlags.Instance) + ?.GetValue(manifest) as string; + var id = manifestType + .GetProperty("Id", BindingFlags.Public | BindingFlags.Instance) + ?.GetValue(manifest) as string; + + var directoryType = modDirectory.GetType(); + var originPath = directoryType + .GetProperty("OriginPath", BindingFlags.Public | BindingFlags.Instance) + ?.GetValue(modDirectory) as string; + + if (string.IsNullOrWhiteSpace(originPath) || !Directory.Exists(originPath)) + { + continue; + } + + var workshopId = ExtractWorkshopId(originPath); + var isLocal = originPath.Replace('\\', '/') + .Contains("/Timberborn/Mods/", StringComparison.OrdinalIgnoreCase); + + var display = !string.IsNullOrWhiteSpace(name) + ? name! + : !string.IsNullOrWhiteSpace(id) ? id! : Path.GetFileName(originPath.TrimEnd(Path.DirectorySeparatorChar)); + + if (!string.IsNullOrWhiteSpace(id) && !string.Equals(display, id, StringComparison.Ordinal)) + { + display += $" ({id})"; + } + + var suffix = !string.IsNullOrWhiteSpace(workshopId) + ? $" [Workshop {workshopId}]" + : isLocal ? " [local]" : ""; + + var origin = $"MOD: {display}{suffix}"; + registeredMods++; + + foreach (var dll in Directory.EnumerateFiles(originPath, "*.dll", SearchOption.AllDirectories)) + { + var assemblyName = Path.GetFileNameWithoutExtension(dll); + Runtime.RegisterModAssemblyOrigin(assemblyName, origin); + Runtime.RegisterModPathOrigin(dll, origin); + registeredAssemblies++; + } + } + + Runtime.Log($"origin registry loaded: mods={registeredMods}, assemblies={registeredAssemblies}"); + } + catch (Exception ex) + { + Runtime.Log($"warning: origin registry failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + static string? ExtractWorkshopId(string path) + { + var normalized = path.Replace('\\', '/'); + const string marker = "/steamapps/workshop/content/1062090/"; + var index = normalized.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (index < 0) + { + return null; + } + + var rest = normalized[(index + marker.Length)..]; + return rest.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + } +} + + +public sealed class OptimizerMenuService( + IOptionsBox optionsBox, + ModSettingsBox modSettingsBox, + ModRepository repository +) : ILoadableSingleton +{ + readonly GameOptionsBox settingsOptionsBox = (GameOptionsBox)optionsBox; + Mod? currentMod; + + public void Load() + { + try + { + currentMod = ResolveMod(repository, "BenchmarkAndOptimizerV11Port"); + if (currentMod is null) + { + Runtime.Log("warning: pause-menu button could not resolve BenchmarkAndOptimizerV11Port in ModRepository"); + return; + } + + // The game stores the menu root in a non-public member. Our build + // stubs are not publicized game assemblies, so access it via reflection + // rather than emitting a direct field access that fails at runtime. + var root = ResolveOptionsRoot(settingsOptionsBox); + if (root is null) + { + Runtime.Log("warning: pause-menu root not available; optimizer menu entry not added"); + return; + } + + var resumeButton = root.Q("ResumeButton") ?? root.Q("Resume"); + if (resumeButton is null) + { + Runtime.Log("warning: pause-menu ResumeButton/Resume not found; optimizer menu entry not added"); + return; + } + + if (root.Q("BenchmarkAndOptimizerButton") is not null) + { + return; + } + + var button = root.AddMenuButton( + "Benchmark & Optimizer", + onClick: () => modSettingsBox.Open(currentMod), + name: "BenchmarkAndOptimizerButton", + stretched: true); + + button.InsertSelfAfter(resumeButton); + Runtime.Log("pause-menu entry installed after ResumeButton/Resume"); + } + catch (Exception ex) + { + // The shortcut must not abort scene loading if a dependency changes. + Runtime.Log($"warning: pause-menu integration disabled after {ex.GetType().Name}: {ex.Message}"); + } + } + + static VisualElement? ResolveOptionsRoot(GameOptionsBox optionsBox) + { + // "_root" is an existing Timberborn field name, not a new member of our + // mod. Keep its exact spelling, and never rely on publicized assembly + // access in a mod compiled against stand-in reference assemblies. + for (Type? type = optionsBox.GetType(); type is not null; type = type.BaseType) + { + var rootField = type.GetField( + "_root", + BindingFlags.Instance | BindingFlags.Public | + BindingFlags.NonPublic | BindingFlags.DeclaredOnly); + + if (rootField is not null) + { + return rootField.GetValue(optionsBox) as VisualElement; + } + } + + return null; + } + + static Mod? ResolveMod(ModRepository repository, string id) + { + try + { + var enabledMods = repository.GetType() + .GetProperty("EnabledMods", BindingFlags.Public | BindingFlags.Instance) + ?.GetValue(repository) as System.Collections.IEnumerable; + + if (enabledMods is null) + { + return null; + } + + foreach (var item in enabledMods) + { + if (item is not Mod mod) + { + continue; + } + + var manifest = item.GetType() + .GetProperty("Manifest", BindingFlags.Public | BindingFlags.Instance) + ?.GetValue(item); + + var manifestId = manifest?.GetType() + .GetProperty("Id", BindingFlags.Public | BindingFlags.Instance) + ?.GetValue(manifest) as string; + + if (manifestId == id) + { + return mod; + } + } + } + catch (Exception ex) + { + Runtime.Log($"warning: failed to resolve optimizer mod for pause-menu button: {ex.GetType().Name}: {ex.Message}"); + } + + return null; + } +} diff --git a/BenchmarkAndOptimizerV11Port/Stubs/Bindito.Core/Bindito.Core.Stub.csproj b/BenchmarkAndOptimizerV11Port/Stubs/Bindito.Core/Bindito.Core.Stub.csproj new file mode 100644 index 00000000..63e9cd1b --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/Bindito.Core/Bindito.Core.Stub.csproj @@ -0,0 +1,3 @@ + + netstandard2.1Bindito.CoreBindito.Core + \ No newline at end of file diff --git a/BenchmarkAndOptimizerV11Port/Stubs/Bindito.Core/Stub.cs b/BenchmarkAndOptimizerV11Port/Stubs/Bindito.Core/Stub.cs new file mode 100644 index 00000000..76abac13 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/Bindito.Core/Stub.cs @@ -0,0 +1,16 @@ +using System; +namespace Bindito.Core +{ + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] + public sealed class ContextAttribute : Attribute { public ContextAttribute(string context) { } } + public interface IContainer + { + T GetInstance(); + } + + public abstract class Configurator { public virtual void Configure() { } } +} +namespace Bindito.Core.Internal +{ + public enum Scope { Singleton, Transient } +} diff --git a/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.CommonUI/ModSettings.CommonUI.Stub.csproj b/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.CommonUI/ModSettings.CommonUI.Stub.csproj new file mode 100644 index 00000000..5fab5d9b --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.CommonUI/ModSettings.CommonUI.Stub.csproj @@ -0,0 +1,14 @@ + + + netstandard2.1 + ModSettings.CommonUI + ModSettings.CommonUI + 0.0.0.0 + 0.0.0.0 + + + + + + + \ No newline at end of file diff --git a/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.CommonUI/Stub.cs b/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.CommonUI/Stub.cs new file mode 100644 index 00000000..480391c6 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.CommonUI/Stub.cs @@ -0,0 +1,11 @@ +using ModSettings.Core; using ModSettings.CoreUI; using UnityEngine.UIElements; +namespace ModSettings.CommonUI +{ + public sealed class ModSettingElement : IModSettingElement + { + public ModSettingElement(VisualElement root, ModSetting modSetting) { Root=root; ModSetting=modSetting; } + public VisualElement Root { get; } + public ModSetting ModSetting { get; } + public bool ShouldBlockInput => false; + } +} diff --git a/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.Core/ModSettings.Core.Stub.csproj b/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.Core/ModSettings.Core.Stub.csproj new file mode 100644 index 00000000..22a9bcc0 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.Core/ModSettings.Core.Stub.csproj @@ -0,0 +1,13 @@ + + + netstandard2.1 + ModSettings.Core + ModSettings.Core + 0.0.0.0 + 0.0.0.0 + + + + + + \ No newline at end of file diff --git a/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.Core/Stub.cs b/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.Core/Stub.cs new file mode 100644 index 00000000..4d056804 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.Core/Stub.cs @@ -0,0 +1,27 @@ +using System; +using Timberborn.Modding; +using Timberborn.SettingsSystem; +namespace ModSettings.Core +{ + [Flags] public enum ModSettingsContext { None=0, MainMenu=1, Game=2, MapEditor=4, All=7 } + public sealed class ModSettingDescriptor + { + public static ModSettingDescriptor Create(string name)=>new ModSettingDescriptor(); + } + public abstract class ModSetting + { + protected ModSetting(ModSettingDescriptor descriptor) { } + public abstract void Reset(); + } + public abstract class NonPersistentSetting : ModSetting + { + protected NonPersistentSetting(ModSettingDescriptor descriptor):base(descriptor) { } + } + public sealed class ModSettingsOwnerRegistry { } + public abstract class ModSettingsOwner + { + protected ModSettingsOwner(ISettings settings, ModSettingsOwnerRegistry registry, ModRepository repository) { } + public abstract string ModId { get; } + public virtual ModSettingsContext ChangeableOn => ModSettingsContext.MainMenu; + } +} diff --git a/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.CoreUI/ModSettings.CoreUI.Stub.csproj b/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.CoreUI/ModSettings.CoreUI.Stub.csproj new file mode 100644 index 00000000..c07df86b --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.CoreUI/ModSettings.CoreUI.Stub.csproj @@ -0,0 +1,13 @@ + + + netstandard2.1 + ModSettings.CoreUI + ModSettings.CoreUI + 0.0.0.0 + 0.0.0.0 + + + + + + \ No newline at end of file diff --git a/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.CoreUI/Stub.cs b/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.CoreUI/Stub.cs new file mode 100644 index 00000000..60d7c79c --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/ModSettings.CoreUI/Stub.cs @@ -0,0 +1,13 @@ +using ModSettings.Core; +using Timberborn.Modding; +using UnityEngine.UIElements; +namespace ModSettings.CoreUI +{ + public interface IModSettingElement { VisualElement Root { get; } ModSetting ModSetting { get; } bool ShouldBlockInput { get; } } + public interface IModSettingElementFactory { int Priority { get; } bool TryCreateElement(ModSetting modSetting, out IModSettingElement? element); } + public class ModSettingsBox + { + public void Open(Mod mod) { } + public VisualElement GetPanel() => new VisualElement(); + } +} diff --git a/BenchmarkAndOptimizerV11Port/Stubs/TimberUi/Stub.cs b/BenchmarkAndOptimizerV11Port/Stubs/TimberUi/Stub.cs new file mode 100644 index 00000000..469882bd --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/TimberUi/Stub.cs @@ -0,0 +1,56 @@ +using System; using System.Collections.Generic; using Bindito.Core; +namespace UiBuilder { public enum GameLabelSize { Normal, Big } public enum GameLabelColor { Default, Yellow } public enum ToggleStyle { Settings, GamePanel } public enum GameButtonSize { Medium, Small, Large } } +namespace TimberUi.CommonUi +{ + public readonly struct SliderValues where T:IComparable + { + public SliderValues(T low, T high, T @default) { Low=low; High=high; Default=@default; } + public T Low { get; } + public T High { get; } + public T Default { get; } + } + public class GameSliderInt : UnityEngine.UIElements.VisualElement + { + public int Value { get; set; } + public GameSliderInt AddEndLabel(Func f)=>this; + public GameSliderInt RegisterChange(Action a)=>this; + public GameSliderInt SetValueWithoutNotify(int v){Value=v;return this;} + } + public class NineSliceTextField : UnityEngine.UIElements.TextField { } + public class NineSliceButton : UnityEngine.UIElements.Button { } +} +namespace Bindito.Core +{ + public static class UiBuilderExtensions + { + public static Configurator BindSingleton(this Configurator c) where T:class => c; + public static Configurator MultiBindSingleton(this Configurator c) where T:class where TImpl:class,T => c; + } +} +namespace UnityEngine.UIElements +{ + public static class UiBuilderExtensions + { + public static T SetPadding(this T e,float padding) where T:VisualElement=>e; + public static T SetMargin(this T e,float top,float right,float bottom,float left) where T:VisualElement=>e; + public static T SetMarginBottom(this T e,float margin=10f) where T:VisualElement=>e; + public static T SetMarginRight(this T e,float margin=10f) where T:VisualElement=>e; + public static T SetWidthPercent(this T e,float p) where T:VisualElement=>e; + public static T SetMaxHeight(this T e,float h) where T:VisualElement=>e; + public static T SetMinSize(this T e,float? minW,float? minH) where T:VisualElement=>e; + public static T SetFlexGrow(this T e,float flexGrow=1) where T:VisualElement=>e; + public static T AlignItems(this T e,Align align=Align.Center) where T:VisualElement=>e; + public static T Initialize(this T e,Timberborn.CoreUI.VisualElementInitializer initializer) where T:VisualElement=>e; + public static VisualElement AddChild(this VisualElement p,Type? type=null,string? name=null,IEnumerable? classes=null)=>new VisualElement(); + public static T AddChild(this VisualElement p,string? name=null,IEnumerable? classes=null) where T:VisualElement,new()=>new T(); + public static VisualElement AddRow(this VisualElement p,string? name=null)=>new VisualElement(); + public static Label AddGameLabel(this VisualElement p,string? text=null,string? name=null,IEnumerable? additionalClasses=null,UiBuilder.GameLabelSize size=default,UiBuilder.GameLabelColor? color=default,bool bold=default,bool centered=default)=>new Label{ text=text??"" }; + public static Toggle AddToggle(this VisualElement p,string? text=null,string? name=null,IEnumerable? additionalClasses=null,Action? onValueChanged=null,UiBuilder.ToggleStyle style=default)=>new Toggle{text=text??""}; + public static TimberUi.CommonUi.GameSliderInt AddSliderInt(this VisualElement p,string? label=null,string? name=null,IEnumerable? additionalClasses=null,in TimberUi.CommonUi.SliderValues? values=default)=>new TimberUi.CommonUi.GameSliderInt(); + public static Timberborn.CoreUI.NineSliceButton AddMenuButton(this VisualElement p,string? text=null,Action? onClick=null,string? name=null,IEnumerable? additionalClasses=null,UiBuilder.GameButtonSize? size=default,bool stretched=false)=>new Timberborn.CoreUI.NineSliceButton{text=text??""}; + public static Timberborn.CoreUI.NineSliceTextField AddTextField(this VisualElement p,string? name=null,Action? changeCallback=null,IEnumerable? additionalClasses=null)=>new Timberborn.CoreUI.NineSliceTextField(); + public static ScrollView AddScrollView(this VisualElement p,string? name=null,IEnumerable? additionalClasses=null,bool greenDecorated=true)=>new ScrollView(); + public static VisualElement InsertSelfAfter(this VisualElement element, VisualElement target)=>element; + public static T SetWidth(this T element, float width) where T : VisualElement => element; + } +} diff --git a/BenchmarkAndOptimizerV11Port/Stubs/TimberUi/TimberUi.Stub.csproj b/BenchmarkAndOptimizerV11Port/Stubs/TimberUi/TimberUi.Stub.csproj new file mode 100644 index 00000000..ab214088 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/TimberUi/TimberUi.Stub.csproj @@ -0,0 +1 @@ +netstandard2.1TimberUiTimberUilatest \ No newline at end of file diff --git a/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.CoreUI/Stub.cs b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.CoreUI/Stub.cs new file mode 100644 index 00000000..83998503 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.CoreUI/Stub.cs @@ -0,0 +1,11 @@ +namespace Timberborn.CoreUI +{ + public class NineSliceButton : UnityEngine.UIElements.Button { } + public class NineSliceVisualElement : UnityEngine.UIElements.VisualElement { } + public class NineSliceTextField : UnityEngine.UIElements.TextField { } + public class NineSliceIntegerField : UnityEngine.UIElements.VisualElement { } + public class VisualElementInitializer + { + public void InitializeVisualElement(UnityEngine.UIElements.VisualElement element) { } + } +} diff --git a/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.CoreUI/Timberborn.CoreUI.Stub.csproj b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.CoreUI/Timberborn.CoreUI.Stub.csproj new file mode 100644 index 00000000..68eac456 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.CoreUI/Timberborn.CoreUI.Stub.csproj @@ -0,0 +1,12 @@ + + + netstandard2.1 + Timberborn.CoreUI + Timberborn.CoreUI + 0.0.0.0 + 0.0.0.0 + + + + + \ No newline at end of file diff --git a/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.ModManagerScene/Stub.cs b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.ModManagerScene/Stub.cs new file mode 100644 index 00000000..8745a8c9 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.ModManagerScene/Stub.cs @@ -0,0 +1,12 @@ +namespace Timberborn.ModManagerScene +{ + public interface IModStarter + { + void StartMod(IModEnvironment modEnvironment); + } + + public interface IModEnvironment + { + string ModPath { get; } + } +} diff --git a/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.ModManagerScene/Timberborn.ModManagerScene.Stub.csproj b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.ModManagerScene/Timberborn.ModManagerScene.Stub.csproj new file mode 100644 index 00000000..d502da16 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.ModManagerScene/Timberborn.ModManagerScene.Stub.csproj @@ -0,0 +1,9 @@ + + + netstandard2.1 + Timberborn.ModManagerScene + Timberborn.ModManagerScene + 0.0.0.0 + 0.0.0.0 + + \ No newline at end of file diff --git a/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.Modding/Stub.cs b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.Modding/Stub.cs new file mode 100644 index 00000000..49fe99c7 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.Modding/Stub.cs @@ -0,0 +1,5 @@ +namespace Timberborn.Modding +{ + public class ModRepository { } + public class Mod { } +} diff --git a/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.Modding/Timberborn.Modding.Stub.csproj b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.Modding/Timberborn.Modding.Stub.csproj new file mode 100644 index 00000000..d529a4de --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.Modding/Timberborn.Modding.Stub.csproj @@ -0,0 +1,9 @@ + + + netstandard2.1 + Timberborn.Modding + Timberborn.Modding + 0.0.0.0 + 0.0.0.0 + + \ No newline at end of file diff --git a/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.Options/Stub.cs b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.Options/Stub.cs new file mode 100644 index 00000000..3be68b1f --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.Options/Stub.cs @@ -0,0 +1,4 @@ +namespace Timberborn.Options +{ + public interface IOptionsBox { } +} diff --git a/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.Options/Timberborn.Options.Stub.csproj b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.Options/Timberborn.Options.Stub.csproj new file mode 100644 index 00000000..9a5709ef --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.Options/Timberborn.Options.Stub.csproj @@ -0,0 +1,9 @@ + + + netstandard2.1 + Timberborn.Options + Timberborn.Options + 0.0.0.0 + 0.0.0.0 + + \ No newline at end of file diff --git a/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.OptionsGame/Stub.cs b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.OptionsGame/Stub.cs new file mode 100644 index 00000000..7f2050dd --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.OptionsGame/Stub.cs @@ -0,0 +1,9 @@ +using Timberborn.Options; +using UnityEngine.UIElements; +namespace Timberborn.OptionsGame +{ + public class GameOptionsBox : IOptionsBox + { + VisualElement _root = new VisualElement(); + } +} diff --git a/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.OptionsGame/Timberborn.OptionsGame.Stub.csproj b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.OptionsGame/Timberborn.OptionsGame.Stub.csproj new file mode 100644 index 00000000..29cd2556 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.OptionsGame/Timberborn.OptionsGame.Stub.csproj @@ -0,0 +1,13 @@ + + + netstandard2.1 + Timberborn.OptionsGame + Timberborn.OptionsGame + 0.0.0.0 + 0.0.0.0 + + + + + + \ No newline at end of file diff --git a/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.SettingsSystem/Stub.cs b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.SettingsSystem/Stub.cs new file mode 100644 index 00000000..5ec0de77 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.SettingsSystem/Stub.cs @@ -0,0 +1 @@ +namespace Timberborn.SettingsSystem { public interface ISettings { } } diff --git a/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.SettingsSystem/Timberborn.SettingsSystem.Stub.csproj b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.SettingsSystem/Timberborn.SettingsSystem.Stub.csproj new file mode 100644 index 00000000..e2b00814 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.SettingsSystem/Timberborn.SettingsSystem.Stub.csproj @@ -0,0 +1,9 @@ + + + netstandard2.1 + Timberborn.SettingsSystem + Timberborn.SettingsSystem + 0.0.0.0 + 0.0.0.0 + + \ No newline at end of file diff --git a/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.SingletonSystem/Stub.cs b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.SingletonSystem/Stub.cs new file mode 100644 index 00000000..d8b14862 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.SingletonSystem/Stub.cs @@ -0,0 +1,22 @@ +namespace Timberborn.SingletonSystem +{ + public interface ILoadableSingleton + { + void Load(); + } + + public interface IUnloadableSingleton + { + void Unload(); + } + + public interface IPostLoadableSingleton + { + void PostLoad(); + } + + public interface IUpdatableSingleton + { + void UpdateSingleton(); + } +} diff --git a/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.SingletonSystem/Timberborn.SingletonSystem.Stub.csproj b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.SingletonSystem/Timberborn.SingletonSystem.Stub.csproj new file mode 100644 index 00000000..fb5eae79 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/Timberborn.SingletonSystem/Timberborn.SingletonSystem.Stub.csproj @@ -0,0 +1,9 @@ + + + netstandard2.1 + Timberborn.SingletonSystem + Timberborn.SingletonSystem + 0.0.0.0 + 0.0.0.0 + + \ No newline at end of file diff --git a/BenchmarkAndOptimizerV11Port/Stubs/UnityEngine.UIElementsModule/Stub.cs b/BenchmarkAndOptimizerV11Port/Stubs/UnityEngine.UIElementsModule/Stub.cs new file mode 100644 index 00000000..88baecfc --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/UnityEngine.UIElementsModule/Stub.cs @@ -0,0 +1,110 @@ +using System; + +namespace UnityEngine.UIElements +{ + public enum ScrollViewMode + { + Vertical, + Horizontal, + VerticalAndHorizontal + } + + public enum ScrollerVisibility + { + Auto, + AlwaysVisible, + Hidden + } + + public enum Align + { + Auto, + FlexStart, + Center, + FlexEnd, + Stretch + } + public enum DisplayStyle { Flex, None } + public enum Position { Relative, Absolute } + public readonly struct StyleLength { public static implicit operator StyleLength(float value) => new StyleLength(); } + public readonly struct StyleEnum where T : struct + { + public static implicit operator StyleEnum(T value) => new StyleEnum(); + } + public interface IStyle + { + StyleEnum display { get; set; } + StyleEnum position { get; set; } + StyleLength left { get; set; } + StyleLength top { get; set; } + } + public class EventBase { } + public class DetachFromPanelEvent : EventBase { } + + public class ChangeEvent : EventBase + { + public T newValue { get; set; } = default!; + } + + public delegate void EventCallback(TEventType evt) where TEventType : EventBase; + + public class VisualElement + { + public IStyle style { get; } = null!; + public string name { get; set; } = ""; + public VisualElement parent { get; } = null!; + public System.Collections.Generic.IEnumerable Children() => System.Array.Empty(); + public void RemoveFromClassList(string className) { } + public void Add(VisualElement child) { } + public void AddToClassList(string className) { } + public void Clear() { } + public void RegisterCallback(EventCallback callback) where TEventType : EventBase { } + } + + public static class UQueryExtensions + { + public static VisualElement Q(this VisualElement element, string name = null, string className = null) => null; + public static T Q(this VisualElement element, string name = null, string className = null) where T : VisualElement => null; + } + + public class Label : VisualElement + { + public Label() { } + public Label(string text) { this.text = text; } + public string text { get; set; } = ""; + } + + public class Toggle : VisualElement + { + public Toggle() { } + public string text { get; set; } = ""; + public bool value { get; set; } + public void SetValueWithoutNotify(bool value) { this.value = value; } + } + + public class ScrollView : VisualElement + { + public ScrollViewMode mode { get; set; } + public ScrollerVisibility horizontalScrollerVisibility { get; set; } + public ScrollerVisibility verticalScrollerVisibility { get; set; } + } + + public class TextField : VisualElement + { + public string value { get; set; } = ""; + } + + public class Button : VisualElement + { + public string text { get; set; } = ""; + public event Action? clicked; + } + + public class SliderInt : VisualElement + { + public int lowValue { get; set; } + public int highValue { get; set; } + public int value { get; set; } + public void SetValueWithoutNotify(int value) { this.value = value; } + } +} diff --git a/BenchmarkAndOptimizerV11Port/Stubs/UnityEngine.UIElementsModule/UnityEngine.UIElementsModule.Stub.csproj b/BenchmarkAndOptimizerV11Port/Stubs/UnityEngine.UIElementsModule/UnityEngine.UIElementsModule.Stub.csproj new file mode 100644 index 00000000..0ca50e04 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/Stubs/UnityEngine.UIElementsModule/UnityEngine.UIElementsModule.Stub.csproj @@ -0,0 +1,9 @@ + + + netstandard2.1 + UnityEngine.UIElementsModule + UnityEngine.UIElements + 0.0.0.0 + 0.0.0.0 + + \ No newline at end of file diff --git a/BenchmarkAndOptimizerV11Port/TICK_SAFETY.md b/BenchmarkAndOptimizerV11Port/TICK_SAFETY.md new file mode 100644 index 00000000..9c833d5f --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/TICK_SAFETY.md @@ -0,0 +1,15 @@ +# BenchmarkAndOptimizerV11Port — tick safety rules + +Purpose: restore original benchmarking / CSV profiling separately from gameplay optimizations. + +## Hard-earned tick protections +- Never throttle `TickableBucketService.TickBuckets`: this is a **global** dispatcher (128 entity buckets + singleton bucket); throttling starved planting and unrelated systems. +- Always keep `PhysicsSimulator`, `BehaviorManager`, `NavMeshObserver`, `NavigationSynchronizer`, `AutomationRunner`, `PlantingControler`, `PlantMonitor`, `SoilContaminationService`, `WaterSimulator`, `SpeedManager`, `ConstructionSite`, `FramesPerSecondPanel`, `FramesPerSecondCounter` at their authoritative/vanilla cadence. +- `PlantingControler` and `PlantMonitor` were checked working; contamination-event planting stalls should not be diagnosed by slowing global ticks. +- When throttling concrete tick methods, preserve inherited-method sharing and resolve actual runtime concrete types. +- Do not patch hundreds of mod methods for diagnostics; use bounded allowlists and compile-check Harmony signatures. +- Keep benchmark mode explicitly opt-in and bounded (original 120s CSV workflow). +- Avoid scanning unfinished MonoMod/Harmony dynamic assemblies during Bindito / SpecTypeCache type discovery. + +## Notes +These are safety policies, not a claim that every profiler or optimization has been restored to an independent build yet. diff --git a/BenchmarkAndOptimizerV11Port/TimberPhysicsBenchmarkPatcher.cs b/BenchmarkAndOptimizerV11Port/TimberPhysicsBenchmarkPatcher.cs new file mode 100644 index 00000000..30680554 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/TimberPhysicsBenchmarkPatcher.cs @@ -0,0 +1,159 @@ +using System.Collections; +using System.Reflection; +using System.Linq.Expressions; +using System.Runtime.CompilerServices; +using Newtonsoft.Json; +using HarmonyLib; +using Timberborn.ModManagerScene; + +namespace BenchmarkAndOptimizerV11; + +static class TimberPhysicsBenchmarkPatcher +{ + const string SimulatorTypeName = "TimberPhysics.Core.PhysicsSimulator"; + const string RegistryTypeName = "TimberPhysics.Core.PhysicalObjectRegistry"; + const string PhysicsTypeName = "UnityEngine.Physics"; + + static bool isPatched; + static MethodBase? simulatorMethod; + static MethodBase? stepAllMethod; + static MethodBase? simulateMethod; + static Type? simulatorType; + static string originLabel = "MOD: Bober's Laws of Motion (eMka.TimberPhysics)"; + + [ThreadStatic] + static int simulatorDepth; + + public static void Patch(Harmony harmony) + { + if (isPatched) + { + return; + } + + simulatorType = AccessTools.TypeByName(SimulatorTypeName); + var registryType = AccessTools.TypeByName(RegistryTypeName); + var physicsType = AccessTools.TypeByName(PhysicsTypeName); + + simulatorMethod = simulatorType is null + ? null + : AccessTools.Method(simulatorType, "UpdateSingleton", Type.EmptyTypes); + stepAllMethod = registryType is null + ? null + : AccessTools.Method(registryType, "StepAll", new[] { typeof(float) }); + simulateMethod = physicsType is null + ? null + : AccessTools.Method(physicsType, "Simulate", new[] { typeof(float) }); + + if (simulatorMethod is null || stepAllMethod is null || simulateMethod is null) + { + Runtime.Log( + $"warning: TimberPhysics split benchmark unavailable; " + + $"simulator={simulatorMethod is not null}, stepAll={stepAllMethod is not null}, " + + $"simulate={simulateMethod is not null}"); + simulatorMethod = null; + stepAllMethod = null; + simulateMethod = null; + simulatorType = null; + return; + } + + originLabel = Runtime.ResolveDiagnosticOrigin(simulatorType); + + harmony.Patch( + simulatorMethod, + prefix: new HarmonyMethod(AccessTools.Method(typeof(TimberPhysicsBenchmarkPatcher), nameof(SimulatorPrefix))) { priority = Priority.First }, + finalizer: new HarmonyMethod(AccessTools.Method(typeof(TimberPhysicsBenchmarkPatcher), nameof(SimulatorFinalizer)))); + + harmony.Patch( + stepAllMethod, + prefix: new HarmonyMethod(AccessTools.Method(typeof(TimberPhysicsBenchmarkPatcher), nameof(StepAllPrefix))), + postfix: new HarmonyMethod(AccessTools.Method(typeof(TimberPhysicsBenchmarkPatcher), nameof(StepAllPostfix)))); + + harmony.Patch( + simulateMethod, + prefix: new HarmonyMethod(AccessTools.Method(typeof(TimberPhysicsBenchmarkPatcher), nameof(SimulatePrefix))), + postfix: new HarmonyMethod(AccessTools.Method(typeof(TimberPhysicsBenchmarkPatcher), nameof(SimulatePostfix)))); + + isPatched = true; + Runtime.Log("TimberPhysics split benchmark installed: StepAll + Physics.Simulate"); + } + + public static void Unpatch(Harmony harmony) + { + if (!isPatched) + { + return; + } + + foreach (var method in new[] { simulatorMethod, stepAllMethod, simulateMethod }) + { + if (method is not null) + { + harmony.Unpatch(method, HarmonyPatchType.All, harmony.Id); + } + } + + isPatched = false; + simulatorDepth = 0; + Runtime.Log("TimberPhysics split benchmark removed"); + } + + static void SimulatorPrefix() + { + if (Runtime.IsBenchmarking) + { + simulatorDepth++; + } + } + + static Exception? SimulatorFinalizer(Exception? __exception) + { + if (simulatorDepth > 0) + { + simulatorDepth--; + } + + return __exception; + } + + static void StepAllPrefix(out long __state) + { + __state = simulatorDepth > 0 && Runtime.IsBenchmarking + ? System.Diagnostics.Stopwatch.GetTimestamp() + : 0; + } + + static void StepAllPostfix(long __state) + { + if (__state == 0) + { + return; + } + + Runtime.RecordDiagnosticBenchmark( + "TimberPhysics.Core.PhysicsSimulator::StepAll", + originLabel, + System.Diagnostics.Stopwatch.GetTimestamp() - __state); + } + + static void SimulatePrefix(out long __state) + { + __state = simulatorDepth > 0 && Runtime.IsBenchmarking + ? System.Diagnostics.Stopwatch.GetTimestamp() + : 0; + } + + static void SimulatePostfix(long __state) + { + if (__state == 0) + { + return; + } + + Runtime.RecordDiagnosticBenchmark( + "TimberPhysics.Core.PhysicsSimulator::Physics.Simulate", + originLabel, + System.Diagnostics.Stopwatch.GetTimestamp() - __state); + } +} diff --git a/BenchmarkAndOptimizerV11Port/manifest.json b/BenchmarkAndOptimizerV11Port/manifest.json new file mode 100644 index 00000000..1d611867 --- /dev/null +++ b/BenchmarkAndOptimizerV11Port/manifest.json @@ -0,0 +1,25 @@ +{ + "Name": "Benchmark & Optimizer (v1.1)", + "Version": "1.1.104", + "Id": "BenchmarkAndOptimizerV11Port", + "MinimumGameVersion": "1.1.1.0", + "Description": "Isolated 120-second CSV and frame/GC profiler, with protected optional per-system update intervals.", + "RequiredMods": [ + { + "Id": "Harmony" + }, + { + "Id": "eMka.ModSettings", + "MinimumVersion": "1.1.1.0" + }, + { + "Id": "TimberUi", + "MinimumVersion": "11.0.1" + } + ], + "OptionalMods": [ + { + "Id": "TBOptimizer" + } + ] +}