translate the launcher: load mod string catalogs before it draws (#794)

the launcher runs before Game:load, so #767's Strings hooks had nothing
filling the catalog and a restart could not help - the ordering is the same
every launch. reads lang/strings.lua from enabled mods only, data not the
entry chunk, sandboxed.

plus a plain pixel fallback on the ui faces, or the kana draw as tofu.
This commit is contained in:
bryanthaboi
2026-08-04 10:15:10 -04:00
committed by GitHub
parent 7d3950658a
commit f56e82de81
6 changed files with 199 additions and 1 deletions
+5
View File
@@ -107,6 +107,11 @@ function FONT_CACHE.get(size, fontPath)
font = love.graphics.newFont(size)
end
-- Per-glyph fallback so a non-Latin UI string is not drawn as tofu.
-- pcall'd require: FlexLove is vendored and must still load standalone.
local okUi, UiFont = pcall(require, "src.render.UiFont")
if okUi and UiFont then UiFont.attach(font, size) end
-- Add to cache with LRU metadata
FONT_CACHE[cacheKey] = {
font = font,
+12
View File
@@ -313,6 +313,18 @@ function love.load(args)
return
end
-- The launcher draws before any game boots, so the mod loader has not run
-- and Strings has no catalog. Routing the launcher's text through Strings
-- (#767) only pays off if something fills that catalog this early, and no
-- restart could: the ordering is the same on every launch. Read the
-- enabled mods' string catalogs -- data only, no entry chunk -- so a
-- translation reaches the launcher too. Game:load replaces this with the
-- real merged catalog once a version boots.
do
local preload = require("src.mods.LauncherMods").translationStrings()
if preload then require("src.core.Strings").load({ strings = preload }) end
end
-- Interactive: the launcher always runs. Red, Blue, and Yellow are each
-- live: a column shows Play when that game's ROM is already imported, or
-- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its
+3 -1
View File
@@ -329,7 +329,9 @@ local function mfont(size)
size = math.max(8, math.floor(size + 0.5))
local f = measureFonts[size]
if not f then
f = love.graphics.newFont(size)
-- same fallback the rendering faces get (FlexLove FontCache), or the
-- launcher measures Latin widths for text it draws with kana
f = require("src.render.UiFont").attach(love.graphics.newFont(size), size)
measureFonts[size] = f
end
return f
+74
View File
@@ -254,6 +254,80 @@ function LauncherMods.list()
return result or {}
end
-- ------- pre-boot translation strings
--
-- The launcher draws before Game:load, so the loader has not run and Strings
-- has no catalog. #767/#791 routed the launcher's text through Strings, but
-- nothing filled the catalog this early, so a translation mod still could not
-- reach the launcher however complete it was -- and no restart helped, because
-- the ordering is the same on every launch.
--
-- This fills it, and deliberately does the smallest thing that can: one
-- declarative file per enabled mod, lang/strings.lua, and never the entry
-- chunk. That keeps the promise the rest of this module is built on -- no mod
-- behaviour runs before the game boots -- because a catalog is data.
--
-- It is still a mod-authored chunk, so it runs with an empty environment: a
-- plain `return { ... }` evaluates fine, while anything reaching for love, io
-- or os raises and is skipped rather than being trusted this early.
--
-- Game:load calls Strings.load(Data) again after the real merge, which
-- replaces whatever this installed, so the two never disagree for long.
local STRINGS_CATALOG = "lang/strings.lua"
local function readStringsCatalog(path)
local fs = love and love.filesystem
if not (fs and fs.read) then return nil end
local rel = path .. "/" .. STRINGS_CATALOG
local raw = fs.read(rel)
if type(raw) ~= "string" or raw == "" then return nil end
local chunk = loadstring(raw, "@" .. rel)
if not chunk then return nil end
-- Lua 5.1/LuaJIT: no _ENV, so setfenv is the sandbox.
if setfenv then setfenv(chunk, {}) end
local ok, result = pcall(chunk)
if not ok or type(result) ~= "table" then return nil end
return result
end
-- deriveStrings(rows, byId, read) -> the merged catalog, pure.
-- rows is deriveList's output, byId the id -> manifest map, and read(path) a
-- reader returning that mod's catalog table (or nil). Split out so the engine
-- tier can table-drive the enable/precedence rules with no filesystem.
function LauncherMods.deriveStrings(rows, byId, read)
local out, any = {}, false
for _, row in ipairs(rows or {}) do
local manifest = row.enabled and byId and byId[row.id] or nil
local catalog = manifest and manifest.path and read(manifest.path)
for source, value in pairs(catalog or {}) do
-- an empty value means "not translated yet", never "translate to
-- blank" -- the same rule the mod's own loader applies
if type(source) == "string" and type(value) == "string"
and value ~= "" then
out[source] = value
any = true
end
end
end
return any and out or nil
end
-- translationStrings() -> a source -> translation map for the launcher, or nil
-- when no enabled mod ships one. Enable-state and ordering are deriveList's,
-- so a mod that wins a key here wins it at boot too.
function LauncherMods.translationStrings()
local ok, merged = pcall(function()
local manifests = discover()
if #manifests == 0 then return nil end
local rows = LauncherMods.deriveList(manifests, SaveData.loadOptions())
local byId = {}
for _, m in ipairs(manifests) do byId[m.id] = m end
return LauncherMods.deriveStrings(rows, byId, readStringsCatalog)
end)
if not ok then return nil end
return merged
end
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
-- Loader:_saveState writes (a plain boolean), so the running game and the
-- in-game ManagerState pick it up unchanged.
+62
View File
@@ -0,0 +1,62 @@
-- Per-glyph fallback for the launcher's UI faces.
--
-- The launcher draws with LÖVE's default face, which covers Latin and little
-- else. That was invisible while the launcher was English-only, but its text
-- now goes through Strings (#767/#791) and a translation mod's catalog is
-- loaded before the first launcher frame (LauncherMods.translationStrings), so
-- the moment one is Japanese every kana lands as a tofu box.
--
-- Font:setFallbacks fills ONLY the codepoints the primary face is missing, so
-- Latin keeps the launcher's own look and nothing about an English install
-- changes; only the glyphs it genuinely cannot draw come from the bundled
-- Plain Pixel (assets/fonts/plainpixel/README.md: CC-BY 4.0, Douglas
-- Vautour), which covers kana and CJK. That is the same face the in-game TTF
-- text mode uses, so a translated launcher and a translated game agree.
--
-- Measuring and rendering must attach the same fallback or the launcher
-- measures a width it does not draw -- which is how buttons clip.
local UiFont = {}
local FALLBACK_PATH = "assets/fonts/plainpixel/PlainPixel-Regular.ttf"
-- Plain Pixel only rasterizes evenly at multiples of its 15px design em, so
-- snap rather than matching the primary size exactly: a fallback glyph a pixel
-- off its grid is far more obvious than one a pixel off its neighbours.
local DESIGN_EM = 15
local cache = {}
local unavailable = false
local function fallbackFor(size)
if unavailable then return nil end
local snapped = math.max(DESIGN_EM,
math.floor(size / DESIGN_EM + 0.5) * DESIGN_EM)
local hit = cache[snapped]
if hit ~= nil then return hit or nil end
local ok, font = pcall(love.graphics.newFont, FALLBACK_PATH, snapped)
if not ok or not font then
-- one failure means the file is absent (a trimmed build); stop retrying
unavailable = true
return nil
end
if font.setFilter then pcall(font.setFilter, font, "nearest", "nearest") end
cache[snapped] = font
return font
end
-- attach(font, size) -> font. Safe to call on every cache miss; a font whose
-- fallback is already set is left alone, and any failure is swallowed so a
-- missing fallback file can never take the launcher down with it.
function UiFont.attach(font, size)
if not (font and font.setFallbacks) then return font end
local fallback = fallbackFor(size or (font.getHeight and font:getHeight()) or DESIGN_EM)
if not fallback or rawequal(fallback, font) then return font end
pcall(font.setFallbacks, font, fallback)
return font
end
function UiFont.clear()
cache, unavailable = {}, false
end
return UiFont
+43
View File
@@ -328,4 +328,47 @@ do
eq(bad.name, "abc", "surrogates and overlongs are dropped")
end
-- ------- pre-boot translation strings (deriveStrings)
--
-- The launcher draws before Game:load, so a translation mod's catalog has to
-- reach Strings without the loader running. These are the rules that decide
-- what it may contribute, with the filesystem read injected.
do
local manifests = {
{ id = "aaa", name = "A", version = "1.0.0", path = "mods/aaa" },
{ id = "zzz", name = "Z", version = "1.0.0", path = "mods/zzz" },
}
local catalogs = {
["mods/aaa"] = { ["Import ROM"] = "A-rom", ["Delete"] = "A-del",
["Cancel"] = "" },
["mods/zzz"] = { ["Import ROM"] = "Z-rom" },
}
local function read(path) return catalogs[path] end
local function byIdMap(ms)
local m = {}
for _, x in ipairs(ms) do m[x.id] = x end
return m
end
local rows = LauncherMods.deriveList(manifests, { mods = {} })
local merged = LauncherMods.deriveStrings(rows, byIdMap(manifests), read)
eq(merged["Delete"], "A-del", "an enabled mod contributes its catalog")
eq(merged["Import ROM"], "Z-rom",
"later id wins a shared key, as it would at boot")
eq(merged["Cancel"], nil,
"an empty value is untranslated, never a blank translation")
local offRows = LauncherMods.deriveList(manifests, { mods = { zzz = false } })
local off = LauncherMods.deriveStrings(offRows, byIdMap(manifests), read)
eq(off["Import ROM"], "A-rom", "a disabled mod contributes nothing")
local none = LauncherMods.deriveStrings(
LauncherMods.deriveList(manifests, { mods = { aaa = false, zzz = false } }),
byIdMap(manifests), read)
eq(none, nil, "no enabled catalog leaves the launcher on its English source")
eq(LauncherMods.deriveStrings(rows, byIdMap(manifests), function() return nil end),
nil, "a mod that ships no catalog is skipped, not an error")
end
T.finish("launcher_mods")