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
+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.