From 6b03801681c9cce64c51a858dfb23e76960fc179 Mon Sep 17 00:00:00 2001 From: Soar Qin Date: Sat, 11 Jul 2026 00:52:58 +0800 Subject: [PATCH] fix: Auto-Construct panel visibility on panel switching --- AGENTS.md | 3 ++ UXAssist/Common/PatchImpl.cs | 25 ++++++++++- UXAssist/Functions/UI/AutoConstructUI.cs | 16 +++++++ .../Patches/Factory/FactoryBuildPatches.cs | 42 ++++++------------- 4 files changed, 56 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 78163d9..1f6cddc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,6 +146,9 @@ The sync is implemented as an inline PowerShell `Exec` step inside the `ZipMod` - **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. +- **Game source facts:** In the original DSP `Assembly-CSharp.dll`, `PlanetFactory.prebuildCount` is computed as `prebuildCursor - prebuildRecycleCursor - 1`, and normal prebuild add/remove paths maintain those cursors. If Auto Construct UI reports zero while visible construction ghosts exist, first suspect the wrong planet/factory, non-prebuild preview state, or a missed UI refresh path before replacing this property with a pool scan. +- **Fail-soft patch application:** `PatchImpl.Enable(true)` applies Harmony patches inside a try/catch. Runtime patching can fail through no fault of ours (Harmony re-runs other mods' transpilers on shared target methods), and an escaping exception would abort the calling `ConfigEntry.SettingChanged` delegate chain, desyncing config UI from config values. On failure it logs a `LogError` with the feature type name, rolls back via `UnpatchSelf()`, and leaves `_patch` null so a later `Enable(true)` can retry. +- **Convergent in-game UI state:** In-game overlay widgets whose visibility depends on game state (e.g. `AutoConstructUI`) must not rely solely on one-shot event-driven refreshes (`SettingChanged` handlers, patch `OnEnable`/`OnDisable`), because a thrown exception earlier in a delegate chain or a failed patch application silently drops the refresh. `AutoConstructUI.OnUpdate()` reconciles button visibility and the pending-construction count with actual game state every 30 frames (also effective while paused); the `AutoConstructPatch` postfix on `PlayerAction_Rts.GameTick` only implements the fly-to-target behavior, and `AutoConstructPatch.OnEnable` logs its visibility predicate inputs once per enable as a remote-diagnosis aid. - **Transpiler patches:** Performance-critical mods (LabOpt, MechaDronesTweaks) use `[HarmonyTranspiler]` to rewrite IL instructions directly for maximum efficiency. All transpilers in UXAssist, CheatEnabler, and UniverseGenTweaks carry a standard header comment (`// Harmony transpiler:`, `// Target:`, `// Fallback:`) documenting the target method and fallback behavior. `UXAssist.Common.Patching.TranspilerGuard` provides a reusable `CodeMatcher.Finish` helper that returns original instructions when a matcher becomes invalid. - **Mod-compatibility reflection:** Use `UXAssist.Common.ModCompat.ModCompatHelper` for BepInEx plugin detection, external mod type/method/field resolution, and property-setter lookup. Use `UXAssist.Common.Utils.DysonSphereReflection` for the DSPOptimizations-compatible `DysonSphereLayer` private fields (`totalNodeSP`, `totalFrameSP`, `totalCP`) instead of resolving them locally in each consumer. - **Build quality gates:** Root `.editorconfig` defines suggestion-only C# style conventions. `Directory.Build.props` enables `TreatWarningsAsErrors` with `NoWarn>0618` for the expected obsolete-API usage, so any new warning fails the build. `.github/workflows/build.yml` runs a Release build and packages the three main mods on every push/PR. diff --git a/UXAssist/Common/PatchImpl.cs b/UXAssist/Common/PatchImpl.cs index 63362ed..06292d5 100644 --- a/UXAssist/Common/PatchImpl.cs +++ b/UXAssist/Common/PatchImpl.cs @@ -40,7 +40,30 @@ public class PatchImpl where T : PatchImpl, new() var guid = typeof(T).GetCustomAttribute()?.Guid ?? $"PatchImpl.{typeof(T).FullName ?? typeof(T).ToString()}"; var callOnEnableBefore = typeof(T).GetCustomAttributes().Any(n => n.Flag == PatchCallbackFlag.CallOnEnableBeforePatch); if (callOnEnableBefore) thisInstance.OnEnable(); - thisInstance._patch = Harmony.CreateAndPatchAll(typeof(T), guid); + // Fail-soft patch application: applying patches at runtime can fail through no + // fault of ours (e.g. Harmony re-runs another mod's fragile transpiler on a shared + // target method). An escaping exception would abort the caller, which is usually a + // ConfigEntry.SettingChanged handler chain, leaving later handlers (such as config + // UI state sync) unexecuted. Log the failure and roll back instead of throwing. + var patch = new Harmony(guid); + try + { + patch.PatchAll(typeof(T)); + } + catch (Exception e) + { + UXAssist.Logger.LogError($"Failed to apply Harmony patches for {typeof(T).FullName}: {e}"); + try + { + patch.UnpatchSelf(); + } + catch (Exception e2) + { + UXAssist.Logger.LogError($"Failed to roll back partially applied patches for {typeof(T).FullName}: {e2}"); + } + return; + } + thisInstance._patch = patch; if (!callOnEnableBefore) thisInstance.OnEnable(); return; } diff --git a/UXAssist/Functions/UI/AutoConstructUI.cs b/UXAssist/Functions/UI/AutoConstructUI.cs index 696fc38..a9a146d 100644 --- a/UXAssist/Functions/UI/AutoConstructUI.cs +++ b/UXAssist/Functions/UI/AutoConstructUI.cs @@ -11,6 +11,7 @@ internal static class AutoConstructUI public static MyCheckButton ToggleAutoConstruct; public static GameObject ConstructCountPanel; public static Text ConstructCountText; + private static int _lastPrebuildCount = -1; public static void Init() { @@ -30,6 +31,21 @@ internal static class AutoConstructUI public static void OnUpdate() { + // Self-healing state sync: the event-driven refreshes (config SettingChanged handlers, + // patch OnEnable/OnDisable) are one-shot and can be lost, e.g. when an exception thrown + // by an earlier handler aborts the delegate chain, or when Harmony patch application + // fails. Periodically reconcile the button visibility and the pending-construction count + // with the actual game state so a missed event never leaves the UI stuck. This also + // works while the game is paused, unlike the PlayerAction_Rts.GameTick polling. + if (Time.frameCount % 30 != 0) return; + if (ToggleAutoConstruct == null) return; + UpdateToggleAutoConstructCheckButtonVisiblility(); + var localPlanet = GameMain.localPlanet; + if (localPlanet == null || !localPlanet.factoryLoaded) return; + var prebuildCount = localPlanet.factory.prebuildCount; + if (prebuildCount == _lastPrebuildCount) return; + _lastPrebuildCount = prebuildCount; + UpdateConstructCountText(prebuildCount); } public static void InitToggleAutoConstructCheckButton() diff --git a/UXAssist/Patches/Factory/FactoryBuildPatches.cs b/UXAssist/Patches/Factory/FactoryBuildPatches.cs index f18bac8..23c336b 100644 --- a/UXAssist/Patches/Factory/FactoryBuildPatches.cs +++ b/UXAssist/Patches/Factory/FactoryBuildPatches.cs @@ -11,55 +11,39 @@ internal static class FactoryBuildPatches { internal class AutoConstructPatch : PatchImpl { - private static int _lastPrebuildCount = -1; - protected override void OnEnable() { Functions.UIFunctions.UpdateToggleAutoConstructCheckButtonVisiblility(); + // Diagnostic aid for reports of the auto-construct button not showing up: + // log the visibility predicate inputs once per enable. + var planet = GameMain.localPlanet; + var factoryLoaded = planet != null && planet.factoryLoaded; + UXAssist.Logger.LogInfo( + $"AutoConstruct button enabled: buttonCreated={Functions.UI.AutoConstructUI.ToggleAutoConstruct != null}, " + + $"localPlanet={planet != null}, factoryLoaded={factoryLoaded}, " + + $"prebuildCount={(factoryLoaded ? planet.factory.prebuildCount : 0)}"); } protected override void OnDisable() { Functions.UIFunctions.UpdateToggleAutoConstructCheckButtonVisiblility(); - _lastPrebuildCount = -1; - } - - [HarmonyPostfix] - [HarmonyPatch(typeof(PlanetData), nameof(PlanetData.NotifyFactoryLoaded))] - private static void PlanetData_NotifyFactoryLoaded_Postfix() - { - Functions.UIFunctions.UpdateToggleAutoConstructCheckButtonVisiblility(); - _lastPrebuildCount = -1; - } - - [HarmonyPostfix] - [HarmonyPatch(typeof(PlanetData), nameof(PlanetData.UnloadFactory))] - private static void PlanetData_UnloadFactory_Postfix() - { - Functions.UIFunctions.UpdateToggleAutoConstructCheckButtonVisiblility(); - _lastPrebuildCount = -1; } + // Button visibility and the pending-construction count text are reconciled periodically + // in AutoConstructUI.OnUpdate() (independent of Harmony patch state), so this postfix + // only implements the auto-construct fly-to-target behavior. This also keeps the patch + // surface small: PlanetData.NotifyFactoryLoaded/UnloadFactory no longer need postfixes. [HarmonyPostfix] [HarmonyPatch(typeof(PlayerAction_Rts), nameof(PlayerAction_Rts.GameTick))] private static void PlayerAction_Rts_GameTick_Postfix(PlayerAction_Rts __instance, long timei) { if (timei % 60L != 0) return; + if (!FactoryPatch.AutoConstructEnabled.Value) return; var planet = GameMain.localPlanet; if (planet == null || !planet.factoryLoaded) return; var factory = planet.factory; var prebuildCount = factory.prebuildCount; - if (_lastPrebuildCount != prebuildCount) - { - if (_lastPrebuildCount <= 0 || prebuildCount == 0) - { - Functions.UIFunctions.UpdateToggleAutoConstructCheckButtonVisiblility(); - } - _lastPrebuildCount = prebuildCount; - Functions.UIFunctions.UpdateConstructCountText(prebuildCount); - } if (prebuildCount <= 0) return; - if (!FactoryPatch.AutoConstructEnabled.Value) return; var player = __instance.player; if (prebuildCount <= player.mecha.constructionModule.buildTargetTotalCount) return; if (player.orders.orderCount > 0) return;