mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
2c356f28ea
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.
378 lines
12 KiB
Lua
Executable File
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
|