From cbb08f76ae33e226cf195d76e3ab6692b66d8e48 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Thu, 6 Aug 2026 13:35:07 +0100 Subject: [PATCH 1/3] Add ROM-free regression test for hit-sfx noise pitch (#826/#902) The super-effective / not-very-effective hit sounds were only covered by the manual ears-only driver (tests/drivers/hit_sfx_bug826_test.lua), so the wFrequencyModifier wiring that fixed #826 (and that #902 reports as a swap had no CI guard. This suite pins the polynomial-counter bytes for all three hit sounds against the real audio/sfx programs: bare they read swapped (super effective ends duller), and pitched they read correct (super effective 4 shift 1 crack, not very effective shift 10 thud). A port that drops the modifier fails 11 of 24 checks. CLOSES #902 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> EOF ) --- tests/engine/hit_sfx_noise_pitch_bug826.lua | 145 ++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 tests/engine/hit_sfx_noise_pitch_bug826.lua diff --git a/tests/engine/hit_sfx_noise_pitch_bug826.lua b/tests/engine/hit_sfx_noise_pitch_bug826.lua new file mode 100644 index 00000000..11c42b12 --- /dev/null +++ b/tests/engine/hit_sfx_noise_pitch_bug826.lua @@ -0,0 +1,145 @@ +-- The battle hit sounds must carry pokered's wFrequencyModifier onto the +-- noise channel (#826; #902 reports the same swap). PlayApplyingAttackSound +-- (engine/battle/animations.asm) picks SFX_DAMAGE / SFX_SUPER_EFFECTIVE / +-- SFX_NOT_VERY_EFFECTIVE off wDamageMultipliers and writes a frequency +-- modifier with it ($20 / $e0 / $50), and Audio2_ApplyFrequencyModifier adds +-- that to the polynomial-counter byte -- the low byte of NR43 -- with 8-bit +-- wrap (audio/engine_2.asm). The three programs are CHAN8-only, so that byte +-- IS their pitch. Dropped, the super effective hit reads as the duller of +-- the two: super effective's tail (shifts 3 then 6) sits below not very +-- effective's (5, 4, 2, 2), which is exactly the "swapped" sound #826 and +-- #902 describe. With the modifier on, super effective goes to shifts 1/4 -- +-- a bright crack -- and not very effective to 10/9/7/7 -- a dull thud. +-- +-- ROM-free: ChipAsm blobs stand in for the sfx headers, so nothing here +-- reads data/generated/. The noise-note streams below are transcribed from +-- audio/sfx/{damage,super_effective,not_very_effective}.asm, so the "sounds +-- swapped when bare" ordering is asserted against the real program shape. +-- luajit tests/engine/hit_sfx_noise_pitch_bug826.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check + +love = require("tests.love_stub") + +local ChipAsm = require("src.audio.ChipAsm") +local ChipSynth = require("src.core.ChipSynth") + +local data = { audio = {} } + +-- noise_note len, volume, fade, parameter (the asm streams, verbatim) +local HITS = { + { + name = "Damage", + pitch = 0x20, + notes = { + { len = 2, parameter = 0x44 }, + { len = 2, parameter = 0x14 }, + { len = 15, parameter = 0x32 }, + }, + want = { 0x64, 0x34, 0x52 }, + }, + { + name = "Super_Effective", + pitch = 0xe0, + notes = { + { len = 4, parameter = 0x34 }, + { len = 15, parameter = 0x64 }, + }, + want = { 0x14, 0x44 }, + }, + { + name = "Not_Very_Effective", + pitch = 0x50, + notes = { + { len = 4, parameter = 0x55 }, + { len = 2, parameter = 0x44 }, + { len = 8, parameter = 0x22 }, + { len = 15, parameter = 0x21 }, + }, + want = { 0xa5, 0x94, 0x72, 0x71 }, + }, +} + +local function hitDef(notes) + local program = {} + for _, n in ipairs(notes) do + program[#program + 1] = { + noiseNote = { len = n.len, volume = 15, fade = 1, parameter = n.parameter }, + } + end + return ChipAsm.sfx{ channels = { { hw = 4, program = program } } } +end + +-- every noise note the program emits, as { parameter, duration }, walking +-- one event at a time by marking each consumed +local function noiseNotes(def, offset) + local engine = ChipSynth.newEngine(data, def, { + sfx = true, allowLoops = false, frequencyOffset = offset, + }) + local channel = assert(engine.channels[1], "hit sfx uses exactly CHAN8") + local out = {} + while not engine:finished() do + channel:sample() + local event = channel.event + if not event then break end + if event.noiseParameter ~= nil then + out[#out + 1] = { parameter = event.noiseParameter, duration = event.duration } + end + event.sample = event.samples -- force the walk on to the next event + end + return out +end + +-- NR43 shift-clock nibble, weighted by each note's on-air duration: the +-- number #826/#902 ears actually compare (higher = duller) +local function weightedShift(notes) + local total, sum = 0, 0 + for _, n in ipairs(notes) do + total = total + n.duration + sum = sum + math.floor(n.parameter / 16) * n.duration + end + return total > 0 and sum / total or 0 +end + +local results = {} +for _, hit in ipairs(HITS) do + local def = hitDef(hit.notes) + local bare = noiseNotes(def, 0) + local pitched = noiseNotes(def, hit.pitch) + results[hit.name] = { bare = bare, pitched = pitched } + check(#bare == #hit.notes, + hit.name .. " program emits " .. #hit.notes .. " notes, not " .. #bare) + for i, n in ipairs(hit.notes) do + check(bare[i] and bare[i].parameter == n.parameter, + ("%s note %d reads NR43 $%02x unmodified"):format(hit.name, i, n.parameter)) + check(pitched[i] and pitched[i].parameter == hit.want[i], + ("%s note %d reads NR43 $%02x once $%02x is applied"):format( + hit.name, i, hit.want[i], hit.pitch)) + end +end + +-- the ordering that IS the bug: unpitched, super effective ends duller than +-- not very effective, so they sound swapped; pitched, the bright crack lands +-- on super effective and the dull thud on not very effective +local superBare = weightedShift(results.Super_Effective.bare) +local nveBare = weightedShift(results.Not_Very_Effective.bare) +check(superBare > nveBare, + ("bare, super effective (%.2f) reads duller than not very effective (%.2f)"):format( + superBare, nveBare)) +local superPitched = weightedShift(results.Super_Effective.pitched) +local nvePitched = weightedShift(results.Not_Very_Effective.pitched) +check(superPitched < nvePitched, + ("pitched, super effective (%.2f) is the brighter hit, not very effective (%.2f) the duller"):format( + superPitched, nvePitched)) + +-- the neutral hit shifts a shade duller than it used to be, per pokered +local damageBare = weightedShift(results.Damage.bare) +local damagePitched = weightedShift(results.Damage.pitched) +check(damagePitched > damageBare, + ("the neutral hit dulls a little under $20 (%.2f -> %.2f)"):format( + damageBare, damagePitched)) + +T.finish("hit sfx noise pitch (#826/#902)") From 6c892cb7c1f8d37a416f2a2e421bdae109dedb12 Mon Sep 17 00:00:00 2001 From: johnjohto Date: Thu, 6 Aug 2026 09:55:28 -0400 Subject: [PATCH 2/3] Move Red's extracted cache under red/ with a legacy migration Importing Red unpacked data/generated, assets/generated and rom-cache.complete straight into the save-dir root, while Blue and Yellow land under blue/ and yellow/. Red now uses cachePrefix red/ like the others. CacheFs.migrateLegacyRedCache moves a pre-existing root cache into red/ on first boot, from RomImporter.new before the readiness loop and from mountVersion, so existing installs keep their import instead of being asked for the ROM again. The move only runs when the root marker resolves to the save directory, so a dev checkout's source tree is never touched, and the portable game folder is skipped when it is the physfs source. Closes #899 --- src/core/GameVersion.lua | 9 +- src/import/CacheFs.lua | 150 ++++++++++++++++--- src/import/RomImporter.lua | 27 ++-- tests/engine/cache_fs_red_migration_test.lua | 69 +++++++++ 4 files changed, 220 insertions(+), 35 deletions(-) create mode 100644 tests/engine/cache_fs_red_migration_test.lua diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 855b632f..a5cabe94 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -4,9 +4,10 @@ -- extracted cache lives, and the save-file suffix -- so the importer, -- cache mount, SaveData, title screen and palette all agree. -- --- Red keeps every un-suffixed path it always used (save.lua, the root cache), --- so existing installs are untouched; Blue is namespaced under blue/ and --- _blue, Yellow under yellow/ and _yellow, so all three can be imported and +-- Red keeps the un-suffixed save paths it always used (save.lua) so existing +-- saves are untouched, but its extracted cache lives under red/ like Blue and +-- Yellow (issue #899); a legacy root cache is moved into red/ once by +-- CacheFs.migrateLegacyRedCache. All three versions can be imported and -- played side by side. -- -- Zero requires, so it loads during love.conf and under plain Lua for tools @@ -23,7 +24,7 @@ GameVersion.VERSIONS = { launcherName = "Red", -- game-panel header in the launcher sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a", manifest = "tools/rom_manifest.json", - cachePrefix = "", -- Red owns the cache root (backwards compatible) + cachePrefix = "red/", -- red/data/generated, red/assets/generated (#899) saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp }, blue = { diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index ad1b9018..4c99b048 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -33,11 +33,11 @@ local Platform = require("src.core.Platform") local SEP = package.config:sub(1, 1) -- Cache-relative paths are prefixed with this before every read/write, so a --- Blue/Yellow import lands under its GameVersion.cachePrefix (blue/, yellow/) --- while a Red import keeps the historical root. The launcher sets it per --- import / per readiness check; it stays "" for Red. Runtime *reads* --- (require / newImage) do NOT go through here -- CacheFs.mountVersion overlays --- the active version's subtree onto the un-prefixed paths instead. +-- version's import lands under its GameVersion.cachePrefix (red/, blue/, +-- yellow/). The launcher sets it per import / per readiness check; it stays +-- "" outside those flows. Runtime *reads* (require / newImage) do NOT go +-- through here -- CacheFs.mountVersion overlays the active version's subtree +-- onto the un-prefixed paths instead. CacheFs.prefix = "" local function withPrefix(rel) @@ -383,17 +383,120 @@ function CacheFs.removeTree(rel) walk(rel) end +-- One-time move of Red's pre-#899 cache (data/generated, assets/generated +-- and the rom-cache.complete marker at the cache root) into red/, the +-- layout Blue and Yellow always used. Idempotent: an existing red/ cache +-- wins and a missing root marker means nothing to do. +-- +-- The two cache homes are handled separately: the save directory goes +-- through love.filesystem so every host (NX included) and the headless test +-- stub take the same path, and the portable game folder goes through +-- os.rename on real paths -- skipped for a source run, where the game +-- folder IS the checkout and its data/generated is Red's source data, not +-- a cache. Called from RomImporter.new (before the readiness loop) and +-- from mountVersion, so no boot path can probe red/ before the move ran. +function CacheFs.migrateLegacyRedCache() + if not (love and love.filesystem and love.filesystem.getInfo) then return end + local fs = love.filesystem + + local function hasFile(p) return fs.getInfo(p, "file") ~= nil end + local function hasDir(p) return fs.getInfo(p, "directory") ~= nil end + + local function moveFile(src, dst) + local data = fs.read(src) + if data then + local parent = dst:match("^(.*)/[^/]+$") + if parent and fs.createDirectory then fs.createDirectory(parent) end + fs.write(dst, data) + end + fs.remove(src) + end + + local function moveTree(src, dst) + for _, child in ipairs(fs.getDirectoryItems(src) or {}) do + local sp, dp = src .. "/" .. child, dst .. "/" .. child + if hasDir(sp) then moveTree(sp, dp) else moveFile(sp, dp) end + end + -- remove only takes an empty directory; a non-empty one simply stays + fs.remove(src) + end + + -- --- save directory + if hasDir("red/data/generated") or hasFile("red/rom-cache.complete") then + -- already on the new layout + elseif hasFile("rom-cache.complete") then + -- The marker must be a save-dir file before anything moves: a developer + -- checkout also resolves data/generated at the root, but from the physfs + -- SOURCE, and moving that tree would gut the repository. + local real = fs.getRealDirectory and fs.getRealDirectory("rom-cache.complete") + if not real or (fs.getSaveDirectory and real == fs.getSaveDirectory()) then + -- cheap path first: renames inside the same directory; the copy below + -- covers whatever rename could not take (or hosts where the save dir + -- is not a plain os path, like the headless stub) + local saveDir = fs.getSaveDirectory and fs.getSaveDirectory() + if saveDir and fs.createDirectory then + fs.createDirectory("red/data") + fs.createDirectory("red/assets") + os.rename(saveDir .. SEP .. "data" .. SEP .. "generated", + saveDir .. SEP .. "red" .. SEP .. "data" .. SEP .. "generated") + os.rename(saveDir .. SEP .. "assets" .. SEP .. "generated", + saveDir .. SEP .. "red" .. SEP .. "assets" .. SEP .. "generated") + os.rename(saveDir .. SEP .. "rom-cache.complete", + saveDir .. SEP .. "red" .. SEP .. "rom-cache.complete") + end + if hasDir("data/generated") then + moveTree("data/generated", "red/data/generated") + end + if hasDir("assets/generated") then + moveTree("assets/generated", "red/assets/generated") + end + if hasFile("rom-cache.complete") then + moveFile("rom-cache.complete", "red/rom-cache.complete") + end + -- drop the emptied roots; a non-empty one (e.g. mods/ beside them is + -- untouched -- only data and assets are cache subtrees) simply stays + fs.remove("data") + fs.remove("assets") + end + end + + -- --- portable game folder (desktop only): rename on real paths + local root = CacheFs.root() + if root and not (fs.getSource and root == fs.getSource()) then + local function rootHas(rel) + local f = io.open(realPath(root, rel), "rb") + if f then f:close() return true end + return false + end + if rootHas("rom-cache.complete") and not rootHas("red/rom-cache.complete") then + local mkdir = resolveMkdir() + if mkdir then + mkdir(realPath(root, "red")) + mkdir(realPath(root, "red/data")) + mkdir(realPath(root, "red/assets")) + os.rename(realPath(root, "data/generated"), + realPath(root, "red/data/generated")) + os.rename(realPath(root, "assets/generated"), + realPath(root, "red/assets/generated")) + os.rename(realPath(root, "rom-cache.complete"), + realPath(root, "red/rom-cache.complete")) + end + end + end +end + -- Overlay the active version's extracted cache onto the un-prefixed read -- paths, so require("data.generated.*") and love.graphics.newImage( -- "assets/generated/*") resolve to that version's files. -- --- Non-Red versions live under blue/ / yellow/ in the save directory. On --- desktop fused+portable we PHYSFS_mount that folder by absolute path. On --- NX (and any host without a working FFI mount) love.filesystem.mount of --- the save-dir-relative name must succeed, or Play boots with Red's paths --- and Data:load dies. Always also prepend-mount the version's --- data/generated + assets/generated onto the un-prefixed paths so PhysFS --- directory non-merge (archive data/ vs save generated) cannot hide them. +-- Each version lives under its cachePrefix folder in the save directory. +-- On desktop fused+portable we PHYSFS_mount that folder by absolute path. +-- On NX (and any host without a working FFI mount) love.filesystem.mount +-- of the save-dir-relative name must succeed, or Play boots with another +-- version's paths and Data:load dies. Always also prepend-mount the +-- version's data/generated + assets/generated onto the un-prefixed paths +-- so PhysFS directory non-merge (archive data/ vs save generated) cannot +-- hide them. local function mountGeneratedTrees(prefix) prefix = prefix or "" if not (love and love.filesystem and love.filesystem.mount) then @@ -416,10 +519,13 @@ local function mountGeneratedTrees(prefix) end function CacheFs.mountVersion(version) + -- A legacy root Red cache has to move into red/ before anything probes + -- red/ paths (idempotent and near-free once migrated, issue #899). + if version == "red" then CacheFs.migrateLegacyRedCache() end local prefix = require("src.core.GameVersion").cachePrefix(version) local sub = prefix:gsub("/+$", "") - -- Save-dir relative mount first (NX / no-FFI). Prepend so blue|yellow win. + -- Save-dir relative mount first (NX / no-FFI). Prepend so the version wins. if sub ~= "" and love.filesystem.mount and love.filesystem.getInfo(sub, "directory") then love.filesystem.mount(sub, "", false) @@ -436,21 +542,21 @@ function CacheFs.mountVersion(version) end end - -- Version-scoped generated trees → un-prefixed paths (Red prefix is ""). + -- Version-scoped generated trees → un-prefixed paths. mountGeneratedTrees(prefix) return true end -- Undo mountVersion. A process normally mounts exactly one version and then --- boots it, but the launcher can open the save editor on a Blue/Yellow save, --- close it, and press Play on Red: with that version's subtree still --- prepended, Red's require("data.generated.*") and its generated art would --- silently resolve to the other game's files. Callers must also drop the --- generated modules from package.loaded (src.core.Data:unloadGenerated) -- --- unmounting alone only fixes the read path, not what require already cached. +-- boots it, but the launcher can open the save editor on one game's save, +-- close it, and press Play on another: with the first version's subtree +-- still prepended, the other's require("data.generated.*") and generated +-- art would silently resolve to the first game's files. Callers must also +-- drop the generated modules from package.loaded +-- (src.core.Data:unloadGenerated) -- unmounting alone only fixes the read +-- path, not what require already cached. -- --- Returns true when nothing was mounted or the unmount took. Red is a no-op --- because its cache lives at the root and was never overlaid. +-- Returns true when nothing was mounted or the unmount took. function CacheFs.unmountVersion(version) local prefix = require("src.core.GameVersion").cachePrefix(version) if prefix == "" then return true end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 0b4570e0..71158f4a 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -32,7 +32,7 @@ end -- carry Red's bank $1f header, wave-table, and CryData offsets. local CACHE_FORMAT = "rom-cache-v9:" -- The completion marker is written under each version's cache prefix --- (rom-cache.complete for Red, blue/rom-cache.complete for Blue). +-- (red/rom-cache.complete, blue/rom-cache.complete, ...). local MARKER_PATH = "rom-cache.complete" -- The marker a finished import writes for a version: the generation tag plus @@ -135,8 +135,8 @@ local PAL = { -- CacheFs.exists checks the game folder directly for a portable install, -- otherwise the save directory through love.filesystem. It honors --- CacheFs.prefix, so we point it at the version's cache subtree (Red at the --- root, Blue under blue/). +-- CacheFs.prefix, so we point it at the version's cache subtree (red/, +-- blue/, yellow/). local function allRequiredFilesExist(version) local CacheFs = require("src.import.CacheFs") local saved = CacheFs.prefix @@ -153,11 +153,15 @@ local function allRequiredFilesExist(version) end -- A developer checkout / Python build leaves Red's generated data in the --- physfs SOURCE at the un-prefixed root; that is always current. Only Red --- ships this way (Blue is import-only), so this stays a Red-root check. +-- physfs SOURCE at the un-prefixed root (the checked-out data/generated and +-- assets/generated); it is always current and never moves into red/. Only +-- Red ships this way (Blue/Yellow are import-only). The check goes through +-- love.filesystem directly so the red/ cache prefix cannot hide the source +-- tree, and the realDirectory test keeps a save-dir cache from counting. local function sourceTreeHasData() - if not allRequiredFilesExist("red") or not love.filesystem.getRealDirectory then - return false + if not love.filesystem.getRealDirectory then return false end + for _, path in ipairs(REQUIRED_FILES) do + if love.filesystem.getInfo(path, "file") == nil then return false end end local real = love.filesystem.getRealDirectory(REQUIRED_FILES[1]) return real == love.filesystem.getSource() @@ -214,8 +218,8 @@ local function purgeSaveDirCache() f:close() return true end - -- Purge each version's stale save-directory copy (Red at the root, Blue - -- under blue/) so it cannot shadow the portable game-folder cache. + -- Purge each version's stale save-directory copy (under its red/ / blue/ + -- / yellow/ prefix) so it cannot shadow the portable game-folder cache. for _, version in ipairs(GameVersion.ORDER) do local prefix = GameVersion.cachePrefix(version) if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then @@ -1121,6 +1125,11 @@ function RomImporter.new(onComplete, opts) _padInited = false, }, RomImporter) + -- Pre-#899 installs keep Red's extracted cache at the save-dir root; move + -- it under red/ before the readiness loop looks for red/ paths, or every + -- such install would read as "never imported" and demand the ROM again. + CacheFs.migrateLegacyRedCache() + for _, version in ipairs(GameVersion.ORDER) do local info = GameVersion.info(version) local ready = RomImporter.isReady(version) and not self.forceImport diff --git a/tests/engine/cache_fs_red_migration_test.lua b/tests/engine/cache_fs_red_migration_test.lua new file mode 100644 index 00000000..7ab2cd2b --- /dev/null +++ b/tests/engine/cache_fs_red_migration_test.lua @@ -0,0 +1,69 @@ +-- Issue #899: Red's extracted cache lives under red/ like blue/ and +-- yellow/, and a legacy root cache (pre-fix installs) is migrated on first +-- boot instead of reading as "never imported". +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +local CacheFs = require("src.import.CacheFs") +local GameVersion = require("src.core.GameVersion") + +eq(GameVersion.cachePrefix("red"), "red/", + "Red's cache is namespaced under red/") + +-- legacy layout: the marker and both generated trees at the save-dir root +love.filesystem.write("rom-cache.complete", "rom-cache-v9:abc") +love.filesystem.write("data/generated/maps.lua", "return {}") +love.filesystem.write("data/generated/constants.lua", "return {}") +love.filesystem.write("assets/generated/fonts/font.png", "font-bytes") + +CacheFs.migrateLegacyRedCache() + +eq(love.filesystem.read("red/rom-cache.complete"), "rom-cache-v9:abc", + "the marker moved under red/") +eq(love.filesystem.read("red/data/generated/maps.lua"), "return {}", + "the data tree moved under red/") +eq(love.filesystem.read("red/assets/generated/fonts/font.png"), "font-bytes", + "the assets tree moved under red/") +check(love.filesystem.read("rom-cache.complete") == nil, + "the root marker is gone") +check(love.filesystem.read("data/generated/maps.lua") == nil, + "the root data tree is gone") +check(love.filesystem.read("assets/generated/fonts/font.png") == nil, + "the root assets tree is gone") + +-- idempotent: a second run leaves the migrated tree alone +CacheFs.migrateLegacyRedCache() +eq(love.filesystem.read("red/rom-cache.complete"), "rom-cache-v9:abc", + "a second run keeps the migrated cache") + +-- an existing red/ cache wins over a legacy root leftover: no clobber +love.filesystem.write("rom-cache.complete", "rom-cache-v9:STALE") +CacheFs.migrateLegacyRedCache() +eq(love.filesystem.read("red/rom-cache.complete"), "rom-cache-v9:abc", + "an existing red/ cache is not clobbered") +check(love.filesystem.read("rom-cache.complete") ~= nil, + "the unmigrated leftover stays (a stale-marker re-import handles it)") +love.filesystem.remove("rom-cache.complete") + +-- mountVersion("red") overlays red/ at the un-prefixed paths, like blue/ +love.filesystem._mounts = {} +check(CacheFs.mountVersion("red") == true, "mountVersion(red) returns true") +eq(love.filesystem.read("assets/generated/fonts/font.png"), "font-bytes", + "post-mount probe reads red assets at the un-prefixed path") +eq(love.filesystem.read("data/generated/constants.lua"), "return {}", + "post-mount probe reads red data at the un-prefixed path") + +-- no legacy cache at all: migration is a no-op, not an error +love.filesystem.remove("red/rom-cache.complete") +love.filesystem.remove("red/data/generated/maps.lua") +love.filesystem.remove("red/data/generated/constants.lua") +love.filesystem.remove("red/assets/generated/fonts/font.png") +CacheFs.migrateLegacyRedCache() +check(love.filesystem.read("red/rom-cache.complete") == nil, + "nothing to migrate invents nothing") + +T.finish() From 984cefdc7bd1ec24cebca799805cf03d0c2e89c7 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 6 Aug 2026 09:57:31 -0400 Subject: [PATCH 3/3] launcher updates --- docs/new-features.md | 662 +------------- src/core/GameVersion.lua | 2 +- src/core/HostShell.lua | 169 +++- src/import/LauncherSettings.lua | 28 +- src/import/LauncherView.lua | 864 +++++++++++------- src/import/RomImporter.lua | 64 +- src/net/Fetch.lua | 35 +- src/net/fetch_worker.lua | 24 +- src/ui/kit/Kit.lua | 42 +- src/ui/kit/Theme.lua | 118 ++- src/update/check_worker.lua | 7 +- tests/engine/host_shell_fetch_errors.lua | 134 +++ .../launcher_one_column_reach_bug852.lua | 95 +- tests/engine/launcher_panel_reflow.lua | 208 +++++ 14 files changed, 1366 insertions(+), 1086 deletions(-) create mode 100644 tests/engine/host_shell_fetch_errors.lua create mode 100644 tests/engine/launcher_panel_reflow.lua diff --git a/docs/new-features.md b/docs/new-features.md index 3a19f226..93c6d23e 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -1,636 +1,26 @@ -# New features (deliberate additions beyond the original) - -Intentional enhancements this port adds on top of faithful Pokémon Red, Blue, -and Yellow behavior. They have no Game Boy equivalent and are kept by design. -Genuine divergences from the original (things still missing, wrong, or -approximated) live in docs/known-differences.md; faithfully-ported behavior is -in docs/behavior-porting-notes.md. - -## Survey zoom - -The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4` -zooms the overworld between 1 pixel per world pixel (full survey) and 2× -the window fit scale (close-up), in crisp integer steps. This has no Game -Boy equivalent: - -- Connected maps render their full bodies, and their NPCs appear as - visual-only "ghosts", they wander but have no sight lines, triggers, - dialogue, or collision until the map is actually entered. -- Menus, text boxes, and battles draw at normal scale on top of the - zoomed world. Zoom input is ignored while a script, menu, or battle is - active; the zoom offset is persisted as `save.options.zoom` (default - `0` = FIT) and survives New Game via `options.lua`. -- Hotkey `4` ticks through every integer zoom level (survey → FIT → - close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`. -- Beyond the border ring the void fill repeats indefinitely (see VOID - FILL below); interiors keep their own border block. Each visible map - area is colorized with its own SGB palette (the original recolored the - whole screen per map). -- Neighbor maps load two connection hops out so corner-adjacent maps - don't pop in and out, and ghost NPCs share instances with the real ones - so their wander positions persist across seamless connection crossings - (a warp or fresh map entry still respawns everything at its script - position, like the original's per-entry sprite init). - -## VOID FILL - -The Options **VOID FILL** row picks what paints the infinite beyond-edge -space on OVERWORLD-tileset maps during survey zoom: - -- **TREES** (default): solid tree wall block `$0F`. -- **WATER**: animated water tile `$14` (same hshift cycle as on-map water). -- **BLACK**: solid black. - -Other tilesets are unchanged (house/cave borders stay as authored). -Persisted as `save.options.voidFill`. - -## Tilt mode - -The `3` key (and the Options menu TILT row) cycles a visual-only perspective -tilt of the overworld through **OFF → 15° → 35° → 50° → OFF** for an HD-2D / -diorama look. Like survey zoom this is purely presentational and has no -Game Boy equivalent: - -- The entire map tilts as one rigid ground plane, paths, grass, water, - floors, and every background-tile structure (buildings, trees, fences, - signs; in Gen 1 these are baked into the tile layer, not sprites), so - rows above the player recede and rows below come toward the viewer. Only - things that actually *stand* on the ground draw as upright billboards, - unscaled and pixel-identical to flat mode: the player, NPCs, item balls, - and the standing FX attached to them (emote bubbles, the fishing rod, - the FLY bird). The Poké Center heal-machine overlay stays on the ground - plane with the machine tiles (it is OAM glued to a BG graphic, not a - standing sprite). An earlier revision tried - billboarding buildings/trees/signs too (cutting them out of the ground - per hand-curated per-tileset tables); that chased an endless tail of - special cases, dense tree canopy, fences fused into grass, building - facades with their own baked-in fake perspective, because Gen 1's art - was never drawn with a clean seam between ground and standing scenery. It - wasn't merged; tilting everything but the characters as one plane is the - simpler, shipped tradeoff (buildings recede/foreshorten with the ground - like a photo of a diorama, rather than standing fully upright next to - a full-height character). -- Cycling tweens the angle between levels over ~0.25s rather than snapping; - with tilt fully off the world pass drops back onto the flat blit path, so - flat rendering stays pixel-identical to tilt-off and off costs nothing. -- Tilt input is gated exactly like survey zoom, honored only while - free-roaming, ignored while a script, menu, or battle is active, and it - composes with survey zoom (the zoom scale feeds the projection). The tilt - level is persisted in `save.options.tilt` (default OFF). -- It applies everywhere the overworld draws, interiors and caves included. - Menus, text boxes, and battles render flat on top, unaffected, and the - infinite beyond-the-border-ring fill stays flat by design. -- Collision, movement, sight lines, triggers, encounters, and scripts are - untouched; nothing about the tilt reaches gameplay. - -## Colors mode - -The `2` key (and the Options menu COLORS row) cycles the display mode -through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED** -(on Blue the first slot labels **OG BLUE**; on Yellow, **OG YELLOW**). -The first three are the real colorizations; the rest are DMG-shade novelties: - -- **OG RED** / **OG BLUE**: the Game Boy Color boot-ROM look for that cart -- - one global BG palette + one OBJ palette, every map, no per-map variation - (Red/Blue ship no CGB code, so on a GBC the boot ROM colors them globally). - The player/NPCs keep the boot-ROM OBJ color over the terrain via the OBP - bake + post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`, or Blue's blue/pink - pair). -- **OG YELLOW** (Yellow playthrough, same `ogred` save id): Pokemon Yellow's - authentic GBC look from `CGBBasePalettes` (`data/palettes_yellow.lua`, - sourced from pret/pokeyellow). Per-map / per-species colors, not a single - boot-ROM ramp -- Yellow was CGB-enhanced. -- **SGB** (default): the per-map Super Game Boy region palettes - (`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on - real SGB. (This is the mode formerly mislabeled "GBC".) -- **ADVANCED**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus - per-species mon colors (`data/palettes_gbc.lua`). (Formerly labeled - "RED++"; it is the richest colorization rather than anything Red-specific.) -- **OG**: force the four DMG grays (colorization off). -- **OG INV**: inverted DMG grays. -- **SGB INV**: each SGB zone palette with shade order reversed. -- **CLASSIC**: original Game Boy pea-soup greens - (`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`). - -The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so -it covers overworld, menus, battles, and tilt upright billboards. OG RED's -global BG palette is supplied by `OverworldState:overworldBgColors` (per-map -override in the overworld pass). Persisted as `save.options.colors`; the -`gbc` / `gbc_inv` / `redpp` save ids are kept for back-compat under the new -labels. - -## GBC FX - -The `5` key (and the Options menu GBC FX row) cycles a "played on real -unlit-GBC hardware" post-process through **OFF → 1 → 2 → 3 → 4**. The -levels are a cumulative ladder: - -- **1**: reflective-screen backing transparency. -- **2**: + LCD pixel grid. -- **3**: + pixel drop shadows. -- **4**: + sunlight glare and rainbow shimmer with a drifting light. - -It runs as a final present pass after world + UI composite in -`Renderer:endFrame`, inspired by the Pixel Transparency RetroArch shader -([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)). -Default OFF; persisted as `save.options.gbcfx`. - -Mobile GPUs often compile the pass but present a black frame, so Android and -iOS hide the row entirely, pin the level to OFF, and rewrite a level already -persisted in `options.lua` (issue #136). `POKEPORT_GBCFX` overrides that -decision either way, same tri-state as `POKEPORT_TOUCH`: `=0` refuses the -effect, `=1` forces it available. The Anbernic handheld pack exports `0` from -its launcher because the device reports `"Linux"` while its GPU is in the -phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)). - -## Performance tier (low-end devices) - -The Options **PERFORMANCE** row scales the port's optional presentation -extras down for weaker hardware. The extras it governs are the three -heaviest things the port adds on top of the original -- the whole-screen 3D -**TILT** (transforms the entire map as a ground plane), the **GBC FX** -post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out -renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard -FPS ceiling. None of this touches game logic, which is fixed-step off `dt` -(`src/core/FixedStep.lua`), so every tier plays identically; they differ -only in how much eye-candy the renderer is allowed to do. - -| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling | -| ------------ | ---- | ------ | ----------- | ----------------- | -| **HIGH** | on | on | on | none | -| **BALANCED** | off | off | on | none | -| **LOW** | off | off | off | 60 | -| **AUTO** | picks a default from the device (below) ||| - -- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds - (e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core - desktops to **BALANCED**, and everything else -- a normal desktop, and - every existing `options.lua` that predates this option -- to **HIGH**, - so the common case is unchanged. See `src/core/Performance.detect`. -- AUTO only chooses the *default*; all four tiers are selectable, so a - wrong guess is one row away from being overridden. -- The clamps are applied **live** against your stored options and never - rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC - FX / ZOOM without forgetting them -- raising the tier restores exactly - what you had. (This is why the TILT / GBC FX / ZOOM rows still show your - saved choice on a clamped tier: it's your preference, waiting for a tier - that can afford it.) -- Persisted as `save.options.performance` (`auto` | `high` | `balanced` | - `low`); unit-tested in `tests/engine/performance_tiers.lua`. - -## Peer-to-peer link play (lua-enet) - -Trades and link battles connect two copies of the game directly over -lua-enet (ENet ships inside LÖVE, nothing to install, no server to run) -on a reliable-ordered channel, replacing the original standalone Python -room-code relay (`tools/relay_server.py`, deleted). HOST A GAME shows the -host's LAN address (UDP 7777; `POKEPORT_LINK_PORT` overrides); JOIN A -GAME enters it. Closing performs a graceful ENet disconnect so the final -confirm/bye always lands; a vanished peer exits with "The link was -broken." Internet play needs a forwarded UDP port or a VPN (deliberate -tradeoff vs. the relay). Headless tests drive the protocol over an -in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file -also exercises real UDP pairing. - -Red, Blue, and Yellow copies link with each other, as the real cable -does. The compatibility fingerprint hashes only data a link mode can -actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the -only R/B/Y link-surface difference) no longer read as different games -(issue #511). Moving the fingerprint is a link parity change: builds -from before this fix will refuse to pair with builds after it. - -## Fair play in link and online matches - -A link session is decided by the battle and nothing else, so for its -duration: - -- **Game speed is pinned to normal.** The GAME SPEED option and - `POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it - closes, and apply again after. Fast-forward otherwise runs one peer's - queue faster than the peer it is locked to and drains a tournament shot - clock faster than the opponent racing it. -- **Online play runs vanilla, except for your language.** Picking ONLINE - MATCH or TOURNAMENT with mods enabled offers to switch the gameplay ones - off and relaunch (mods merge at boot, so a restart is the only way). The - restart is confirmed, not silent. They stay listed as disabled, ready to - switch back on. A mod that declares itself a translation and provably - writes nothing but text stays on: the two games hash the same link - surface, so a Spanish install and an English one can battle and trade, - each reading the game in its own language and naming the other player's - party out of its own text. -- **Only a meaningful split ends a match.** The per-turn state signature - both peers exchange is split three ways: `actives` and `bench` carry - species, HP, status, stat stages, PP and the rest of the party, and a - divergence there ends the match as a draw. `volatile` carries per-turn - flags both sides recompute anyway - a divergence there is logged and - reported to mods, and play continues. - -The relay logs which component diverged on which turn, so a desync report -names something specific. - -## Custom boot text - -The boot sequence replaces the Nintendo / GAME FREAK identifiers with -"bois club" / "bryanthaboi", a deliberate branding customization. The -rest of the boot beats (copyright splash, "presents" shooting-star, the -Nidorino-vs-Gengar attract scene) mirror the original. - - -## Custom Options - -Options persist in a standalone `options.lua` (separate from the game -progress `save.lua`), so audio/display/battle preferences survive New Game -and aren't wiped when a save slot is cleared. Changing a row in the Options -menu or cycling hotkeys `2`/`3`/`4`/`5` writes immediately; an in-game save also -flushes the live options. Old saves that still embed an `options` table are -migrated once into `options.lua` on load. - -- Music / SFX volume -- PIKACHU VOL (0-7, Yellow only): trims Pikachu's PCM voice clips under the - SFX level, so the follower's constant chatter, the title-screen cry and - every in-battle "Pika!" can be pulled down (or muted at 0) without - quieting the rest of the sound effects. The row is hidden on Red/Blue, - which have no voice clips. -- Music Filter -- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules) -- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below -- COLORS (OG RED / SGB / ADVANCED / OG / OG INV / SGB INV / CLASSIC), also - hotkey `2` (OG RED = GBC boot-ROM look; ADVANCED uses pokered-gbc - SuperPalettes + per-species mon colors) -- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming -- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and - `-`/`=` step one level and save -- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space -- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5` -- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60), - a hard render frame-rate cap (`save.options.fpsCap`). - -## Battle transition cascade + white battle letterbox - -Into-battle wipes still run the original eight styles inside the classic -160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8 -blocks cascade outward from that square into the surrounding world so the -void outside the OG wipe fills in lockstep. Once the battle state is up, -letterbox voids around the battle canvas fill **white** instead of black -so the whole window reads as one continuous battle screen. - -## Widescreen battle layout - -Options **BATTLE LAYOUT** picks the battle screen's composition: **OG** -(the default: the original 160×144 arrangement, unchanged) or **WIDE**, -which gives battles a 304×144 native-pixel surface and a Gen 3-style -arrangement on it: - -- the foe's status box upper left, the foe's picture upper right; -- the player's picture lower left, the player's status box lower right, - with a longer HP bar and the numeric HP under it; -- a full-width message window; -- a split "What will X do?" prompt / 2×2 command window; -- a 2×2 move menu, navigated with all four directions, with a PP and type - panel attached to its right. - -Only the composition changes. Pictures, palettes, HP-bar colors, font -pages, window borders, sounds, animations, timing and every battle rule -stay the engine's, so a COLORS mode or an asset mod still owns the look. -Each side's picture keeps its original pixels and placement math and is -composited into its own region of the wider battlefield -- nothing is -scaled or squeezed -- and animations, which are authored in the original -160-pixel space, shift as one rigid group onto whichever side they play -on. The whole screen is drawn at the window's integer fit scale for the -wider surface, so a 304-pixel screen is drawn a step smaller than a -160-pixel one in the same window. - -The wide surface is live only while the battle itself is the screen on -top: a party menu, the bag or a nickname prompt is a 160×144 screen and -brings the classic surface back with it. - -## On-screen touch controls (mobile) - -On Android/iOS the game draws a translucent d-pad (bottom-left), A/B -buttons (bottom-right, Game Boy diagonal), and +/- START/SELECT (bottom -center) over the frame, using Xelu's CC0 controller prompts -(`assets/touch/`). Real buttons, not gestures: press lands the frame the -finger does, sliding on the d-pad changes direction without lifting, and -multi-touch chords (e.g. hold a direction + tap B) work. The overlay only -appears while no controller is being used: the first gamepad button or -stick push hides it, the next screen touch brings it back, and unplugging -the last controller restores it immediately. Layout re-derives from the -window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces -the overlay on and lets the mouse act as a finger (`=0` forces it off). - -The launcher's **Touch Controls** button opens a drag editor: move each -button freely, resize the whole pad with **-/+** (60% to 160%), **Disable** -to hide the overlay permanently (for controllers / emulation handhelds -- -distinct from the temporary gamepad auto-hide), **Reset** for defaults, -**Done** to save into `options.lua` as normalized window fractions so a -different screen keeps the relative placement. - -Portrait and landscape are edited and saved separately (#633): the editor -follows whichever orientation is on screen, and **Reset** only clears that -one, so a layout that works held upright does not have to double as the -one used sideways. An `options.lua` from before this split keeps its single -layout in both orientations until one of them is edited. In-game, Options → -**TOUCH PAD** toggles the same on/off flag without leaving a play session. - -## Haptic feedback (mobile) - -Options → **VIBRATION** (also in the launcher's gear menu) buzzes the device -the instant an on-screen control takes a button (#806). A glass pad has no -edges under a thumb, so the pulse is what tells you the press landed: -sliding the d-pad from one direction to the next buzzes again, a second -finger landing on a button that is already held does not, and releasing -never does. - -Four levels: **OFF**, **LIGHT** (the default), **MEDIUM**, **HEAVY**. -"Intensity" is really a pulse length -- the platform call takes a duration -and nothing else -- so LIGHT is a 12 ms tick, MEDIUM 25 ms, HEAVY 45 ms. -Stepping the row fires one sample pulse at the level you land on, so the -three can be compared without leaving the menu. On iOS the system -vibration has one fixed length, so all three levels feel the same there and -the row is effectively on/off. The setting lives in `options.lua` and the -row only appears where the on-screen pad can (Android/iOS, or desktop with -`POKEPORT_TOUCH=1`, where it does nothing since desktop LOVE has no -vibrator). - -## Screen orientation lock (Android) - -Options → **ORIENTATION** (also in the launcher's gear menu) locks the -screen to **PORTRAIT**, **LANDSCAPE** (either landscape, following the -device), or **REVERSE LANDSCAPE**, or leaves it on **AUTO** (#592). AUTO -allows every orientation but defers to the system: with auto-rotate turned -off in Android's quick settings, the game stays put instead of following -the sensor (#716). Changes apply immediately -- the screen rotates as the -row is stepped -- and persist in `options.lua`. Android only: iOS follows -the app's fixed orientation list, and desktop windows rotate nothing. - -## Translation support - -Every string the player can read is now reachable from a mod, so a -translation is an ordinary content mod rather than a fork. - -Two things had to change. Text layout stopped counting bytes: the dialogue -box measures a line in glyphs (charmap sequences), so a 3-byte character -costs one column, a cut never lands inside a character, and a page with a -non-default `advance` re-measures instead of overflowing. That also fixed -25 vanilla English lines that were wrapping early because `é` in POKéMON -and POKéDEX costs two bytes ("I study POKéMON as" is 19 bytes and 18 -glyphs, and the box was breaking it). - -Second, the text the engine writes itself - battle messages, item results, -menu labels, the link-play screens - moved behind `src/core/Strings.lua` -and the new `strings` registry. Extracted script text was already -overridable through `text`; this covers the other half. Entries are keyed -by the English source, so a translation that has not reached a string yet -keeps rendering in English and a half-finished translation stays playable. - -Authors generate the whole thing: - -```sh -python3 tools/modkit.py translation francais --language "Francais" -``` - -That scaffolds a mod with every translatable string as an empty catalog, -plus a glyph-page and charmap stub, a naming-grid stub, and a -`francais-worksheet/` directory holding the English to translate from -(deliberately outside the mod: extracted text is ROM content and must not -be packed). `--refresh` re-harvests after an engine update, keeping -existing translations and parking orphaned keys rather than dropping them. - -A translation can also skip glyph pages entirely: scaffolding with -`--pixel-font` (or registering `mod.content.font:register("ttf", {})` in -an existing mod) renders text through a bundled TTF covering Latin with -diacritics, Cyrillic, kana and CJK, while box borders and ``-style -macro glyphs keep their tiles. The font is "Plain Pixel Font" by Douglas -Vautour (Burpy Fresh), licensed under CC-BY 4.0 (5x11 base characters, -11x11 double-width; see `assets/fonts/plainpixel/README.md`). Options on -the registry entry: `file` for a mod-shipped TTF, `size` (the font's -design em; Plain Pixel rasterizes cleanly only at multiples of 15), -`spacing` added to every advance, `yOffset` for vertical alignment -against the 8px cell grid, `bold`, which double-prints at a 1px -offset for fonts whose strokes read too light, and `tiles`, the -characters that keep their ROM tile instead of coming from the TTF. - -`tiles` matters for a CJK translation. Sizing the font so a kana fills -the 8px cell leaves Latin narrower than the tile font it replaces, which -pulls the numeric columns out of line: the party menu's `:L12` stops -sitting over `34/ 34`. Naming `"0123456789/:"` keeps those on the -vanilla tiles, so numbers render exactly as they do in English while -kana still come from the font. It takes a string of characters, or a -list when a multi-character charmap sequence is meant. - -See the wiki's Translations guide. - -## Save editor (bundled, reachable from the launcher) - -The save editor ships inside every build instead of being a developer-only -script, and the launcher's SAVE SLOT card grows an **Edit** label next to -Delete on every slot that actually holds a save. Edit suspends the -launcher, opens that slot's file in the editor, and **Close** hands the -process back to the launcher with the slot list re-read (a rename, a badge -or a dex change shows up on the row immediately). Unsaved edits arm a -confirm first, so leaving cannot lose work. `love . --editor` still opens -it standalone, where Close quits instead; `--save ` points it at any -file, and a save can be dragged onto the window. - -The editor now wears the launcher's visual language - the same navy radial -field, 16px translucent cards, tri-colour version rail and green/yellow/red -semantics - so the two windows read as one app. Six tabs: - -- **Party**: the roster with sprites, HP bars and level chips on the left, - and the mon inspector permanently docked on the right instead of floating - over the list. Species, level, DVs and moves all round-trip through the - Gen 1 formulas, so the inspector can never show illegal stats. -- **Boxes**: the 12 PC boxes as a 5x4 grid with a fill meter per box and a - party dock, so deposit and withdraw live in one place. Empty slots are - clickable and create a mon there. -- **Items**: money, a searchable item picker (replacing the arrows that - cycled one id at a time through ~250 items), the configurable bag (20 slots - by default), PC storage - with no slot cap, and the eight badges as toggle chips. The picker, the bag - and PC storage all scroll under the mouse wheel, so the whole catalog is - reachable one-handed without typing a query. -- **Events**: flags, defeated trainers, taken items and per-map object - toggles, with a real filter field and a two-column paged grid. -- **Map**: any map rendered with the game's own renderer, warps followable, - and the player / lastHeal / lastOutdoor spawn points settable by clicking - a cell. Setting lastOutdoor on a map the game would not accept as an - outdoor source is refused with the reason. -- **Dex**: seen / owned completion meters and a four-column grid; owning - implies seen and un-seeing clears owned, exactly as the game requires. - -Two rules run through all of it. Every mutation goes through one funnel -that sets the dirty flag and writes the status line together, so nothing -changes silently and no branch can quietly no-op - "Party is full", "Bag is -full", "click a cell first" all say so. And every destructive verb (Remove, -Release, Clear all, Wipe dex) arms on the first click and commits on the -second, relabelling itself to `Confirm?` in between. - -A validation pill in the tab rail mirrors what the running game would -quarantine on load; clicking it jumps to the tab holding the first problem. - -## Tiled map editing (mod authoring) - -`tools/tiled_export.py` turns the imported ROM cache into a Tiled workspace, -so maps can be edited in a real map editor and exported back out as a mod. -It has its own document: docs/tiled-map-editing.md. - -## Pokédex diploma (both versions) - -The Celadon Mansion 3F game designer shows the dex-completion diploma -once 150 species are owned. On Yellow, the graphic artist next to him -then offers to print it, saving the certificate as a PNG under `prints/` -in the save directory, and Bill's PC gains Yellow's PRINT BOX item which -exports the current box list the same way. - -## Pokédex printing (Yellow) - -Yellow's Game Boy Printer PRNT option in the Pokédex side menu is stood in -for by an image export: choosing PRNT renders the mon's entry page (sprite, -kind, number, height/weight, dex text) to a PNG at 4x scale under -`prints/` in the save directory, then reports the filename in a dialog. -No printer hardware or link cable emulation involved; the file is the -printout. - -## Find Mods (community mod indexes) - -A FIND MODS tab sits beside MODS in the launcher and browses a published -mod index: a metadata-only feed listing mods that live in their authors' -own repositories. No index ships with the launcher and none is ever added -automatically, so the tab opens on an "Add an index" prompt until you name -one; paste an index URL or its `owner/repo` and it is remembered in -`options.lua`. More than one index can be added, and the listings merge. - -A feed author can publish per-mod release stats by adding three optional -fields to an entry -- `downloads` (total across every release), and -`first_release` / `last_release` (ISO days) -- which the listing shows in -the same gold line the MODS tab uses. When a feed does not carry them, -the row fetches the mod's own GitHub releases instead -- the same cached -`ModUpdate` fetch the MODS tab uses, one entry per frame -- so the stats -appear for any mod with a `github` field regardless of feed maintenance. -The fields are additive: feeds that carry them stay readable by every -build that predates them, and feeds that do not render exactly as before. - -## Soft reset (all versions) - -Holding A, B, START and SELECT together restarts the game the way flicking -a Game Boy's power switch did, dropping straight back to the title screen. -It works from anywhere, including mid-battle, which the QUIT entry on the -start menu cannot do: the original combo is how stationary and gift -Pokemon get their stats rerolled without sitting through a full relaunch. -Unsaved progress is discarded, exactly as on hardware. - -As on the original, the four buttons have to stay held for 16 straight -polls (better than a quarter of a second) and any direction in the mix -cancels it, so it is hard to hit by accident -- including on the on-screen -touch controls, where it would take four fingers held on four separate -controls. - -## Controls rebinding (CONTROLS screen) - -OPTIONS -> CONTROLS lists every Game Boy button with its current keyboard -key and controller button side by side (Z/A). Press A on a row, then press -and release the key or pad button you want; the rebind commits on the -release. If that input already belongs to another row, the two rows swap, -so no button is ever stranded without an input and no input ever serves -two buttons. Holding a second key or pad button while the first is still -down backs out of the capture without touching a keyboard; Escape still -cancels too. SELECT clears one row back to its default, and START resets -every binding after a confirmation. - -Controllers a system has no mapping for (common on Linux handhelds and -off-brand pads) report bare button numbers rather than names. Those are -rebindable on the same screen and show up as JOY1, JOY2 and so on in the -controller column. Recognized controllers are read only through their -named buttons, so a rebind on those is never shadowed by the factory -layout underneath it. - -## Mod profiles (#593) - -The mod manager's PROFILES tab holds named setups. A profile remembers which -mods are on, every mod's own options, and which save slot each game version -plays, so swapping profiles swaps the whole playthrough and not just the mod -list. The setup that existed before profiles shipped becomes PROFILE 1 the -first time the manager opens. - -EXPORT.. writes the selected profile to `profiles/.g1rmodlist` in the -save directory; drop a `.g1rmodlist` someone shared into that folder and -IMPORT.. adds it. Imported profiles never overwrite an existing one (a name -clash gets a number). Mods the shared profile names but that are not installed -are reported when the profile is applied; installing them is still a manual -trip through the mods list or Find Mods. - -## Windows: no console windows on launcher actions - -Checking for updates, browsing a mod index, adding a mod repo, installing a -mod and picking a ROM all run a host tool (curl, PowerShell) in a child -process. On Windows those children used to each open their own console -window, so a session could end up buried under half a dozen of them. The -game now claims one console for itself at boot and hides it; the children -inherit that invisible console and nothing pops up. Nothing else changes: -file pickers are ordinary desktop dialogs and still appear normally, and a -run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers) -keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt -out. - -## A faster, higher-contrast launcher - -The launcher and the save editor were rebuilt on one small immediate-mode UI -kit (`src/ui/kit/`), replacing the vendored FlexLove layout engine. The -visible result is that the launcher is quick: building and drawing a frame -went from about 9 ms to under 1 ms on the same machine and the same data, at -every window size, so the window keeps up with the pointer instead of -trailing it. Measure it yourself with `POKEPORT_LAUNCHER_PROF=200 love .`. - -**Nothing blocks the window any more.** Fetching a mod index, checking a mod -for updates, listing versions, downloading an install and pulling thumbnails -all run on background threads. Opening FIND MODS on a cold cache used to -freeze the launcher for as long as the server took -- often minutes, with no -indication anything was happening. Mod indexes are now also fetched at boot, -so the tab is usually already populated by the time you reach it. - -**Anything you wait on says so.** Every operation that takes time raises a -loading panel with a spinner or a progress bar that cannot be clicked around -or dismissed, so a half-finished install can never be interrupted by a stray -click. Work that only affects one row (a mod's update check) shows a small -spinner on that row instead and leaves the rest of the list usable. - -**Lists page instead of scrolling.** Mods, Find Mods, save slots, settings, -release notes and the version list all show a fixed number of rows with a -pager underneath, and the number of rows comes from the window height -- a -tall window shows more, a phone shows fewer. A long list costs exactly what a -short one does. The mouse wheel turns pages. - -**Updates live in the top right.** The in-app updater moved next to the -settings gear and pulses when an update is waiting, instead of sitting in a -banner at the bottom of the page that you had to scroll to notice. Checking -for updates from there shows a loader like everything else. - -**A quit button.** An X sits to the right of the settings gear and closes -the app cleanly, the same shutdown path as the window's close button. Mostly -for platforms where reaching the window chrome is awkward (Android, Steam -Deck, fullscreen desktops). - -**The look.** Black background, white outlines, no gradients or glows, and -buttons that are solid colour-coded keys: green commits, blue navigates, red -destroys, yellow wants attention. The three game tabs keep their red, blue -and gold cartridge colours. Everything is about a third larger than before. -The save editor follows the same theme, and adding an item there is now a -searchable pop-up like adding a Pokemon, rather than a cramped list wedged -into the tab. - -**Reset rebinds.** Input rebinds are additive, so there was no in-game way to -undo one. A RESET REBINDS row in Settings, and a matching button under Touch -Controls on each game tab, restore the stock keyboard, gamepad and touch -layout. Both ask twice. - -## Launch options: boot straight into a game - -`love . --game=red` skips the launcher and starts that game; `--slot=` picks the save slot to load, and `--launcher` forces the launcher -anyway. Spell these with an `=`: LÖVE reads the command line first and takes a -bare word as a path to a game, so `--game red` fails looking for a folder -called `red`. `POKEPORT_GAME` / `POKEPORT_SLOT` do the same for shortcuts that -can only pass environment variables. This is for one-click entries: a desktop -shortcut per game, a Steam entry, or a handheld frontend. Asking for a game -whose ROM has not been imported opens the launcher on that game's tab rather -than failing. +# New Features + +Features intentionally added beyond the original Pokémon Red, Blue, and Yellow games: + +* **Survey zoom** with connected-map rendering and configurable void fill +* **Perspective tilt mode** for an HD-2D-style overworld +* **Multiple color modes**, including original, SGB, advanced GBC, monochrome, and classic green +* **Optional GBC screen effects**, including pixel grids, shadows, glare, and transparency +* **Performance presets** and configurable FPS limits +* **Peer-to-peer link play** for trades and battles between Red, Blue, and Yellow +* **Persistent custom options** stored separately from game saves +* **Optional widescreen battle layout** +* **Mobile touch controls** with editable layouts, vibration, and orientation settings +* **Translation and custom font support** +* **Built-in save editor** for parties, boxes, items, events, maps, and Pokédex data +* **Tiled map editing tools** for mod authors +* **Pokédex diploma and printer image exports** +* **Community mod browser** +* **Soft reset button combination** +* **Keyboard and controller rebinding** +* **Mod profiles** with separate mod settings and save slots +* **Improved launcher and save editor UI**, including background downloads and update checks +* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends +* **Custom boot branding** + +Actual approximations, and missing original behavior are documented separately in `docs/known-differences.md`. diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 855b632f..8032aaa2 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -40,7 +40,7 @@ GameVersion.VERSIONS = { id = "yellow", label = "Yellow", displayName = "Pokemon Yellow", - launcherName = "Yellow (alpha)", + launcherName = "Yellow", sha1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1", manifest = "tools/rom_manifest_yellow.json", cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 448db31e..0c39e3c3 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -88,14 +88,60 @@ function HostShell.releasePointerGrab() end end +-- POPEN IS NOT THREAD SAFE, and this app calls it from four threads (the main +-- one, the update checker, and a pool of three fetch workers). +-- +-- On Darwin, popen() flushes every open stream first: _fwalk walks libc's +-- global FILE list and locks each entry as it goes. pclose() frees a FILE and +-- takes it off that list. Run the two concurrently and the walker can end up +-- waiting on the lock of a FILE another thread has already freed -- a wait +-- that nothing will ever satisfy. That is the launcher freezing on close +-- after a visit to the mod tabs: sampling a hung process shows a fetch worker +-- parked in popen -> _fwalk -> flockfile with NO curl running anywhere on the +-- machine, and the main thread blocked in Thread:wait() for that worker, which +-- is why LOVE never reaches the process exit. +-- +-- The fix is a process-wide mutex around the two list-mutating calls, and only +-- those: a LOVE Channel's performAtomic runs its callback holding the +-- channel's own mutex, which is the one lock primitive shared across love +-- threads. Reading a pipe stays outside it, so the fetch pool still runs its +-- transfers in parallel -- a spawn is microseconds, a transfer is seconds. +local POPEN_LOCK = "hostshell_popen_lock" + +local function popenLock() + if not (love and love.thread and love.thread.getChannel) then return nil end + local ok, ch = pcall(love.thread.getChannel, POPEN_LOCK) + return ok and ch or nil +end + +-- Run `fn` with the spawn lock held, or plain when there is no love.thread to +-- take one from (the headless test stub, a plain luajit run). +local function withPopenLock(fn) + local ch = popenLock() + if not ch then return fn() end + local okAtomic = pcall(function() ch:performAtomic(fn) end) + if not okAtomic then fn() end +end + -- Wraps io.popen with the AppImage env fix applied and lua errors swallowed function HostShell.popen(command, mode) HostShell.releasePointerGrab() - local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r") - if not ok or not pipe then return nil end + local pipe + withPopenLock(function() + local ok, p = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r") + pipe = (ok and p) or nil + end) return pipe end +-- Close a pipe HostShell.popen opened. Callers MUST use this rather than +-- pipe:close(): pclose is the other half of the race above, and a close that +-- skips the lock can free a FILE out from under another thread's spawn. +function HostShell.pclose(pipe) + if not pipe then return end + withPopenLock(function() pcall(function() pipe:close() end) end) +end + -- Restart the whole app. The obvious love.event.quit("restart") re-runs LÖVE's -- boot in-process, which calls love.filesystem.init a second time -- and inside -- an AppImage physfs is already initialized, so that second init throws @@ -157,6 +203,65 @@ end -- block the calling thread and deal in whole files, so callers keep exactly -- the contract they had with curl. +-- DIAGNOSING A FAILED FETCH. curl's own stderr ("curl: (56) The requested +-- URL returned error: 403") went straight to the terminal, naming neither the +-- URL nor which of the launcher's many fetches produced it, while the caller +-- got back a generic "empty response". Both curl branches below now merge +-- stderr into the pipe and ask curl for the HTTP status with --write-out, so +-- the message that reaches the UI and the log says which URL failed and how. +-- +-- The status rides a marker rather than a bare "%{http_code}": a GET streams +-- its body through the same pipe, so the code has to be findable at the end +-- of arbitrary text. Matched from the END, and only the last occurrence is +-- cut, so a body that happens to contain the marker keeps its content. +-- Two spellings on purpose. HTTP_MARK is what comes back down the pipe; the +-- FMT one is what goes to curl, where the newline MUST be the two characters +-- backslash-n (curl expands the escape itself). A literal newline inside the +-- argument would be quoted fine by a POSIX shell and be a syntax error in +-- cmd.exe, which has no multi-line quoted string. +local HTTP_MARK = "\n__gen1recomp_http__" +local HTTP_MARK_FMT = "\\n__gen1recomp_http__%{http_code}" + +-- Split a curl pipe's output into (body, status, noise). `status` is nil +-- when curl never got far enough to have one (DNS failure, no route, a +-- timeout), in which case `noise` carries curl's own complaint. +local function splitCurlOutput(out) + out = tostring(out or "") + local at = nil + local from = 1 + while true do + local s = out:find(HTTP_MARK, from, true) + if not s then break end + at, from = s, s + 1 + end + if not at then return out, nil, out end + local body = out:sub(1, at - 1) + local code = tonumber(out:sub(at + #HTTP_MARK):match("^(%d+)")) + -- curl writes http_code 0 when it never got a response at all (DNS, no + -- route, connect timeout). That is not a status, and reporting it as + -- "HTTP 0" buries the real reason, which is in curl's own message. + if code == 0 then code = nil end + return body, code, body +end + +-- The error string a caller (and the launcher's notice line) sees. It always +-- names the URL, because "403" on its own is unactionable when the launcher +-- has an index feed, a releases API and a page of thumbnails in flight. +local function fetchError(url, status, noise) + if status then + local extra = (noise or ""):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + if #extra > 160 then extra = extra:sub(1, 157) .. "..." end + if extra ~= "" then + return ("HTTP %d from %s (%s)"):format(status, url, extra) + end + return ("HTTP %d from %s"):format(status, url) + end + local why = (noise or ""):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + if why == "" then why = "no response" end + if #why > 160 then why = why:sub(1, 157) .. "..." end + return ("fetch failed for %s: %s"):format(url, why) +end + -- Shell quoting for one curl argument; cmd.exe has no single-quote form. function HostShell.quote(s) s = tostring(s) @@ -167,12 +272,21 @@ function HostShell.quote(s) return "'" .. s:gsub("'", "'\\''") .. "'" end +-- MEMOISED per Lua state (so once per thread). This used to spawn a whole +-- `curl --version` process on every single fetch -- twice for a GET through +-- the Android-bridge fallback -- which doubled the number of spawns the lock +-- above has to serialise, for an answer that cannot change while the app is +-- running. +local curlAvailable = nil + function HostShell.haveCurl() + if curlAvailable ~= nil then return curlAvailable end local pipe = HostShell.popen("curl --version") - if not pipe then return false end + if not pipe then curlAvailable = false return false end local readOk, out = pcall(function() return pipe:read("*a") end) - pcall(function() pipe:close() end) - return readOk and out ~= nil and out:find("curl", 1, true) ~= nil + HostShell.pclose(pipe) + curlAvailable = readOk and out ~= nil and out:find("curl", 1, true) ~= nil + return curlAvailable end -- An older mobile build reports nil here and falls back to the "no transport" @@ -217,11 +331,24 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime) if accept then cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " end - cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " " .. HostShell.quote(url) + cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " " + .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " + .. HostShell.quote(url) .. " 2>&1" local pipe = HostShell.popen(cmd) if not pipe then return nil, "could not start download" end - pcall(function() pipe:read("*a") end) - pcall(function() pipe:close() end) + local readOk, out = pcall(function() return pipe:read("*a") end) + HostShell.pclose(pipe) + -- The file is still what the caller judges success by (-f writes nothing + -- on an HTTP error, and the callers all check the file anyway). The + -- status is here purely so the failure can NAME itself: "download failed" + -- with no URL and no code is the report this whole change exists to fix. + local body, status, noise = splitCurlOutput(readOk and out or "") + if status and (status < 200 or status >= 300) then + return nil, fetchError(url, status, body) + end + if not status and (noise or ""):match("%S") then + return nil, fetchError(url, nil, noise) + end return true end if not haveBridge() then @@ -229,7 +356,7 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime) end local ok, done = pcall(love.system.httpDownload, url, absPath, userAgent, accept) if ok and done then return true end - return nil, "download failed" + return nil, "download failed for " .. url end -- GET returning the body. curl streams it through a pipe; the Android bridge @@ -239,20 +366,32 @@ function HostShell.httpGet(url, userAgent, accept, maxTime) if type(url) ~= "string" or url == "" then return nil, "missing url" end userAgent = userAgent or "gen1recomp" if HostShell.haveCurl() then - local cmd = ("curl -fsSL --connect-timeout 10 --max-time %d ") + -- No -f here (the download branch keeps it). -f suppresses the error + -- BODY, and on the two services this talks to that body is the whole + -- diagnosis: GitHub's 403 says "API rate limit exceeded for ", which + -- tells a user to wait rather than to go hunting for a broken index. + local cmd = ("curl -sSL --connect-timeout 10 --max-time %d ") :format(tonumber(maxTime) or 40) .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " if accept then cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " end - cmd = cmd .. HostShell.quote(url) + cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " + .. HostShell.quote(url) .. " 2>&1" local pipe = HostShell.popen(cmd) if not pipe then return nil, "could not run curl" end local readOk, out = pcall(function() return pipe:read("*a") end) - pcall(function() pipe:close() end) - if not readOk then return nil, "fetch failed: " .. tostring(out) end - if not out or out == "" then return nil, "empty response from " .. url end - return out + HostShell.pclose(pipe) + if not readOk then + return nil, fetchError(url, nil, tostring(out)) + end + local body, status, noise = splitCurlOutput(out) + if not status then return nil, fetchError(url, nil, noise) end + if status < 200 or status >= 300 then + return nil, fetchError(url, status, body) + end + if body == "" then return nil, "empty response from " .. url end + return body end if not haveBridge() then return nil, "no network transport on this platform" diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 45292922..aba010b6 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -62,7 +62,7 @@ local FILTERS = { "OFF", "1X", "2X", "3X" } -- The core rows. Helper modules are required lazily under pcall: they are -- pure label/cycle tables, but the launcher must never die because a render -- module grew a dependency on live game data. -local function coreRows(opts) +local function coreRows(opts, hooks) local rows = {} local function add(label, value, step) rows[#rows + 1] = { label = label, value = value, step = step } @@ -257,6 +257,26 @@ local function coreRows(opts) end end + -- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a + -- button on the game panel, once per game -- but the overlay layout is + -- global (options.touchControls.layouts), so three tabs offered three + -- buttons that edited the same thing while crowding the column that has to + -- hold Play. It belongs with the other control rows, behind the gear. + -- The host owns the editor screen, so the row only fires when a hook was + -- supplied (the standalone save editor opens this model with none). + if hooks and hooks.editTouchControls then + rows[#rows + 1] = { + label = Strings("TOUCH CONTROLS"), + actionLabel = Strings("Edit"), + action = function() + hooks.editTouchControls() + -- The editor replaces the whole screen: nothing left to persist here + -- beyond what the caller already saved on the way out. + return false + end, + } + end + -- RESET REBINDS, directly under the touch-pad row. Rebinds are additive -- (src/core/Input.lua:applyBindings layers options.bindings over the -- defaults rather than replacing them), so a player who has bound @@ -417,10 +437,12 @@ end -- sections of rows, and a save() that persists it. The caller keeps the -- model for as long as the panel is open; nothing else in the launcher -- writes options while a modal covers it, so the cached table stays true. -function LauncherSettings.open() +-- `hooks` carries the host actions a row cannot perform itself: +-- editTouchControls() -- hand the screen to the touch-overlay editor +function LauncherSettings.open(hooks) local opts = SaveData.loadOptions() local sections = { - { title = Strings("OPTIONS"), rows = coreRows(opts) }, + { title = Strings("OPTIONS"), rows = coreRows(opts, hooks) }, } for _, mod in ipairs(discoverModSchemas(opts)) do local rows = modRows(opts, mod) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 6b9e5c9b..c1732e6a 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -271,6 +271,45 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac end end +-- A square icon control: the header's gear and quit, and the game panel's +-- manage button. Inverts to a solid white fill when hot, the same signal +-- every other control here uses, and rounds to the shared control radius. +-- `image` draws a texture; `drawFn(x, y, size, hot)` draws a hand-rolled +-- glyph (the quit X, which ships no asset). +local function iconButton(imp, key, x, y, size, image, action, drawFn) + Kit._audit("control", x, y, size, size, key) + local focused = Kit.focusable(key, x, y, size, size) + local hot = focused or Kit.hover(x, y, size, size) + Theme.fillRounded(x, y, size, size, hot and PAL.ink or PAL.surface, 1) + Theme.strokeRounded(x, y, size, size, PAL.line, + hot and Theme.A.focus or Theme.A.hairline, 1) + if image then + local iw, ih = image:getDimensions() + local pad = math.floor(size * 0.24) + local s = math.min((size - 2 * pad) / iw, (size - 2 * pad) / ih) + if hot then love.graphics.setColor(0, 0, 0, 1) + else love.graphics.setColor(1, 1, 1, 0.85) end + love.graphics.draw(image, Theme.snap(x + (size - iw * s) / 2), + Theme.snap(y + (size - ih * s) / 2), 0, s, s) + love.graphics.setColor(1, 1, 1, 1) + elseif drawFn then + drawFn(x, y, size, hot) + end + if action and (Kit.press(x, y, size, size) or Kit._activateId == key) then + queueAction(imp, key, action) + end +end + +-- The cartridge colour for a game, matching its tab in the header. Play +-- wears it, so "which game is this button going to boot" is answered before +-- the label is read. Unknown versions fall back to the commit green. +local CART_COLOR = { + red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold, +} +local function cartColor(version) + return CART_COLOR[version] or PAL.green +end + local function modStatusColor(status) if status == "ok" then return Strings("Ready"), PAL.green end if status == "conflict" then return Strings("Conflict"), PAL.red end @@ -337,15 +376,22 @@ local function buildHeader(imp, m) local rowH = m.logoH + math.floor(12 * m.s) local gear = m.chip - -- The logo is centred in the FULL row, then the right cluster is drawn over - -- its own reserved space, so the wordmark never drifts as buttons appear. - if imp.logo then + -- The wordmark is centred in the row MINUS the right cluster, mirrored on + -- the left so it still reads as centred in the window. Centring it in the + -- FULL row (what this used to do) let a phone-width wordmark run straight + -- under the gear and the quit X -- "the settings is covering the logo". + -- Reserving the space on both sides costs a little width and cannot + -- overlap at any window size. + local clusterW = 2 * gear + math.floor(6 * m.s) + m.pad + local boxX = m.x + clusterW + local boxW = math.max(0, m.w - 2 * clusterW) + if imp.logo and boxW > 0 then local lw, lh = imp.logo:getDimensions() - local maxW = math.min(320 * m.s, m.w * 0.55) + local maxW = math.min(320 * m.s, boxW) local scale = math.min(maxW / lw, m.logoH / lh) local dw, dh = lw * scale, lh * scale love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(imp.logo, Theme.snap(m.x + (m.w - dw) / 2), + love.graphics.draw(imp.logo, Theme.snap(boxX + (boxW - dw) / 2), Theme.snap(y + (rowH - dh) / 2), 0, scale, scale) end @@ -359,47 +405,23 @@ local function buildHeader(imp, m) local quitX = rx - gear rx = quitX - math.floor(6 * m.s) - -- Settings gear. + -- Settings gear. It now also owns the CONTROL settings (touch overlay + -- editor, reset rebinds), which used to be buttons stacked in the game + -- panel -- see LauncherSettings.coreRows. imp._gearIcon = imp._gearIcon or love.graphics.newImage("assets/launcher/gear.png") rx = rx - gear - do - local x = rx - Kit._audit("control", x, by, gear, gear, "gear") - local focused = Kit.focusable("gear", x, by, gear, gear) - local hot = focused or Kit.hover(x, by, gear, gear) - Theme.fill(x, by, gear, gear, hot and PAL.ink or PAL.bg, 1) - Theme.stroke(x, by, gear, gear, PAL.line, - hot and Theme.A.focus or Theme.A.hairline, 1) - local iw, ih = imp._gearIcon:getDimensions() - local pad = math.floor(gear * 0.22) - local s = math.min((gear - 2 * pad) / iw, (gear - 2 * pad) / ih) - if hot then love.graphics.setColor(0, 0, 0, 1) - else love.graphics.setColor(1, 1, 1, 0.85) end - love.graphics.draw(imp._gearIcon, Theme.snap(x + (gear - iw * s) / 2), - Theme.snap(by + (gear - ih * s) / 2), 0, s, s) - love.graphics.setColor(1, 1, 1, 1) - if Kit.press(x, by, gear, gear) or Kit._activateId == "gear" then - queueAction(imp, "gear", function() imp:_openSettings() end) - end - end + iconButton(imp, "gear", rx, by, gear, imp._gearIcon, + function() imp:_openSettings() end) -- Quit, top-right corner. - do - local x = quitX - Kit._audit("control", x, by, gear, gear, "quit") - local focused = Kit.focusable("quit", x, by, gear, gear) - local hot = focused or Kit.hover(x, by, gear, gear) - Theme.fill(x, by, gear, gear, hot and PAL.ink or PAL.bg, 1) - Theme.stroke(x, by, gear, gear, PAL.line, - hot and Theme.A.focus or Theme.A.hairline, 1) - local pad = math.floor(gear * 0.32) - drawCross(x + pad, by + pad, gear - 2 * pad, - hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 }) - if Kit.press(x, by, gear, gear) or Kit._activateId == "quit" then - queueAction(imp, "quit", function() imp:_quitApp() end) - end - end + iconButton(imp, "quit", quitX, by, gear, nil, + function() imp:_quitApp() end, + function(x, y, size, hot) + local pad = math.floor(size * 0.32) + drawCross(x + pad, y + pad, size - 2 * pad, + hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 }) + end) -- The self-update control lives in the FOOTER next to the BCG mark (small, -- out of the wordmark's way -- it used to overlap the logo on a phone). It @@ -438,9 +460,9 @@ local function buildHeader(imp, m) local hot = focused or Kit.hover(tx, ty, w, tabH) local invert = active or hot local tint = t.color or PAL.ink - Theme.fill(tx, ty, w, tabH, invert and tint or PAL.bg, 1) + Theme.fillRounded(tx, ty, w, tabH, invert and tint or PAL.surface, 1) if not invert then - Theme.stroke(tx, ty, w, tabH, tint, + Theme.strokeRounded(tx, ty, w, tabH, tint, t.color and Theme.A.hover or Theme.A.hairline, 1) end -- Ink on a filled tab must contrast with THAT fill: black on the light @@ -503,162 +525,142 @@ end -- ------------------------------------------------------------ game panel --- One card of actions: Re-import ROM on top, the Import/Export save pair --- under it. The old ROM card's caption/filename/"Verified." furniture and --- the SAVE FILES caption are gone -- a ready game shows only the buttons. --- The ROM import STATE lines survive (progress, "Import failed", the no-ROM --- drop hint): while there is no ROM they are the whole story of this card. -local function buildActionsCard(imp, x, y, w, m, version, info, ready, locked, - maxH) +-- What this version's ROM situation is, as a plain table. The panel and the +-- per-game manage modal both read it, so the two can never disagree about +-- whether a ROM is present or what the import button should say. +-- state a headline, or nil when there is nothing to report (ready) +-- detail the paragraph under it +-- label the import button's caption +-- enabled whether that button may be pressed +-- progress 0-1 while an import for THIS version is running +local function romModel(imp, version, info, ready, locked) + local importLabel = imp.isNX and Strings("Scan again") or Strings("Import ROM") + if locked then + return { state = Strings("Not supported yet"), + detail = Strings("Support for this game is on the way."), + label = Strings("Import unavailable"), enabled = false } + end local dropHint = imp.isNX and Strings("Copy the .gb/.gbc via MTP into imports/.") or (imp.android and Strings("Copy the .gb/.gbc via USB.") or Strings("Or drop the .gb/.gbc file here.")) - local importLabel = imp.isNX and Strings("Scan again") or Strings("Import ROM") - local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress - if locked then - romState, romDetail = Strings("Not supported yet"), - Strings("Support for this game is on the way.") - romBtnLabel, romBtnEnabled = Strings("Import unavailable"), false - else - local importing = imp.importing == version - local erroring = imp.workState == "error" and imp.errorVersion == version - local notice = imp.notice and imp.notice.version == version and imp.notice - if importing and (imp.workState == "working" or imp.workState == "complete") then - romState = imp.status or Strings("Importing") - romDetail = imp.detail or "" - romProgress = imp.progress or 0 - elseif ready then - romBtnLabel, romBtnEnabled = Strings("Re-import ROM"), true - elseif erroring then - romState = Strings("Import failed") - romDetail = imp.detail or Strings("That ROM could not be imported.") - romBtnLabel, romBtnEnabled = importLabel, true - elseif notice then - romState = Strings("No ROM imported") - romDetail = ((notice.status or "") .. " " .. (notice.detail or "")) - :gsub("^%s+", ""):gsub("%s+$", "") - romBtnLabel, romBtnEnabled = importLabel, true - elseif imp.returning[version] then - romState = Strings("Update required") - romDetail = Strings("This build needs a few more things from your ") - .. info.label .. Strings(" ROM. Re-import to continue.") - romBtnLabel, romBtnEnabled = Strings("Re-import ROM"), true - else - romState = Strings("No ROM imported") - romDetail = Strings("The ROM is verified before any files are created. ") - .. dropHint - romBtnLabel, romBtnEnabled = importLabel, true - end + local importing = imp.importing == version + local erroring = imp.workState == "error" and imp.errorVersion == version + local notice = imp.notice and imp.notice.version == version and imp.notice + if importing and (imp.workState == "working" or imp.workState == "complete") then + return { state = imp.status or Strings("Importing"), + detail = imp.detail or "", progress = imp.progress or 0 } + elseif erroring then + -- An import that FAILED is reported even on a ready game (a re-import + -- that could not read the new file): the failure is the only reason the + -- library still holds the old cache, and it must not be silent (the + -- "Import failed with no explanation" report). + return { state = Strings("Import failed"), + detail = imp.detail or Strings("That ROM could not be imported."), + label = importLabel, enabled = true } + elseif ready then + return { label = Strings("Re-import ROM"), enabled = true } + elseif notice then + return { state = Strings("No ROM imported"), + detail = ((notice.status or "") .. " " .. (notice.detail or "")) + :gsub("^%s+", ""):gsub("%s+$", ""), + label = importLabel, enabled = true } + elseif imp.returning[version] then + return { state = Strings("Update required"), + detail = Strings("This build needs a few more things from your ") + .. info.label .. Strings(" ROM. Re-import to continue."), + label = Strings("Re-import ROM"), enabled = true } end + return { state = Strings("No ROM imported"), + detail = Strings("The ROM is verified before any files are created. ") + .. dropHint, + label = importLabel, enabled = true } +end - local sfImportEnabled, sfExportEnabled = false, false - if not locked then - imp:_ensureSlots(version) - sfImportEnabled = ready and true or false - local activeId = imp.activeSlot[version] - for _, sl in ipairs(imp.slots[version] or {}) do - if sl.id == activeId and sl.exists then sfExportEnabled = true break end - end +-- The import action behind whichever button carries it. +local function romAction(imp, version, mdl) + if not mdl.enabled then return nil end + return function() + if imp.ready[version] then imp:reimport(version) + else imp:choose(version) end end - local sfNotice = (not locked) and imp.saveNotice[version] or nil - local hintText, hintCol - if sfNotice then - hintText, hintCol = sfNotice.text, (sfNotice.ok and PAL.green or PAL.red) - elseif locked then - hintText, hintCol = Strings("Not available yet."), PAL.muted - else - hintText, hintCol = imp:_savesDefaultHint(version), PAL.muted - end - local savImportLabel = imp.isNX and Strings("Scan again") or Strings("Import save") +end +-- The ROM card: the state headline, its paragraph, and the Import button. +-- It exists ONLY while there is something to report -- a game with a verified +-- ROM shows Play, not a card of file management (that moved behind the manage +-- button next to Play, and the save file controls moved into the slot card). +-- Returns the height it consumed, 0 when it drew nothing. +local function buildRomCard(imp, x, y, w, m, version, mdl, maxH) + if not (mdl.state or mdl.progress) then return 0 end local pad = math.floor(14 * m.s) local iw = w - 2 * pad local lineH = Kit.textHeight("small") - local folderRow = sfNotice and sfNotice.dir - -- The buttons and pads are fixed furniture that always fits; the two text - -- runs are the elastic part. ROM detail lines come first (they only exist - -- while there is an import state to explain), the save hint takes whatever - -- lines remain. Without this the card overflowed its budget and got - -- clipped mid-button -- the failure a no-scroll layout must design out. - local fixedH = pad + m.btnH + math.floor(10 * m.s) + m.btnH - + math.floor(8 * m.s) + pad - + (folderRow and (math.floor(6 * m.s) + lineH) or 0) - local stateHeadH = romState - and (Kit.textHeight("button") + math.floor(4 * m.s) + math.floor(10 * m.s)) - or 0 - local detailLines = romState and 3 or 0 - local hintLines = 3 + local hasButton = mdl.progress == nil and mdl.label ~= nil + -- Pads and the button are fixed furniture that always fits; the detail + -- paragraph is the elastic part and gets trimmed to whatever lines the + -- budget leaves. Without that trim the card overflowed and got clipped + -- mid-button, which is the failure a no-scroll layout must design out. + local fixedH = pad + Kit.textHeight("button") + math.floor(4 * m.s) + + math.floor(10 * m.s) + + ((hasButton or mdl.progress) and (m.btnH + math.floor(2 * m.s)) or 0) + + pad + local detailLines = 3 if maxH then - local room = math.floor((maxH - fixedH - stateHeadH) / lineH) - detailLines = math.max(0, math.min(detailLines, room)) - hintLines = math.max(0, math.min(hintLines, room - detailLines)) + detailLines = math.max(0, + math.min(detailLines, math.floor((maxH - fixedH) / lineH))) end - local detailH = romState - and Kit.wrapHeight("small", romDetail, iw, detailLines) or 0 - local hintH = Kit.wrapHeight("small", hintText, iw, hintLines) - local h = fixedH + stateHeadH + detailH + hintH + local detailH = Kit.wrapHeight("small", mdl.detail or "", iw, detailLines) + local h = fixedH + detailH Kit.card(x, y, w, h) local cy = y + pad - if romState then - Kit.text("button", Kit.ellipsize("button", romState, iw), x + pad, cy, - PAL.heading) - cy = cy + Kit.textHeight("button") + math.floor(4 * m.s) - cy = cy + Kit.textWrapped("small", romDetail, x + pad, cy, iw, PAL.detail, - detailLines) - cy = cy + math.floor(10 * m.s) - end - if romProgress ~= nil then + Kit.text("button", Kit.ellipsize("button", mdl.state or "", iw), x + pad, cy, + PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(4 * m.s) + cy = cy + Kit.textWrapped("small", mdl.detail or "", x + pad, cy, iw, + PAL.detail, detailLines) + cy = cy + math.floor(10 * m.s) + if mdl.progress ~= nil then Kit.progress(x + pad, cy + (m.btnH - math.floor(10 * m.s)) / 2, iw, - math.floor(10 * m.s), romProgress) - else - btn(imp, x + pad, cy, iw, m.btnH, "rom-" .. version, romBtnLabel, { - kind = "accent", - enabled = romBtnEnabled, - action = romBtnEnabled and function() - if imp.ready[version] then imp:reimport(version) - else imp:choose(version) end - end or nil, + math.floor(10 * m.s), mdl.progress) + elseif hasButton then + btn(imp, x + pad, cy, iw, m.btnH, "rom-" .. version, mdl.label, { + kind = "accent", enabled = mdl.enabled, + action = romAction(imp, version, mdl), }) end - cy = cy + m.btnH + math.floor(10 * m.s) - local gap = math.floor(10 * m.s) - local halfW = math.floor((iw - gap) / 2) - btn(imp, x + pad, cy, halfW, m.btnH, "sav-import-" .. version, savImportLabel, { - kind = "accent", enabled = sfImportEnabled, - action = sfImportEnabled and function() imp:chooseSaveImport(version) end or nil, - }) - btn(imp, x + pad + halfW + gap, cy, halfW, m.btnH, "sav-export-" .. version, - Strings("Export save"), { - kind = "accent", enabled = sfExportEnabled, - action = sfExportEnabled and function() imp:exportSave(version) end or nil, - }) - cy = cy + m.btnH + math.floor(8 * m.s) - cy = cy + Kit.textWrapped("small", hintText, x + pad, cy, iw, hintCol, - hintLines) - if folderRow then - cy = cy + math.floor(6 * m.s) - local key = "sav-folder-" .. version - local label = Strings("Open folder") - local lw = Kit.textWidth("small", label) - local lh = Kit.textHeight("small") - Kit.focusable(key, x + pad, cy, lw, lh) - Kit.text("small", label, x + pad, cy, PAL.blue) - Theme.fill(x + pad, cy + lh - 1, lw, 1, PAL.blue, 0.6) - if Kit.press(x + pad, cy, lw, lh) or Kit._activateId == key then - local dir = sfNotice.dir - queueAction(imp, key, function() - love.system.openURL(imp:fileUrl(dir)) - end) - end - end return h end -- Save slots, PAGINATED. This was a fixed-height scroller with momentum; it -- is now a page of rows sized to whatever height the column has left, which -- is why 40 slots cost exactly what 4 do. -local function buildSlotCard(imp, x, y, w, availH, m, version) +-- Lay a row's action chips out right-aligned, wrapping onto further lines +-- when they cannot all fit across the row. A narrow window (the 150%-scaled +-- desktop and the portrait phone in the reports) could not fit four chips on +-- one line, and a fixed right-to-left cluster simply walked them off the left +-- edge and under the row's own text. Returns an array of lines, each an +-- array of chips, so the caller can size the row BEFORE drawing it. +local function chipLines(chips, inner, gap) + local lines, line, used = {}, {}, 0 + for _, c in ipairs(chips) do + if #line > 0 and used + gap + c.w > inner then + lines[#lines + 1] = line + line, used = {}, 0 + end + used = used + ((#line > 0) and gap or 0) + c.w + line[#line + 1] = c + end + if #line > 0 then lines[#lines + 1] = line end + return lines +end + +-- The width a chip needs for its caption, at the row-chip font. +local function chipWidth(label, m) + return Kit.textWidth("small", label) + math.floor(20 * m.s) +end + +local function buildSlotCard(imp, x, y, w, availH, m, version, ready) imp:_ensureSlots(version) local slots = imp.slots[version] or {} local active = imp.activeSlot[version] @@ -667,17 +669,71 @@ local function buildSlotCard(imp, x, y, w, availH, m, version) local iw = w - 2 * pad local gap = math.floor(8 * m.s) - -- A slot row: name + LOADED tag, meta line, action buttons. + -- A slot row: name + LOADED tag, meta line, then the action chips. The + -- chip set is measured against the WIDEST possible row (every chip present) + -- so every row on the page is the same height even though an empty slot + -- offers fewer -- pagination derives its row count from a uniform height. local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s)) - local rowH = math.floor(8 * m.s) + Kit.textHeight("button") - + math.floor(4 * m.s) + Kit.textHeight("small") - + math.floor(8 * m.s) + chipH + math.floor(8 * m.s) + local rowInner = iw - math.floor(20 * m.s) + local maxChips = { + { w = chipWidth(Strings("Export"), m) }, + { w = chipWidth(Strings("Rename"), m) }, + { w = chipWidth(Strings("Edit"), m) }, + -- Delete's width is pinned to the WIDER of its two captions so arming to + -- "Sure?" never reflows the row under the pointer (#433). + { w = math.max(chipWidth(DELETE_LABEL(false), m), + chipWidth(DELETE_LABEL(true), m)) }, + } + local chipGap = math.floor(6 * m.s) + local maxChipsW = 0 + for i, c in ipairs(maxChips) do + maxChipsW = maxChipsW + c.w + ((i > 1) and chipGap or 0) + end + -- BESIDE the text when the row is wide enough to hold both and still leave + -- the name and meta lines a readable share, UNDER it when it is not. A + -- desktop row costs one text block instead of a text block plus a button + -- strip, which is what lets a two-column window show several slots per page + -- instead of one; a phone row keeps the taller shape rather than squeezing + -- four chips and a name into one line. + local textH = Kit.textHeight("button") + math.floor(4 * m.s) + + Kit.textHeight("small") + -- The threshold is what the TEXT needs, not a fraction of the row: a slot + -- name plus its badges/time/dex line wants about this much before it starts + -- ellipsizing anything a player came to read. + local textMinW = math.floor(150 * m.s) + local sideBySide = + (rowInner - maxChipsW - math.floor(12 * m.s)) >= textMinW + local chipRowCount = #chipLines(maxChips, rowInner, chipGap) + local chipBlockH = chipRowCount * chipH + + math.max(0, chipRowCount - 1) * chipGap + local rowH + if sideBySide then + rowH = math.floor(8 * m.s) + math.max(textH, chipH) + math.floor(8 * m.s) + else + rowH = math.floor(8 * m.s) + textH + math.floor(8 * m.s) + chipBlockH + + math.floor(8 * m.s) + end - local headH = Kit.textHeight("caption") + math.floor(8 * m.s) + -- The header carries "Import save": a .sav import CREATES a slot, so it + -- belongs to the slot list rather than to the ROM card it used to sit in. + local headH = math.max(Kit.textHeight("caption"), m.btnH) + math.floor(8 * m.s) local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local newBtnH = m.btnH + local sfNotice = imp.saveNotice[version] + local hintText, hintCol + if sfNotice then + hintText, hintCol = sfNotice.text, (sfNotice.ok and PAL.green or PAL.red) + else + hintText, hintCol = nil, PAL.muted + end + local hintH = hintText + and (Kit.wrapHeight("small", hintText, iw, 2) + math.floor(8 * m.s)) or 0 + local folderRow = sfNotice and sfNotice.dir + if folderRow then hintH = hintH + Kit.textHeight("small") + math.floor(4 * m.s) end + -- Rows get whatever is left after the card's fixed furniture. - local listH = availH - (pad * 2 + headH + pagerH + gap + newBtnH + gap) + local listH = availH + - (pad * 2 + headH + hintH + pagerH + gap + newBtnH + gap) local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 12) local pageKey = "slots-" .. version local first, last, cur, pages = Kit.pageBounds(page(imp, pageKey), n, perPage) @@ -686,14 +742,28 @@ local function buildSlotCard(imp, x, y, w, availH, m, version) local shown = math.max(0, last - first + 1) local usedListH = (n == 0) and math.floor(70 * m.s) or (shown * rowH + math.max(0, shown - 1) * gap) - local h = pad + headH + usedListH + gap + local h = pad + headH + usedListH + gap + hintH + (pages > 1 and (pagerH + gap) or 0) + newBtnH + pad Kit.card(x, y, w, h) local cy = y + pad - Kit.caption(x + pad, cy, Strings("SAVE SLOT")) - Kit.textRight("small", n == 1 and Strings("1 slot") or Strings("%d slots", n), - x + w - pad, cy, PAL.muted) + local capY = cy + math.floor((m.btnH - Kit.textHeight("caption")) / 2) + Kit.caption(x + pad, capY, Strings("SAVE SLOT")) + local savImportLabel = imp.isNX and Strings("Scan again") + or Strings("Import save") + local impW = chipWidth(savImportLabel, m) + math.floor(8 * m.s) + btn(imp, x + w - pad - impW, cy, impW, m.btnH, "sav-import-" .. version, + savImportLabel, { + kind = "accent", font = "small", enabled = ready and true or false, + action = ready and function() imp:chooseSaveImport(version) end or nil, + }) + local countW = (x + w - pad - impW - math.floor(8 * m.s)) + - (x + pad + Kit.captionWidth(Strings("SAVE SLOT")) + math.floor(8 * m.s)) + if countW > 0 then + Kit.textRight("small", + n == 1 and Strings("1 slot") or Strings("%d slots", n), + x + w - pad - impW - math.floor(8 * m.s), capY, PAL.muted) + end cy = cy + headH if n == 0 then @@ -715,17 +785,23 @@ local function buildSlotCard(imp, x, y, w, availH, m, version) local px = x + pad + math.floor(10 * m.s) local inner = iw - math.floor(20 * m.s) + -- Beside the chips, the text block only owns what they leave; under + -- them it owns the row. Either way the width is fixed before anything + -- prints, so the name ellipsizes into its own space rather than into + -- a button. + local textW = sideBySide + and (inner - maxChipsW - math.floor(12 * m.s)) or inner local ly = ry + math.floor(8 * m.s) + + (sideBySide and math.floor((math.max(textH, chipH) - textH) / 2) or 0) local name = slot.label or slot.name or Strings("NEW GAME") local tagW = 0 if selected then tagW = Kit.textWidth("micro", Strings("LOADED")) + math.floor(16 * m.s) - Kit.tag(x + pad + iw - math.floor(10 * m.s) - tagW, ly, - tagW, Kit.textHeight("button"), Strings("LOADED"), - selected and PAL.inverse or PAL.green) + Kit.tag(px + textW - tagW, ly, tagW, Kit.textHeight("button"), + Strings("LOADED"), PAL.inverse) tagW = tagW + math.floor(8 * m.s) end - Kit.text("button", Kit.ellipsize("button", name, inner - tagW), px, ly, ink) + Kit.text("button", Kit.ellipsize("button", name, textW - tagW), px, ly, ink) ly = ly + Kit.textHeight("button") + math.floor(4 * m.s) local metaTxt if slot.exists and slot.meta then @@ -734,45 +810,92 @@ local function buildSlotCard(imp, x, y, w, availH, m, version) else metaTxt = Strings("empty slot") end - Kit.text("small", Kit.ellipsize("small", metaTxt, inner), px, ly, + Kit.text("small", Kit.ellipsize("small", metaTxt, textW), px, ly, selected and PAL.inverse or PAL.muted) - ly = ly + Kit.textHeight("small") + math.floor(8 * m.s) + -- Where the chip block starts: centred on the row beside the text, or + -- on its own line under it. + ly = sideBySide and (ry + (rowH - chipBlockH) / 2) + or (ly + Kit.textHeight("small") + math.floor(8 * m.s)) - -- Action chips, right-aligned. A selected row is a white fill, so its - -- chips invert too or they would vanish. - local place = Layout.rightCluster(px, inner, math.floor(6 * m.s)) + -- Action chips, right-aligned and wrapped onto as many lines as the row + -- width needs. Export lives HERE rather than beside the ROM buttons: + -- an export is a property of a slot, so the control belongs on the slot + -- it exports (it selects the row first, since the exporter writes + -- whichever slot is active). local armed = deleteArmed(imp, "slot", slot.id, version) - -- Width pinned to the WIDER of the two captions so arming to "Sure?" - -- never reflows the row under the pointer (#433), and a translation - -- whose "delete" is shorter than its "sure?" is not clipped. - local delW = math.max(Kit.textWidth("small", DELETE_LABEL(false)), - Kit.textWidth("small", DELETE_LABEL(true))) + math.floor(20 * m.s) - btn(imp, place(delW), ly, delW, chipH, rowKey .. "-del", DELETE_LABEL(armed), { - kind = "danger", font = "small", keepArm = true, + local chips = {} + if slot.exists then + chips[#chips + 1] = { label = Strings("Export"), kind = "accent", + key = rowKey .. "-export", + action = function() + imp:_selectSlot(version, slot.id) + imp:exportSave(version) + end } + end + if not imp.android then + chips[#chips + 1] = { label = Strings("Rename"), kind = "accent", + key = rowKey .. "-rename", + action = function() imp:_beginRename(version, slot.id) end } + end + if imp.onEditSave and slot.exists then + chips[#chips + 1] = { label = Strings("Edit"), kind = "accent", + key = rowKey .. "-edit", + action = function() imp.onEditSave(version, slot.id) end } + end + chips[#chips + 1] = { label = DELETE_LABEL(armed), kind = "danger", + keepArm = true, key = rowKey .. "-del", + -- Pinned width, so arming to "Sure?" cannot reflow the cluster. + w = math.max(chipWidth(DELETE_LABEL(false), m), + chipWidth(DELETE_LABEL(true), m)), action = function() imp:pressDelete("slot", slot.id, version, function() imp:_deleteSlot(version, slot.id) end) - end, - }) - if imp.onEditSave and slot.exists then - local ew = Kit.textWidth("small", Strings("Edit")) + math.floor(20 * m.s) - btn(imp, place(ew), ly, ew, chipH, rowKey .. "-edit", Strings("Edit"), { - kind = "accent", font = "small", - action = function() imp.onEditSave(version, slot.id) end, - }) - end - if not imp.android then - local rw = Kit.textWidth("small", Strings("Rename")) + math.floor(20 * m.s) - btn(imp, place(rw), ly, rw, chipH, rowKey .. "-rename", Strings("Rename"), { - kind = "accent", font = "small", - action = function() imp:_beginRename(version, slot.id) end, - }) + end } + for _, c in ipairs(chips) do c.w = c.w or chipWidth(c.label, m) end + for li, line in ipairs(chipLines(chips, inner, chipGap)) do + local total = 0 + for i, c in ipairs(line) do + total = total + c.w + ((i > 1) and chipGap or 0) + end + local cx = px + inner - total + local cly = ly + (li - 1) * (chipH + chipGap) + for _, c in ipairs(line) do + btn(imp, cx, cly, c.w, chipH, c.key, c.label, { + kind = c.kind, font = "small", keepArm = c.keepArm, + action = c.action, + }) + cx = cx + c.w + chipGap + end end end cy = cy + usedListH + gap end + -- The save-file notice (import/export result) lands in this card now that + -- the buttons that produce it do. + if hintText then + cy = cy + Kit.textWrapped("small", hintText, x + pad, cy, iw, hintCol, 2) + if folderRow then + cy = cy + math.floor(4 * m.s) + local key = "sav-folder-" .. version + local label = Strings("Open folder") + local lw = Kit.textWidth("small", label) + local lh = Kit.textHeight("small") + Kit.focusable(key, x + pad, cy, lw, lh) + Kit.text("small", label, x + pad, cy, PAL.blue) + Theme.fill(x + pad, cy + lh - 1, lw, 1, PAL.blue, 0.6) + if Kit.press(x + pad, cy, lw, lh) or Kit._activateId == key then + local dir = sfNotice.dir + queueAction(imp, key, function() + love.system.openURL(imp:fileUrl(dir)) + end) + end + cy = cy + lh + end + cy = cy + math.floor(8 * m.s) + end + if pages > 1 then local newPage = Kit.pager(x + pad, cy, iw, cur, n, perPage, pageKey) setPage(imp, pageKey, newPage) @@ -817,84 +940,57 @@ local function buildGamePanel(imp, x, y, w, availH, m, version) lx, lw, rx2, rw = x, w, x, w end - -- LEFT COLUMN. The controls that must always be reachable -- Play, and the - -- control-reset pair -- are PINNED to the bottom of the column and laid out - -- upward; the informational cards fill downward from the top into whatever - -- is left. Without that pinning the column is a stack whose height depends - -- on how much text the actions card happens to carry, and at a - -- large UI scale on a short window the Play button is what falls off the - -- bottom -- the one thing that must never happen, and with no scrollbar to - -- rescue it. - local playH = math.max(m.btnH, math.floor(52 * m.s)) - local bottom = cy + remaining - local py = bottom - playH - btn(imp, lx, py, lw, playH, "play-" .. version, - ready and (Strings("Play ") .. gameName) - or (locked and Strings("Coming soon") or Strings("Import a ROM to play")), - { - kind = ready and "primary" or "ghost", font = "stat", - enabled = ready, - action = ready and function() imp:play(version) end or nil, - }) - - if imp.controlsNotice then - local nh = Kit.wrapHeight("small", imp.controlsNotice.text, lw, 2) - py = py - nh - math.floor(4 * m.s) - Kit.textWrapped("small", imp.controlsNotice.text, lx, py, lw, - imp.controlsNotice.ok and PAL.green or PAL.red, 2) - end - -- Reset rebinds, directly under the touch controls. Rebinds are additive - -- (Input:applyBindings layers them over the defaults), so there is no - -- in-game way to undo one -- this is the way back. Two-press confirm, - -- same as every other destructive control here. - do - py = py - math.floor(6 * m.s) - m.btnH - local armed = deleteArmed(imp, "rebinds", "all", nil) - btn(imp, lx, py, lw, m.btnH, "reset-rebinds", - armed and Strings("Sure? Reset all rebinds") or Strings("Reset rebinds"), { - kind = "danger", keepArm = true, - action = function() - imp:pressDelete("rebinds", "all", nil, function() - imp:_resetRebinds() - end) - end, - }) - end - if imp.onEditTouchControls then - py = py - math.floor(6 * m.s) - m.btnH - btn(imp, lx, py, lw, m.btnH, "touch-controls", Strings("Touch Controls"), { - kind = "accent", - action = function() imp.onEditTouchControls() end, - }) - end - - -- The actions card fills the space above the pinned block, clipped so a - -- long ROM error message can never paint over the controls below it; it - -- trims its own elastic text to fit. The clip is a backstop, not the - -- mechanism. - local cardsH = py - gap - cy - Kit.pushClip(lx, cy, lw, math.max(0, cardsH)) + -- LEFT COLUMN, laid out DOWNWARD from the top. It used to pin Play and a + -- Touch-Controls/Reset-rebinds pair to the BOTTOM and fill the cards + -- downward into whatever was left, which meant the column's height was + -- whatever its text happened to need -- and on any window shorter than + -- that pile the pinned block simply left the window (Play was measurably + -- off-screen at 1280x720 and on every phone shape). The controls pair has + -- moved behind the gear (they are global settings, not per-game), the ROM + -- and save file management moved into the manage modal and the slot card, + -- and what is left is short enough to lay out top-down and always fit. + local mdl = romModel(imp, version, info, ready, locked) local ly = cy - -- In one column the save-slot card shares this region, so the actions - -- card gets a bounded share of it rather than the whole thing. - local infoBudget = m.twoCol and cardsH or math.floor(cardsH * 0.42) - local actH = buildActionsCard(imp, lx, ly, lw, m, version, info, ready, - locked, infoBudget) - ly = ly + actH + gap - Kit.popClip() - -- Save slots. Two columns put them beside the info cards; ONE column - -- stacks them underneath, in the space between those cards and the pinned - -- controls at the bottom. Placing them after the pinned block (the - -- obvious reading of "stack it under the left column") drew them off the - -- bottom of the window and over the footer, with no scrollbar to reach - -- them -- in a no-scroll layout, anything below the fold is simply gone. + if ready then + -- Play IS the panel: it takes the space the ROM buttons used to hold, at + -- the top of the column where the eye lands, wearing this game's own + -- cartridge colour rather than a generic green. + -- Play grows into the room the column has: it is the one thing on this + -- screen the player came for, and the space freed by moving ROM and + -- control management out belongs to it rather than to a gap. Clamped at + -- both ends so a short window still gets a real button and a tall one + -- does not get a billboard. + local playH = math.floor(clamp(remaining * 0.30, 64 * m.s, 132 * m.s)) + local mgW = playH + local bgap = math.floor(8 * m.s) + btn(imp, lx, ly, lw - mgW - bgap, playH, "play-" .. version, + Strings("Play ") .. gameName, { + fill = cartColor(version), ink = PAL.inverse, font = "stat", + action = function() imp:play(version) end, + }) + imp._gearIcon = imp._gearIcon + or love.graphics.newImage("assets/launcher/gear.png") + iconButton(imp, "manage-" .. version, lx + lw - mgW, ly, playH, + imp._gearIcon, function() imp._gameManage = version end) + ly = ly + playH + gap + end + + -- The ROM card, which now only exists while there is something to report: + -- no ROM, a failed import, an import in flight, or an unsupported game. + local romH = buildRomCard(imp, lx, ly, lw, m, version, mdl, + m.twoCol and remaining or math.floor(remaining * 0.5)) + if romH > 0 then ly = ly + romH + gap end + + -- Save slots. Two columns put them beside the left stack; ONE column + -- stacks them underneath. Either way the card is clipped to the room it + -- actually has, and sizes its own list to that budget. if not locked then local slotY = m.twoCol and cy or ly - local slotAvail = m.twoCol and remaining or (py - gap - ly) + local slotAvail = m.twoCol and remaining or (cy + remaining - ly) if slotAvail > 80 * m.s then Kit.pushClip(rx2, slotY, rw, math.max(0, slotAvail)) - buildSlotCard(imp, rx2, slotY, rw, slotAvail, m, version) + buildSlotCard(imp, rx2, slotY, rw, slotAvail, m, version, ready) Kit.popClip() end end @@ -1457,7 +1553,12 @@ end -- z-ordered hit test, so this ordering IS the z-order. local function modalPanel(m, w, h) - Theme.fill(0, 0, m.W, m.H, PAL.bg, 0.82) + -- A near-opaque scrim, not a tint. At 0.82 the header and the wordmark + -- still read through the settings panel and the screen looked like two + -- layouts fighting rather than one panel on top ("the settings is covering + -- the logo"); at this weight the page behind is present but plainly out of + -- play, which is what a modal is supposed to say. + Theme.fill(0, 0, m.W, m.H, PAL.bg, 0.93) Kit.blockClicks = true local pw = math.floor(math.min(w, m.W - 2 * m.pad)) local ph = math.floor(math.min(h, m.H - 2 * m.pad)) @@ -1930,6 +2031,73 @@ local function buildFindEntryModal(imp, m) action = function() imp._findEntry = nil end }) end +-- Per-game file management, behind the manage button beside Play. A ready +-- game's panel is Play and its saves; everything episodic about the FILES -- +-- swapping the ROM out, finding them on disk -- lives here instead of taking +-- two permanent buttons out of a column that has to fit on a phone. +local function buildGameManageModal(imp, m) + local version = imp._gameManage + local info = GameVersion.info(version) + local ready = imp.ready[version] or false + local mdl = romModel(imp, version, info, ready, info == nil) + local gameName = info and (info.launcherName or info.displayName) + or tostring(version) + local saveDir = love.filesystem.getSaveDirectory + and love.filesystem.getSaveDirectory() or nil + -- The folder link is desktop-only: Android and NX have no browsable path to + -- open, and both already print their own transfer hint on the slot card. + local canOpenFolder = saveDir and not imp.android and not imp.isNX + + local pad = math.floor(18 * m.s) + local w = math.floor(460 * m.s) + local gap = math.floor(8 * m.s) + local bodyW = w - 2 * pad + local detailH = Kit.wrapHeight("small", + mdl.detail or Strings("The ROM for this game is imported and verified."), + bodyW, 3) + local pathH = saveDir + and (Kit.textHeight("micro") + math.floor(8 * m.s)) or 0 + local nBtns = 1 + (canOpenFolder and 1 or 0) + 1 + local h = pad + Kit.textHeight("button") + math.floor(8 * m.s) + detailH + + math.floor(12 * m.s) + pathH + nBtns * (m.btnH + gap) - gap + pad + local px, py, pw = modalPanel(m, w, h) + local cy = py + pad + + Kit.text("button", Kit.ellipsize("button", + Strings("Manage ") .. gameName, pw - 2 * pad), px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(8 * m.s) + cy = cy + Kit.textWrapped("small", + mdl.detail or Strings("The ROM for this game is imported and verified."), + px + pad, cy, pw - 2 * pad, mdl.state and PAL.detail or PAL.green, 3) + cy = cy + math.floor(12 * m.s) + if saveDir then + -- Truncated from the LEFT: the tail of a save path is the part that + -- identifies it. + Kit.text("micro", Kit.ellipsizeLeft("micro", saveDir, pw - 2 * pad), + px + pad, cy, PAL.faint) + cy = cy + Kit.textHeight("micro") + math.floor(8 * m.s) + end + + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "manage-rom", + mdl.label or Strings("Re-import ROM"), { + kind = "accent", font = "small", enabled = mdl.enabled ~= false, + action = (mdl.enabled ~= false) and function() + imp._gameManage = nil + local fn = romAction(imp, version, mdl) + if fn then fn() end + end or nil }) + cy = cy + m.btnH + gap + if canOpenFolder then + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "manage-folder", + Strings("Open folder"), { kind = "accent", font = "small", + action = function() love.system.openURL(imp:fileUrl(saveDir)) end }) + cy = cy + m.btnH + gap + end + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "manage-close", + Strings("Close"), { font = "small", + action = function() imp._gameManage = nil end }) +end + local function buildSettingsModal(imp, m) local model = imp._settings local pad = math.floor(18 * m.s) @@ -1944,10 +2112,12 @@ local function buildSettingsModal(imp, m) Strings("Close"), { font = "small", action = function() imp:_closeSettings() end }) cy = cy + math.max(Kit.textHeight("stat"), m.btnH) + math.floor(6 * m.s) - Kit.text("micro", Strings( + -- WRAPPED, not printed flat: on a portrait panel this line ran straight off + -- the right edge and the sentence ended mid-word at the card border. + cy = cy + Kit.textWrapped("micro", Strings( "Saved to your options file; the game applies these on its next start."), - px + pad, cy, PAL.muted) - cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s) + px + pad, cy, pw - 2 * pad, PAL.muted, 2) + + math.floor(10 * m.s) -- Settings rows are PAGINATED, flattened across sections so a page is a -- uniform run of rows. Section titles ride along as their own entry. @@ -1962,8 +2132,39 @@ local function buildSettingsModal(imp, m) end imp._settingsFlat = flat end + -- The widest label in the whole model decides the row shape (below), so it + -- is measured once per model rather than per row per frame. Measuring the + -- WIDEST rather than each row keeps every row the same height, which is + -- what lets the list paginate off a uniform row. + if not flat.labelW or flat.labelFont ~= Kit.fonts.scale then + local widest = 0 + for _, item in ipairs(flat) do + if item.row then + widest = math.max(widest, Kit.textWidth("small", item.row.label)) + end + end + flat.labelW, flat.labelFont = widest, Kit.fonts.scale + end - local rowH = math.max(Kit.tapMin(), math.floor(36 * m.s)) + local stepW = math.floor(34 * m.s) + local valW = math.floor(140 * m.s) + local inner = pw - 2 * pad - math.floor(24 * m.s) + -- STACKED ROWS. Side by side, a row spends most of its width on the value + -- ladder and leaves the label whatever remains -- on a portrait phone that + -- was three characters and an ellipsis ("TEX...", "BAT...", "BAT..."), so + -- the panel listed a dozen settings none of which could be identified. + -- When the widest label does not fit beside its control, every row puts the + -- label on its own line ABOVE the control instead. All-or-nothing, because + -- a list that switches shape row by row is harder to scan than either form. + local stacked = flat.labelW + > (inner - 2 * stepW - valW - math.floor(24 * m.s)) + local rowH + if stacked then + rowH = Kit.textHeight("small") + math.floor(4 * m.s) + m.btnH + + math.floor(10 * m.s) + else + rowH = math.max(Kit.tapMin(), math.floor(36 * m.s)) + end local gap = math.floor(4 * m.s) local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local listH = (py + ph - pad) - cy - pagerH - math.floor(8 * m.s) @@ -1989,19 +2190,34 @@ local function buildSettingsModal(imp, m) else local row = item.row local key = "set-" .. i - Theme.stroke(px + pad, ry, pw - 2 * pad, rowH, PAL.line, + Theme.strokeRounded(px + pad, ry, pw - 2 * pad, rowH, PAL.line, Theme.A.hairline, 1) local ix = px + pad + math.floor(12 * m.s) - local inner = pw - 2 * pad - math.floor(24 * m.s) - local ly = ry + (rowH - Kit.textHeight("small")) / 2 + -- Where the label prints, and where the control band starts. Stacked: + -- label on its own full-width line, controls on the line below it. + -- Inline: both centred on one line, label left, controls right. + local labelY, ctlY, labelW + if stacked then + labelY = ry + math.floor(6 * m.s) + ctlY = labelY + Kit.textHeight("small") + math.floor(4 * m.s) + labelW = inner + else + labelY = ry + (rowH - Kit.textHeight("small")) / 2 + ctlY = ry + (rowH - m.btnH) / 2 + labelW = nil -- per-shape below: what the controls leave over + end + local rx = ix + inner + if row.editText then local ew = Kit.textWidth("small", Strings("Edit")) + math.floor(20 * m.s) local vw = math.floor(160 * m.s) Kit.text("small", Kit.ellipsize("small", row.label, - inner - ew - vw - math.floor(20 * m.s)), ix, ly, PAL.text) + labelW or (inner - ew - vw - math.floor(20 * m.s))), + ix, labelY, PAL.text) Kit.textRight("small", Kit.ellipsize("small", tostring(row.value()), vw), - ix + inner - ew - math.floor(10 * m.s), ly, PAL.detail) - btn(imp, ix + inner - ew, ry + (rowH - m.btnH) / 2, ew, m.btnH, + rx - ew - math.floor(10 * m.s), + ctlY + (m.btnH - Kit.textHeight("small")) / 2, PAL.detail) + btn(imp, rx - ew, ctlY, ew, m.btnH, key .. "-edit", Strings("Edit"), { kind = "accent", font = "small", action = function() imp._settingsText = { row = row, text = tostring(row.value() or ""), @@ -2009,30 +2225,34 @@ local function buildSettingsModal(imp, m) imp:_armTextInput() end }) elseif row.action then - -- A plain action row (Reset rebinds): the whole right side is one - -- button rather than a value ladder. + -- A plain action row (Reset rebinds, Touch controls): the whole right + -- side is one button rather than a value ladder. local aw = Kit.textWidth("small", row.actionLabel or Strings("Run")) + math.floor(24 * m.s) Kit.text("small", Kit.ellipsize("small", row.label, - inner - aw - math.floor(12 * m.s)), ix, ly, PAL.text) - btn(imp, ix + inner - aw, ry + (rowH - m.btnH) / 2, aw, m.btnH, + labelW or (inner - aw - math.floor(12 * m.s))), ix, labelY, PAL.text) + btn(imp, rx - aw, ctlY, aw, m.btnH, key .. "-act", row.actionLabel or Strings("Run"), { kind = row.danger and "danger" or "ghost", font = "small", action = function() if row.action() ~= false then model.save() end end }) else - local stepW = math.floor(34 * m.s) - local valW = math.floor(140 * m.s) Kit.text("small", Kit.ellipsize("small", row.label, - inner - 2 * stepW - valW - math.floor(24 * m.s)), ix, ly, PAL.text) - local rx = ix + inner - btn(imp, rx - stepW, ry + (rowH - m.btnH) / 2, stepW, m.btnH, + labelW or (inner - 2 * stepW - valW - math.floor(24 * m.s))), + ix, labelY, PAL.text) + -- Stacked rows give the value the whole span between the steppers, + -- which is where the extra width goes now that the label is not + -- competing for it. + local vw = stacked and (inner - 2 * stepW - math.floor(16 * m.s)) + or valW + btn(imp, rx - stepW, ctlY, stepW, m.btnH, key .. "-next", ">", { font = "small", action = function() if row.step and row.step(1) then model.save() end end }) - Kit.textCenter("small", Kit.ellipsize("small", tostring(row.value()), valW), - rx - stepW - valW, ly, valW, PAL.heading) - btn(imp, rx - stepW - valW - stepW, ry + (rowH - m.btnH) / 2, stepW, + Kit.textCenter("small", Kit.ellipsize("small", tostring(row.value()), vw), + rx - stepW - vw, ctlY + (m.btnH - Kit.textHeight("small")) / 2, vw, + PAL.heading) + btn(imp, rx - stepW - vw - stepW, ctlY, stepW, m.btnH, key .. "-prev", "<", { font = "small", action = function() if row.step and row.step(-1) then model.save() end end }) end @@ -2053,7 +2273,7 @@ local function modalUp(imp) or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes or imp._findDetails or imp._modVersions or imp._sortPopup or imp._filterPopup or imp._indexManage or imp._modActions - or imp._findEntry) ~= nil + or imp._findEntry or imp._gameManage) ~= nil end local function buildModals(imp, m) @@ -2130,6 +2350,7 @@ local function buildModals(imp, m) if imp._indexManage then buildIndexesModal(imp, m) return true end if imp._modActions then buildModActionsModal(imp, m) return true end if imp._findEntry then buildFindEntryModal(imp, m) return true end + if imp._gameManage then buildGameManageModal(imp, m) return true end return false end @@ -2200,15 +2421,16 @@ end -- pinned Play block used to walk up over the cards on a short window, which -- is unusable, and the footer simply lives below the fold until scrolled to. local function minPanelHeight(m) - -- One column stacks the actions card, the slot card and the pinned Play - -- block in a single pile, so it needs more room than the side-by-side - -- layout: 460 was tuned for two columns, and on a squat one-column window - -- (a 4:3 device, a phone held upright) it left the slot card clipped - -- inert against the pinned buttons -- Kit's clip bounds hit-testing, so - -- no slot could be picked at all (#852). 660 fits the actions card, one - -- slot row with its pager and New button, and the pinned block; whatever - -- the window cannot show, the page scroll above reaches. - return math.floor((m.twoCol and 460 or 660) * m.s) + -- One column stacks the ROM card and the slot card in a single pile, so it + -- needs more room than the side-by-side layout; two columns only have to + -- fit the taller of the two. Both numbers came DOWN sharply when the + -- pinned Touch-Controls / Reset-rebinds pair moved behind the gear and the + -- save-file buttons moved into the slot card: the pile they used to sit on + -- top of was what forced 460/660 (#852), and a threshold larger than the + -- content pushes the whole page below the fold on windows that could have + -- shown it outright (a 1280x720 desktop was scrolling for 93px of nothing). + -- Whatever a window still cannot show, the page scroll above reaches. + return math.floor((m.twoCol and 340 or 470) * m.s) end function LauncherView.draw(imp) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 0b4570e0..3544c7f4 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -328,7 +328,10 @@ local function commandOutput(command) local pipe = HostShell.popen(command) if not pipe then return nil end local result = pipe:read("*a") - pipe:close() + -- HostShell.pclose, never pipe:close(): closing a pipe outside the spawn + -- lock can free a FILE while a worker thread's popen is walking the stream + -- list, which deadlocks that thread for good (see HostShell). + HostShell.pclose(pipe) result = trim(result) return result ~= "" and result or nil end @@ -1909,6 +1912,12 @@ function RomImporter:update(dt) "Mods are not reviewed - trust the author." }, } end + -- POKEPORT_LAUNCHER_SETTINGS=1 opens the gear panel, the other layout + -- a capture cannot otherwise reach without a click. Pair it with + -- POKEPORT_LAUNCHER_SETTINGS_PAGE to land on a page past the first. + if os.getenv("POKEPORT_LAUNCHER_SETTINGS") == "1" then + self:_openSettings() + end local query = os.getenv("POKEPORT_LAUNCHER_QUERY") if query and query ~= "" then self.findQuery = query @@ -2450,8 +2459,19 @@ end -- ------- settings gear (options.lua + enabled mods' option schemas) function RomImporter:_openSettings() + -- The touch-overlay editor is a host screen, so the model gets it as a + -- hook rather than reaching for main.lua's handler itself. Closing the + -- settings panel FIRST persists the pending edits (_closeSettings saves) + -- and leaves no modal behind the editor to return to. + local hooks = {} + if self.onEditTouchControls then + hooks.editTouchControls = function() + self:_closeSettings() + self.onEditTouchControls() + end + end local ok, model = pcall(function() - return require("src.import.LauncherSettings").open() + return require("src.import.LauncherSettings").open(hooks) end) if ok and model then self._settings = model end end @@ -3334,34 +3354,11 @@ function RomImporter:_pumpFindFetch() self:_clearBusy() end --- Clear every input rebind and the dragged touch-overlay layout, restoring --- the stock keyboard/gamepad bindings. Rebinds are ADDITIVE --- (src/core/Input.lua:applyBindings layers options.bindings over the --- defaults instead of replacing them), so a player who has bound themselves --- into a corner has no in-game way out; this is it. The running game reads --- bindings on its next start, which is the same contract every other --- launcher setting has. -function RomImporter:_resetRebinds() - local ok = pcall(function() - local SaveData = require("src.core.SaveData") - local opts = SaveData.loadOptions() - opts.bindings = nil - if type(opts.touchControls) == "table" then - opts.touchControls.layouts = nil - end - SaveData.saveOptions(opts) - end) - -- Its own notice slot: this button lives on the game panel, and borrowing - -- the mods or save notice would print the result on a tab the user is not - -- looking at. - if ok then - self.controlsNotice = { ok = true, - text = Strings("Controls reset to defaults. Applies on the next start.") } - else - self.controlsNotice = { ok = false, - text = Strings("Could not reset controls.") } - end -end +-- Clearing rebinds used to live here, behind a button on the game panel. It +-- is now the RESET REBINDS row of the settings model +-- (src/import/LauncherSettings.lua), which edits the same options table the +-- rest of that panel does and saves through the same save() -- one control +-- for a setting that was never per-game in the first place. -- ------- busy state (drives the non-dismissable loader overlay) -- Anything that makes the user wait sets this; LauncherView renders it as a @@ -3440,7 +3437,12 @@ function RomImporter:_findThumb(entry) :format(tostring(entry.id):gsub("[^%w%-_]", "_"), ext) local Fetch = require("src.net.Fetch") self._findThumbFetch[entry.id] = { - job = Fetch.download(url, name, { userAgent = "gen1recomp-mod-index" }), + -- A short ceiling on purpose: a page of these is queued at once, and + -- each one's ceiling is part of the worst case for closing the window + -- (Fetch.shutdown). A thumbnail that has not arrived in 15s is not + -- worth holding the process open for -- the card shows its placeholder. + job = Fetch.download(url, name, + { userAgent = "gen1recomp-mod-index", maxSeconds = 15 }), } end return nil diff --git a/src/net/Fetch.lua b/src/net/Fetch.lua index d570146e..370ad1a4 100644 --- a/src/net/Fetch.lua +++ b/src/net/Fetch.lua @@ -29,6 +29,7 @@ local Fetch = {} local CMD = "fetch_cmd" local RESULT = "fetch_result" +local QUIT = "fetch_quit" -- Worker count. Three is enough to overlap the common burst (a mod index -- refresh plus a couple of per-mod release checks) without spawning a thread @@ -36,7 +37,7 @@ local RESULT = "fetch_result" local POOL = 3 local workers = {} -local cmdCh, resCh +local cmdCh, resCh, quitCh local ready -- nil = untried, true = running, false = unavailable local jobs = {} -- id -> { status, body, err, progress, path } local nextId = 0 @@ -50,6 +51,13 @@ local function ensureWorkers() end cmdCh = love.thread.getChannel(CMD) resCh = love.thread.getChannel(RESULT) + quitCh = love.thread.getChannel(QUIT) + -- Channels outlive a pool (they are global to the process, keyed by name), + -- so a pool started after a shutdown -- the save editor opens from a live + -- launcher and hands the screen back -- must clear the previous round's + -- flag and leftovers or its workers quit on their first job. + quitCh:clear() + cmdCh:clear() for i = 1, POOL do local ok, th = pcall(love.thread.newThread, "src/net/fetch_worker.lua") if ok and th and pcall(function() th:start() end) then @@ -114,12 +122,14 @@ local function submit(cmd) end -- GET a URL, returning the body as a string. --- opts: { userAgent, accept } +-- opts: { userAgent, accept, maxSeconds } +-- maxSeconds is the transfer ceiling, and it is also this job's worst-case +-- contribution to how long closing the window takes (see Fetch.shutdown). function Fetch.get(url, opts) opts = opts or {} return submit({ kind = "get", url = url, userAgent = opts.userAgent or "gen1recomp", - accept = opts.accept }) + accept = opts.accept, maxSeconds = opts.maxSeconds }) end -- Download a URL to `saveRel`, a path relative to the LOVE save directory. @@ -129,7 +139,7 @@ function Fetch.download(url, saveRel, opts) return submit({ kind = "download", url = url, dest = saveRel, size = opts.size, userAgent = opts.userAgent or "gen1recomp", - accept = opts.accept }) + accept = opts.accept, maxSeconds = opts.maxSeconds }) end -- Non-blocking status. Returns a table; never nil, even for an unknown id @@ -176,13 +186,28 @@ end -- End every worker. Their command loops sit in Channel:demand(), which never -- returns on its own, and LOVE waits for every live love.thread before the -- process exits (#339). +-- +-- ORDER MATTERS, and getting it wrong is what froze the launcher on close +-- after a visit to the mod tabs. A quit pushed as an ordinary command is +-- just another item in a FIFO the workers are already chewing through: a page +-- of thumbnail downloads sits in front of it, and th:wait() below blocks the +-- main thread until every one of them finishes. So: +-- 1. raise the quit FLAG, which workers check after every demand(), +-- 2. CLEAR the queue -- nobody will read those results, and dropping them +-- is what turns "wait for the backlog" into "wait for what is in flight", +-- 3. push one wake sentinel per worker, because a worker idling inside +-- demand() has nothing to check the flag on until something arrives. +-- What remains is at most one transfer per worker, bounded by the caller's +-- maxSeconds; there is no portable way to interrupt a running curl. function Fetch.shutdown() + if quitCh then quitCh:push(true) end if cmdCh then + cmdCh:clear() for _ = 1, #workers do cmdCh:push({ kind = "quit" }) end end for _, th in ipairs(workers) do pcall(function() th:wait() end) end workers = {} - cmdCh, resCh, ready = nil, nil, false + cmdCh, resCh, quitCh, ready = nil, nil, nil, false end return Fetch diff --git a/src/net/fetch_worker.lua b/src/net/fetch_worker.lua index ad028098..f86d78f1 100644 --- a/src/net/fetch_worker.lua +++ b/src/net/fetch_worker.lua @@ -27,16 +27,27 @@ local HostShell = loadModule("src/core/HostShell.lua") local cmdCh = love.thread.getChannel("fetch_cmd") local resCh = love.thread.getChannel("fetch_result") +-- Raised by Fetch.shutdown BEFORE the wake sentinels go out. A worker checks +-- it after every demand() and drops whatever it just pulled, so a quit does +-- not have to wait its turn behind a queue of jobs nobody will ever read the +-- results of. +local quitCh = love.thread.getChannel("fetch_quit") local saveDir = love.filesystem.getSaveDirectory() -- See the note in doGet: these bound how long a quit can block. A mod index -- or a release list is a small JSON document, and a mod zip is a few MB; the -- old 300s download ceiling was sized for the self-updater's whole payload, --- which does not come through this pool. +-- which does not come through this pool. Callers may pass a shorter one +-- (job.maxSeconds) -- a thumbnail has no business holding the process open +-- for as long as a mod install does. local GET_MAX_SECONDS = 20 local DOWNLOAD_MAX_SECONDS = 90 +local function quitting() + return quitCh:peek() ~= nil +end + local function post(t) resCh:push(t) end local function doGet(job) @@ -48,7 +59,7 @@ local function doGet(job) -- command, and LOVE waits for live threads before exiting (#339), so this -- ceiling is also the worst case for how long closing the window can take. local body, err = HostShell.httpGet(job.url, job.userAgent, job.accept, - GET_MAX_SECONDS) + tonumber(job.maxSeconds) or GET_MAX_SECONDS) if not body then post({ id = job.id, ok = false, err = err or "fetch failed" }) return @@ -74,7 +85,7 @@ local function doDownload(job) love.filesystem.remove(rel) local ok, err = HostShell.httpDownload(job.url, abs, job.userAgent, - job.accept, DOWNLOAD_MAX_SECONDS) + job.accept, tonumber(job.maxSeconds) or DOWNLOAD_MAX_SECONDS) if not ok then post({ id = job.id, ok = false, err = err or "download failed" }) return @@ -90,6 +101,13 @@ end while true do local job = cmdCh:demand() + -- The flag is checked before the job's KIND, so a worker woken by a + -- sentinel abandons whatever real job it happened to pull instead of + -- running it. Without this the quit commands queued behind a page of + -- thumbnail downloads and closing the window blocked for as long as those + -- transfers took -- the launcher froze on close after a visit to the mods + -- tabs, which is exactly what LOVE waiting on live threads looks like. + if quitting() then break end if type(job) == "table" then if job.kind == "quit" then break diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index a127439e..8c423e2e 100644 --- a/src/ui/kit/Kit.lua +++ b/src/ui/kit/Kit.lua @@ -29,8 +29,10 @@ -- 4. Lists PAGINATE. Row count is bounded by the page size, so a 500-mod -- index costs exactly what a 10-mod one does. There is no virtualised -- scroller and no momentum integrator to run. --- 5. Draw flat. No stencil, no mesh, no blend-mode change, no rounded --- corners (see Theme.lua) -- every one is a pipeline flush. +-- 5. Draw flat. No stencil, no mesh, no canvas, no shader, no blend-mode +-- change -- every one is a pipeline flush. Rounded corners, the emboss +-- and a card's drop shadow are allowed because they only add VERTICES at +-- the same pipeline state (see Theme.lua's header for the full rule). -- -- ACCESSIBILITY / INPUT: every control is reachable four ways -- mouse, -- touch (>= 30px targets), keyboard (spatial focus ring, arrows + Enter), @@ -544,8 +546,9 @@ function Kit.row(x, y, w, h, selected, id) -- The focus ring is a second inset outline, so it reads on both a black -- row and a white selected one. if focused then - Theme.stroke(x + 2, y + 2, w - 4, h - 4, - selected and PAL.inverse or PAL.lineStrong, Theme.A.focus, 1) + Theme.strokeRounded(x + 2, y + 2, w - 4, h - 4, + selected and PAL.inverse or PAL.lineStrong, Theme.A.focus, 1, + Theme.radius()) end local clicked = Kit.press(x, y, w, h) or (id ~= nil and Kit._activateId == id) @@ -557,7 +560,7 @@ end -- hairline says the same thing for one rect.) function Kit.emptyBox(x, y, w, h, message) if not G then return end - Theme.stroke(x, y, w, h, PAL.line, 0.22, 1) + Theme.strokeRounded(x, y, w, h, PAL.line, 0.22, 1, Theme.radius()) Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale), x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted) end @@ -596,11 +599,16 @@ local KINDS = { } Kit.KINDS = KINDS --- opts: { kind, font, enabled, align, id, glow } +-- opts: { kind, font, enabled, align, id, glow, fill, ink } -- id -- opts into the focus ring (give every real control one) -- glow -- a pulsing outline for "something is waiting for you" (the -- update button). No blend-mode change: the alpha of the -- existing outline is animated instead. +-- fill/ink -- override the kind's colours. The ONE caller is the +-- launcher's Play button, which wears its cartridge colour +-- (red/blue/gold) rather than a semantic one: on that screen +-- "which game am I launching" outranks "what kind of verb is +-- this", and the colour is already the tab's identity. -- Returns true when activated, by click OR by the focus ring's Enter/A. function Kit.button(x, y, w, h, label, opts) opts = opts or {} @@ -611,6 +619,9 @@ function Kit.button(x, y, w, h, label, opts) local focused = enabled and opts.id and Kit.focusable(opts.id, x, y, w, h) or false local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"] + if enabled and opts.fill then + kind = { fill = opts.fill, ink = opts.ink or PAL.inverse } + end local hot = enabled and Kit.hover(x, y, w, h) if G then @@ -700,12 +711,13 @@ function Kit.checkbox(x, y, w, h, checked, label, id, labelColor) local box = 20 * Kit.scale local bx, by = x + 12 * Kit.scale, y + (h - box) / 2 if G then + local br = math.min(Theme.radius(), box / 3) if checked then - Theme.fill(bx, by, box, box, PAL.ink, 1) + Theme.fillRounded(bx, by, box, box, PAL.ink, 1, br) Kit.textCenter("small", "X", bx, by + (box - Kit.textHeight("small")) / 2, box, PAL.inverse) else - Theme.stroke(bx, by, box, box, PAL.line, Theme.A.hover, 1) + Theme.strokeRounded(bx, by, box, box, PAL.line, Theme.A.hover, 1, br) end local lx = bx + box + 12 * Kit.scale Kit.text("mono", Kit.ellipsize("mono", label, x + w - lx - 10 * Kit.scale), @@ -724,12 +736,14 @@ function Kit.toggle(x, y, w, h, on, id) -- Track, then a knob inset inside it, so the control reads as a switch -- rather than as a white square with a word next to it. The label sits -- in the empty half, which is the half that says what pressing does. - Theme.stroke(x, y, w, h, PAL.line, - (focused or Kit.hover(x, y, w, h)) and Theme.A.focus or Theme.A.hover, 1) + local r = math.min(Theme.radius(), h / 2) + Theme.fillRounded(x, y, w, h, PAL.rowBg, 1, r) + Theme.strokeRounded(x, y, w, h, PAL.line, + (focused or Kit.hover(x, y, w, h)) and Theme.A.focus or Theme.A.hover, 1, r) local inset = 3 local knob = w / 2 - inset - Theme.fill(on and (x + w / 2) or (x + inset), y + inset, knob, h - 2 * inset, - PAL.ink, 1) + Theme.fillRounded(on and (x + w / 2) or (x + inset), y + inset, knob, + h - 2 * inset, PAL.ink, 1, math.min(r, (h - 2 * inset) / 2)) Kit.textCenter("micro", on and "ON" or "OFF", on and x or (x + w / 2), y + (h - Kit.textHeight("micro")) / 2, w / 2, PAL.text) @@ -768,8 +782,8 @@ function Kit.textfield(id, x, y, w, h, value, placeholder) end end if G then - Theme.fill(x, y, w, h, PAL.bg, 1) - Theme.stroke(x, y, w, h, PAL.line, + Theme.fillRounded(x, y, w, h, PAL.bg, 1) + Theme.strokeRounded(x, y, w, h, PAL.line, (focused or focusRing) and Theme.A.focus or Theme.A.hairline, focused and 2 or 1) local pad = 10 * Kit.scale diff --git a/src/ui/kit/Theme.lua b/src/ui/kit/Theme.lua index 7e9c8f26..426f16da 100644 --- a/src/ui/kit/Theme.lua +++ b/src/ui/kit/Theme.lua @@ -1,7 +1,9 @@ --- High-contrast theme shared by the launcher (src/import/LauncherView.lua) --- and the save editor (tools/save-editor/). This replaces the old navy --- gradient look wholesale: black field, white hairline outlines, flat fills, --- no gradients and no glows anywhere. +-- High-contrast theme for the launcher (src/import/LauncherView.lua). The +-- save editor keeps its own tools/save-editor/Theme.lua, whose primitives +-- take a radius where these take a colour -- do not cross-wire them. This +-- replaces the old navy gradient look wholesale: a near-black field with the +-- faintest red cast, cards a few values above it, white hairline outlines, +-- flat fills, no gradients and no glows anywhere. -- -- That is not only a visual choice. Every effect this theme drops was a GPU -- pipeline flush in the old renderer: @@ -9,10 +11,13 @@ -- (G.stencil / setStencilTest / draw(mesh) = 3 state changes per card), -- * glows set blend mode "add", drew 7 stacked rects, then set it back. -- Flat fills with a 1px outline all share one pipeline state, so LOVE batches --- an entire panel into a couple of draw calls. Controls do carry a small --- corner radius and a two-rect emboss, which cost extra vertices but no state --- change -- that is the tier of expense this theme is willing to pay, and the --- tier above it (stencils, meshes, blend modes) is the one it will not. +-- an entire panel into a couple of draw calls. What this theme DOES pay for +-- is extra vertices at the same pipeline state: rounded corners, the +-- two-rect emboss on a control, and the three stacked rounded rects that make +-- a card's drop shadow (Theme.shadow). Vertices are the tier of expense this +-- theme is willing to pay; the tier above it -- stencils, meshes, blend-mode +-- changes, canvases, shaders -- is the one it will not, and a shadow drawn as +-- a blurred canvas would land squarely in it. -- -- Emphasis is carried by INVERSION, not by colour weight: a selected or -- focused control fills white and prints black. That keeps contrast at @@ -25,10 +30,16 @@ local Theme = {} local PAL = { - -- field + surfaces. Only three fills exist in the whole UI. - bg = { 0, 0, 0 }, -- the page, and every card interior - surface = { 0, 0, 0 }, -- cards/rows: same black, told apart by outline - raised = { 20, 20, 20 }, -- the one non-black fill: hover feedback + -- field + surfaces. The field carries a FAINT red cast (a few points of + -- red over an otherwise neutral near-black) and cards sit a few steps above + -- it in the same hue, so a card reads as a raised object rather than as an + -- outline drawn on the page. These are still flat fills -- the depth comes + -- from the value step plus Theme.shadow, not from a gradient. + field = { 16, 8, 10 }, -- the page BEHIND the cards + bg = { 0, 0, 0 }, -- true black: button rests, field interiors + surface = { 28, 21, 24 }, -- card interiors + rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it + raised = { 44, 34, 38 }, -- hover feedback ink = { 255, 255, 255 }, -- the selected/focused fill -- outlines. Two weights only: a hairline for structure, solid for focus. line = { 255, 255, 255 }, -- hairline, drawn at alpha 0.35 @@ -55,7 +66,6 @@ local PAL = { } -- Semantic aliases kept so ported call sites read the same as before. PAL.cardBorder = PAL.line -PAL.rowBg = PAL.surface PAL.greenInk = PAL.inverse PAL.blueInk = PAL.blue PAL.redSoft = PAL.red @@ -106,11 +116,34 @@ function Theme.fill(x, y, w, h, c, a) G.rectangle("fill", snap(x), snap(y), snap(w), snap(h)) end --- Corner radius for controls. Small and fixed: enough to read as a physical --- key rather than a painted rectangle, small enough that the extra --- tessellation is noise next to the rest of the frame. +-- Corner radius for controls. Fixed rather than scaled: LOVE tessellates a +-- rounded rect by radius, so a scale-driven radius would change the vertex +-- count with the window size, and these are the two tiers the design needs. +-- Controls get the smaller one, containers the larger, so a button never +-- looks like a card and a card never looks like a button. function Theme.radius() - return 4 + return 8 +end + +function Theme.cardRadius() + return 14 +end + +-- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider +-- and one step lower than the last -- a cheap falloff that needs no blur, no +-- canvas and no blend-mode change, so it stays inside the pipeline budget the +-- rest of this file is written to. Drawn BEFORE the surface it belongs to, +-- and never for a control (only containers cast one, or the whole screen +-- reads as floating debris). +function Theme.shadow(x, y, w, h, r) + if not G or w <= 0 or h <= 0 then return end + r = r or Theme.cardRadius() + for i = 1, 3 do + local spread = i * 2 + col(PAL.bg, 0.13) + G.rectangle("fill", snap(x - spread), snap(y - spread + i * 3), + snap(w + 2 * spread), snap(h + 2 * spread), r + spread, r + spread) + end end function Theme.fillRounded(x, y, w, h, c, a, r) @@ -139,7 +172,10 @@ function Theme.emboss(x, y, w, h, strength) if not G or w <= 2 or h <= 2 then return end strength = strength or 1 local t = math.max(1, math.floor(h * 0.10)) - local r = Theme.radius() + -- The inset must clear the corner arc, but a narrow control (a stepper, a + -- row chip) is thinner than two radii -- clamp or the highlight rect goes + -- negative-width and vanishes. + local r = math.min(Theme.radius(), math.floor(w / 3)) -- highlight along the top col(PAL.ink, 0.28 * strength) G.rectangle("fill", snap(x) + r, snap(y) + 1, snap(w) - 2 * r, t) @@ -166,35 +202,46 @@ function Theme.stroke(x, y, w, h, c, a, lw) if probe("setLineWidth") then G.setLineWidth(1) end end --- The design's only container: black interior, white hairline. `emphasis` --- raises the outline to full white (used for the focused/active card). +-- The design's only container: a rounded surface a few values above the +-- field, its own drop shadow, and a white hairline. `emphasis` raises the +-- outline to full white (used for the focused/active card). function Theme.card(x, y, w, h, emphasis) - Theme.fill(x, y, w, h, PAL.bg, 1) - Theme.stroke(x, y, w, h, PAL.line, emphasis and Theme.A.focus or Theme.A.hairline, 1) + local r = Theme.cardRadius() + Theme.shadow(x, y, w, h, r) + Theme.fillRounded(x, y, w, h, PAL.surface, 1, r) + Theme.strokeRounded(x, y, w, h, PAL.line, + emphasis and Theme.A.focus or Theme.A.hairline, 1, r) end -- A list row. Three states, each one rect plus one outline: --- normal black fill, hairline --- hover near-black fill, brighter hairline +-- normal one value below the card it sits in, hairline +-- hover lifted fill, brighter hairline -- selected WHITE fill (callers print ink = PAL.inverse over it) function Theme.row(x, y, w, h, state) + local r = Theme.radius() if state == "selected" then - Theme.fill(x, y, w, h, PAL.ink, 1) + Theme.fillRounded(x, y, w, h, PAL.ink, 1, r) return PAL.inverse end - Theme.fill(x, y, w, h, state == "hover" and PAL.raised or PAL.surface, 1) - Theme.stroke(x, y, w, h, PAL.line, - state == "hover" and Theme.A.hover or Theme.A.hairline, 1) + Theme.fillRounded(x, y, w, h, + state == "hover" and PAL.raised or PAL.rowBg, 1, r) + Theme.strokeRounded(x, y, w, h, PAL.line, + state == "hover" and Theme.A.hover or Theme.A.hairline, 1, r) return PAL.text end -- A percentage meter (HP, box fill, dex completion, import progress). --- pct is 0-100. Outline + solid white fill, no rounding. +-- pct is 0-100. Outline + solid fill, rounded to the track's own half-height +-- so a thin bar reads as a capsule instead of a clipped rectangle. function Theme.meter(x, y, w, h, pct, c) if not G then return end - Theme.stroke(x, y, w, h, PAL.line, Theme.A.hairline, 1) + local r = math.min(Theme.radius(), h / 2) + Theme.strokeRounded(x, y, w, h, PAL.line, Theme.A.hairline, 1, r) local fill = (w - 2) * clamp((pct or 0) / 100, 0, 1) - if fill > 0 then Theme.fill(x + 1, y + 1, fill, h - 2, c or PAL.ink, 1) end + if fill > 0 then + Theme.fillRounded(x + 1, y + 1, fill, h - 2, c or PAL.ink, 1, + math.min(r, fill / 2)) + end end -- The 4px tri-colour rail across the top of both windows: the only brand @@ -325,11 +372,14 @@ function Theme.ellipsizeLeft(font, text, maxW) return ell end --- The background: a flat black clear. One call, no mesh, no fan, no --- allocation -- the old radial field built a 66-vertex mesh EVERY frame. +-- The background: one flat clear to the faintly red-cast field colour. One +-- call, no mesh, no fan, no allocation -- the old radial field built a +-- 66-vertex mesh EVERY frame. The tint is deliberately small (a handful of +-- points of red at near-black): enough that the cards read as sitting ON +-- something, not enough to compete with the tri-colour rail for brand duty. function Theme.field() if not G then return end - G.clear(0, 0, 0, 1) + G.clear(PAL.field[1] / 255, PAL.field[2] / 255, PAL.field[3] / 255, 1) end -- ------------------------------------------------------------------- fonts diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua index 32388b82..7a0dd1ba 100644 --- a/src/update/check_worker.lua +++ b/src/update/check_worker.lua @@ -80,7 +80,10 @@ local function curlCapture(url) local pipe = HostShell.popen(cmd) if not pipe then return nil end local out = pipe:read("*a") - pipe:close() + -- HostShell.pclose, not pipe:close(): a close outside the spawn lock can + -- free a FILE while another thread's popen walks the stream list, which + -- deadlocks that thread permanently (see HostShell's popen notes). + HostShell.pclose(pipe) if not out or out == "" then return nil end return out end @@ -89,7 +92,7 @@ local function haveCurl() local pipe = HostShell.popen("curl --version") if not pipe then return false end local out = pipe:read("*a") - pipe:close() + HostShell.pclose(pipe) return out ~= nil and out:find("curl", 1, true) ~= nil end diff --git a/tests/engine/host_shell_fetch_errors.lua b/tests/engine/host_shell_fetch_errors.lua new file mode 100644 index 00000000..ad5383fe --- /dev/null +++ b/tests/engine/host_shell_fetch_errors.lua @@ -0,0 +1,134 @@ +-- HostShell's HTTP error reporting. No pokered cite: host transport is +-- port-only plumbing. +-- +-- A user hit the launcher's mod index against a rate-limited GitHub and all +-- they got was one line on the terminal: +-- +-- curl: (56) The requested URL returned error: 403 +-- +-- That is curl talking to its own stderr. It names no URL, so with an index +-- feed, a releases API and a page of thumbnails all in flight there was no way +-- to tell WHICH fetch failed, and the caller upstream got a generic "empty +-- response" that said even less. HostShell now merges curl's stderr into the +-- pipe and asks for the status with --write-out, so every failure names its +-- URL and its HTTP code, and a 403 body ("API rate limit exceeded") reaches +-- the launcher's notice line where a user can act on it. +-- +-- The seam is io.popen: these cases stub it to replay exactly what curl writes +-- for each outcome, which is the only way to pin the parsing without a network +-- and a cooperating server. +-- luajit tests/engine/host_shell_fetch_errors.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +love = love or require("tests.love_stub") + +local HostShell = require("src.core.HostShell") + +-- The marker HostShell asks curl to print before the status code. Spelled +-- here the way it arrives (a real newline), not the way it is passed to curl +-- (a backslash-n escape curl expands itself). +local MARK = "\n__gen1recomp_http__" + +-- Replay `output` as the next popen's whole stdout. `curl --version` is +-- answered separately so HostShell.haveCurl agrees a transport exists. +local realPopen = io.popen +local lastCommand +local function stubPopen(output) + io.popen = function(cmd, mode) + lastCommand = cmd + if cmd:find("--version", 1, true) then + return { read = function() return "curl 8.7.1 (test)" end, + close = function() return true end } + end + return { read = function() return output end, + close = function() return true end } + end +end +local function restorePopen() io.popen = realPopen end + +local URL = "https://api.github.com/repos/example/thing/releases" + +-- ------------------------------------------------------------------- 200 +stubPopen('{"tag_name":"v1.2.3"}' .. MARK .. "200") +local body, err = HostShell.httpGet(URL, "gen1recomp", nil, 10) +check(body == '{"tag_name":"v1.2.3"}', + "a 200 returns the body with the status marker stripped: " .. tostring(body)) +check(err == nil, "a 200 reports no error") +check(lastCommand:find("%-w ") ~= nil, + "the GET asks curl for the status code") +check(lastCommand:find("2>&1", 1, true) ~= nil, + "the GET captures curl's stderr instead of leaking it to the terminal") +check(lastCommand:find(" -f", 1, true) == nil, + "the GET does NOT pass -f: the error body is the diagnosis") + +-- ------------------------------------------------------------------- 403 +-- What GitHub actually sends when the launcher has burned its unauthenticated +-- hourly allowance, with curl's own stderr merged in ahead of it. +stubPopen('{"message":"API rate limit exceeded for 203.0.113.7."}' + .. MARK .. "403") +local body403, err403 = HostShell.httpGet(URL, "gen1recomp", nil, 10) +check(body403 == nil, "a 403 is a failure, not a body") +check(err403:find(URL, 1, true) ~= nil, + "a 403 names the URL that failed: " .. tostring(err403)) +check(err403:find("403", 1, true) ~= nil, "a 403 names the status code") +check(err403:find("rate limit", 1, true) ~= nil, + "a 403 carries the server's own explanation through to the caller") + +-- --------------------------------------------------- no response at all +-- DNS failure: curl writes its complaint and a http_code of 0. Zero is not a +-- status, and reporting "HTTP 0" would bury the only useful line there is. +stubPopen("curl: (6) Could not resolve host: nope.invalid" .. MARK .. "0") +local bodyDns, errDns = HostShell.httpGet("https://nope.invalid/x", "ua", nil, 10) +check(bodyDns == nil, "an unresolvable host is a failure") +check(errDns:find("HTTP 0", 1, true) == nil, + "a no-response failure is not reported as HTTP 0: " .. tostring(errDns)) +check(errDns:find("https://nope.invalid/x", 1, true) ~= nil, + "an unresolvable host still names the URL") +check(errDns:find("Could not resolve", 1, true) ~= nil, + "an unresolvable host reports curl's own reason") + +-- --------------------------------------------- a body containing the marker +-- The status is cut from the LAST marker only, so a payload that happens to +-- contain the token keeps every byte of its content. +local sneaky = "prefix" .. MARK .. "999" .. "suffix" +stubPopen(sneaky .. MARK .. "200") +local bodySneaky = HostShell.httpGet(URL, "gen1recomp", nil, 10) +check(bodySneaky == sneaky, + "only the trailing status marker is stripped: " .. tostring(bodySneaky)) + +-- ------------------------------------------------------------- downloads +-- The download branch keeps -f (no error body is written to the file), but it +-- must still name the URL and the code rather than "download failed". +stubPopen("curl: (56) The requested URL returned error: 403" .. MARK .. "403") +local ok, dlErr = HostShell.httpDownload(URL, "/tmp/gen1recomp-test.bin", + "gen1recomp", nil, 10) +check(ok == nil, "a 403 download fails") +check(dlErr:find(URL, 1, true) ~= nil, + "a failed download names the URL: " .. tostring(dlErr)) +check(dlErr:find("403", 1, true) ~= nil, "a failed download names the code") + +stubPopen(MARK .. "200") +local ok2, dlErr2 = HostShell.httpDownload(URL, "/tmp/gen1recomp-test.bin", + "gen1recomp", nil, 10) +check(ok2 == true, "a 200 download succeeds: " .. tostring(dlErr2)) + +restorePopen() + +-- ---------------------------------------------------------------- pclose +-- Every pipe HostShell hands out must be closed through pclose: a bare +-- pipe:close() from one thread can free a FILE while another thread's popen +-- is walking libc's stream list, and that thread never wakes up again (the +-- launcher freezing on close after a visit to the mod tabs). Nothing here can +-- exercise the race headlessly -- the test stub has no love.thread -- so this +-- pins the entry point's existence and its tolerance of junk. +check(type(HostShell.pclose) == "function", "HostShell exposes pclose") +local closed = false +HostShell.pclose({ close = function() closed = true return true end }) +check(closed, "pclose closes the pipe it is given") +local okNil = pcall(HostShell.pclose, nil) +check(okNil, "pclose on nil is a no-op rather than an error") + +T.finish("host shell fetch errors") diff --git a/tests/engine/launcher_one_column_reach_bug852.lua b/tests/engine/launcher_one_column_reach_bug852.lua index 00e85560..5b56d4e5 100644 --- a/tests/engine/launcher_one_column_reach_bug852.lua +++ b/tests/engine/launcher_one_column_reach_bug852.lua @@ -14,9 +14,18 @@ -- The seam is LauncherView.draw itself: it publishes the page-scroll extent -- on the importer (imp._pageScroll / imp._pageScrollMax, the values the -- touch-drag and wheel paths feed), so a headless draw shows whether the --- scroll engaged without reading any file-local constant. The 480x900 --- window below is the discriminator: its natural panel space satisfies the --- old flat threshold (inert, slot card clipped) but not the one-column one. +-- scroll engaged without reading any file-local constant. +-- +-- What is asserted here is REACHABILITY, not scrolling: "+ New save slot" is +-- the control that sits at the very bottom of the one-column pile, and the +-- bug was that it drew where no tap could land. Either it fits in the +-- window outright, or the page scrolls far enough to bring it in -- both are +-- correct, and which one a given window gets depends on how tall the panel's +-- content happens to be. Asserting "this window scrolls" instead pinned the +-- test to the size of the stack: when the pinned Touch-Controls / +-- Reset-rebinds pair moved behind the gear and the save-file buttons moved +-- into the slot card, 480x900 started fitting outright and a scroll +-- assertion failed on a window that had just got BETTER. -- -- #810 gets its unit-conversion pin in tests/engine/safe_area_units_test.lua; -- here the complementary end-to-end anchor: Layout.metrics must place the @@ -37,6 +46,7 @@ love.graphics.setLineJoin = love.graphics.setLineJoin or function() end love.graphics.newShader = love.graphics.newShader or function() return {} end local Layout = require("src.ui.kit.Layout") +local Kit = require("src.ui.kit.Kit") local RomImporter = require("src.import.RomImporter") local LauncherView = require("src.import.LauncherView") @@ -52,7 +62,43 @@ local function freshLauncher() return RomImporter.new(function() end, { launcher = true }) end --- ------------------------------------------------ #852: the scroll engages +-- Draw a frame with the layout audit on and report where "+ New save slot" +-- landed. Kit records the rect every clickable control occupies plus the +-- clip that bounds its hit test, so this sees exactly what a tap would. +local function newSlotRect(imp) + Kit.audit = {} + LauncherView.draw(imp) + local found + for _, r in ipairs(Kit.audit) do + if r.class == "control" and tostring(r.label):find("New save slot", 1, true) then + found = r + end + end + Kit.audit = nil + return found +end + +-- The control is reachable when its rect, intersected with whatever clip +-- bounds it, still has real area inside the window -- either straight away or +-- after the page is scrolled to the bottom. +local function reachable(imp, H) + local function visible() + local r = newSlotRect(imp) + if not r then return false end + local y1, y2 = r.y, r.y + r.h + if r.clip then + y1 = math.max(y1, r.clip.y) + y2 = math.min(y2, r.clip.y + r.clip.h) + end + y1, y2 = math.max(y1, 0), math.min(y2, H) + return (y2 - y1) > 1 + end + if visible() then return true end + imp._pageScroll = 1e6 -- clamps to the extent inside draw() + return visible() +end + +-- ------------------------------------------------ #852: the bottom is reachable -- 480x900 one column: enough room for the old flat 460*s threshold, not for -- the one-column stack. Before the fix draw() left _pageScrollMax at 0 here -- and the slot card sat clipped inert against the pinned buttons. @@ -61,30 +107,37 @@ local m = Layout.metrics(1200) eq(m.twoCol, false, "480-wide window lays out one column") local imp = freshLauncher() LauncherView.draw(imp) -check((imp._pageScrollMax or 0) > 0, - "one-column window short of the stack engages the page scroll") +check(reachable(imp, 900), + "one-column window can reach the bottom of the slot card") +imp = freshLauncher() +LauncherView.draw(imp) eq(imp._pageScroll, 0, "a fresh page starts at the top") --- The wheel moves the page (the same offset the touch drag feeds), and the --- offset clamps to the extent, so the whole stack down to "+ New save slot" --- and the footer is reachable rather than clipped away. -local extent = imp._pageScrollMax -imp._wheelY = -1 -LauncherView.draw(imp) -eq(imp._pageScroll, math.min(math.floor(48 * m.s), extent), - "one wheel notch scrolls the page down by its step") -imp._pageScroll = 1e6 -LauncherView.draw(imp) -eq(imp._pageScroll, imp._pageScrollMax, - "an offset past the end clamps to the extent, so the bottom is reachable") - --- The reporter's portrait phone (360x780 units) is shorter still and must --- also scroll; before the fix its slot list was unreachable. +-- The reporter's portrait phone (360x780 units) is shorter and narrower, so +-- it is the one that still engages the scroll; before the fix its slot list +-- was unreachable at any offset. window(360, 780) +local pm = Layout.metrics(1200) local phone = freshLauncher() LauncherView.draw(phone) check((phone._pageScrollMax or 0) > 0, "portrait-phone one-column window engages the page scroll") +eq(phone._pageScroll, 0, "a fresh page starts at the top") + +-- The wheel moves the page (the same offset the touch drag feeds), and the +-- offset clamps to the extent, so the whole stack down to "+ New save slot" +-- and the footer is reachable rather than clipped away. +local extent = phone._pageScrollMax +phone._wheelY = -1 +LauncherView.draw(phone) +eq(phone._pageScroll, math.min(math.floor(48 * pm.s), extent), + "one wheel notch scrolls the page down by its step") +phone._pageScroll = 1e6 +LauncherView.draw(phone) +eq(phone._pageScroll, phone._pageScrollMax, + "an offset past the end clamps to the extent, so the bottom is reachable") +check(reachable(phone, 780), + "the scrolled portrait phone reaches the bottom of the slot card") -- A one-column window tall enough for the whole stack stays inert: the -- column-aware minimum is a floor, not a permanent scroll. diff --git a/tests/engine/launcher_panel_reflow.lua b/tests/engine/launcher_panel_reflow.lua new file mode 100644 index 00000000..4e4db52a --- /dev/null +++ b/tests/engine/launcher_panel_reflow.lua @@ -0,0 +1,208 @@ +-- Launcher panel reflow. No pokered cite: the launcher is port-only chrome. +-- +-- Three reports from the same round of testing, all of them the same root +-- cause -- a panel laying out more content than its window could hold, with +-- no scrollbar to rescue what fell off: +-- +-- * "Import failed only appears in that single line" -- the ROM card's +-- detail paragraph is elastic and got trimmed to zero lines whenever the +-- card's height budget was tight, so a failed import printed a headline +-- with no reason under it. The reporter's German ROM was rejected for a +-- specific, printable reason and the launcher swallowed it. +-- * "The settings labels are barely visible at all" -- settings rows put +-- the label and the value ladder side by side, and on a portrait phone +-- the ladder took so much of the width that every label ellipsized to +-- three characters ("TEX...", "BAT...", "BAT..."). +-- * "these buttons don't appear correctly" / Play walking off the bottom -- +-- the game panel pinned Play and a Touch-Controls/Reset-rebinds pair to +-- the bottom of a column whose height was whatever its cards needed, so +-- on a short window the pinned block left the window entirely. +-- +-- The audit sweep at the end is the general form of the third: Kit records +-- every control that could take a click (plus the clip that bounds its hit +-- test) while Kit.audit is set, so a window-size sweep can assert that no two +-- controls overlap and that nothing escapes a window which is not scrolling. +-- A window that IS scrolling legitimately draws below the fold -- reachability +-- there is pinned by tests/engine/launcher_one_column_reach_bug852.lua. +-- luajit tests/engine/launcher_panel_reflow.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +local Kit = require("src.ui.kit.Kit") +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +local function window(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end +end + +local function freshLauncher() + return RomImporter.new(function() end, { launcher = true }) +end + +-- Every string the frame printed. The kit falls back to love.graphics.print +-- under the stub (no newText), so recording that call captures the text the +-- panel actually put on screen -- ellipsis and all, which is the point. +local realPrint = love.graphics.print +local function drawAndCapture(imp) + local seen = {} + love.graphics.print = function(str, ...) + seen[#seen + 1] = tostring(str) + return realPrint(str, ...) + end + local ok, err = pcall(LauncherView.draw, imp) + love.graphics.print = realPrint + check(ok, "the frame draws: " .. tostring(err)) + return table.concat(seen, "\n") +end + +-- ------------------------------------- a failed import explains itself +-- setError stores the reason on imp.detail; the ROM card must print it, not +-- just the "Import failed" headline above it. Checked on the reporter's +-- phone shape, since a narrow window is exactly where the old budget +-- arithmetic trimmed the paragraph away. +local REASON = "This is a German ROM; only the English releases are supported." +window(360, 780) +local failed = freshLauncher() +failed:setError(REASON, "red") +failed.tab = "red" +local text = drawAndCapture(failed) +check(text:find("Import failed", 1, true) ~= nil, + "a failed import prints its headline") +check(text:find(REASON, 1, true) ~= nil, + "a failed import prints the REASON it failed, not just the headline") + +-- The same on a desktop window, so the detail is not an artefact of one shape. +window(1280, 720) +local failedWide = freshLauncher() +failedWide:setError(REASON, "red") +failedWide.tab = "red" +check(drawAndCapture(failedWide):find(REASON, 1, true) ~= nil, + "the failure reason survives on a desktop window too") + +-- ------------------------------- settings labels stay readable in portrait +-- Every core row's label must print in FULL. Side by side they could not, +-- so a narrow panel stacks the label on its own line above its control; the +-- assertion is on the text, not on the layout mode, because "the label is +-- readable" is the property that broke. +local function settingsText(w, h) + window(w, h) + local imp = freshLauncher() + imp:_openSettings() + check(imp._settings ~= nil, "the gear opens the settings model") + drawAndCapture(imp) -- first frame paginates + return drawAndCapture(imp) +end + +local LONG_LABELS = { "TEXT SPEED", "BATTLE ANIMATION", "BATTLE STYLE" } +local portrait = settingsText(360, 780) +for _, label in ipairs(LONG_LABELS) do + check(portrait:find(label, 1, true) ~= nil, + ("portrait settings print %q in full"):format(label)) +end +check(portrait:find("BAT...", 1, true) == nil, + "no settings label is clipped to an ellipsis on a portrait phone") + +-- A desktop window has room for the side-by-side shape and must not regress. +local desktop = settingsText(1280, 720) +for _, label in ipairs(LONG_LABELS) do + check(desktop:find(label, 1, true) ~= nil, + ("desktop settings print %q in full"):format(label)) +end + +-- ----------------------------------------------- the layout audit sweep +local function clipped(r) + local x1, y1, x2, y2 = r.x, r.y, r.x + r.w, r.y + r.h + if r.clip then + x1 = math.max(x1, r.clip.x); y1 = math.max(y1, r.clip.y) + x2 = math.min(x2, r.clip.x + r.clip.w); y2 = math.min(y2, r.clip.y + r.clip.h) + end + if x2 - x1 <= 1 or y2 - y1 <= 1 then return nil end + return x1, y1, x2, y2 +end + +local function overlap(a, b) + local ax1, ay1, ax2, ay2 = clipped(a) + if not ax1 then return false end + local bx1, by1, bx2, by2 = clipped(b) + if not bx1 then return false end + return math.min(ax2, bx2) - math.max(ax1, bx1) > 1 + and math.min(ay2, by2) - math.max(ay1, by1) > 1 +end + +-- `scrolling` windows are allowed to draw below the fold: that is the page +-- scroll doing its job, and the reach test covers it. +local function auditFrame(label, W, H, scrolling) + local controls = {} + for _, r in ipairs(Kit.audit or {}) do + if r.class == "control" then controls[#controls + 1] = r end + end + check(#controls > 0, label .. ": the frame dispatched controls at all") + local collisions, escapes = 0, 0 + for i = 1, #controls do + local a = controls[i] + local x1, y1, x2, y2 = clipped(a) + if x1 and not scrolling + and (x1 < -0.5 or y1 < -0.5 or x2 > W + 0.5 or y2 > H + 0.5) then + escapes = escapes + 1 + print((" escape: %s (%.0f,%.0f %.0fx%.0f)") + :format(a.label, a.x, a.y, a.w, a.h)) + end + for j = i + 1, #controls do + if overlap(a, controls[j]) then + collisions = collisions + 1 + print((" overlap: '%s' vs '%s' at (%.0f,%.0f) / (%.0f,%.0f)") + :format(a.label, controls[j].label, a.x, a.y, + controls[j].x, controls[j].y)) + end + end + end + check(collisions == 0, label .. ": no two controls overlap") + check(escapes == 0, label .. ": every control stays inside the window") +end + +-- The shapes the reports came from, plus the desktop ones they have to keep +-- serving: portrait phones, a 150%-scaled Linux handheld, 4:3, and widescreen. +local SIZES = { + { 360, 780 }, { 412, 915 }, { 480, 900 }, { 720, 1280 }, + { 1280, 720 }, { 1024, 768 }, { 900, 700 }, { 1920, 1080 }, +} + +for _, size in ipairs(SIZES) do + local W, H = size[1], size[2] + window(W, H) + for _, tab in ipairs({ "red", "yellow", "mods", "find" }) do + local imp = freshLauncher() + imp.tab = tab + LauncherView.draw(imp) -- warm frame: pagination settles + Kit.audit = {} + local ok, err = pcall(LauncherView.draw, imp) + Kit.audit = ok and Kit.audit or nil + check(ok, ("%dx%d %s draws: %s"):format(W, H, tab, tostring(err))) + if ok then + auditFrame(("%dx%d %s"):format(W, H, tab), W, H, + (imp._pageScrollMax or 0) > 0) + end + Kit.audit = nil + end + -- The settings panel is its own layout and its own reflow. + local imp = freshLauncher() + imp:_openSettings() + LauncherView.draw(imp) + Kit.audit = {} + local ok, err = pcall(LauncherView.draw, imp) + Kit.audit = ok and Kit.audit or nil + check(ok, ("%dx%d settings draws: %s"):format(W, H, tostring(err))) + if ok then auditFrame(("%dx%d settings"):format(W, H), W, H, false) end + Kit.audit = nil +end + +T.finish("launcher panel reflow")