From ab1d20683df43f18aaa81f24ca8b5103e5e707f5 Mon Sep 17 00:00:00 2001 From: Soar Qin Date: Mon, 29 Jun 2026 02:56:54 +0800 Subject: [PATCH] fix(UXAssist): centralize mod-feature lifecycle to prevent duplicate execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModFeatureRegistry is a static class with shared collections accumulating features from all mods. Dependent mods (CheatEnabler, UniverseGenTweaks) each independently called InitAll/StartAll/OnInputUpdateAll/OnUpdateAll/ UninitAll, re-running lifecycle for ALL accumulated features including other mods' — per-frame Update ran 2-3x (breaking CheatEnabler key toggles to net no-ops), Init/Start/Uninit ran 2-3x (double RegisterExporter causing save corruption, double SettingChanged subscriptions, double keybind registration). Fix: - Init now runs eagerly at Discover/Register time (Awake-phase), preserving the original timing that keybind registration depends on (game's UIOptionWindow._OnCreate copies keybinds only after all plugins load) - InitAll removed entirely - StartAll/UninitAll/OnInputUpdateAll/OnUpdateAll made internal so only UXAssist (host, same assembly, no InternalsVisibleTo) can drive them - Start deferred to UXAssist.Start (after all dependents' Awake complete; BepInEx runs all Awakes before any Start) - Per-feature Started idempotency + per-frame Time.frameCount guards as defense-in-depth - Dependent mods reduced to Discover-only in Awake Document the Init/Start timing contract on IModFeature and ModFeatureAttribute so future mods can rely on it. --- AGENTS.md | 1 + CheatEnabler/CheatEnabler.cs | 22 +-- UXAssist/Common/ModFeatures/IModFeature.cs | 53 +++++++- .../Common/ModFeatures/ModFeatureAttribute.cs | 14 +- .../Common/ModFeatures/ModFeatureRegistry.cs | 126 ++++++++++++++---- UXAssist/UXAssist.cs | 7 +- UniverseGenTweaks/UniverseGenTweaks.cs | 7 +- docs/PublicApiSurface.md | 22 ++- 8 files changed, 181 insertions(+), 71 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 11063a0..78163d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,6 +142,7 @@ The sync is implemented as an inline PowerShell `Exec` step inside the `ZipMod` ## Key Architectural Patterns - **Shared library:** `UXAssist` acts as a common library. `CheatEnabler` and `UniverseGenTweaks` reference `UXAssist.csproj` directly to reuse `Common/`, `UI/`, and config panel infrastructure. +- **Centralized mod-feature lifecycle:** `UXAssist.Common.ModFeatures.ModFeatureRegistry` holds shared static lists of mod features discovered across all mods. **Only UXAssist drives the shared deferred lifecycle** (`StartAll`/`UninitAll`/`OnInputUpdateAll`/`OnUpdateAll`); these dispatchers are `internal` so dependent mods (separate assemblies, no `InternalsVisibleTo`) cannot call them and re-trigger other mods' features. A feature's `Init` runs **eagerly** when it is registered (via `Discover`/`Register`), preserving the original `Awake`-phase timing that keybind registration and other early setup rely on — the game's `UIOptionWindow._OnCreate` copies registered keybinds only after all plugins have finished loading. Dependent mods only call `ModFeatureRegistry.Discover(Assembly.GetExecutingAssembly())` (and optionally `Register()`) in their `Awake`. UXAssist defers `StartAll` to its own `Start`, so all dependents have registered (and initialized) first (BepInEx runs every plugin's `Awake` before any plugin's `Start`). The registry also guards start idempotency per feature (start at most once; uninit resets) and per-frame re-entrancy (`Time.frameCount`) for the update dispatchers, as defense-in-depth. - **Preloader pattern:** `DustbinPreloader` and `LabOptPreloader` use Mono.Cecil to inject new fields into game assemblies at BepInEx preload time, enabling their corresponding main mods to read/write those fields via normal C# without reflection. - **Internationalization:** `UXAssist/Common/I18N.cs` provides bilingual (EN + ZH) string lookup used across UXAssist and CheatEnabler. Localization keys are declared as `public const string` in per-project registration classes (`UXAssist/Common/I18NKeys.cs`, `CheatEnabler/Localization.cs`, `UniverseGenTweaks/Localization.cs`) and registered through a single `Register()` call from each mod's `Awake()`. Do not pass Chinese string literals to `.Translate()` at call sites. - **Centralized game constants:** Hard-coded item IDs, tech IDs, logistics capacities, and Dyson sphere geometry defaults live in `UXAssist/Common/GameConstants` (`ItemIds`, `TechIds`, `LogisticsConstants`, `DysonSphereConstants`). Prefer these constants over inline literals in UXAssist patches. diff --git a/CheatEnabler/CheatEnabler.cs b/CheatEnabler/CheatEnabler.cs index b726491..457c6c5 100644 --- a/CheatEnabler/CheatEnabler.cs +++ b/CheatEnabler/CheatEnabler.cs @@ -1,9 +1,7 @@ -using System; -using System.Reflection; +using System.Reflection; using BepInEx; using CheatEnabler.Patches; using CheatEnabler.Patches.Factory; -using HarmonyLib; using UXAssist.Common; using UXAssist.Common.ModFeatures; @@ -101,25 +99,9 @@ public class CheatEnabler : BaseUnityPlugin "Buildings invincible"); Localization.Register(); UIConfigWindow.Init(); + // Register features (Init runs eagerly here); UXAssist drives the deferred lifecycle (Start/Uninit/Update). ModFeatureRegistry.Discover(Assembly.GetExecutingAssembly()); - ModFeatureRegistry.InitAll(); I18N.Apply(); } - - private void Start() - { - ModFeatureRegistry.StartAll(); - } - - private void OnDestroy() - { - ModFeatureRegistry.UninitAll(); - } - - private void Update() - { - if (VFInput.inputing) return; - ModFeatureRegistry.OnInputUpdateAll(); - } } \ No newline at end of file diff --git a/UXAssist/Common/ModFeatures/IModFeature.cs b/UXAssist/Common/ModFeatures/IModFeature.cs index c863744..b48b671 100644 --- a/UXAssist/Common/ModFeatures/IModFeature.cs +++ b/UXAssist/Common/ModFeatures/IModFeature.cs @@ -2,32 +2,71 @@ namespace UXAssist.Common.ModFeatures; /// /// Interface implemented by instance mod features registered with . -/// All lifecycle methods are invoked by the registry in the order described below. +/// The registry drives the lifecycle; individual mods never call these methods directly. /// +/// +/// +/// Timing contract. The registry guarantees the following relative ordering, which +/// feature implementations may rely on: +/// +/// +/// runs eagerly at registration time — synchronously inside +/// , during the registering mod's BepInEx Awake phase. +/// It therefore completes before the game scene loads, before any game object's +/// Start, and before the host mod's . This is the only phase where +/// early setup that must precede game initialization (e.g. keybind registration via CommonAPI's +/// CustomKeyBindSystem, whose registered bindings are copied by the game's +/// UIOptionWindow._OnCreate only after all plugins finish loading) can safely run. Implementations +/// must not depend on the game being loaded here. +/// runs once during the host mod's (UXAssist) Start, +/// which is guaranteed to occur after every mod's Awake has finished (BepInEx runs all +/// plugins' Awake synchronously during load, before Unity dispatches any Start). This is +/// the phase for activating behavior that requires the game/runtime to be ready. It is driven solely by +/// UXAssist; dependent mods must not start features themselves. +/// runs during the host's teardown (OnDestroy) and resets the feature +/// so it could be started again. +/// and are called every frame by UXAssist; the +/// registry guarantees at most one invocation per frame even if multiple drivers exist. +/// +/// +/// The same timing contract applies to static features discovered via ; +/// see that attribute for details. +/// +/// public interface IModFeature { /// - /// Called once when the mod is initialized. + /// Called eagerly at registration time, during the registering mod's Awake phase, before the + /// game scene loads and before any plugin's . Use this for early setup that must + /// precede game initialization (e.g. keybind registration). Do not depend on the game being loaded + /// here. Runs at most once per registration. /// void Init(); /// - /// Called once after initialization, when the mod should begin active behavior. + /// Called once during the host mod's (UXAssist) Start, after all mods have finished + /// Awake (and thus after every feature's ). Use this to activate behavior + /// that requires the game/runtime to be ready. Driven solely by UXAssist; runs at most once + /// (a repeated driver call is a no-op for an already-started feature). /// void Start(); /// - /// Called once when the mod is being shut down or re-initialized. + /// Called during the host's teardown (OnDestroy). Reset any state created in + /// / so the feature could be re-started. Runs on every + /// registered feature. /// void Uninit(); /// - /// Called every frame for input handling. Should be lightweight. + /// Called every frame for input handling. Should be lightweight. The registry guarantees at most + /// one invocation per frame. /// void OnInputUpdate(); /// - /// Called every frame for general updates. Should be lightweight. + /// Called every frame for general updates. Should be lightweight. The registry guarantees at most + /// one invocation per frame. /// void OnUpdate(); -} +} \ No newline at end of file diff --git a/UXAssist/Common/ModFeatures/ModFeatureAttribute.cs b/UXAssist/Common/ModFeatures/ModFeatureAttribute.cs index ebbee18..92c5696 100644 --- a/UXAssist/Common/ModFeatures/ModFeatureAttribute.cs +++ b/UXAssist/Common/ModFeatures/ModFeatureAttribute.cs @@ -4,8 +4,20 @@ namespace UXAssist.Common.ModFeatures; /// /// Marks a class as a mod feature so that can discover it. -/// Lifecycle methods (Init, Start, Uninit, OnInputUpdate, OnUpdate) are optional. +/// Lifecycle methods (Init, Start, Uninit, OnInputUpdate, OnUpdate) +/// are optional and are skipped if missing. /// +/// +/// +/// Timing contract (same as ): Init runs eagerly at +/// discovery time, synchronously inside , during the +/// registering mod's BepInEx Awake phase — before the game scene loads, before any plugin's +/// Start. This is the phase where early setup that must precede game initialization (e.g. +/// keybind registration) must run. Start runs once during the host mod's (UXAssist) Start, +/// after all mods' Awake have completed. The per-frame methods are called by UXAssist with at +/// most one invocation per frame. +/// +/// [AttributeUsage(AttributeTargets.Class, Inherited = false)] public sealed class ModFeatureAttribute : Attribute { diff --git a/UXAssist/Common/ModFeatures/ModFeatureRegistry.cs b/UXAssist/Common/ModFeatures/ModFeatureRegistry.cs index a29d5d4..7d148f2 100644 --- a/UXAssist/Common/ModFeatures/ModFeatureRegistry.cs +++ b/UXAssist/Common/ModFeatures/ModFeatureRegistry.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Reflection; +using UnityEngine; namespace UXAssist.Common.ModFeatures; @@ -10,21 +11,36 @@ namespace UXAssist.Common.ModFeatures; /// and holds registered instance mod features. Discovered features invoke static lifecycle methods; /// static lifecycle methods are optional and are skipped if missing. /// +/// +/// +/// The registry uses shared static collections that accumulate features from all mods. To avoid duplicate +/// lifecycle execution, only UXAssist (the host mod) drives the deferred lifecycle phases +/// (, , , +/// ). runs eagerly when a feature is registered via +/// /, preserving the original BepInEx Awake timing +/// that keybind registration and other early setup depend on (the game's UIOptionWindow._OnCreate +/// copies registered keybinds, which happens only after all plugins have finished loading). +/// +/// +/// The deferred dispatchers are internal to enforce host-only driving at compile time (no +/// InternalsVisibleTo is declared, so cross-assembly callers are rejected by the compiler), and +/// each carries runtime idempotency / per-frame guards as defense-in-depth. +/// +/// public static class ModFeatureRegistry { private sealed class StaticFeature { public Type Type { get; } - public Action Init { get; } public Action Start { get; } public Action Uninit { get; } public Action OnInputUpdate { get; } public Action OnUpdate { get; } + public bool Started { get; set; } public StaticFeature(Type type) { Type = type; - Init = GetDelegate(type, "Init"); Start = GetDelegate(type, "Start"); Uninit = GetDelegate(type, "Uninit"); OnInputUpdate = GetDelegate(type, "OnInputUpdate"); @@ -41,14 +57,30 @@ public static class ModFeatureRegistry } } + private sealed class InstanceFeature + { + public IModFeature Feature { get; } + public bool Started { get; set; } + + public InstanceFeature(IModFeature feature) + { + Feature = feature; + } + } + private static readonly List _staticFeatures = []; - private static readonly List _instanceFeatures = []; + private static readonly List _instanceFeatures = []; private static readonly HashSet _registeredInstanceTypes = []; private static readonly HashSet _discoveredAssemblies = []; + private static int _lastInputUpdateFrame = -1; + private static int _lastUpdateFrame = -1; + /// - /// Discovers mod feature classes marked with in the given assembly. - /// Each assembly is only discovered once. + /// Discovers mod feature classes marked with in the given assembly, + /// and initializes each one immediately (calling its static Init method if present). Each + /// assembly is only discovered once. Dependent mods call this in their Awake; the host + /// (UXAssist) drives the deferred lifecycle phases ( etc.). /// /// The assembly to scan. public static void Discover(Assembly assembly) @@ -63,12 +95,20 @@ public static class ModFeatureRegistry foreach (var type in staticTypes.OrderBy(GetOrder)) { if (_staticFeatures.All(f => f.Type != type)) - _staticFeatures.Add(new StaticFeature(type)); + { + var feature = new StaticFeature(type); + _staticFeatures.Add(feature); + // Init eagerly at registration time, preserving the original Awake-phase timing that + // keybind registration and other early setup rely on. + InitStatic(type); + } } } /// - /// Registers a new instance mod feature if an instance of the same type is not already registered. + /// Registers a new instance mod feature, initializing it immediately. If an instance of the same + /// type is already registered, this is a no-op. Dependent mods call this in their Awake; the + /// host (UXAssist) drives the deferred lifecycle phases. /// /// The mod feature type to register. public static void Register() where T : class, IModFeature, new() @@ -77,59 +117,87 @@ public static class ModFeatureRegistry if (!_registeredInstanceTypes.Add(type)) return; var instance = new T(); - _instanceFeatures.Add(instance); - } - - /// - /// Calls on all registered instance features - /// and invokes the cached static Init methods on all discovered mod feature classes. - /// - public static void InitAll() - { - foreach (var f in _staticFeatures) f.Init?.Invoke(); - foreach (var f in _instanceFeatures) f.Init(); + _instanceFeatures.Add(new InstanceFeature(instance)); + // Init eagerly at registration time, preserving the original Awake-phase timing. + instance.Init(); } /// /// Calls on all registered instance features /// and invokes the cached static Start methods on all discovered mod feature classes. + /// Each feature is started at most once; subsequent calls are no-ops for already-started features. /// - public static void StartAll() + internal static void StartAll() { - foreach (var f in _staticFeatures) f.Start?.Invoke(); - foreach (var f in _instanceFeatures) f.Start(); + foreach (var f in _staticFeatures) + { + if (f.Started) continue; + f.Start?.Invoke(); + f.Started = true; + } + foreach (var f in _instanceFeatures) + { + if (f.Started) continue; + f.Feature.Start(); + f.Started = true; + } } /// /// Calls on all registered instance features - /// and invokes the cached static Uninit methods on all discovered mod feature classes. + /// and invokes the cached static Uninit methods on all discovered mod feature classes, + /// then resets their state so they can be re-started. /// - public static void UninitAll() + internal static void UninitAll() { - foreach (var f in _staticFeatures) f.Uninit?.Invoke(); - foreach (var f in _instanceFeatures) f.Uninit(); + foreach (var f in _staticFeatures) + { + f.Uninit?.Invoke(); + f.Started = false; + } + foreach (var f in _instanceFeatures) + { + f.Feature.Uninit(); + f.Started = false; + } } /// /// Calls on all registered instance features /// and invokes the cached static OnInputUpdate delegates on all discovered mod feature classes. /// This method is meant to be called every frame; no reflection is performed here. + /// Guarded per-frame to prevent duplicate execution within the same frame. /// - public static void OnInputUpdateAll() + internal static void OnInputUpdateAll() { + var frame = Time.frameCount; + if (frame == _lastInputUpdateFrame) return; + _lastInputUpdateFrame = frame; foreach (var f in _staticFeatures) f.OnInputUpdate?.Invoke(); - foreach (var f in _instanceFeatures) f.OnInputUpdate(); + foreach (var f in _instanceFeatures) f.Feature.OnInputUpdate(); } /// /// Calls on all registered instance features /// and invokes the cached static OnUpdate delegates on all discovered mod feature classes. /// This method is meant to be called every frame; no reflection is performed here. + /// Guarded per-frame to prevent duplicate execution within the same frame. /// - public static void OnUpdateAll() + internal static void OnUpdateAll() { + var frame = Time.frameCount; + if (frame == _lastUpdateFrame) return; + _lastUpdateFrame = frame; foreach (var f in _staticFeatures) f.OnUpdate?.Invoke(); - foreach (var f in _instanceFeatures) f.OnUpdate(); + foreach (var f in _instanceFeatures) f.Feature.OnUpdate(); + } + + private static void InitStatic(Type type) + { + var init = type.GetMethod("Init", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, + null, Type.EmptyTypes, null); + init?.Invoke(null, null); } private static int GetOrder(Type type) diff --git a/UXAssist/UXAssist.cs b/UXAssist/UXAssist.cs index 429632e..bcf245a 100644 --- a/UXAssist/UXAssist.cs +++ b/UXAssist/UXAssist.cs @@ -251,8 +251,11 @@ public class UXAssist : BaseUnityPlugin, IModCanSave object[] parameters = [_harmony]; _compats?.Do(type => type.GetMethod("Init")?.Invoke(null, parameters)); + // Register UXAssist's own features (Init runs eagerly here, preserving the original Awake timing + // that keybind registration relies on). Dependent mods register theirs during their own Awake + // phase. Start is deferred to UXAssist.Start below so that all dependents have registered before + // any feature starts. ModFeatureRegistry.Discover(Assembly.GetExecutingAssembly()); - ModFeatureRegistry.InitAll(); I18N.Apply(); } @@ -262,6 +265,8 @@ public class UXAssist : BaseUnityPlugin, IModCanSave MyWindowManager.InitBaseObjects(); MyWindowManager.Enable(true); + // UXAssist is the sole lifecycle driver. All dependents have already registered (and initialized) + // their features during their Awake phase (BepInEx runs every plugin's Awake before any plugin's Start). ModFeatureRegistry.StartAll(); _patches?.Do(type => type.GetMethod("Start")?.Invoke(null, null)); diff --git a/UniverseGenTweaks/UniverseGenTweaks.cs b/UniverseGenTweaks/UniverseGenTweaks.cs index 9146438..2a35e3d 100644 --- a/UniverseGenTweaks/UniverseGenTweaks.cs +++ b/UniverseGenTweaks/UniverseGenTweaks.cs @@ -58,17 +58,12 @@ public class UniverseGenTweaks : BaseUnityPlugin, IModCanSave Localization.Register(); UIConfigWindow.Init(); + // Register features (Init runs eagerly here); UXAssist drives the deferred lifecycle (Start/Uninit/Update). ModFeatureRegistry.Discover(Assembly.GetExecutingAssembly()); - ModFeatureRegistry.InitAll(); I18N.Apply(); } - private void OnDestroy() - { - ModFeatureRegistry.UninitAll(); - } - #region IModCanSave private const ushort ModSaveVersion = 1; diff --git a/docs/PublicApiSurface.md b/docs/PublicApiSurface.md index 10ce55d..8e38bb9 100644 --- a/docs/PublicApiSurface.md +++ b/docs/PublicApiSurface.md @@ -250,13 +250,21 @@ public interface IModFeature public static class ModFeatureRegistry ``` -- `public static void Discover(Assembly assembly)` -- `public static void Register() where T : class, IModFeature, new()` -- `public static void InitAll()` -- `public static void StartAll()` -- `public static void UninitAll()` -- `public static void OnInputUpdateAll()` -- `public static void OnUpdateAll()` +- `public static void Discover(Assembly assembly)` — register + eagerly `Init` features; called by every mod (host + dependents) in `Awake` +- `public static void Register() where T : class, IModFeature, new()` — register + eagerly `Init` an instance feature +- `internal static void StartAll()` — host-only deferred lifecycle driver (UXAssist), idempotent per feature +- `internal static void UninitAll()` — host-only lifecycle driver (UXAssist) +- `internal static void OnInputUpdateAll()` — host-only per-frame driver (UXAssist), guarded per-frame +- `internal static void OnUpdateAll()` — host-only per-frame driver (UXAssist), guarded per-frame + +> `Init` runs eagerly when a feature is registered (via `Discover`/`Register`), preserving the original +> `Awake`-phase timing that keybind registration and other early setup rely on (the game's +> `UIOptionWindow._OnCreate` copies registered keybinds only after all plugins have loaded). `Start` is +> the only deferred phase: UXAssist calls `StartAll` in its own `Start` so all dependents have registered +> first (BepInEx runs every plugin's `Awake` before any plugin's `Start`). The deferred dispatchers are +> `internal` so only UXAssist (the host, same assembly) can drive them; no `InternalsVisibleTo` is +> declared, so cross-assembly calls are rejected at compile time. Runtime idempotency (per-feature +> start-once) and per-frame re-entrancy guards (`Time.frameCount`) act as defense-in-depth. ### `UXAssist.Common.Config`