diff --git a/libs/flexlove/modules/FontCache.lua b/libs/flexlove/modules/FontCache.lua index 7aef441c..99e82d17 100644 --- a/libs/flexlove/modules/FontCache.lua +++ b/libs/flexlove/modules/FontCache.lua @@ -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, diff --git a/main.lua b/main.lua index e1937fca..07f98b45 100644 --- a/main.lua +++ b/main.lua @@ -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 diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 7a1be91a..10401edf 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -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 diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 2091af91..bc6b7626 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -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. diff --git a/src/render/UiFont.lua b/src/render/UiFont.lua new file mode 100644 index 00000000..b0ec5f03 --- /dev/null +++ b/src/render/UiFont.lua @@ -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 diff --git a/tests/engine/launcher_mods_tests.lua b/tests/engine/launcher_mods_tests.lua index 02118482..368d6a07 100644 --- a/tests/engine/launcher_mods_tests.lua +++ b/tests/engine/launcher_mods_tests.lua @@ -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")