diff --git a/README.md b/README.md index 03eba0f..64a8fde 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,14 @@ See [`CLAUDE.md`](CLAUDE.md) for the full conventions and repo layout. The **original mod code and the Slovak translation** in this repository are released under the [MIT License](LICENSE). -The bundled UI font in `assets/slovakfont` is **[Cabin](https://github.com/impallari/Cabin)** -by the Cabin Project Authors, used under the **SIL Open Font License 1.1** -(see [`assets/Cabin-OFL.txt`](assets/Cabin-OFL.txt)). It is only used for the few -menu/HUD elements whose stock font was never baked with Slovak caron glyphs — see -[`SlovakFont.cs`](SlovakFont.cs). +The bundled fallback font in `assets/slovakfont` is +**[Cabin](https://github.com/impallari/Cabin)** by the Cabin Project Authors, used +under the **SIL Open Font License 1.1** (see +[`assets/Cabin-OFL.txt`](assets/Cabin-OFL.txt)). The game's own menu and HUD fonts +were never baked with Slovak caron glyphs, so those labels are redrawn with a font +that has them — preferably one of the game's own dynamic fonts, and Cabin only where +it ships none. See [`SlovakFont.cs`](SlovakFont.cs), and the `preferGameTypeface` +setting if you would rather have Cabin everywhere. This is an **unofficial** fan project. It is **not affiliated with, approved by, or endorsed by Mobius Digital or Annapurna Interactive.** *Outer Wilds*, its story, diff --git a/SlovakFont.cs b/SlovakFont.cs index 526b068..7b933ea 100644 --- a/SlovakFont.cs +++ b/SlovakFont.cs @@ -1,9 +1,8 @@ using HarmonyLib; using OWML.Common; using OWML.ModHelper; -using System; using System.Collections.Generic; -using System.Reflection; +using System.Text; using UnityEngine; using UnityEngine.UI; @@ -15,36 +14,70 @@ namespace SlovakTranslation /// Outer Wilds renders Latin languages with a handful of *static* font atlases, /// and only some of them were baked with Latin Extended-A: /// - /// Gill Sans MT / Gill Sans MT Menu (menus, prompts) 243 glyphs — no carons - /// ParaType - Futura PT Medium (language font) 663 glyphs — complete - /// SpaceMono-Regular (translator) 612 glyphs — complete + /// Adobe - SerifGothicStd (+ExtraBold) menus 253 glyphs — no carons + /// Gill Sans MT (+Menu, Bold) HUD prompts 243 glyphs — no carons + /// ParaType - Futura PT Medium dialogue 663 glyphs — complete + /// SpaceMono-Regular translator 612 glyphs — complete /// /// That is why dialogue and the Nomai translator always looked right while - /// "Pokračovať" came out as "Pokra ova". Registering a whole custom language - /// font via the Localization Utility fixes the menus but also flips - /// TextTranslation.IsLanguageLatin() to false, which drags dialogue, - /// the translator and the ship log off their proper fonts too. + /// "Pokračovať" came out as "Pokra ova". /// - /// So instead we leave the language font alone and swap fonts *only* where the - /// vanilla one physically cannot draw Slovak — checked at runtime with - /// Font.HasCharacter, so we never touch a font that renders fine. + /// Registering a whole custom language font via the Localization Utility fixes + /// the menus, but it also flips TextTranslation.IsLanguageLatin() to + /// false, which drags dialogue, the translator and the ship log off their + /// proper fonts. And UIStyleManager.GetMenuFont() — the obvious hook — + /// is dead code with no callers; the fonts are assigned straight onto the + /// components in the scenes. + /// + /// So we substitute at the Text level, but the decision is made **per font, not + /// per label**: a font that cannot draw Slovak is replaced everywhere it is + /// used, so a menu never mixes two typefaces. Fonts that can draw Slovak — + /// the dialogue and translator fonts — are left completely alone. + /// + /// The replacement is preferably the game's *own* dynamic cut of the same + /// typeface (it ships several, e.g. Adobe - SerifGothicStd_Dynamic), which keeps + /// the menu pixel-identical to vanilla. Bundled Cabin is the fallback. /// internal static class SlovakFont { - /// Cabin (SIL OFL 1.1), a humanist sans close to the vanilla Gill Sans MT. - /// Shipped as a dynamic font, so it can draw any glyph Slovak needs. + /// Cabin (SIL OFL 1.1), a humanist sans in the Gill Sans lineage, used + /// where the game has no dynamic cut of its own. Dynamic, so it can draw any + /// glyph Slovak needs. private static Font _font; - /// Characters the vanilla menu atlas is missing. If a font can draw all - /// of these it is left completely alone. - private static readonly char[] Probe = { 'č', 'ď', 'ĺ', 'ľ', 'ň', 'ŕ', 'ť', 'ž', 'ô', 'ä' }; + /// The letters Slovak adds on top of plain ASCII. Only these are worth + /// probing — anything else in the text was already fine in English. + private const string SlovakLetters = "áäčďéíĺľňóôŕšťúýžÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽ"; - private static readonly Dictionary _coverage = new Dictionary(); + /// Stylised display faces (the ship's readouts, seven-segment digits). + /// Replacing these wholesale would cost more in looks than it gains, so they + /// are only substituted on the individual labels that actually need a glyph. + private static readonly string[] Stylised = { "VCR_OSD_MONO", "digital-7", "PCBius" }; + + /// Font instance ID → the Slovak letters that font cannot draw. + private static readonly Dictionary _gaps = new Dictionary(); + + /// Labels we substituted, and the font the game wanted on them. + private static readonly Dictionary _swapped = new Dictionary(); + + /// Original font instance ID → what we put in its place. + private static readonly Dictionary _substitutes = new Dictionary(); + + /// The game's own dynamic fonts, which can draw glyphs their static + /// siblings were never baked with. + private static Font[] _gameDynamicFonts = new Font[0]; + + /// When set, a static font is replaced by the game's own dynamic cut of the + /// same typeface where one exists, keeping the vanilla look. Cabin otherwise. + internal static bool PreferGameTypeface = true; + + private static ModBehaviour _mod; internal static bool Loaded => _font != null; internal static void Load(ModBehaviour mod) { + _mod = mod; var path = mod.ModHelper.Manifest.ModFolderPath + "assets/slovakfont"; var bundle = AssetBundle.LoadFromFile(path); if (bundle == null) @@ -57,7 +90,7 @@ namespace SlovakTranslation { if (font.name != "SlovakUI") continue; _font = font; - UnityEngine.Object.DontDestroyOnLoad(font); + Object.DontDestroyOnLoad(font); break; } @@ -65,122 +98,192 @@ namespace SlovakTranslation mod.ModHelper.Console.WriteLine("Font bundle has no 'SlovakUI' font.", MessageType.Error); } - /// Returns unchanged unless Slovenčina is the - /// active language and that font cannot draw Slovak. - internal static Font Fix(Font font) + /// Give a font that can draw Slovak, or hand back + /// the game's own font once it is no longer needed. + internal static void Apply(Text text) { - if (_font == null || font == null || font == _font) return font; + if (_font == null || text == null) return; - // Dynamic fonts rasterise straight from an embedded font file; every one the - // game ships for Latin covers Slovak, and HasCharacter is unreliable for them. - if (font.dynamic) return font; + var current = text.font; + Font stored; + var swapped = _swapped.TryGetValue(text, out stored) && stored != null && + current == SubstituteFor(stored); - if (!IsActive()) return font; + // If anything else assigned a font since our swap, that is the new baseline. + var wanted = swapped ? stored : current; + if (!swapped) _swapped.Remove(text); + if (wanted == null) return; - var id = font.GetInstanceID(); - if (!_coverage.TryGetValue(id, out var complete)) + var needsHelp = NeedsSubstitute(wanted, text.text); + if (needsHelp == swapped) return; + + if (needsHelp) { - complete = true; - foreach (var c in Probe) - { - if (font.HasCharacter(c)) continue; - complete = false; - break; - } - _coverage[id] = complete; + _swapped[text] = wanted; + text.font = SubstituteFor(wanted); + } + else + { + _swapped.Remove(text); + text.font = wanted; } - - return complete ? font : _font; } - #region "is Slovenčina selected?" + /// What to draw 's labels with instead. The + /// game ships dynamic cuts of several of its typefaces; borrowing the matching one + /// keeps the menu looking exactly like vanilla, which no substitute font can. + private static Font SubstituteFor(Font original) + { + Font cached; + if (_substitutes.TryGetValue(original.GetInstanceID(), out cached) && cached != null) return cached; + if (!PreferGameTypeface) return _font; - // The Localization Utility invents a TextTranslation.Language value for each - // custom language, so it is not in the enum at compile time. Ask the utility - // once, then cache the value and compare enums from then on. - private static bool _resolved; - private static TextTranslation.Language _slovak; - private static MethodInfo _tryGetLanguage; - private static object _utility; + if (_gameDynamicFonts.Length == 0) RefreshGameFonts(); - private static bool IsActive() + Font best = null; + var bestLength = 0; + foreach (var candidate in _gameDynamicFonts) + { + var baseName = candidate.name.EndsWith("_Dynamic") + ? candidate.name.Substring(0, candidate.name.Length - "_Dynamic".Length) + : candidate.name; + + if (baseName.Length <= bestLength) continue; + if (!original.name.StartsWith(baseName, System.StringComparison.Ordinal)) continue; + if (GapsOf(candidate).Length != 0) continue; + + best = candidate; + bestLength = baseName.Length; + } + + if (best == null) return _font; + + _substitutes[original.GetInstanceID()] = best; + _mod?.ModHelper.Console.WriteLine( + $"drawing \"{original.name}\" with the game's own \"{best.name}\"", MessageType.Info); + return best; + } + + private static void RefreshGameFonts() + { + var found = new List(); + foreach (var font in Resources.FindObjectsOfTypeAll()) + { + if (font != null && font.dynamic && font != _font) found.Add(font); + } + _gameDynamicFonts = found.ToArray(); + } + + /// Drop every cached decision, so a settings change takes effect at once. + internal static void ForgetChoices() + { + _substitutes.Clear(); + _gameDynamicFonts = new Font[0]; + foreach (var pair in _swapped) + { + if (pair.Key != null && pair.Value != null) pair.Key.font = pair.Value; + } + _swapped.Clear(); + } + + /// Re-check every live label. Scene loads and language changes reassign + /// fonts wholesale, and menus are built lazily, so sweep periodically too. + internal static void ApplyToAll() + { + if (_font == null) return; + + // Labels that have since been destroyed would otherwise pile up. + var dead = new List(); + foreach (var pair in _swapped) + { + if (pair.Key == null) dead.Add(pair.Key); + } + foreach (var text in dead) _swapped.Remove(text); + + RefreshGameFonts(); + foreach (var text in Object.FindObjectsOfType()) Apply(text); + } + + private static bool NeedsSubstitute(Font font, string content) + { + if (!CustomLanguageActive()) return false; + + var gaps = GapsOf(font); + if (gaps.Length == 0) return false; + + // Ordinary UI faces are replaced everywhere they appear, so a menu never + // mixes typefaces. Stylised readouts only give way when a glyph is at stake. + if (!IsStylised(font.name)) return true; + + if (string.IsNullOrEmpty(content)) return false; + foreach (var c in content) + { + if (gaps.IndexOf(c) >= 0) return true; + } + return false; + } + + private static string GapsOf(Font font) + { + var id = font.GetInstanceID(); + string gaps; + if (_gaps.TryGetValue(id, out gaps)) return gaps; + + // Dynamic fonts only report a character as present once it has been + // rasterised, so ask for the whole Slovak set before probing. + if (font.dynamic) font.RequestCharactersInTexture(SlovakLetters); + + var builder = new StringBuilder(); + foreach (var c in SlovakLetters) + { + if (!font.HasCharacter(c)) builder.Append(c); + } + gaps = builder.ToString(); + _gaps[id] = gaps; + + if (gaps.Length > 0) + { + _mod?.ModHelper.Console.WriteLine( + $"font \"{font.name}\" cannot draw \"{gaps}\" — " + + (IsStylised(font.name) ? "substituting only where needed" : "substituting everywhere"), + MessageType.Info); + } + return gaps; + } + + private static bool IsStylised(string fontName) + { + foreach (var stylised in Stylised) + { + if (fontName.IndexOf(stylised, System.StringComparison.OrdinalIgnoreCase) >= 0) return true; + } + return false; + } + + /// True while a mod-registered language is selected. The Localization + /// Utility appends custom languages past Language.TOTAL, and treats + /// everything up to it as vanilla — same test it uses itself. + private static bool CustomLanguageActive() { if (TextTranslation.s_theTable == null) return false; - - var current = TextTranslation.Get().GetLanguage(); - if (_resolved) return current == _slovak; - - if (_utility == null) - { - var type = SlovakTranslation.LocalizationApi?.GetType().Assembly.GetType("LocalizationUtility.LocalizationUtility"); - if (type == null) return false; - _utility = type.GetField("Instance", BindingFlags.Public | BindingFlags.Static)?.GetValue(null); - foreach (var m in type.GetMethods()) - { - var p = m.GetParameters(); - if (m.Name == "TryGetLanguage" && p.Length == 2 && - p[0].ParameterType == typeof(TextTranslation.Language)) - { - _tryGetLanguage = m; - break; - } - } - if (_utility == null || _tryGetLanguage == null) return false; - } - - var args = new object[] { current, null }; - if (!(bool)_tryGetLanguage.Invoke(_utility, args) || args[1] == null) return false; - - var name = args[1].GetType().GetProperty("Name")?.GetValue(args[1], null) as string; - if (name != SlovakTranslation.LanguageName) return false; - - _slovak = current; - _resolved = true; - return true; + return TextTranslation.Get().GetLanguage() > TextTranslation.Language.TOTAL; } - - #endregion } /// - /// Every place the game hands out a font that is not the language font. Each one - /// runs through , which is a no-op unless the font - /// really is missing Slovak glyphs. + /// Hooks: every label as it appears or changes, plus a sweep whenever the game + /// reassigns fonts wholesale. /// [HarmonyPatch] internal static class SlovakFontPatches { - [HarmonyPostfix, HarmonyPatch(typeof(UIStyleManager), nameof(UIStyleManager.GetMenuFont))] - public static void MenuFont(ref Font __result) => __result = SlovakFont.Fix(__result); + [HarmonyPostfix, HarmonyPatch(typeof(Text), "OnEnable")] + public static void TextEnabled(Text __instance) => SlovakFont.Apply(__instance); - [HarmonyPostfix, HarmonyPatch(typeof(UIStyleManager), nameof(UIStyleManager.GetTranslatorFont))] - public static void TranslatorFont(ref Font __result) => __result = SlovakFont.Fix(__result); + [HarmonyPostfix, HarmonyPatch(typeof(Text), "text", MethodType.Setter)] + public static void TextChanged(Text __instance) => SlovakFont.Apply(__instance); - [HarmonyPostfix, HarmonyPatch(typeof(UIStyleManager), nameof(UIStyleManager.GetShipLogFont))] - public static void ShipLogFont(ref Font __result) => __result = SlovakFont.Fix(__result); - - [HarmonyPostfix, HarmonyPatch(typeof(UIStyleManager), nameof(UIStyleManager.GetShipLogCardFont))] - public static void ShipLogCardFont(ref Font __result) => __result = SlovakFont.Fix(__result); - - // Prompts ("Podrž", "Pokračovať"…) cache their font in a private field and then - // rebuild, so patch after the fact and rebuild once more. - [HarmonyPostfix, HarmonyPatch(typeof(PromptManager), "OnLanguageChanged")] - public static void PromptFont(PromptManager __instance) - { - var field = AccessTools.Field(typeof(PromptManager), "_currentFont"); - var current = field.GetValue(__instance) as Font; - var fixedFont = SlovakFont.Fix(current); - if (fixedFont == current) return; - - field.SetValue(__instance, fixedFont); - AccessTools.Method(typeof(PromptManager), "RebuildUI")?.Invoke(__instance, null); - } - - [HarmonyPostfix, HarmonyPatch(typeof(GameOverController), nameof(GameOverController.SetupGameOverScreen))] - public static void DeathFont(GameOverController __instance) - { - var text = AccessTools.Field(typeof(GameOverController), "_deathText").GetValue(__instance) as Text; - if (text != null) text.font = SlovakFont.Fix(text.font); - } + [HarmonyPostfix, HarmonyPatch(typeof(TextTranslation), nameof(TextTranslation.SetLanguage))] + public static void LanguageChanged() => SlovakFont.ApplyToAll(); } } diff --git a/SlovakTranslation.cs b/SlovakTranslation.cs index 580d54f..96ab18b 100644 --- a/SlovakTranslation.cs +++ b/SlovakTranslation.cs @@ -1,6 +1,7 @@ using HarmonyLib; using OWML.Common; using OWML.ModHelper; +using UnityEngine; namespace SlovakTranslation { @@ -47,8 +48,34 @@ namespace SlovakTranslation if (SlovakFont.Loaded) { new Harmony("Homer.SlovakTranslation").PatchAll(); + UnityEngine.SceneManagement.SceneManager.sceneLoaded += (scene, mode) => SlovakFont.ApplyToAll(); ModHelper.Console.WriteLine("Slovak menu/HUD font (Cabin) loaded and patched in.", MessageType.Success); } } + + /// + /// preferGameTypeface — when on, a menu font that can't draw Slovak is + /// replaced by the game's own dynamic cut of the same typeface where one exists, + /// so the menu keeps its vanilla look. Turn it off to use the bundled Cabin + /// everywhere instead. + /// + public override void Configure(IModConfig config) + { + SlovakFont.PreferGameTypeface = config.GetSettingsValue("preferGameTypeface"); + if (!SlovakFont.Loaded) return; + + SlovakFont.ForgetChoices(); + SlovakFont.ApplyToAll(); + } + + /// Labels can be built long after a scene loads, so keep sweeping. + private float _nextSweep; + + private void Update() + { + if (!SlovakFont.Loaded || Time.unscaledTime < _nextSweep) return; + _nextSweep = Time.unscaledTime + 2f; + SlovakFont.ApplyToAll(); + } } } diff --git a/default-config.json b/default-config.json index 4e609c7..7be9bdc 100644 --- a/default-config.json +++ b/default-config.json @@ -1,3 +1,6 @@ { - "enabled": true + "enabled": true, + "settings": { + "preferGameTypeface": true + } }