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:
+217
-114
@@ -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
|
||||
/// <c>TextTranslation.IsLanguageLatin()</c> 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
|
||||
/// <c>Font.HasCharacter</c>, 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 <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 close to the vanilla Gill Sans MT.
|
||||
/// Shipped as a dynamic font, so it can draw any glyph Slovak needs.</summary>
|
||||
/// <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>Characters the vanilla menu atlas is missing. If a font can draw all
|
||||
/// of these it is left completely alone.</summary>
|
||||
private static readonly char[] Probe = { 'č', 'ď', 'ĺ', 'ľ', 'ň', 'ŕ', 'ť', 'ž', 'ô', 'ä' };
|
||||
/// <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 = "áäčďéíĺľňóôŕšťúýžÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽ";
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
/// <summary>Returns <paramref name="font"/> unchanged unless Slovenčina is the
|
||||
/// active language and that font cannot draw Slovak.</summary>
|
||||
internal static Font Fix(Font font)
|
||||
/// <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 || 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?"
|
||||
/// <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;
|
||||
|
||||
// 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<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;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every place the game hands out a font that is not the language font. Each one
|
||||
/// runs through <see cref="SlovakFont.Fix"/>, 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.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user