Files
Outer-Wilds-SK-Translation-Mod/SlovakFont.cs
T
Jaroslav Beneš 4dabcaa13c Strip the font bundle to 59 KB and replace the polling sweep with a hook
The bundle was built by rewriting the Korean translation mod's asset bundle, so
it still carried everything that mod needed and we did not: three unused font
assets, their materials and textures, and a .resS stream holding two 2048x2048
atlases. Deleted those, dropped the stream, and rewrote m_Container and
m_PreloadTable to reference only the four surviving objects. 862 KB -> 59 KB,
verified by reloading: the embedded font still round-trips as Cabin with full
Slovak coverage and the Gill Sans MT metrics.

The 2s sweep existed to catch labels whose font the game reassigns without any
text change, which happens on a language switch. A postfix on the Text.font
setter catches exactly that, at the moment it happens rather than up to two
seconds later, and costs nothing when idle. Re-entry is safe: our own assignment
fires the hook once, which finds the state already correct and returns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:46:47 +02:00

297 lines
12 KiB
C#

using HarmonyLib;
using OWML.Common;
using OWML.ModHelper;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.UI;
namespace SlovakTranslation
{
/// <summary>
/// Fixes the missing Slovak caron glyphs (č ď ĺ ľ ň ŕ ť ž) in the menus and HUD.
///
/// Outer Wilds renders Latin languages with a handful of *static* font atlases,
/// and only some of them were baked with Latin Extended-A:
///
/// 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 it also flips <c>TextTranslation.IsLanguageLatin()</c> to
/// 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>
internal static class SlovakFont
{
/// <summary>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.</summary>
private static Font _font;
/// <summary>The letters Slovak adds on top of plain ASCII. Only these are worth
/// probing — anything else in the text was already fine in English.</summary>
private const string SlovakLetters = "áäčďéíĺľňóôŕšťúýžÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽ";
/// <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 void Load(ModBehaviour mod)
{
_mod = mod;
var path = mod.ModHelper.Manifest.ModFolderPath + "assets/slovakfont";
var bundle = AssetBundle.LoadFromFile(path);
if (bundle == null)
{
mod.ModHelper.Console.WriteLine("Could not load font bundle at " + path, MessageType.Error);
return;
}
foreach (var font in bundle.LoadAllAssets<Font>())
{
if (font.name != "SlovakUI") continue;
_font = font;
Object.DontDestroyOnLoad(font);
break;
}
if (_font == null)
mod.ModHelper.Console.WriteLine("Font bundle has no 'SlovakUI' font.", MessageType.Error);
}
/// <summary>Give <paramref name="text"/> a font that can draw Slovak, or hand back
/// the game's own font once it is no longer needed.</summary>
internal static void Apply(Text text)
{
if (_font == null || text == null) return;
var current = text.font;
Font stored;
var swapped = _swapped.TryGetValue(text, out stored) && stored != null &&
current == SubstituteFor(stored);
// 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)
{
if (candidate == null) continue;
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();
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;
}
/// <summary>True while a mod-registered language is selected. The Localization
/// Utility appends custom languages past <c>Language.TOTAL</c>, and treats
/// everything up to it as vanilla — same test it uses itself.</summary>
private static bool CustomLanguageActive()
{
if (TextTranslation.s_theTable == null) return false;
return TextTranslation.Get().GetLanguage() > TextTranslation.Language.TOTAL;
}
}
/// <summary>
/// Hooks: every label as it appears or changes, plus a sweep whenever the game
/// reassigns fonts wholesale.
/// </summary>
[HarmonyPatch]
internal static class SlovakFontPatches
{
[HarmonyPostfix, HarmonyPatch(typeof(Text), "OnEnable")]
public static void TextEnabled(Text __instance) => SlovakFont.Apply(__instance);
[HarmonyPostfix, HarmonyPatch(typeof(Text), "text", MethodType.Setter)]
public static void TextChanged(Text __instance) => SlovakFont.Apply(__instance);
// The game reassigns fonts wholesale when the language changes. Re-deciding here
// is safe: our own assignment re-enters once, finds the state already correct,
// and stops.
[HarmonyPostfix, HarmonyPatch(typeof(Text), "font", MethodType.Setter)]
public static void FontAssigned(Text __instance) => SlovakFont.Apply(__instance);
[HarmonyPostfix, HarmonyPatch(typeof(TextTranslation), nameof(TextTranslation.SetLanguage))]
public static void LanguageChanged() => SlovakFont.ApplyToAll();
}
}