fix(UXAssist): centralize mod-feature lifecycle to prevent duplicate execution

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.
This commit is contained in:
2026-06-29 02:56:54 +08:00
parent c2016909ff
commit ab1d20683d
8 changed files with 181 additions and 71 deletions
+1
View File
@@ -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<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.
- **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.