mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user