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
+ }
}