From b19bbc02c8f3f4aeb23ebaa53cdcd1a06205f90a Mon Sep 17 00:00:00 2001 From: Jonathan Montenez Date: Sat, 2 May 2026 15:45:24 +0200 Subject: [PATCH] fix(locale): swallow ApplyLocaleFontSubstitution exception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the platform locale is non-English (reproduced with fr-FR), the sts2.dll method MegaCrit.Sts2.Core.Localization.Fonts.FontControlUtils. ApplyLocaleFontSubstitution() throws a NullReferenceException through LocString.GetFormattedText() during the static .cctor of UI nodes (NTopBarFloorIcon / NPotionHolder). The unhandled exception aborts engine startup and the user gets a hard black screen on launch — the launcher itself starts fine, the crash only fires once the game scene begins to initialize. The bug lives inside the shipping sts2.dll, so it can only be worked around from the launcher side via Harmony. Adding a finalizer to the buggy method that returns null marks the exception as handled, lets the substitution call return without effect, and the engine continues with the default (already-loaded) font set — which renders fr-FR (and other affected locales) correctly. src/STS2Mobile/Patches/FontSubstitutionPatches.cs is new and is wired into ModEntry alongside the other game patches. The patch is fully defensive: if the FontControlUtils type or the target method are not present in the linked sts2.dll, it logs and skips rather than failing. The first suppressed exception is logged for visibility; subsequent ones are silenced to avoid log spam. Repro on a non-English Android device: E/godot: System.NullReferenceException at MegaCrit.Sts2.Core.Localization.LocString.GetFormattedText() at MegaCrit.Sts2.Core.HoverTips.HoverTip..ctor(LocString, ...) at MegaCrit.sts2.Core.Nodes.TopBar.NTopBarFloorIcon..cctor() After the patch: the game boots normally and the suppression is reported once via PatchHelper.Log. Note: building this patch on top of current origin/main also requires the ModLoaderPatches API-drift fix submitted as a separate PR; that PR restores the launcher's ability to compile against the shipping sts2.dll in the first place. --- src/STS2Mobile/ModEntry.cs | 1 + .../Patches/FontSubstitutionPatches.cs | 94 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/STS2Mobile/Patches/FontSubstitutionPatches.cs diff --git a/src/STS2Mobile/ModEntry.cs b/src/STS2Mobile/ModEntry.cs index fff6124..22b6c76 100644 --- a/src/STS2Mobile/ModEntry.cs +++ b/src/STS2Mobile/ModEntry.cs @@ -61,6 +61,7 @@ public static void Apply() // Game patches require sts2.dll; if missing, fall through to standalone launcher. try { + FontSubstitutionPatches.Apply(_harmony); ModelDbInitPatch.Apply(_harmony); PlatformPatches.Apply(_harmony); SettingsPatches.Apply(_harmony); diff --git a/src/STS2Mobile/Patches/FontSubstitutionPatches.cs b/src/STS2Mobile/Patches/FontSubstitutionPatches.cs new file mode 100644 index 0000000..383a87f --- /dev/null +++ b/src/STS2Mobile/Patches/FontSubstitutionPatches.cs @@ -0,0 +1,94 @@ +using System; +using System.Reflection; +using System.Threading; +using HarmonyLib; +using MegaCrit.Sts2.Core.Nodes; + +namespace STS2Mobile.Patches; + +// Works around a crash in MegaCrit.Sts2.Core.Localization.Fonts.FontControlUtils. +// ApplyLocaleFontSubstitution() throws a NullReferenceException when the platform +// locale is non-English (e.g. fr-FR), surfaces through LocString.GetFormattedText +// during the static .cctor of UI nodes (NTopBarFloorIcon / NPotionHolder), and +// brings the engine down before the main menu can load — the visible symptom is a +// hard black screen on launch. +// +// Letting the substitution call fail silently lets the engine continue with the +// default (already-loaded) font set, which renders the affected locales correctly. +public static class FontSubstitutionPatches +{ + private const string TargetTypeName = + "MegaCrit.Sts2.Core.Localization.Fonts.FontControlUtils"; + private const string TargetMethodName = "ApplyLocaleFontSubstitution"; + + private static int _suppressionCount; + + public static void Apply(Harmony harmony) + { + var sts2Asm = typeof(NGame).Assembly; + var fontUtilsType = sts2Asm.GetType(TargetTypeName); + if (fontUtilsType == null) + { + PatchHelper.Log( + $"FontSubstitution: type {TargetTypeName} not present in sts2.dll; skipping" + ); + return; + } + + const BindingFlags methodFlags = + BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.Static + | BindingFlags.Instance; + + var targetMethod = fontUtilsType.GetMethod(TargetMethodName, methodFlags); + if (targetMethod == null) + { + PatchHelper.Log( + $"FontSubstitution: {TargetTypeName}.{TargetMethodName} not found; skipping" + ); + return; + } + + var finalizerMethod = typeof(FontSubstitutionPatches).GetMethod( + nameof(SwallowFinalizer), + BindingFlags.Static | BindingFlags.NonPublic + ); + + try + { + harmony.Patch(targetMethod, finalizer: new HarmonyMethod(finalizerMethod)); + PatchHelper.Log( + $"Patched {TargetTypeName}.{TargetMethodName} (finalizer; swallows locale errors)" + ); + } + catch (Exception ex) + { + PatchHelper.Log($"FontSubstitution: failed to install finalizer: {ex.Message}"); + } + } + + // Harmony finalizer convention: returning null marks the original exception + // (passed via the special __exception parameter) as handled, so callers see + // a normal return instead of an unhandled exception. Returning a non-null + // Exception would replace it; returning the same instance would re-throw. + private static Exception SwallowFinalizer(Exception __exception) + { + if (__exception == null) + { + return null; + } + + // Only log the first swallow to avoid spamming on repeated calls. + if (Interlocked.Increment(ref _suppressionCount) == 1) + { + PatchHelper.Log( + $"FontSubstitution: suppressed {__exception.GetType().Name} " + + $"from {TargetMethodName} (\"{__exception.Message}\"); " + + "further occurrences will be silenced" + ); + } + + return null; + } +}