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
+46 -7
View File
@@ -2,32 +2,71 @@ namespace UXAssist.Common.ModFeatures;
/// <summary>
/// Interface implemented by instance mod features registered with <see cref="ModFeatureRegistry"/>.
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// <strong>Timing contract.</strong> The registry guarantees the following relative ordering, which
/// feature implementations may rely on:
/// </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
/// <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>
/// <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
/// registry guarantees at most one invocation per frame even if multiple drivers exist.</item>
/// </list>
/// <para>
/// The same timing contract applies to static features discovered via <see cref="ModFeatureAttribute"/>;
/// see that attribute for details.
/// </para>
/// </remarks>
public interface IModFeature
{
/// <summary>
/// Called once when the mod is initialized.
/// 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.
/// </summary>
void Init();
/// <summary>
/// Called once after initialization, when the mod should begin active behavior.
/// 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).
/// </summary>
void Start();
/// <summary>
/// Called once when the mod is being shut down or re-initialized.
/// Called during the host's teardown (<c>OnDestroy</c>). Reset any state created in
/// <see cref="Init"/>/<see cref="Start"/> so the feature could be re-started. Runs on every
/// registered feature.
/// </summary>
void Uninit();
/// <summary>
/// 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.
/// </summary>
void OnInputUpdate();
/// <summary>
/// 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.
/// </summary>
void OnUpdate();
}
}
@@ -4,8 +4,20 @@ namespace UXAssist.Common.ModFeatures;
/// <summary>
/// Marks a class as a mod feature so that <see cref="ModFeatureRegistry"/> can discover it.
/// Lifecycle methods (<c>Init</c>, <c>Start</c>, <c>Uninit</c>, <c>OnInputUpdate</c>, <c>OnUpdate</c>) are optional.
/// Lifecycle methods (<c>Init</c>, <c>Start</c>, <c>Uninit</c>, <c>OnInputUpdate</c>, <c>OnUpdate</c>)
/// are optional and are skipped if missing.
/// </summary>
/// <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.
/// </para>
/// </remarks>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class ModFeatureAttribute : Attribute
{
@@ -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.
/// </summary>
/// <remarks>
/// <para>
/// The registry uses shared static collections that accumulate features from all mods. To avoid duplicate
/// lifecycle execution, <strong>only UXAssist</strong> (the host mod) drives the deferred lifecycle phases
/// (<see cref="StartAll"/>, <see cref="UninitAll"/>, <see cref="OnInputUpdateAll"/>,
/// <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).
/// </para>
/// <para>
/// The deferred dispatchers are <c>internal</c> to enforce host-only driving at compile time (no
/// <c>InternalsVisibleTo</c> is declared, so cross-assembly callers are rejected by the compiler), and
/// each carries runtime idempotency / per-frame guards as defense-in-depth.
/// </para>
/// </remarks>
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<StaticFeature> _staticFeatures = [];
private static readonly List<IModFeature> _instanceFeatures = [];
private static readonly List<InstanceFeature> _instanceFeatures = [];
private static readonly HashSet<Type> _registeredInstanceTypes = [];
private static readonly HashSet<Assembly> _discoveredAssemblies = [];
private static int _lastInputUpdateFrame = -1;
private static int _lastUpdateFrame = -1;
/// <summary>
/// Discovers mod feature classes marked with <see cref="ModFeatureAttribute"/> in the given assembly.
/// Each assembly is only discovered once.
/// 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.).
/// </summary>
/// <param name="assembly">The assembly to scan.</param>
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);
}
}
}
/// <summary>
/// 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 <c>Awake</c>; the
/// host (UXAssist) drives the deferred lifecycle phases.
/// </summary>
/// <typeparam name="T">The mod feature type to register.</typeparam>
public static void Register<T>() 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);
}
/// <summary>
/// Calls <see cref="IModFeature.Init"/> on all registered instance features
/// and invokes the cached static <c>Init</c> methods on all discovered mod feature classes.
/// </summary>
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();
}
/// <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.
/// </summary>
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;
}
}
/// <summary>
/// Calls <see cref="IModFeature.Uninit"/> on all registered instance features
/// and invokes the cached static <c>Uninit</c> methods on all discovered mod feature classes.
/// and invokes the cached static <c>Uninit</c> methods on all discovered mod feature classes,
/// then resets their state so they can be re-started.
/// </summary>
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;
}
}
/// <summary>
/// Calls <see cref="IModFeature.OnInputUpdate"/> on all registered instance features
/// and invokes the cached static <c>OnInputUpdate</c> 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.
/// </summary>
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();
}
/// <summary>
/// Calls <see cref="IModFeature.OnUpdate"/> on all registered instance features
/// and invokes the cached static <c>OnUpdate</c> 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.
/// </summary>
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)