Files
gen1recomp/src/ui/kit/FileBrowser.lua
T
Shane McGovern 2c356f28ea fix(game3): save, item, map, script and importer bugs found in review
The rest of the Gen 3 review fixes, each with a gated suite in tests/engine/.
ROM semantics were checked against pret/pokefirered.

Scripts
- givemon carried the wrong operand layout, found earlier; four more layout
  desyncs came out of pret asm/macros/event.inc: comparestat is {byte,word},
  and setptr / loadbytefromptr / setptrbyte each carry a leading byte plus a
  word. A wrong size mis-decodes every instruction after the bad one, so
  Versions.CACHE_VERSION moves to 113 and existing caches re-import.
- handlers for previously handler-less verbs: comparestat,
  bufferitemnameplural, setmonmove, setmonmetlocation, the modern
  fateful-encounter pair, the script-locals family (copylocal, setptr,
  loadbytefromptr, setptrbyte, copybyte, compare_local_to_* and
  compare_ptr_to_*), the RAM-script family (setvaddress, vgoto, vcall,
  vgoto_if, vcall_if, vmessage, vbuffermessage, vbufferstring, endram,
  returnram) and the same-map forms of the *at verbs.
- setdooropen / setdoorclosed read their coordinates through VarGet.

Battles
- Knock Off and Thief / Trick persist the item change instead of only
  touching the in-battle copy.
- knocked-off party slots are tracked in a bitmask, so a slot reused later
  does not inherit the flag.

Field and UI
- Player.reset restores facing and clears the surf flags.
- a definition-less Map.load no longer leaves collision unbound.
- an unresolved region-map section no longer reports PALLET TOWN.
- the naming screen splits input from the timer, so update(dt) stops
  indexing a number.
- the hall of fame commits through the engine save path and serializes its
  fields.

Persistence
- gameStats, the link-battle records and the trainer card are serialized.
- the PC deposit refuses at the 999 cap instead of destroying the overflow.

Robustness
- Data.load runs cached modules sandboxed.
- the file browser quotes shell arguments.
- .meta dimensions are bounds-checked and mids.idx validates its header.
2026-09-22 03:28:39 +01:00

378 lines
12 KiB
Lua
Executable File

-- Immediate-mode in-launcher File Browser for handhelds and gamepads.
-- Allows navigating directories to pick ROM files (.gb/.gbc), import save files (.sav),
-- and select mod/skin archives (.zip) without requiring host desktop GUI pickers (zenity/kdialog).
local Theme = require("src.ui.kit.Theme")
local HostShell = require("src.core.HostShell")
-- POSIX shell quoting for the ls/test commands below. HostShell.quote is the
-- shared helper, but the launcher may run where HostShell is a minimal shim
-- (the NX gate), so fall back to the same escaping locally.
local function shQuote(path)
if HostShell and type(HostShell.quote) == "function" then
return HostShell.quote(path)
end
return "'" .. tostring(path):gsub("'", "'\\''") .. "'"
end
local PAL = Theme.PAL
local Kit = nil
local function getKit()
if not Kit then Kit = require("src.ui.kit.Kit") end
return Kit
end
local FileBrowser = {
active = false,
title = "Select File",
mode = "rom", -- "rom", "save", "mod", "all"
currentDir = "/",
entries = {},
selectedIdx = 1,
page = 1,
perPage = 8,
onSelect = nil,
onCancel = nil,
shortcutIdx = 1,
}
local SHORTCUTS = {
{ label = "SD Card", path = "/mnt/SDCARD" },
{ label = "GB ROMs", path = "/roms/gb" },
{ label = "GBC ROMs", path = "/roms/gbc" },
{ label = "Ports", path = "/roms/ports" },
{ label = "Game Dir", path = "." },
{ label = "Storage", path = "/storage" },
{ label = "Userdata", path = "/userdata/roms" },
{ label = "Root /", path = "/" },
}
local function findSdCardRoot()
local candidates = {
"/mnt/SDCARD",
"/mnt/sdcard",
"/mnt/mmc",
"/sdcard",
"/roms",
"/storage/roms",
"/userdata/roms",
"/storage",
"/userdata",
}
for _, path in ipairs(candidates) do
local ok, h = pcall(io.popen, string.format("test -d %s && echo \"yes\"", shQuote(path)))
if ok and h then
local res = h:read("*a")
h:close()
if res and res:match("yes") then
return path
end
end
end
return "/"
end
local function normalizePath(p)
if not p or p == "" then return "/" end
p = p:gsub("\\", "/")
if p:sub(1, 1) ~= "/" and p:sub(1, 2) ~= "./" and p:sub(1, 3) ~= "../" then
p = "/" .. p
end
p = p:gsub("/+", "/")
return p
end
local function parentDir(p)
p = normalizePath(p)
if p == "/" or p == "." then return "/" end
local parent = p:match("^(.*)/[^/]+/?$")
if not parent or parent == "" then return "/" end
return parent
end
local function isMatchingFilter(name, isDir, mode)
if isDir then return true end
local ext = name:match("%.([^.]+)$")
if not ext then return (mode == "all") end
ext = ext:lower()
if mode == "rom" then
return (ext == "gb" or ext == "gbc" or ext == "zip")
elseif mode == "save" then
return (ext == "sav")
elseif mode == "mod" then
return (ext == "zip")
end
return true
end
local function scanDirectory(dir, mode)
dir = normalizePath(dir)
local entries = {}
local ok, handle = pcall(io.popen, string.format("ls -1ap %s 2>/dev/null", shQuote(dir)))
if ok and handle then
local output = handle:read("*a")
handle:close()
if output and #output > 0 then
for line in output:gmatch("[^\r\n]+") do
if line ~= "./" and line ~= "../" and line ~= "." and line ~= ".." then
local isDir = (line:sub(-1) == "/")
local cleanName = isDir and line:sub(1, -2) or line
if isMatchingFilter(cleanName, isDir, mode) then
table.insert(entries, {
name = cleanName,
isDir = isDir,
path = (dir == "/" and "/" .. cleanName) or (dir .. "/" .. cleanName),
})
end
end
end
end
end
if #entries == 0 and love and love.filesystem and love.filesystem.getDirectoryItems then
local okFs, items = pcall(love.filesystem.getDirectoryItems, dir)
if okFs and items then
for _, name in ipairs(items) do
local full = (dir == "/" and "/" .. name) or (dir .. "/" .. name)
local info = love.filesystem.getInfo and love.filesystem.getInfo(full)
local isDir = info and info.type == "directory"
if isMatchingFilter(name, isDir, mode) then
table.insert(entries, {
name = name,
isDir = isDir,
path = full,
})
end
end
end
end
table.sort(entries, function(a, b)
if a.isDir ~= b.isDir then
return a.isDir
end
return a.name:lower() < b.name:lower()
end)
return entries
end
function FileBrowser.open(opts)
opts = opts or {}
FileBrowser.active = true
FileBrowser.title = opts.title or "Select File"
FileBrowser.mode = opts.mode or "rom"
FileBrowser.onSelect = opts.onSelect
FileBrowser.onCancel = opts.onCancel
FileBrowser.selectedIdx = 1
FileBrowser.page = 1
local initPath = opts.initialPath or findSdCardRoot()
FileBrowser.currentDir = initPath
FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode)
if #FileBrowser.entries == 0 and initPath ~= "/" then
FileBrowser.currentDir = "/"
FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode)
end
end
function FileBrowser.close(path)
if not FileBrowser.active then return end
local onSelect = FileBrowser.onSelect
local onCancel = FileBrowser.onCancel
FileBrowser.active = false
FileBrowser.onSelect = nil
FileBrowser.onCancel = nil
if path and onSelect then
onSelect(path)
elseif not path and onCancel then
onCancel()
end
end
function FileBrowser.setDirectory(dir)
FileBrowser.currentDir = normalizePath(dir)
FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode)
FileBrowser.selectedIdx = 1
FileBrowser.page = 1
end
function FileBrowser.cycleShortcut()
FileBrowser.shortcutIdx = (FileBrowser.shortcutIdx % #SHORTCUTS) + 1
local target = SHORTCUTS[FileBrowser.shortcutIdx]
FileBrowser.setDirectory(target.path)
end
function FileBrowser.gamepadpressed(button)
if not FileBrowser.active then return false end
local total = #FileBrowser.entries
local perPage = FileBrowser.perPage
local maxPage = math.max(1, math.ceil(total / perPage))
if button == "dpup" then
if FileBrowser.selectedIdx > 1 then
FileBrowser.selectedIdx = FileBrowser.selectedIdx - 1
FileBrowser.page = math.floor((FileBrowser.selectedIdx - 1) / perPage) + 1
end
return true
elseif button == "dpdown" then
if FileBrowser.selectedIdx < total then
FileBrowser.selectedIdx = FileBrowser.selectedIdx + 1
FileBrowser.page = math.floor((FileBrowser.selectedIdx - 1) / perPage) + 1
end
return true
elseif button == "dpleft" or button == "leftshoulder" or button == "triggerleft" or button == "lefttrigger" or button == "l2" then
if FileBrowser.page > 1 then
FileBrowser.page = FileBrowser.page - 1
FileBrowser.selectedIdx = (FileBrowser.page - 1) * perPage + 1
end
return true
elseif button == "dpright" or button == "rightshoulder" or button == "triggerright" or button == "righttrigger" or button == "r2" then
if FileBrowser.page < maxPage then
FileBrowser.page = FileBrowser.page + 1
FileBrowser.selectedIdx = math.min(total, (FileBrowser.page - 1) * perPage + 1)
end
return true
elseif button == "a" or button == "start" then
local entry = FileBrowser.entries[FileBrowser.selectedIdx]
if entry then
if entry.isDir then
FileBrowser.setDirectory(entry.path)
else
FileBrowser.close(entry.path)
end
end
return true
elseif button == "b" then
if FileBrowser.currentDir ~= "/" and FileBrowser.currentDir ~= "." then
FileBrowser.setDirectory(parentDir(FileBrowser.currentDir))
else
FileBrowser.close(nil)
end
return true
elseif button == "x" then
FileBrowser.cycleShortcut()
return true
elseif button == "back" or button == "select" then
FileBrowser.close(nil)
return true
end
return true
end
function FileBrowser.keypressed(key)
if not FileBrowser.active then return false end
if key == "escape" then
FileBrowser.close(nil)
return true
elseif key == "up" then
return FileBrowser.gamepadpressed("dpup")
elseif key == "down" then
return FileBrowser.gamepadpressed("dpdown")
elseif key == "left" then
return FileBrowser.gamepadpressed("dpleft")
elseif key == "right" then
return FileBrowser.gamepadpressed("dpright")
elseif key == "return" then
return FileBrowser.gamepadpressed("a")
elseif key == "backspace" then
return FileBrowser.gamepadpressed("b")
end
return false
end
function FileBrowser.draw(m)
if not FileBrowser.active then return end
local Kit = getKit()
local s = m and m.s or Kit.scale or 1
local W = (m and m.W) or (love.graphics and love.graphics.getWidth()) or 640
local H = (m and m.H) or (love.graphics and love.graphics.getHeight()) or 480
Theme.fill(0, 0, W, H, PAL.bg, 0.94)
Kit.blockClicks = true
local pad = math.floor(14 * s)
local modalW = math.floor(math.min(560 * s, W - 20 * s))
local modalH = math.floor(math.min(420 * s, H - 20 * s))
local px = math.floor((W - modalW) / 2)
local py = math.floor((H - modalH) / 2)
Kit.card(px, py, modalW, modalH, true)
local cy = py + pad
-- Header
Kit.text("button", FileBrowser.title, px + pad, cy, PAL.heading)
Kit.tag(px + modalW - pad - math.floor(60 * s), cy, math.floor(60 * s), math.floor(18 * s),
FileBrowser.mode:upper(), PAL.blue)
cy = cy + Kit.textHeight("button") + math.floor(6 * s)
-- Breadcrumb / Current directory bar
local pathBarH = math.floor(24 * s)
Theme.fillRounded(px + pad, cy, modalW - 2 * pad, pathBarH, PAL.bg, 1)
Theme.strokeRounded(px + pad, cy, modalW - 2 * pad, pathBarH, PAL.line, Theme.A.hairline, 1)
local pathStr = Kit.ellipsizeLeft("small", FileBrowser.currentDir, modalW - 4 * pad)
Kit.text("small", pathStr, px + pad + math.floor(6 * s), cy + math.floor(4 * s), PAL.heading)
cy = cy + pathBarH + math.floor(8 * s)
-- File / Directory List
local total = #FileBrowser.entries
local perPage = FileBrowser.perPage
local startIdx = (FileBrowser.page - 1) * perPage + 1
local endIdx = math.min(total, startIdx + perPage - 1)
local rowH = math.floor(26 * s)
local rowGap = math.floor(3 * s)
if total == 0 then
Theme.fillRounded(px + pad, cy, modalW - 2 * pad, rowH * 4, PAL.bg, 1)
Kit.textCenter("small", "No files found in this folder.", px + pad, cy + rowH * 1.5, modalW - 2 * pad, PAL.muted)
cy = cy + rowH * 4 + math.floor(8 * s)
else
for i = startIdx, endIdx do
local entry = FileBrowser.entries[i]
local isSel = (FileBrowser.selectedIdx == i)
local rx = px + pad
local rw = modalW - 2 * pad
if Kit.press(rx, cy, rw, rowH) then
FileBrowser.selectedIdx = i
if entry.isDir then
FileBrowser.setDirectory(entry.path)
else
FileBrowser.close(entry.path)
end
end
if isSel then
Theme.fillRounded(rx, cy, rw, rowH, PAL.ink, 1)
local icon = entry.isDir and "[DIR] " or "[FILE] "
Kit.text("small", icon .. entry.name, rx + math.floor(8 * s), cy + math.floor(4 * s), PAL.inverse)
else
Theme.fillRounded(rx, cy, rw, rowH, PAL.surface, 1)
Theme.strokeRounded(rx, cy, rw, rowH, PAL.line, Theme.A.hairline, 1)
local icon = entry.isDir and "[DIR] " or "[FILE] "
local col = entry.isDir and PAL.blue or PAL.text
Kit.text("small", icon .. entry.name, rx + math.floor(8 * s), cy + math.floor(4 * s), col)
end
cy = cy + rowH + rowGap
end
end
-- Pager info & Controls guide
local bottomY = py + modalH - pad - math.floor(18 * s)
local pageInfo = string.format("Page %d of %d (%d items)", FileBrowser.page, math.max(1, math.ceil(total / perPage)), total)
Kit.text("micro", pageInfo, px + pad, bottomY, PAL.muted)
local guide = "A: Open/Select B: Parent X: Shortcut Start: Confirm Select: Cancel"
Kit.textRight("micro", guide, px + modalW - pad, bottomY, PAL.muted)
Kit.blockClicks = false
end
return FileBrowser