fix(UXAssist): start late-discovered mod features

This commit is contained in:
2026-07-11 14:25:46 +08:00
parent 15c5bbdd72
commit 66ecebaca7
8 changed files with 82 additions and 57 deletions
+1 -1
View File
@@ -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<T>()`) 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<T>()`) 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.
+2 -1
View File
@@ -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();
+18 -19
View File
@@ -11,18 +11,17 @@ namespace UXAssist.Common.ModFeatures;
/// </para>
/// <list type="bullet">
/// <item><see cref="Init"/> runs <strong>eagerly</strong> at registration time — synchronously inside
/// <see cref="ModFeatureRegistry.Register{T}"/>, during the registering mod's BepInEx <c>Awake</c> phase.
/// It therefore completes <em>before</em> the game scene loads, <em>before</em> any game object's
/// <c>Start</c>, and <em>before</em> the host mod's <see cref="Start"/>. This is the only phase where
/// early setup that must precede game initialization (e.g. keybind registration via CommonAPI's
/// <see cref="ModFeatureRegistry.Register{T}"/>, normally during the registering mod's BepInEx
/// <c>Awake</c> phase. It is the phase for early setup such as keybind registration via CommonAPI's
/// <c>CustomKeyBindSystem</c>, whose registered bindings are copied by the game's
/// <c>UIOptionWindow._OnCreate</c> only after all plugins finish loading) can safely run. Implementations
/// must not depend on the game being loaded here.</item>
/// <item><see cref="Start"/> runs <strong>once</strong> during the host mod's (UXAssist) <c>Start</c>,
/// which is guaranteed to occur after <em>every</em> mod's <c>Awake</c> has finished (BepInEx runs all
/// plugins' <c>Awake</c> synchronously during load, before Unity dispatches any <c>Start</c>). 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.</item>
/// <c>UIOptionWindow._OnCreate</c> 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.</item>
/// <item><see cref="Start"/> runs <strong>once</strong> when UXAssist starts the deferred lifecycle.
/// This is normally during the host mod's <c>Start</c>; if a feature is registered afterwards, the registry
/// starts that feature immediately after <see cref="Init"/>. 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.</item>
/// <item><see cref="Uninit"/> runs during the host's teardown (<c>OnDestroy</c>) and resets the feature
/// so it could be started again.</item>
/// <item><see cref="OnInputUpdate"/> and <see cref="OnUpdate"/> are called every frame by UXAssist; the
@@ -36,18 +35,18 @@ namespace UXAssist.Common.ModFeatures;
public interface IModFeature
{
/// <summary>
/// Called eagerly at registration time, during the registering mod's <c>Awake</c> phase, before the
/// game scene loads and before any plugin's <see cref="Start"/>. 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 <c>Awake</c> 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.
/// </summary>
void Init();
/// <summary>
/// Called once during the host mod's (UXAssist) <c>Start</c>, after all mods have finished
/// <c>Awake</c> (and thus after every feature's <see cref="Init"/>). 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 <see cref="Init"/>. 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).
/// </summary>
void Start();
@@ -10,12 +10,12 @@ namespace UXAssist.Common.ModFeatures;
/// <remarks>
/// <para>
/// <strong>Timing contract</strong> (same as <see cref="IModFeature"/>): <c>Init</c> runs eagerly at
/// discovery time, synchronously inside <see cref="ModFeatureRegistry.Discover"/>, during the
/// registering mod's BepInEx <c>Awake</c> phase — before the game scene loads, before any plugin's
/// <c>Start</c>. This is the phase where early setup that must precede game initialization (e.g.
/// keybind registration) must run. <c>Start</c> runs once during the host mod's (UXAssist) <c>Start</c>,
/// after all mods' <c>Awake</c> have completed. The per-frame methods are called by UXAssist with at
/// most one invocation per frame.
/// discovery time, synchronously inside <see cref="ModFeatureRegistry.Discover"/>, normally during the
/// registering mod's BepInEx <c>Awake</c> 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.
/// <c>Start</c> runs once during the host mod's (UXAssist) <c>Start</c>.
/// If a feature is discovered after that lifecycle has already begun, the registry starts it immediately
/// after <c>Init</c>. The per-frame methods are called by UXAssist with at most one invocation per frame.
/// </para>
/// </remarks>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
@@ -19,7 +19,9 @@ namespace UXAssist.Common.ModFeatures;
/// <see cref="OnUpdateAll"/>). <see cref="Init"/> runs eagerly when a feature is registered via
/// <see cref="Discover"/>/<see cref="Register{T}"/>, preserving the original BepInEx <c>Awake</c> timing
/// that keybind registration and other early setup depend on (the game's <c>UIOptionWindow._OnCreate</c>
/// 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.
/// </para>
/// <para>
/// The deferred dispatchers are <c>internal</c> to enforce host-only driving at compile time (no
@@ -73,6 +75,7 @@ public static class ModFeatureRegistry
private static readonly HashSet<Type> _registeredInstanceTypes = [];
private static readonly HashSet<Assembly> _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 <see cref="ModFeatureAttribute"/> in the given assembly,
/// and initializes each one immediately (calling its static <c>Init</c> method if present). Each
/// assembly is only discovered once. Dependent mods call this in their <c>Awake</c>; the host
/// (UXAssist) drives the deferred lifecycle phases (<see cref="StartAll"/> etc.).
/// (UXAssist) drives the deferred lifecycle phases (<see cref="StartAll"/> etc.). If discovery
/// occurs after the host has started the deferred lifecycle, the new feature also starts immediately.
/// </summary>
/// <param name="assembly">The assembly to scan.</param>
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
/// <summary>
/// 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 <c>Awake</c>; 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.
/// </summary>
/// <typeparam name="T">The mod feature type to register.</typeparam>
public static void Register<T>() 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);
}
/// <summary>
/// Calls <see cref="IModFeature.Start"/> on all registered instance features
/// and invokes the cached static <c>Start</c> 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.
/// </summary>
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);
}
/// <summary>
@@ -150,6 +150,7 @@ public static class ModFeatureRegistry
/// </summary>
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",
+5 -6
View File
@@ -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));
+2 -1
View File
@@ -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();
+7 -7
View File
@@ -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<T>() 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<T>() 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`