From 66ecebaca76149a091fceeb97747fcdb68275626 Mon Sep 17 00:00:00 2001 From: Soar Qin Date: Sat, 11 Jul 2026 14:25:46 +0800 Subject: [PATCH] fix(UXAssist): start late-discovered mod features --- AGENTS.md | 2 +- CheatEnabler/CheatEnabler.cs | 3 +- UXAssist/Common/ModFeatures/IModFeature.cs | 37 ++++++------ .../Common/ModFeatures/ModFeatureAttribute.cs | 12 ++-- .../Common/ModFeatures/ModFeatureRegistry.cs | 57 +++++++++++++------ UXAssist/UXAssist.cs | 11 ++-- UniverseGenTweaks/UniverseGenTweaks.cs | 3 +- docs/PublicApiSurface.md | 14 ++--- 8 files changed, 82 insertions(+), 57 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 566e29e..3f9475a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,7 +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. +- **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 begins the deferred lifecycle from its own `Start`; if a dependent feature is discovered after that transition, the registry starts it immediately after initialization so it cannot miss `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 9bc4261..56aec96 100644 --- a/CheatEnabler/CheatEnabler.cs +++ b/CheatEnabler/CheatEnabler.cs @@ -102,7 +102,8 @@ public class CheatEnabler : BaseUnityPlugin "Buildings invincible"); Localization.Register(); UIConfigWindow.Init(); - // Register features (Init runs eagerly here); UXAssist drives the deferred lifecycle (Start/Uninit/Update). + // Register features (Init runs eagerly here); UXAssist drives the deferred lifecycle and starts + // this feature set immediately if its lifecycle has already begun. ModFeatureRegistry.Discover(Assembly.GetExecutingAssembly()); I18N.Apply(); diff --git a/UXAssist/Common/ModFeatures/IModFeature.cs b/UXAssist/Common/ModFeatures/IModFeature.cs index b48b671..5af1edd 100644 --- a/UXAssist/Common/ModFeatures/IModFeature.cs +++ b/UXAssist/Common/ModFeatures/IModFeature.cs @@ -11,18 +11,17 @@ namespace UXAssist.Common.ModFeatures; /// /// /// 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 +/// , normally during the registering mod's BepInEx +/// Awake phase. It is the phase for early setup such as 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. +/// UIOptionWindow._OnCreate only after all plugins finish loading. A late-discovered feature may +/// initialize after the host lifecycle has begun, so implementations must not depend on either the game +/// being loaded or unloaded here. +/// runs once when UXAssist starts the deferred lifecycle. +/// This is normally during the host mod's Start; if a feature is registered afterwards, the registry +/// starts that feature immediately after . 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 @@ -36,18 +35,18 @@ namespace UXAssist.Common.ModFeatures; public interface IModFeature { /// - /// 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. + /// Called eagerly at registration time, normally during the registering mod's Awake phase. + /// Use this for early setup such as keybind registration. A late-discovered feature may initialize + /// after the host lifecycle has begun, so do not depend on the game being loaded or unloaded here. + /// Runs at most once per registration. /// void Init(); /// - /// 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). + /// Called once when UXAssist starts the deferred lifecycle. If this feature is registered after the + /// lifecycle has already started, the registry invokes this immediately after . 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(); diff --git a/UXAssist/Common/ModFeatures/ModFeatureAttribute.cs b/UXAssist/Common/ModFeatures/ModFeatureAttribute.cs index 92c5696..fd50e0b 100644 --- a/UXAssist/Common/ModFeatures/ModFeatureAttribute.cs +++ b/UXAssist/Common/ModFeatures/ModFeatureAttribute.cs @@ -10,12 +10,12 @@ namespace UXAssist.Common.ModFeatures; /// /// /// 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. +/// discovery time, synchronously inside , normally during the +/// registering mod's BepInEx Awake phase. This is the phase where early setup such as keybind +/// registration must run. A late-discovered feature can initialize after the host lifecycle has begun. +/// Start runs once during the host mod's (UXAssist) Start. +/// If a feature is discovered after that lifecycle has already begun, the registry starts it immediately +/// after Init. The per-frame methods are called by UXAssist with at most one invocation per frame. /// /// [AttributeUsage(AttributeTargets.Class, Inherited = false)] diff --git a/UXAssist/Common/ModFeatures/ModFeatureRegistry.cs b/UXAssist/Common/ModFeatures/ModFeatureRegistry.cs index 7d148f2..3595202 100644 --- a/UXAssist/Common/ModFeatures/ModFeatureRegistry.cs +++ b/UXAssist/Common/ModFeatures/ModFeatureRegistry.cs @@ -19,7 +19,9 @@ namespace UXAssist.Common.ModFeatures; /// ). 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). +/// copies registered keybinds, which happens only after all plugins have finished loading). If a +/// dependent feature is discovered after the host has started the deferred lifecycle, the registry starts +/// that feature immediately after its eager initialization so it cannot miss the lifecycle. /// /// /// The deferred dispatchers are internal to enforce host-only driving at compile time (no @@ -73,6 +75,7 @@ public static class ModFeatureRegistry private static readonly HashSet _registeredInstanceTypes = []; private static readonly HashSet _discoveredAssemblies = []; + private static bool _deferredLifecycleStarted; private static int _lastInputUpdateFrame = -1; private static int _lastUpdateFrame = -1; @@ -80,7 +83,8 @@ public static class ModFeatureRegistry /// 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.). + /// (UXAssist) drives the deferred lifecycle phases ( etc.). If discovery + /// occurs after the host has started the deferred lifecycle, the new feature also starts immediately. /// /// The assembly to scan. public static void Discover(Assembly assembly) @@ -101,6 +105,7 @@ public static class ModFeatureRegistry // Init eagerly at registration time, preserving the original Awake-phase timing that // keybind registration and other early setup rely on. InitStatic(type); + StartIfDeferredLifecycleStarted(feature); } } } @@ -108,7 +113,8 @@ public static class ModFeatureRegistry /// /// 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. + /// host (UXAssist) drives the deferred lifecycle phases. A feature registered after that lifecycle + /// has started is also started immediately. /// /// The mod feature type to register. public static void Register() where T : class, IModFeature, new() @@ -117,30 +123,24 @@ public static class ModFeatureRegistry if (!_registeredInstanceTypes.Add(type)) return; var instance = new T(); - _instanceFeatures.Add(new InstanceFeature(instance)); + var feature = new InstanceFeature(instance); + _instanceFeatures.Add(feature); // Init eagerly at registration time, preserving the original Awake-phase timing. instance.Init(); + StartIfDeferredLifecycleStarted(feature); } /// /// 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. + /// Features registered after this lifecycle has started are started immediately by the registry. /// internal static void StartAll() { - 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; - } + _deferredLifecycleStarted = true; + foreach (var f in _staticFeatures) Start(f); + foreach (var f in _instanceFeatures) Start(f); } /// @@ -150,6 +150,7 @@ public static class ModFeatureRegistry /// internal static void UninitAll() { + _deferredLifecycleStarted = false; foreach (var f in _staticFeatures) { f.Uninit?.Invoke(); @@ -192,6 +193,30 @@ public static class ModFeatureRegistry foreach (var f in _instanceFeatures) f.Feature.OnUpdate(); } + private static void StartIfDeferredLifecycleStarted(StaticFeature feature) + { + if (_deferredLifecycleStarted) Start(feature); + } + + private static void StartIfDeferredLifecycleStarted(InstanceFeature feature) + { + if (_deferredLifecycleStarted) Start(feature); + } + + private static void Start(StaticFeature feature) + { + if (feature.Started) return; + feature.Start?.Invoke(); + feature.Started = true; + } + + private static void Start(InstanceFeature feature) + { + if (feature.Started) return; + feature.Feature.Start(); + feature.Started = true; + } + private static void InitStatic(Type type) { var init = type.GetMethod("Init", diff --git a/UXAssist/UXAssist.cs b/UXAssist/UXAssist.cs index f3a66d1..7eae900 100644 --- a/UXAssist/UXAssist.cs +++ b/UXAssist/UXAssist.cs @@ -250,10 +250,9 @@ 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. + // Register UXAssist's own features. Init runs eagerly here, preserving the original Awake timing + // that keybind registration relies on. The registry immediately starts any dependent feature that + // is discovered after UXAssist begins the deferred lifecycle below. ModFeatureRegistry.Discover(Assembly.GetExecutingAssembly()); I18N.Apply(); @@ -264,8 +263,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). + // UXAssist is the sole lifecycle driver. The registry also starts features discovered later so a + // dependent cannot miss this one-time transition. ModFeatureRegistry.StartAll(); _patches?.Do(type => type.GetMethod("Start")?.Invoke(null, null)); diff --git a/UniverseGenTweaks/UniverseGenTweaks.cs b/UniverseGenTweaks/UniverseGenTweaks.cs index 2a35e3d..8f5a5a1 100644 --- a/UniverseGenTweaks/UniverseGenTweaks.cs +++ b/UniverseGenTweaks/UniverseGenTweaks.cs @@ -58,7 +58,8 @@ public class UniverseGenTweaks : BaseUnityPlugin, IModCanSave Localization.Register(); UIConfigWindow.Init(); - // Register features (Init runs eagerly here); UXAssist drives the deferred lifecycle (Start/Uninit/Update). + // Register features (Init runs eagerly here); UXAssist drives the deferred lifecycle and starts + // this feature set immediately if its lifecycle has already begun. ModFeatureRegistry.Discover(Assembly.GetExecutingAssembly()); I18N.Apply(); diff --git a/docs/PublicApiSurface.md b/docs/PublicApiSurface.md index 8e38bb9..3719fad 100644 --- a/docs/PublicApiSurface.md +++ b/docs/PublicApiSurface.md @@ -250,8 +250,8 @@ public interface IModFeature public static class ModFeatureRegistry ``` -- `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 +- `public static void Discover(Assembly assembly)` — register + eagerly `Init` features; starts a feature immediately if the host lifecycle has already begun +- `public static void Register() where T : class, IModFeature, new()` — register + eagerly `Init` an instance feature; starts it immediately if the host lifecycle has already begun - `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 @@ -260,11 +260,11 @@ public static class ModFeatureRegistry > `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. +> driven solely by UXAssist: it calls `StartAll` in its own `Start`, and the registry immediately starts +> any feature registered after that transition. 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`