Draw menu carons with the game's own typeface, per font rather than per label

The previous commit claimed this was fixed; it was not. It hooked
UIStyleManager.GetMenuFont, GetTranslatorFont, GetShipLogFont and
GetShipLogCardFont — all four of which are dead code with no callers anywhere in
Assembly-CSharp. Fonts are assigned straight onto the Text components in the
scene data, so nothing the mod did was ever consulted.

Three further things were wrong and are fixed here:

- The language check reflected into the Localization Utility for TryGetLanguage,
  which the installed version does not have, so it could only ever fail. It now
  uses the utility's own definition instead: custom languages are numbered past
  Language.TOTAL. No reflection, nothing to fail silently.
- The substitution was decided per label, from whether that label's own string
  needed a glyph, which left menus mixing two typefaces line by line. It is now
  decided per font: a face that cannot draw Slovak is replaced everywhere it is
  used. Faces that can — Futura PT for dialogue, Space Mono for the translator —
  are still never touched.
- The menu font was assumed to be Gill Sans MT. Logging the fonts actually in
  use showed the menus are Adobe - SerifGothicStd, which had been listed as a
  stylised face to leave alone. Gill Sans is the HUD prompts.

The replacement now prefers the game's own dynamic cut of the same typeface
(Adobe - SerifGothicStd_Dynamic and friends), matched by name prefix so bold
variants resolve too, which keeps the menu looking like vanilla. Bundled Cabin
is the fallback where the game ships no dynamic sibling, and the new
preferGameTypeface setting forces Cabin everywhere.

Hooks are Text.OnEnable, the Text.text setter, TextTranslation.SetLanguage and
scene load, plus a 2s sweep for menus built lazily after all of those.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-21 21:39:19 +02:00
parent 645b8b070b
commit 2268334c30
4 changed files with 256 additions and 120 deletions
+8 -5
View File
@@ -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 The **original mod code and the Slovak translation** in this repository are
released under the [MIT License](LICENSE). released under the [MIT License](LICENSE).
The bundled UI font in `assets/slovakfont` is **[Cabin](https://github.com/impallari/Cabin)** The bundled fallback font in `assets/slovakfont` is
by the Cabin Project Authors, used under the **SIL Open Font License 1.1** **[Cabin](https://github.com/impallari/Cabin)** by the Cabin Project Authors, used
(see [`assets/Cabin-OFL.txt`](assets/Cabin-OFL.txt)). It is only used for the few under the **SIL Open Font License 1.1** (see
menu/HUD elements whose stock font was never baked with Slovak caron glyphs — see [`assets/Cabin-OFL.txt`](assets/Cabin-OFL.txt)). The game's own menu and HUD fonts
[`SlovakFont.cs`](SlovakFont.cs). 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 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, endorsed by Mobius Digital or Annapurna Interactive.** *Outer Wilds*, its story,
+215 -112
View File
@@ -1,9 +1,8 @@
using HarmonyLib; using HarmonyLib;
using OWML.Common; using OWML.Common;
using OWML.ModHelper; using OWML.ModHelper;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Reflection; using System.Text;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
@@ -15,36 +14,70 @@ namespace SlovakTranslation
/// Outer Wilds renders Latin languages with a handful of *static* font atlases, /// Outer Wilds renders Latin languages with a handful of *static* font atlases,
/// and only some of them were baked with Latin Extended-A: /// and only some of them were baked with Latin Extended-A:
/// ///
/// Gill Sans MT / Gill Sans MT Menu (menus, prompts) 243 glyphs — no carons /// Adobe - SerifGothicStd (+ExtraBold) menus 253 glyphs — no carons
/// ParaType - Futura PT Medium (language font) 663 glyphs — complete /// Gill Sans MT (+Menu, Bold) HUD prompts 243 glyphs — no carons
/// SpaceMono-Regular (translator) 612 glyphs — complete /// 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 /// That is why dialogue and the Nomai translator always looked right while
/// "Pokračovať" came out as "Pokra ova". Registering a whole custom language /// "Pokračovať" came out as "Pokra ova".
/// font via the Localization Utility fixes the menus but also flips
/// <c>TextTranslation.IsLanguageLatin()</c> to false, which drags dialogue,
/// the translator and the ship log off their proper fonts too.
/// ///
/// So instead we leave the language font alone and swap fonts *only* where the /// Registering a whole custom language font via the Localization Utility fixes
/// vanilla one physically cannot draw Slovak — checked at runtime with /// the menus, but it also flips <c>TextTranslation.IsLanguageLatin()</c> to
/// <c>Font.HasCharacter</c>, so we never touch a font that renders fine. /// false, which drags dialogue, the translator and the ship log off their
/// proper fonts. And <c>UIStyleManager.GetMenuFont()</c> — the obvious hook —
/// is dead code with no callers; the fonts are assigned straight onto the
/// <see cref="Text"/> 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.
/// </summary> /// </summary>
internal static class SlovakFont internal static class SlovakFont
{ {
/// <summary>Cabin (SIL OFL 1.1), a humanist sans close to the vanilla Gill Sans MT. /// <summary>Cabin (SIL OFL 1.1), a humanist sans in the Gill Sans lineage, used
/// Shipped as a dynamic font, so it can draw any glyph Slovak needs.</summary> /// where the game has no dynamic cut of its own. Dynamic, so it can draw any
/// glyph Slovak needs.</summary>
private static Font _font; private static Font _font;
/// <summary>Characters the vanilla menu atlas is missing. If a font can draw all /// <summary>The letters Slovak adds on top of plain ASCII. Only these are worth
/// of these it is left completely alone.</summary> /// probing — anything else in the text was already fine in English.</summary>
private static readonly char[] Probe = { 'č', 'ď', 'ĺ', 'ľ', 'ň', 'ŕ', 'ť', 'ž', 'ô', 'ä' }; private const string SlovakLetters = "áäčďéíĺľňóôŕšťúýžÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽ";
private static readonly Dictionary<int, bool> _coverage = new Dictionary<int, bool>(); /// <summary>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.</summary>
private static readonly string[] Stylised = { "VCR_OSD_MONO", "digital-7", "PCBius" };
/// <summary>Font instance ID → the Slovak letters that font cannot draw.</summary>
private static readonly Dictionary<int, string> _gaps = new Dictionary<int, string>();
/// <summary>Labels we substituted, and the font the game wanted on them.</summary>
private static readonly Dictionary<Text, Font> _swapped = new Dictionary<Text, Font>();
/// <summary>Original font instance ID → what we put in its place.</summary>
private static readonly Dictionary<int, Font> _substitutes = new Dictionary<int, Font>();
/// <summary>The game's own dynamic fonts, which can draw glyphs their static
/// siblings were never baked with.</summary>
private static Font[] _gameDynamicFonts = new Font[0];
/// <summary>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.</summary>
internal static bool PreferGameTypeface = true;
private static ModBehaviour _mod;
internal static bool Loaded => _font != null; internal static bool Loaded => _font != null;
internal static void Load(ModBehaviour mod) internal static void Load(ModBehaviour mod)
{ {
_mod = mod;
var path = mod.ModHelper.Manifest.ModFolderPath + "assets/slovakfont"; var path = mod.ModHelper.Manifest.ModFolderPath + "assets/slovakfont";
var bundle = AssetBundle.LoadFromFile(path); var bundle = AssetBundle.LoadFromFile(path);
if (bundle == null) if (bundle == null)
@@ -57,7 +90,7 @@ namespace SlovakTranslation
{ {
if (font.name != "SlovakUI") continue; if (font.name != "SlovakUI") continue;
_font = font; _font = font;
UnityEngine.Object.DontDestroyOnLoad(font); Object.DontDestroyOnLoad(font);
break; break;
} }
@@ -65,122 +98,192 @@ namespace SlovakTranslation
mod.ModHelper.Console.WriteLine("Font bundle has no 'SlovakUI' font.", MessageType.Error); mod.ModHelper.Console.WriteLine("Font bundle has no 'SlovakUI' font.", MessageType.Error);
} }
/// <summary>Returns <paramref name="font"/> unchanged unless Slovenčina is the /// <summary>Give <paramref name="text"/> a font that can draw Slovak, or hand back
/// active language and that font cannot draw Slovak.</summary> /// the game's own font once it is no longer needed.</summary>
internal static Font Fix(Font font) 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 var current = text.font;
// game ships for Latin covers Slovak, and HasCharacter is unreliable for them. Font stored;
if (font.dynamic) return font; 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 needsHelp = NeedsSubstitute(wanted, text.text);
if (needsHelp == swapped) return;
if (needsHelp)
{
_swapped[text] = wanted;
text.font = SubstituteFor(wanted);
}
else
{
_swapped.Remove(text);
text.font = wanted;
}
}
/// <summary>What to draw <paramref name="original"/>'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.</summary>
private static Font SubstituteFor(Font original)
{
Font cached;
if (_substitutes.TryGetValue(original.GetInstanceID(), out cached) && cached != null) return cached;
if (!PreferGameTypeface) return _font;
if (_gameDynamicFonts.Length == 0) RefreshGameFonts();
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<Font>();
foreach (var font in Resources.FindObjectsOfTypeAll<Font>())
{
if (font != null && font.dynamic && font != _font) found.Add(font);
}
_gameDynamicFonts = found.ToArray();
}
/// <summary>Drop every cached decision, so a settings change takes effect at once.</summary>
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();
}
/// <summary>Re-check every live label. Scene loads and language changes reassign
/// fonts wholesale, and menus are built lazily, so sweep periodically too.</summary>
internal static void ApplyToAll()
{
if (_font == null) return;
// Labels that have since been destroyed would otherwise pile up.
var dead = new List<Text>();
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<Text>()) 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(); var id = font.GetInstanceID();
if (!_coverage.TryGetValue(id, out var complete)) 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)
{ {
complete = true; if (!font.HasCharacter(c)) builder.Append(c);
foreach (var c in Probe) }
gaps = builder.ToString();
_gaps[id] = gaps;
if (gaps.Length > 0)
{ {
if (font.HasCharacter(c)) continue; _mod?.ModHelper.Console.WriteLine(
complete = false; $"font \"{font.name}\" cannot draw \"{gaps}\" — " +
break; (IsStylised(font.name) ? "substituting only where needed" : "substituting everywhere"),
MessageType.Info);
} }
_coverage[id] = complete; return gaps;
} }
return complete ? font : _font; private static bool IsStylised(string fontName)
{
foreach (var stylised in Stylised)
{
if (fontName.IndexOf(stylised, System.StringComparison.OrdinalIgnoreCase) >= 0) return true;
}
return false;
} }
#region "is Slovenčina selected?" /// <summary>True while a mod-registered language is selected. The Localization
/// Utility appends custom languages past <c>Language.TOTAL</c>, and treats
// The Localization Utility invents a TextTranslation.Language value for each /// everything up to it as vanilla — same test it uses itself.</summary>
// custom language, so it is not in the enum at compile time. Ask the utility private static bool CustomLanguageActive()
// 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;
private static bool IsActive()
{ {
if (TextTranslation.s_theTable == null) return false; if (TextTranslation.s_theTable == null) return false;
return TextTranslation.Get().GetLanguage() > TextTranslation.Language.TOTAL;
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;
}
#endregion
}
/// <summary> /// <summary>
/// Every place the game hands out a font that is not the language font. Each one /// Hooks: every label as it appears or changes, plus a sweep whenever the game
/// runs through <see cref="SlovakFont.Fix"/>, which is a no-op unless the font /// reassigns fonts wholesale.
/// really is missing Slovak glyphs.
/// </summary> /// </summary>
[HarmonyPatch] [HarmonyPatch]
internal static class SlovakFontPatches internal static class SlovakFontPatches
{ {
[HarmonyPostfix, HarmonyPatch(typeof(UIStyleManager), nameof(UIStyleManager.GetMenuFont))] [HarmonyPostfix, HarmonyPatch(typeof(Text), "OnEnable")]
public static void MenuFont(ref Font __result) => __result = SlovakFont.Fix(__result); public static void TextEnabled(Text __instance) => SlovakFont.Apply(__instance);
[HarmonyPostfix, HarmonyPatch(typeof(UIStyleManager), nameof(UIStyleManager.GetTranslatorFont))] [HarmonyPostfix, HarmonyPatch(typeof(Text), "text", MethodType.Setter)]
public static void TranslatorFont(ref Font __result) => __result = SlovakFont.Fix(__result); public static void TextChanged(Text __instance) => SlovakFont.Apply(__instance);
[HarmonyPostfix, HarmonyPatch(typeof(UIStyleManager), nameof(UIStyleManager.GetShipLogFont))] [HarmonyPostfix, HarmonyPatch(typeof(TextTranslation), nameof(TextTranslation.SetLanguage))]
public static void ShipLogFont(ref Font __result) => __result = SlovakFont.Fix(__result); public static void LanguageChanged() => SlovakFont.ApplyToAll();
[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);
}
} }
} }
+27
View File
@@ -1,6 +1,7 @@
using HarmonyLib; using HarmonyLib;
using OWML.Common; using OWML.Common;
using OWML.ModHelper; using OWML.ModHelper;
using UnityEngine;
namespace SlovakTranslation namespace SlovakTranslation
{ {
@@ -47,8 +48,34 @@ namespace SlovakTranslation
if (SlovakFont.Loaded) if (SlovakFont.Loaded)
{ {
new Harmony("Homer.SlovakTranslation").PatchAll(); 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); ModHelper.Console.WriteLine("Slovak menu/HUD font (Cabin) loaded and patched in.", MessageType.Success);
} }
} }
/// <summary>
/// <c>preferGameTypeface</c> — 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.
/// </summary>
public override void Configure(IModConfig config)
{
SlovakFont.PreferGameTypeface = config.GetSettingsValue<bool>("preferGameTypeface");
if (!SlovakFont.Loaded) return;
SlovakFont.ForgetChoices();
SlovakFont.ApplyToAll();
}
/// <summary>Labels can be built long after a scene loads, so keep sweeping.</summary>
private float _nextSweep;
private void Update()
{
if (!SlovakFont.Loaded || Time.unscaledTime < _nextSweep) return;
_nextSweep = Time.unscaledTime + 2f;
SlovakFont.ApplyToAll();
}
} }
} }
+4 -1
View File
@@ -1,3 +1,6 @@
{ {
"enabled": true "enabled": true,
"settings": {
"preferGameTypeface": true
}
} }